(() => { const storageKey = "plotline.theme"; const choices = document.querySelectorAll("[data-theme-choice]"); const readTheme = () => { try { return localStorage.getItem(storageKey); } catch { return null; } }; const saveTheme = (theme) => { try { localStorage.setItem(storageKey, theme); } catch { // Theme still applies for the current page if persistence is unavailable. } }; const applyTheme = (theme) => { const nextTheme = theme === "dark" ? "dark" : "light"; document.documentElement.dataset.theme = nextTheme; document.documentElement.dataset.bsTheme = nextTheme; saveTheme(nextTheme); choices.forEach((button) => { const isActive = button.dataset.themeChoice === nextTheme; button.classList.toggle("active", isActive); button.setAttribute("aria-pressed", String(isActive)); }); }; choices.forEach((button) => { button.addEventListener("click", () => applyTheme(button.dataset.themeChoice)); }); applyTheme(readTheme() || "light"); })(); (() => { const banner = document.querySelector("[data-trial-banner]"); if (!banner) { return; } const key = `plotline.trialBanner.dismissed.${banner.dataset.trialBannerKey || "current"}`; try { if (localStorage.getItem(key) === "true") { banner.hidden = true; return; } } catch { // The banner can still be dismissed for this page view if storage is unavailable. } banner.querySelector("[data-trial-banner-dismiss]")?.addEventListener("click", () => { banner.hidden = true; try { localStorage.setItem(key, "true"); } catch { // Dismissal persistence is a convenience only. } }); })(); (() => { const inspectorRoots = document.querySelectorAll(".scene-inspector-root"); if (!inspectorRoots.length) { return; } const currentReturnUrl = () => `${window.location.pathname}${window.location.search}${window.location.hash}`; inspectorRoots.forEach((root) => { root.addEventListener("submit", (event) => { const form = event.target; if (!(form instanceof HTMLFormElement)) { return; } let returnUrlInput = form.querySelector("input[name='ReturnUrl']"); if (!returnUrlInput) { returnUrlInput = document.createElement("input"); returnUrlInput.type = "hidden"; returnUrlInput.name = "ReturnUrl"; form.appendChild(returnUrlInput); } returnUrlInput.value = currentReturnUrl(); }, true); }); })(); (() => { const roots = document.querySelectorAll(".scene-inspector-root"); if (!roots.length) { return; } const setStatus = (form, text, state) => { const status = form.querySelector("[data-scene-occupancy-status]"); if (!status) { return; } status.textContent = text; status.dataset.saveState = state || ""; }; const replaceOccupancyForm = (form, html) => { const wrapper = document.createElement("div"); wrapper.innerHTML = html; const nextForm = wrapper.querySelector("[data-scene-occupancy-form]"); if (nextForm) { form.replaceWith(nextForm); const sceneForm = nextForm.closest(".scene-inspector-root")?.querySelector(".scene-inspector-form"); const floorPlanInput = sceneForm?.querySelector("[name='FloorPlanID']"); const initialFloorInput = sceneForm?.querySelector("[name='InitialFloorPlanFloorID']"); const nextFloorPlan = nextForm.querySelector("[name='FloorPlanID']"); const nextInitialFloor = nextForm.querySelector("[name='InitialFloorPlanFloorID']"); if (floorPlanInput && nextFloorPlan) { floorPlanInput.value = nextFloorPlan.value; } if (initialFloorInput && nextInitialFloor) { initialFloorInput.value = nextInitialFloor.value; } } }; roots.forEach((root) => { root.addEventListener("change", async (event) => { const planSelect = event.target.closest?.("[data-occupancy-plan-select]"); if (!planSelect) { return; } const form = planSelect.closest("[data-scene-occupancy-form]"); const sceneId = form?.querySelector("[name='SceneID']")?.value; const editorUrl = form?.dataset.editorUrl; if (!form || !sceneId || !editorUrl) { return; } setStatus(form, "Loading...", "saving"); const url = new URL(editorUrl, window.location.origin); url.searchParams.set("sceneId", sceneId); if (planSelect.value) { url.searchParams.set("floorPlanId", planSelect.value); } try { const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } }); if (!response.ok) { throw new Error("The occupancy editor could not be refreshed."); } replaceOccupancyForm(form, await response.text()); } catch (error) { setStatus(form, error.message || "Load failed", "failed"); } }); root.addEventListener("submit", async (event) => { const form = event.target.closest?.("[data-scene-occupancy-form]"); if (!form) { return; } event.preventDefault(); setStatus(form, "Saving...", "saving"); try { const response = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { "X-Requested-With": "XMLHttpRequest" } }); if (!response.ok) { let message = "Save failed"; const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const payload = await response.json(); message = payload.message || message; } throw new Error(message); } replaceOccupancyForm(form, await response.text()); } catch (error) { setStatus(form, error.message || "Save failed", "failed"); } }); }); })(); (() => { const modalElement = document.getElementById("plotlineConfirmModal"); const modal = modalElement && window.bootstrap ? new bootstrap.Modal(modalElement) : null; const modalTitle = modalElement?.querySelector("#plotlineConfirmModalTitle"); const modalMessage = modalElement?.querySelector("#plotlineConfirmModalMessage"); const modalSubmit = modalElement?.querySelector("[data-confirm-submit]"); const deleteField = modalElement?.querySelector("[data-delete-confirm-field]"); const deleteInput = modalElement?.querySelector("[data-delete-confirm-input]"); let pendingForm = null; let pendingSubmitter = null; const splitMessage = (message) => { const parts = message.split(/\n\s*\n/); return { title: parts.shift()?.trim() || "Confirm action", body: parts.join("\n\n").trim() }; }; const resetModal = () => { pendingForm = null; pendingSubmitter = null; if (deleteInput) { deleteInput.value = ""; } deleteField?.classList.add("d-none"); modalSubmit?.removeAttribute("disabled"); }; modalElement?.addEventListener("hidden.bs.modal", resetModal); deleteInput?.addEventListener("input", () => { if (pendingForm?.dataset.requireDeleteText === "true" && modalSubmit) { modalSubmit.disabled = deleteInput.value.trim() !== "DELETE"; } }); modalSubmit?.addEventListener("click", () => { if (!pendingForm) { return; } if (pendingForm.dataset.requireDeleteText === "true") { const target = pendingForm.querySelector("[data-delete-confirm-target], [name='confirmationText']"); if (target) { target.value = deleteInput?.value.trim() || ""; } } pendingForm.dataset.confirmed = "true"; modal?.hide(); if (pendingForm.requestSubmit && pendingSubmitter) { pendingForm.requestSubmit(pendingSubmitter); } else if (pendingForm.requestSubmit) { pendingForm.requestSubmit(); } else { pendingForm.submit(); } }); document.addEventListener("submit", (event) => { const form = event.target; if ( !(form instanceof HTMLFormElement) || form.dataset.confirmed === "true" || form.dataset.confirmSkip === "true" || form.dataset.noDeleteConfirm === "true" ) { return; } const explicitMessage = form.dataset.confirmMessage; const submitter = event.submitter instanceof HTMLElement ? event.submitter : form.querySelector("button[type='submit']"); const buttonText = submitter?.textContent || ""; const actionPath = form.getAttribute("action") || ""; const restoreAction = /restore/i.test(buttonText) || /\/restore\b/i.test(actionPath); const deleteForeverAction = /delete forever/i.test(buttonText) || /\/deleteforever\b/i.test(actionPath); const archiveAction = !restoreAction && !deleteForeverAction && (/archive/i.test(buttonText) || /\/archive\b/i.test(actionPath)); const removeAction = !deleteForeverAction && /(delete|remove)/i.test(buttonText); if (!explicitMessage && !archiveAction && !restoreAction && !removeAction && !deleteForeverAction) { return; } const message = explicitMessage || (archiveAction ? "Archive this item?\n\nThis will hide it from active lists and timelines, but the data will be kept and can be restored later." : restoreAction ? "Restore this item?\n\nThis will return it to active lists and timelines where applicable." : deleteForeverAction ? "Permanently delete this item?\n\nThis cannot be undone. Any linked planning data may also be removed or become unavailable. Only continue if you are certain." : "Remove this item?\n\nThis removes the link or supporting record. Major story items are archived instead of permanently deleted."); if (!modal || !modalElement || !modalTitle || !modalMessage || !modalSubmit) { return; } event.preventDefault(); const content = splitMessage(message); pendingForm = form; pendingSubmitter = submitter; modalTitle.textContent = form.dataset.confirmTitle || content.title; modalMessage.textContent = form.dataset.confirmBody || content.body; modalSubmit.textContent = form.dataset.confirmButtonText || buttonText.trim() || "Confirm"; modalSubmit.className = `btn ${form.dataset.confirmButtonClass || (restoreAction ? "btn-primary" : "btn-danger")}`; const needsDeleteText = form.dataset.requireDeleteText === "true" || deleteForeverAction; deleteField?.classList.toggle("d-none", !needsDeleteText); if (needsDeleteText && modalSubmit) { modalSubmit.disabled = true; deleteInput?.focus(); } modal.show(); if (needsDeleteText) { window.setTimeout(() => deleteInput?.focus(), 200); } }); })(); (() => { const icons = document.querySelectorAll("[data-help-icon]"); if (!icons.length || !window.bootstrap) { return; } const popovers = new Map(); let activeIcon = null; const tooltipElement = (icon) => icon.querySelector("[data-help-tooltip]") || icon; const hideTooltip = (icon) => bootstrap.Tooltip.getInstance(tooltipElement(icon))?.hide(); const hideActivePopover = (exceptIcon = null) => { if (activeIcon && activeIcon !== exceptIcon) { bootstrap.Popover.getInstance(activeIcon)?.hide(); } }; const hideAllHelpTooltips = () => icons.forEach((icon) => hideTooltip(icon)); icons.forEach((icon) => { const tooltipTarget = tooltipElement(icon); const micro = tooltipTarget.dataset.helpMicro || "Help article not found."; const title = icon.dataset.helpTitle || "Help unavailable"; const summary = icon.dataset.helpSummary || "Help content has not yet been written for this feature."; bootstrap.Tooltip.getOrCreateInstance(tooltipTarget, { title: micro, trigger: "hover focus", placement: "top", container: "body" }); const popover = bootstrap.Popover.getOrCreateInstance(icon, { title, trigger: "click", placement: "auto", container: "body", html: true, customClass: "plotline-help-popover", content() { const wrapper = document.createElement("div"); const summaryText = document.createElement("p"); summaryText.className = "mb-2"; summaryText.textContent = summary; const moreLink = document.createElement("a"); moreLink.href = icon.dataset.helpUrl || "#"; moreLink.textContent = "More..."; moreLink.className = "plotline-help-more"; moreLink.addEventListener("click", (event) => { event.preventDefault(); hideActivePopover(); hideAllHelpTooltips(); icon.dispatchEvent(new CustomEvent("plotline:help-more", { bubbles: true, detail: { key: icon.dataset.helpKey || "" } })); }); wrapper.append(summaryText, moreLink); return wrapper; } }); popovers.set(icon, popover); icon.addEventListener("click", (event) => { if (!window.PlotLineHelpDrawer?.isActive?.()) { return; } event.preventDefault(); event.stopPropagation(); hideActivePopover(); hideTooltip(icon); window.PlotLineHelpPopovers?.hideAll(); window.PlotLineHelpDrawer.open(icon.dataset.helpKey || ""); }); icon.addEventListener("show.bs.popover", (event) => { if (window.PlotLineHelpDrawer?.isActive?.()) { event.preventDefault(); return; } hideActivePopover(icon); hideTooltip(icon); }); icon.addEventListener("shown.bs.popover", () => { activeIcon = icon; hideTooltip(icon); }); icon.addEventListener("hidden.bs.popover", () => { if (activeIcon === icon) { activeIcon = null; } }); }); document.addEventListener("click", (event) => { const target = event.target instanceof Element ? event.target : null; if (!target || !activeIcon) { return; } if (target.closest("[data-help-icon]")) { return; } const activePopoverElement = document.querySelector(`#${activeIcon.getAttribute("aria-describedby")}`); if (activePopoverElement?.contains(target)) { return; } hideActivePopover(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { hideActivePopover(); } }); window.PlotLineHelpPopovers = { hideAll() { popovers.forEach((popover) => popover.hide()); hideAllHelpTooltips(); activeIcon = null; } }; })(); (() => { const layout = document.querySelector("[data-help-layout]"); const drawer = document.querySelector("[data-help-drawer]"); const content = document.querySelector("[data-help-content]"); const splitter = document.querySelector("[data-help-splitter]"); const closeButton = document.querySelector("[data-help-close]"); const collapseButton = document.querySelector("[data-help-collapse]"); const expandStrip = document.querySelector("[data-help-expand]"); const pinButton = document.querySelector("[data-help-pin]"); const heading = document.querySelector("[data-help-drawer-heading]"); if (!layout || !drawer || !content) { return; } const storage = { width: "plotline.helpDrawer.width", pinned: "plotline.helpDrawer.pinned", key: "plotline.helpDrawer.key", collapsed: "plotline.helpDrawer.collapsed" }; const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const readBool = (key) => { try { return localStorage.getItem(key) === "true"; } catch { return false; } }; const writeValue = (key, value) => { try { localStorage.setItem(key, value); } catch { // Help drawer preferences are conveniences; blocked storage should not break help. } }; const setWidth = (value) => { const width = `${clamp(Number.parseFloat(value) || 25, 20, 50)}%`; layout.style.setProperty("--help-drawer-width", width); writeValue(storage.width, width); }; const setPinned = (isPinned) => { writeValue(storage.pinned, String(isPinned)); pinButton?.setAttribute("aria-pressed", String(isPinned)); if (pinButton) { pinButton.textContent = isPinned ? "Unpin" : "Pin"; } }; const setCollapsed = (isCollapsed) => { layout.classList.toggle("help-collapsed", isCollapsed); writeValue(storage.collapsed, String(isCollapsed)); if (collapseButton) { collapseButton.textContent = isCollapsed ? "Expand" : "Collapse"; } }; const openDrawer = async (key, options = {}) => { const contextKey = key || ""; layout.classList.add("help-open"); if (!options.keepCollapsed) { setCollapsed(false); } writeValue(storage.key, contextKey); if (heading) { heading.textContent = contextKey || "Help"; } content.innerHTML = "
Loading help...
"; try { const response = await fetch(`/HelpDrawer/Article?key=${encodeURIComponent(contextKey)}`, { headers: { "X-Requested-With": "XMLHttpRequest" } }); content.innerHTML = response.ok ? await response.text() : "Help for this feature has not yet been written.
"; const title = content.querySelector(".help-drawer-article h2")?.textContent?.trim(); if (heading && title) { heading.textContent = title; } } catch { content.innerHTML = "Help for this feature has not yet been written.
"; } }; const closeDrawer = () => { layout.classList.remove("help-open", "help-collapsed"); setPinned(false); }; window.PlotLineHelpDrawer = { isActive() { return layout.classList.contains("help-open"); }, open(key) { return openDrawer(key); } }; setWidth(localStorage.getItem(storage.width) || "25%"); setPinned(readBool(storage.pinned)); setCollapsed(readBool(storage.collapsed)); document.addEventListener("plotline:help-more", (event) => { const key = event.detail?.key || event.target?.dataset?.helpKey || ""; window.PlotLineHelpPopovers?.hideAll(); openDrawer(key); }); document.addEventListener("click", (event) => { const opener = event.target instanceof Element ? event.target.closest("[data-help-open]") : null; if (!opener) { return; } event.preventDefault(); window.PlotLineHelpPopovers?.hideAll(); openDrawer(opener.dataset.helpOpen || ""); }); closeButton?.addEventListener("click", closeDrawer); collapseButton?.addEventListener("click", () => setCollapsed(!layout.classList.contains("help-collapsed"))); expandStrip?.addEventListener("click", () => setCollapsed(false)); expandStrip?.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setCollapsed(false); } }); pinButton?.addEventListener("click", () => setPinned(!readBool(storage.pinned))); splitter?.addEventListener("pointerdown", (event) => { if (!layout.classList.contains("help-open") || layout.classList.contains("help-collapsed")) { return; } event.preventDefault(); document.body.classList.add("help-resizing"); splitter.setPointerCapture?.(event.pointerId); const onMove = (moveEvent) => { const rect = layout.getBoundingClientRect(); const widthPercent = ((rect.right - moveEvent.clientX) / rect.width) * 100; setWidth(widthPercent); }; const onUp = () => { document.body.classList.remove("help-resizing"); document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); }; document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp); }); const pinnedKey = (() => { try { return localStorage.getItem(storage.key) || ""; } catch { return ""; } })(); if (readBool(storage.pinned) && pinnedKey) { openDrawer(pinnedKey, { keepCollapsed: true }); } })(); (() => { const roots = document.querySelectorAll(".scene-inspector-root"); if (!roots.length) { return; } const slugify = (value) => value .toLowerCase() .replace(/&/g, "and") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); roots.forEach((root) => { const sceneId = root.dataset.sceneId || root.querySelector(".scene-inspector-form [name='SceneID']")?.value || "new"; const storagePrefix = `plotline.inspector.${sceneId}.`; const defaultOpen = new Set(["overview", "timing", "characters"]); const sections = [...root.querySelectorAll(".inspector-section")]; const form = root.querySelector(".scene-inspector-form"); const timeEditor = root.querySelector("[data-scene-time-editor]"); if (form && timeEditor && timeEditor.dataset.enhanced !== "true") { timeEditor.dataset.enhanced = "true"; const modeSelect = form.querySelector("[name='TimeModeID']"); const exactDateTimeModeId = timeEditor.dataset.exactDatetimeModeId || ""; const exactDateModeId = timeEditor.dataset.exactDateModeId || ""; const timeFields = [...timeEditor.querySelectorAll("[data-time-part-field]")]; const dateInputs = { StartDateTime: timeEditor.querySelector("[data-date-part-for='StartDateTime']"), EndDateTime: timeEditor.querySelector("[data-date-part-for='EndDateTime']") }; const timeInputs = { StartDateTime: timeEditor.querySelector("[data-time-part-for='StartDateTime']"), EndDateTime: timeEditor.querySelector("[data-time-part-for='EndDateTime']") }; const hiddenInputs = { StartDateTime: form.querySelector("input[type='hidden'][name='StartDateTime']"), EndDateTime: form.querySelector("input[type='hidden'][name='EndDateTime']") }; const isExactDateTime = () => modeSelect?.value === exactDateTimeModeId; const isExactDate = () => modeSelect?.value === exactDateModeId; const setTimeVisibility = () => { const showTime = isExactDateTime(); timeFields.forEach((field) => { field.hidden = !showTime; field.classList.toggle("d-none", !showTime); }); }; const writeCombinedDateTime = (fieldName) => { const hidden = hiddenInputs[fieldName]; const date = dateInputs[fieldName]?.value || ""; const time = timeInputs[fieldName]?.value || ""; if (!hidden) { return; } if (!date) { hidden.value = ""; return; } if (isExactDateTime()) { hidden.value = `${date}T${time || "00:00"}`; return; } if (isExactDate()) { hidden.value = date; return; } const existingTime = hidden.value.match(/^\\d{4}-\\d{2}-\\d{2}T(\\d{2}:\\d{2})/)?.[1]; hidden.value = existingTime ? `${date}T${existingTime}` : date; }; const syncPostedDateTimes = () => { writeCombinedDateTime("StartDateTime"); writeCombinedDateTime("EndDateTime"); }; modeSelect?.addEventListener("change", setTimeVisibility); form.addEventListener("submit", syncPostedDateTimes); setTimeVisibility(); } sections.forEach((section) => { if (section.dataset.enhanced === "true") { return; } const heading = section.querySelector("h3, h2"); const title = section.dataset.sectionTitle || heading?.textContent?.trim() || "Overview"; const slug = slugify(title); const sectionId = section.dataset.sectionId || `inspector-${slug}`; const storageKey = sectionId.replace(/^inspector-/, ""); section.id = sectionId; section.dataset.searchText = `${title} ${section.textContent}`.toLowerCase(); section.dataset.enhanced = "true"; const body = document.createElement("div"); body.className = "inspector-accordion-body"; while (section.firstChild) { body.appendChild(section.firstChild); } const button = document.createElement("button"); button.type = "button"; button.className = "inspector-accordion-toggle"; button.setAttribute("aria-expanded", "false"); section.append(button, body); const count = body.querySelectorAll(".asset-event-item, .thread-event-item, .warning-mini-card, .dependency-card, .metric-slider").length; button.innerHTML = `${title}${count || ""}`; const stored = localStorage.getItem(`${storagePrefix}${storageKey}`); const isOpen = stored === null ? defaultOpen.has(storageKey) : stored === "open"; section.classList.toggle("is-open", isOpen); button.setAttribute("aria-expanded", String(isOpen)); button.addEventListener("click", () => { const nextOpen = !section.classList.contains("is-open"); section.classList.toggle("is-open", nextOpen); button.setAttribute("aria-expanded", String(nextOpen)); localStorage.setItem(`${storagePrefix}${storageKey}`, nextOpen ? "open" : "closed"); }); }); root.querySelectorAll(".inspector-nav-links a").forEach((link) => { link.addEventListener("click", (event) => { const target = root.querySelector(link.getAttribute("href")); if (!target) { return; } event.preventDefault(); target.classList.add("is-open"); target.querySelector(".inspector-accordion-toggle")?.setAttribute("aria-expanded", "true"); target.scrollIntoView({ behavior: "smooth", block: "start" }); }); }); const search = root.querySelector("[data-inspector-search]"); search?.addEventListener("input", () => { const query = search.value.trim().toLowerCase(); sections.forEach((section) => { const matches = !query || section.dataset.searchText.includes(query); section.classList.toggle("is-filtered-out", !matches); if (matches && query) { section.classList.add("is-open"); section.querySelector(".inspector-accordion-toggle")?.setAttribute("aria-expanded", "true"); } }); }); if ("IntersectionObserver" in window) { const navLinks = [...root.querySelectorAll(".inspector-nav-links a")]; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) { return; } navLinks.forEach((link) => { link.classList.toggle("active", link.getAttribute("href") === `#${entry.target.id}`); }); }); }, { root: root.closest(".scene-inspector-panel") || null, rootMargin: "-10% 0px -70% 0px" }); sections.forEach((section) => observer.observe(section)); } }); })(); (() => { const chartRoots = document.querySelectorAll("[data-metric-shape-chart]"); if (!chartRoots.length) { return; } const colours = [ "#2f6f63", "#b35f3b", "#6750a4", "#c58b2b", "#7d3f52", "#33759b", "#9c4d8f", "#a33d3d", "#6f8f3d", "#d08a2f", "#444b57" ]; const globalDefaultMetrics = new Set(["Overall Intensity", "Tension", "Emotional Weight", "Action"]); const metricCharts = []; const resizeMetricCharts = () => { window.requestAnimationFrame(() => { metricCharts.forEach((chart) => chart.resize()); }); }; window.PlotLineMetricCharts = { resizeAll: resizeMetricCharts }; window.addEventListener("plotline:timeline-layout-changed", resizeMetricCharts); const chapterGuidePlugin = { id: "plotlineChapterGuides", afterDraw(chart) { const scenes = chart.config.options.plugins.plotlineScenes || []; const { ctx, chartArea, scales } = chart; if (!scenes.length || !scales.x) { return; } ctx.save(); ctx.strokeStyle = "rgba(47, 111, 99, 0.16)"; ctx.fillStyle = "rgba(82, 75, 65, 0.58)"; ctx.lineWidth = 1; ctx.font = "700 10px system-ui, -apple-system, Segoe UI, sans-serif"; const chapterStarts = []; scenes.forEach((scene, index) => { if (!index || scene.chapterLabel !== scenes[index - 1].chapterLabel) { chapterStarts.push({ scene, index }); } }); const realChapterStarts = chapterStarts.filter((entry) => !entry.scene.isPlaceholder); const labelInterval = realChapterStarts.length > 40 ? 5 : realChapterStarts.length > 24 ? 4 : realChapterStarts.length > 14 ? 2 : 1; realChapterStarts.forEach((entry, chapterIndex) => { const { scene, index } = entry; const x = scales.x.getPixelForValue(index + 0.5); ctx.beginPath(); ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom); ctx.stroke(); const chapterNumber = Number.parseInt(String(scene.chapterLabel).replace(/[^0-9]/g, ""), 10); const shouldLabel = chapterIndex === 0 || chapterIndex === realChapterStarts.length - 1 || labelInterval === 1 || (Number.isFinite(chapterNumber) && chapterNumber % labelInterval === 0); if (shouldLabel) { ctx.fillText(scene.chapterLabel, Math.min(x + 4, chartArea.right - 70), chartArea.bottom + 22); } }); ctx.restore(); } }; if (window.Chart) { Chart.register(chapterGuidePlugin); } chartRoots.forEach((root) => { const payloadScript = root.querySelector("script[type='application/json']"); const canvas = root.querySelector("canvas"); const toggleHost = root.querySelector("[data-metric-toggles]"); const fallback = root.querySelector("[data-chart-fallback]"); const editToolbar = root.querySelector("[data-timeline-metric-edit-toolbar]"); const editStatus = root.querySelector("[data-edit-metric-status]"); const canEditTimelineMetric = root.hasAttribute("data-timeline-metric-editor") && editToolbar && editStatus; if (!payloadScript || !canvas) { return; } let payload; try { payload = JSON.parse(payloadScript.textContent || "{}"); } catch { fallback?.classList.remove("d-none"); return; } if (!window.Chart || !payload.scenes?.length || !payload.metrics?.length) { fallback?.classList.remove("d-none"); return; } const defaultMetrics = new Set(payload.defaultMetrics?.length ? payload.defaultMetrics : globalDefaultMetrics); const columnCount = payload.scenes.length; const tickLabelInterval = columnCount > 80 ? 10 : columnCount > 40 ? 5 : columnCount > 24 ? 4 : columnCount > 14 ? 2 : 1; const datasets = payload.metrics.map((metric, metricIndex) => { const pointMap = new Map(metric.points.map((point) => [point.sceneId, point.value])); const colour = colours[metricIndex % colours.length]; return { label: metric.metricName, metricTypeId: metric.metricTypeId, minValue: Number.isFinite(metric.minValue) ? metric.minValue : 1, maxValue: Number.isFinite(metric.maxValue) ? metric.maxValue : 10, data: payload.scenes.map((scene, columnIndex) => { const value = pointMap.get(scene.sceneId); return value == null ? { x: columnIndex + 0.5, y: null, sceneId: scene.sceneId } : { x: columnIndex + 0.5, y: value, sceneId: scene.sceneId }; }), borderColor: colour, backgroundColor: colour, pointBackgroundColor: colour, pointBorderColor: "#fff", pointBorderWidth: 1.5, pointRadius: 3, pointHoverRadius: 5, borderWidth: 2, tension: 0.35, spanGaps: true, hidden: !defaultMetrics.has(metric.metricName) }; }); let activeMetricDrag = null; let suppressMetricClick = false; const setMetricEditStatus = (message) => { if (editStatus) { editStatus.textContent = message; } }; const getVisibleDatasetIndexes = () => datasets .map((_, index) => index) .filter((index) => chart?.isDatasetVisible(index)); const getEditableDatasetIndex = () => { const visibleIndexes = getVisibleDatasetIndexes(); return visibleIndexes.length === 1 ? visibleIndexes[0] : -1; }; const updateMetricEditUi = () => { if (!canEditTimelineMetric) { return; } const editableIndex = getEditableDatasetIndex(); datasets.forEach((dataset, index) => { dataset.pointRadius = index === editableIndex ? 5 : 3; dataset.pointHoverRadius = index === editableIndex ? 7 : 5; dataset.borderWidth = index === editableIndex ? 3 : 2; }); const visibleIndexes = getVisibleDatasetIndexes(); const canDrag = editableIndex >= 0; canvas.classList.toggle("metric-curve-editing", canDrag); editStatus.classList.toggle("d-none", visibleIndexes.length === 0); if (visibleIndexes.length === 0) { setMetricEditStatus(""); } else if (canDrag) { setMetricEditStatus("Drag points up or down to edit this metric."); } else { setMetricEditStatus("Show only one metric line to edit its curve."); } chart?.update(); }; toggleHost.innerHTML = ""; datasets.forEach((dataset, index) => { const id = `metric-toggle-${Math.random().toString(36).slice(2)}-${index}`; const label = document.createElement("label"); label.className = "metric-toggle-pill"; label.style.setProperty("--metric-colour", dataset.borderColor); label.innerHTML = `${dataset.label}`; toggleHost.appendChild(label); label.querySelector("input").addEventListener("change", (event) => { chart.setDatasetVisibility(index, event.target.checked); updateMetricEditUi(); chart.update(); }); }); const chart = new Chart(canvas, { type: "line", data: { datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "nearest", intersect: false }, scales: { y: { min: 1, max: 10, ticks: { stepSize: 1 }, grid: { color: "rgba(82, 75, 65, 0.10)" } }, x: { type: "linear", min: 0, max: columnCount, bounds: "ticks", ticks: { maxRotation: 0, autoSkip: false, maxTicksLimit: 16, callback(value) { const columnIndex = Math.floor(Number(value)); if (columnIndex < 0 || columnIndex >= columnCount) { return ""; } const scene = payload.scenes[columnIndex]; if (columnIndex !== 0 && columnIndex !== columnCount - 1 && columnIndex % tickLabelInterval !== 0) { return ""; } return scene?.isPlaceholder ? "" : scene?.axisLabel || ""; } }, afterBuildTicks(scale) { scale.ticks = payload.scenes.map((_, columnIndex) => ({ value: columnIndex + 0.5 })); }, grid: { display: false } } }, plugins: { legend: { display: false }, plotlineScenes: payload.scenes, tooltip: { callbacks: { title(items) { const columnIndex = Math.floor(items[0].parsed.x); const scene = payload.scenes[columnIndex]; if (scene.isPlaceholder) { return scene.sceneTitle || "No scene"; } return `${scene.chapterLabel}, Scene ${scene.sceneNumber}: ${scene.sceneTitle}`; }, label(item) { return `${item.dataset.label}: ${item.formattedValue}`; }, afterBody(items) { const columnIndex = Math.floor(items[0].parsed.x); const scene = payload.scenes[columnIndex]; const lines = []; if (scene.characterPresence) { lines.push(scene.characterPresence); } else if (scene.characterAppears !== undefined) { lines.push(scene.characterAppears ? "Character appears" : "Character does not appear"); } if (scene.bookTitle) { lines.push(scene.bookTitle); } return lines; } } } } } }); const getMetricDragPoint = (event) => { if (!canEditTimelineMetric) { return null; } const editableIndex = getEditableDatasetIndex(); if (editableIndex < 0) { return null; } const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true); const match = points.find((point) => point.datasetIndex === editableIndex); if (!match) { return null; } const dataset = chart.data.datasets[match.datasetIndex]; const point = dataset?.data?.[match.index]; if (!point?.sceneId || point.y == null) { return null; } return { datasetIndex: match.datasetIndex, pointIndex: match.index, previousValue: point.y }; }; const dragValueFromEvent = (event, dataset) => { const rawValue = chart.scales.y.getValueForPixel(event.offsetY); const roundedValue = Math.round(rawValue); return Math.max(dataset.minValue, Math.min(dataset.maxValue, roundedValue)); }; const saveMetricDrag = async (dragState) => { const dataset = chart.data.datasets[dragState.datasetIndex]; const point = dataset?.data?.[dragState.pointIndex]; if (!dataset || !point?.sceneId) { return; } const token = editToolbar?.querySelector("input[name='__RequestVerificationToken']")?.value || ""; setMetricEditStatus("Saving..."); try { const response = await fetch(root.dataset.updateUrl || "/SceneMetrics/UpdateSceneMetricValue", { method: "POST", headers: { "Content-Type": "application/json", "RequestVerificationToken": token }, body: JSON.stringify({ sceneId: Number(point.sceneId), metricId: Number(dataset.metricTypeId), value: Number(point.y) }) }); const result = await response.json().catch(() => ({})); if (!response.ok || !result.success) { throw new Error(result.message || "Save failed"); } point.y = result.value; setMetricEditStatus("Saved"); chart.update(); } catch { point.y = dragState.previousValue; setMetricEditStatus("Save failed"); chart.update(); } }; if (canEditTimelineMetric) { updateMetricEditUi(); canvas.addEventListener("pointerdown", (event) => { activeMetricDrag = getMetricDragPoint(event); if (!activeMetricDrag) { return; } event.preventDefault(); canvas.setPointerCapture?.(event.pointerId); suppressMetricClick = true; }); canvas.addEventListener("pointermove", (event) => { if (!activeMetricDrag) { return; } const dataset = chart.data.datasets[activeMetricDrag.datasetIndex]; const point = dataset?.data?.[activeMetricDrag.pointIndex]; if (!dataset || !point) { return; } point.y = dragValueFromEvent(event, dataset); chart.update("none"); }); canvas.addEventListener("pointerup", async (event) => { if (!activeMetricDrag) { return; } const dragState = activeMetricDrag; activeMetricDrag = null; canvas.releasePointerCapture?.(event.pointerId); await saveMetricDrag(dragState); window.setTimeout(() => { suppressMetricClick = false; }, 0); }); canvas.addEventListener("pointercancel", (event) => { if (!activeMetricDrag) { return; } const dataset = chart.data.datasets[activeMetricDrag.datasetIndex]; const point = dataset?.data?.[activeMetricDrag.pointIndex]; if (point) { point.y = activeMetricDrag.previousValue; } activeMetricDrag = null; canvas.releasePointerCapture?.(event.pointerId); suppressMetricClick = false; chart.update(); }); } canvas.addEventListener("click", (event) => { if (suppressMetricClick) { event.preventDefault(); return; } const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true); if (!points.length) { return; } const point = chart.data.datasets[points[0].datasetIndex]?.data?.[points[0].index]; const columnIndex = point ? Math.floor(point.x) : points[0].index; const scene = payload.scenes[columnIndex]; if (scene?.timelineUrl) { window.PlotLineTimelineScroll?.save(); window.location.href = scene.timelineUrl; } }); metricCharts.push(chart); const resizeObserver = window.ResizeObserver ? new ResizeObserver(() => chart.resize()) : null; if (resizeObserver) { resizeObserver.observe(root); resizeObserver.observe(root.querySelector(".metric-chart-frame") || root); } }); })(); (() => { const root = document.querySelector("[data-timeline-root]"); const controls = document.querySelector("[data-timeline-controls]"); if (!root || !controls) { return; } const storageKey = "plotline.timeline.visibility"; const defaultState = { "scene-cards": true, "plot-lines": true, assets: true, characters: true, locations: true, warnings: true, metrics: true }; const readState = () => { try { return { ...defaultState, ...JSON.parse(localStorage.getItem(storageKey) || "{}") }; } catch { return { ...defaultState }; } }; const applyState = (state) => { Object.entries(state).forEach(([key, visible]) => { root.classList.toggle(`hide-${key}`, !visible); document.querySelectorAll(`[data-timeline-section='${key}']`).forEach((section) => { section.classList.toggle("timeline-section-hidden", !visible); }); }); document.querySelectorAll("[data-location-marker]").forEach((marker) => { marker.classList.toggle("timeline-section-hidden", !state.locations); }); document.querySelectorAll("[data-warning-marker]").forEach((marker) => { marker.classList.toggle("timeline-section-hidden", !state.warnings); }); document.querySelector("[data-visibility-json]")?.setAttribute("value", JSON.stringify(state)); }; const state = readState(); controls.querySelectorAll("[data-timeline-toggle]").forEach((toggle) => { const key = toggle.dataset.timelineToggle; toggle.checked = state[key] ?? true; toggle.addEventListener("change", () => { state[key] = toggle.checked; localStorage.setItem(storageKey, JSON.stringify(state)); applyState(state); }); }); controls.querySelector("[data-timeline-preset-form]")?.addEventListener("submit", () => { document.querySelector("[data-visibility-json]")?.setAttribute("value", JSON.stringify(state)); }); applyState(state); })(); (() => { const root = document.querySelector("[data-timeline-root]"); if (!root) { return; } const workspace = root.querySelector(".timeline-workspace") || root; const densitySelect = document.querySelector("[data-timeline-density]"); const zoomSelect = document.querySelector("[data-timeline-zoom]"); const searchInput = document.querySelector("[data-timeline-search]"); const minimap = root.querySelector("[data-timeline-minimap]"); const jumpSelects = document.querySelectorAll("[data-timeline-jump], [data-timeline-warning-jump], [data-timeline-entity-jump]"); const sceneCards = [...root.querySelectorAll(".timeline-scene-card[data-scene-id]")]; const collapsedKey = "plotline.timeline.collapsed"; const densityKey = "plotline.timeline.density"; const zoomKey = "plotline.timeline.zoom"; const scrollKey = "plotline.timeline.pending-scroll"; const navigationDiscoveryKey = "plotdirector.timelineNavigationDiscovered"; const drawerWidthKey = "plotline.timeline.inspectorWidth"; const horizontalOverflowTolerance = 8; const scrollContainerSelector = [ ".timeline-shell", ".plot-lane-board", ".asset-lane-board", ".character-lane-board", ".metric-graph-stack", "[data-timeline-minimap]" ].join(","); const densityValues = ["compact", "comfortable", "expanded"]; const zoomValues = ["book", "chapter", "scene-detail", "scene-overview"]; const inspectorDrawer = root.querySelector("[data-inspector-drawer]"); const inspectorResizer = root.querySelector("[data-inspector-resizer]"); const inspectorClose = root.querySelector("[data-inspector-close]"); const readSet = (key) => { try { return new Set(JSON.parse(localStorage.getItem(key) || "[]")); } catch { return new Set(); } }; const hasCollapsedPreference = localStorage.getItem(collapsedKey) !== null; const collapsed = readSet(collapsedKey); const getScrollContainers = () => [...root.querySelectorAll(scrollContainerSelector)]; const setupTimelineNavigationGuidance = () => { const card = root.querySelector("[data-timeline-navigation-card]"); if (!card) { return; } const dismissButton = card.querySelector("[data-timeline-navigation-dismiss]"); const demoButton = card.querySelector("[data-timeline-navigation-demo]"); const readDiscovered = () => { try { return localStorage.getItem(navigationDiscoveryKey) === "true"; } catch { return false; } }; const primaryScrollContainer = () => root.querySelector(".timeline-shell"); const hasHorizontalOverflow = (container) => Boolean(container && container.scrollWidth > container.clientWidth + horizontalOverflowTolerance); const maxScrollLeft = (container) => Math.max(0, container.scrollWidth - container.clientWidth); const allScrollContainers = () => [workspace, ...getScrollContainers()].filter((container, index, containers) => ( container && containers.indexOf(container) === index )); let dismissedForPage = false; let isDemoing = false; let isArmed = false; let dismissTimer = null; const knownLeft = new WeakMap(); const rememberPosition = (container) => knownLeft.set(container, container.scrollLeft); const prefersReducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; const showCard = () => { window.clearTimeout(dismissTimer); card.hidden = false; card.classList.remove("is-dismissing"); }; const hideCard = (animate = false) => { window.clearTimeout(dismissTimer); if (!animate || prefersReducedMotion() || card.hidden) { card.classList.remove("is-dismissing"); card.hidden = true; return; } card.classList.add("is-dismissing"); dismissTimer = window.setTimeout(() => { card.hidden = true; card.classList.remove("is-dismissing"); }, 1300); }; const updateEdgeIndicators = (container) => { const hasOverflow = hasHorizontalOverflow(container); const maxLeft = maxScrollLeft(container); container.classList.toggle("timeline-horizontal-overflow", hasOverflow); container.classList.toggle("has-scroll-left", hasOverflow && container.scrollLeft > horizontalOverflowTolerance); container.classList.toggle("has-scroll-right", hasOverflow && container.scrollLeft < maxLeft - horizontalOverflowTolerance); }; const updateGuidanceState = () => { const shouldShow = hasHorizontalOverflow(primaryScrollContainer()) && !readDiscovered() && !dismissedForPage; if (shouldShow) { showCard(); } else if (!card.classList.contains("is-dismissing")) { hideCard(false); } allScrollContainers().forEach(updateEdgeIndicators); }; const markDiscovered = () => { dismissedForPage = true; hideCard(true); try { localStorage.setItem(navigationDiscoveryKey, "true"); } catch { // The card can still disappear for this page view if storage is unavailable. } }; const detectHorizontalScroll = (container) => { updateEdgeIndicators(container); if (!isArmed || isDemoing) { rememberPosition(container); return; } const previous = knownLeft.get(container) ?? container.scrollLeft; if (Math.abs(container.scrollLeft - previous) > 4 && hasHorizontalOverflow(container)) { markDiscovered(); return; } rememberPosition(container); }; const containers = allScrollContainers(); containers.forEach((container) => { rememberPosition(container); container.addEventListener("wheel", (event) => { if (event.defaultPrevented) { return; } if (event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX) && hasHorizontalOverflow(container)) { const previous = container.scrollLeft; event.preventDefault(); container.scrollLeft += event.deltaY; window.requestAnimationFrame(() => { updateEdgeIndicators(container); if (!isDemoing && Math.abs(container.scrollLeft - previous) > 4) { markDiscovered(); } rememberPosition(container); }); } }, { passive: false }); container.addEventListener("scroll", () => detectHorizontalScroll(container), { passive: true }); }); dismissButton?.addEventListener("click", markDiscovered); demoButton?.addEventListener("click", () => { const container = primaryScrollContainer(); if (!hasHorizontalOverflow(container)) { updateGuidanceState(); return; } const originalLeft = container.scrollLeft; const availableRight = maxScrollLeft(container) - originalLeft; let distance = Math.min(220, availableRight); let direction = 1; if (distance < 24 && originalLeft > 0) { distance = Math.min(220, originalLeft); direction = -1; } if (distance < 4) { return; } isDemoing = true; container.scrollTo({ left: originalLeft + (distance * direction), behavior: "smooth" }); window.setTimeout(() => { container.scrollTo({ left: originalLeft, behavior: "smooth" }); }, 420); window.setTimeout(() => { isDemoing = false; allScrollContainers().forEach((scrollContainer) => { rememberPosition(scrollContainer); updateEdgeIndicators(scrollContainer); }); updateGuidanceState(); }, 950); }); window.setTimeout(() => { isArmed = true; allScrollContainers().forEach(rememberPosition); }, 800); window.requestAnimationFrame(updateGuidanceState); window.addEventListener("resize", updateGuidanceState); window.addEventListener("plotline:timeline-layout-changed", updateGuidanceState); densitySelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState)); zoomSelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState)); }; const drawerWidthBounds = () => { const availableWidth = root.getBoundingClientRect().width || window.innerWidth; const minimum = Math.min(420, Math.max(320, availableWidth - 360)); const maximum = Math.max(minimum, Math.min(availableWidth * 0.65, availableWidth - 420)); return { minimum, maximum, defaultWidth: Math.max(minimum, Math.min(maximum, availableWidth * 0.4)) }; }; const applyDrawerWidth = (width) => { if (!inspectorDrawer) { return; } const { minimum, maximum, defaultWidth } = drawerWidthBounds(); const nextWidth = Math.max(minimum, Math.min(maximum, Number(width) || defaultWidth)); root.style.setProperty("--timeline-inspector-width", `${Math.round(nextWidth)}px`); }; if (inspectorDrawer) { applyDrawerWidth(localStorage.getItem(drawerWidthKey)); window.addEventListener("resize", () => applyDrawerWidth(localStorage.getItem(drawerWidthKey))); } setupTimelineNavigationGuidance(); inspectorResizer?.addEventListener("pointerdown", (event) => { event.preventDefault(); document.body.classList.add("timeline-inspector-resizing"); inspectorResizer.setPointerCapture(event.pointerId); const resize = (moveEvent) => { const rootRight = root.getBoundingClientRect().right; const nextWidth = rootRight - moveEvent.clientX; applyDrawerWidth(nextWidth); }; const finish = () => { document.body.classList.remove("timeline-inspector-resizing"); const width = Math.round(inspectorDrawer?.getBoundingClientRect().width || 0); if (width > 0) { localStorage.setItem(drawerWidthKey, String(width)); } window.dispatchEvent(new Event("plotline:timeline-layout-changed")); inspectorResizer.removeEventListener("pointermove", resize); inspectorResizer.removeEventListener("pointerup", finish); inspectorResizer.removeEventListener("pointercancel", finish); }; inspectorResizer.addEventListener("pointermove", resize); inspectorResizer.addEventListener("pointerup", finish); inspectorResizer.addEventListener("pointercancel", finish); }); inspectorClose?.addEventListener("click", (event) => { event.preventDefault(); const url = new URL(window.location.href); const focusType = url.searchParams.get("FocusType") || url.searchParams.get("focusType"); url.searchParams.delete("SelectedSceneID"); url.searchParams.delete("selectedSceneId"); url.searchParams.delete("selectedsceneid"); if (!focusType || focusType.toLowerCase() === "scene") { url.searchParams.delete("FocusType"); url.searchParams.delete("focusType"); url.searchParams.delete("FocusID"); url.searchParams.delete("focusId"); } saveScrollPosition(); window.location.href = url.toString(); }); const saveScrollPosition = () => { try { sessionStorage.setItem(scrollKey, JSON.stringify({ path: window.location.pathname, top: window.scrollY, left: window.scrollX, workspaceLeft: workspace.scrollLeft, workspaceTop: workspace.scrollTop, containers: getScrollContainers().map((container, index) => ({ index, left: container.scrollLeft, top: container.scrollTop })) })); } catch { // Storage can be unavailable in private or restricted browser modes. } }; const restoreScrollPosition = () => { let scrollPosition = null; try { scrollPosition = JSON.parse(sessionStorage.getItem(scrollKey) || "null"); sessionStorage.removeItem(scrollKey); } catch { try { sessionStorage.removeItem(scrollKey); } catch { // Ignore storage failures; normal navigation should continue. } return; } if (!scrollPosition || scrollPosition.path !== window.location.pathname) { return; } window.requestAnimationFrame(() => { workspace.scrollLeft = Number(scrollPosition.workspaceLeft) || 0; workspace.scrollTop = Number(scrollPosition.workspaceTop) || 0; const containers = getScrollContainers(); (scrollPosition.containers || []).forEach((saved) => { const container = containers[saved.index]; if (container) { container.scrollLeft = Number(saved.left) || 0; container.scrollTop = Number(saved.top) || 0; } }); window.scrollTo(Number(scrollPosition.left) || 0, Number(scrollPosition.top) || 0); }); }; window.PlotLineTimelineScroll = { save: saveScrollPosition }; const applyMode = (kind, value, values) => { values.forEach((item) => root.classList.remove(`${kind}-${item}`)); root.classList.add(`${kind}-${value}`); }; const applyDensity = (value) => { const nextValue = densityValues.includes(value) ? value : "comfortable"; applyMode("density", nextValue, densityValues); if (densitySelect) { densitySelect.value = nextValue; } localStorage.setItem(densityKey, nextValue); window.requestAnimationFrame(renderPlotLineSvg); }; const applyZoom = (value) => { const nextValue = zoomValues.includes(value) ? value : "chapter"; applyMode("zoom", nextValue, zoomValues); if (zoomSelect) { zoomSelect.value = nextValue; } localStorage.setItem(zoomKey, nextValue); window.requestAnimationFrame(renderPlotLineSvg); }; const getScene = (sceneId) => root.querySelector(`[data-scene-id='${CSS.escape(String(sceneId))}']`); const scrollToScene = (sceneId, focus = true) => { const target = getScene(sceneId); if (!target) { return; } target.classList.add("keyboard-focus"); target.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" }); if (focus) { target.focus({ preventScroll: true }); } window.setTimeout(() => target.classList.remove("keyboard-focus"), 1800); }; const scrollActiveChapterIntoView = (behavior = "auto") => { const activeChapter = minimap?.querySelector("[data-active-chapter='true']"); if (!minimap || !activeChapter) { return; } const hostRect = minimap.getBoundingClientRect(); const activeRect = activeChapter.getBoundingClientRect(); const activeCenter = activeRect.left + activeRect.width / 2; const hostCenter = hostRect.left + hostRect.width / 2; if (activeRect.left < hostRect.left || activeRect.right > hostRect.right) { minimap.scrollBy({ left: activeCenter - hostCenter, behavior }); } }; const saveCollapsed = () => localStorage.setItem(collapsedKey, JSON.stringify([...collapsed])); const renderPlotLineSvg = () => { root.querySelectorAll("[data-plot-line-svg-board]").forEach((board) => { const svg = board.querySelector("[data-plot-line-svg]"); if (!svg) { return; } let healthSvg = board.querySelector("[data-plot-line-health-svg]"); if (!healthSvg) { healthSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); healthSvg.setAttribute("class", "plot-lane-svg-overlay plot-lane-svg-health-overlay"); healthSvg.setAttribute("data-plot-line-health-svg", ""); healthSvg.setAttribute("aria-hidden", "true"); svg.after(healthSvg); } svg.replaceChildren(); healthSvg.replaceChildren(); const boardRect = board.getBoundingClientRect(); const width = Math.max(board.scrollWidth, boardRect.width); const height = Math.max(board.scrollHeight, boardRect.height); [svg, healthSvg].forEach((layer) => { layer.setAttribute("viewBox", `0 0 ${width} ${height}`); layer.setAttribute("width", String(width)); layer.setAttribute("height", String(height)); }); const makeSvg = (name, attributes = {}) => { const element = document.createElementNS("http://www.w3.org/2000/svg", name); Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); return element; }; const rowCenterY = (row) => { const rowRect = row.getBoundingClientRect(); return rowRect.top - boardRect.top + rowRect.height / 2; }; const pointFor = (row, marker) => { const slot = marker.closest("[data-plot-line-slot]"); if (!slot) { return null; } const slotRect = slot.getBoundingClientRect(); const markerRect = marker.getBoundingClientRect(); const markerCenterX = markerRect.left - boardRect.left + markerRect.width / 2; const markerCenterY = markerRect.top - boardRect.top + markerRect.height / 2; return { x: markerCenterX, y: markerCenterY, sceneX: slotRect.left - boardRect.left + slotRect.width / 2, radius: Math.max(markerRect.width, markerRect.height) / 2, sceneIndex: 0, type: (marker.dataset.plotEventType || "Progress").toLowerCase(), targetPlotLineId: marker.dataset.targetPlotLineId || "", targetPlotLineIds: (marker.dataset.targetPlotLineIds || "") .split(",") .map((value) => value.trim()) .filter(Boolean) }; }; const sceneXs = [...new Set([...board.querySelectorAll("[data-plot-line-slot]")] .map((slot) => { const slotRect = slot.getBoundingClientRect(); return Math.round(slotRect.left - boardRect.left + slotRect.width / 2); }))] .sort((a, b) => a - b); const latestSceneIndex = Math.max(sceneXs.length - 1, 0); const rowLookup = new Map(); board.querySelectorAll("[data-plot-line-row]").forEach((row) => { if (row.dataset.plotLineId) { rowLookup.set(row.dataset.plotLineId, row); } }); const rowData = [...rowLookup.values()].map((row) => ({ row, plotLineId: row.dataset.plotLineId, colour: row.dataset.plotColour || "#2f6f63", points: [...row.querySelectorAll("[data-plot-event-node]")] .map((marker) => pointFor(row, marker)) .filter(Boolean) .map((point) => ({ ...point, sceneIndex: Math.max(0, sceneXs.findIndex((x) => x === Math.round(point.sceneX))) })) .sort((a, b) => a.x - b.x), incomingStartXs: [] })); const rowDataById = new Map(rowData.map((item) => [item.plotLineId, item])); const healthFor = (points) => { const hasResolve = points.some((point) => point.type === "resolve"); const hasAbandon = points.some((point) => point.type === "abandon"); const resolvePoint = points.find((point) => point.type === "resolve"); const abandonPoint = points.find((point) => point.type === "abandon"); const hasMerge = points.some((point) => point.type === "merge" && point.targetPlotLineId); const hasSplit = points.some((point) => point.type === "split" && point.targetPlotLineIds.length >= 2); const hasBranchFromResolution = Boolean(resolvePoint) && points.some((point) => point.type === "branch" && point.targetPlotLineId && point.sceneIndex >= resolvePoint.sceneIndex); const lastSceneIndex = points.reduce((max, point) => Math.max(max, point.sceneIndex), 0); const isNeglected = !hasResolve && !hasAbandon && latestSceneIndex - lastSceneIndex >= 10; const isDangling = !hasResolve && !hasAbandon && !hasMerge && !hasSplit && latestSceneIndex >= lastSceneIndex; const isQuestionable = hasResolve && !hasMerge && !hasSplit && !hasBranchFromResolution; let state = "active"; if (hasAbandon) { state = "abandoned"; } else if (hasResolve) { state = isQuestionable ? "questionable" : "resolved"; } else if (isNeglected) { state = "neglected"; } return { state, isNeglected, isDangling, isQuestionable, isAbandoned: hasAbandon, resolvePoint, abandonPoint }; }; const healthWarningTooltip = (warnings) => warnings.map((warning) => [ `Warning: ${warning.label}`, `Reason: ${warning.reason}`, `Suggested Action: ${warning.action}` ].join("\n")).join("\n\n"); const drawHealthMarker = (point, warnings) => { const offsetX = Math.max(18, (point.radius || 12) + 8); const primaryKind = warnings[0]?.kind || "warning"; const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${primaryKind}` }); const title = makeSvg("title"); title.textContent = healthWarningTooltip(warnings); group.append(title); const markerX = point.x + offsetX; const markerY = point.y - 13; if (primaryKind === "questionable") { group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 9 })); const text = makeSvg("text", { x: markerX, y: markerY + 4, "text-anchor": "middle" }); text.textContent = warnings.length > 1 ? String(warnings.length) : "?"; group.append(text); } else { group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 10 })); group.append(makeSvg("path", { d: `M ${markerX} ${markerY - 6} L ${markerX + 6} ${markerY + 5} L ${markerX - 6} ${markerY + 5} Z` })); group.append(makeSvg("line", { x1: markerX, y1: markerY - 2, x2: markerX, y2: markerY + 2.5 })); group.append(makeSvg("circle", { cx: markerX, cy: markerY + 5.3, r: 1.2 })); if (warnings.length > 1) { const text = makeSvg("text", { x: markerX + 11, y: markerY - 6, "text-anchor": "middle", class: "plotline-health-count" }); text.textContent = String(warnings.length); group.append(text); } } healthSvg.append(group); }; const drawConnector = (source, targetRow, colour, type) => { if (!targetRow) { if (window.console?.warn) { window.console.warn(`Plot line ${type} connector skipped because target plot line data is missing.`); } return; } const target = { x: source.x, y: rowCenterY(targetRow) }; const flowX = Math.max(28, Math.min(46, Math.abs(target.y - source.y) * 0.28)); const sourceRadius = Math.max(8, source.radius || 12); const startX = source.x + sourceRadius; const targetX = startX + flowX; const midX = startX + flowX / 2; svg.append(makeSvg("path", { class: `plot-line-svg-connector plot-line-svg-connector-${type}`, d: `M ${startX} ${source.y} C ${midX} ${source.y}, ${midX} ${target.y}, ${targetX} ${target.y}`, stroke: colour })); }; rowData.forEach((item) => { item.points.forEach((point) => { if (point.type === "branch" && point.targetPlotLineId) { rowDataById.get(point.targetPlotLineId)?.incomingStartXs.push(point.x); } if (point.type === "split") { point.targetPlotLineIds.forEach((targetPlotLineId) => { rowDataById.get(targetPlotLineId)?.incomingStartXs.push(point.x); }); } }); }); rowData.forEach((item) => { item.health = healthFor(item.points); item.state = item.health.state; }); rowData.forEach((item) => { const { colour, points, incomingStartXs, state } = item; if (points.length > 0) { const explicitStart = points.find((point) => point.type === "start"); const incomingStartX = incomingStartXs.length ? Math.min(...incomingStartXs) : null; const terminatingPoint = points.find((point) => point.type === "split" || point.type === "merge"); const first = points[0]; const last = points[points.length - 1]; const startX = incomingStartX !== null && !(explicitStart && explicitStart.x < incomingStartX) ? incomingStartX : first.x; const endX = terminatingPoint ? terminatingPoint.x : last.x; item.lineEndPoint = { x: endX, y: first.y }; const sortedPoints = [...points].sort((a, b) => a.x - b.x); const terminatingIndex = terminatingPoint ? sortedPoints.indexOf(terminatingPoint) : -1; const linePoints = terminatingIndex >= 0 ? sortedPoints.slice(0, terminatingIndex + 1) : sortedPoints; if (incomingStartX !== null && incomingStartX < linePoints[0].x) { linePoints.unshift({ ...linePoints[0], x: incomingStartX, sceneIndex: Math.max(0, linePoints[0].sceneIndex - 1) }); } // Only the segment between two events is dormant when their scene gap crosses the threshold. // Thread-level resolved/abandoned/neglected state must not make the entire historical line dotted. const dormantGapThreshold = 10; linePoints.forEach((point, index) => { const nextPoint = linePoints[index + 1]; if (!nextPoint || nextPoint.x <= point.x) { return; } const isDormantGap = Math.abs(nextPoint.sceneIndex - point.sceneIndex) >= dormantGapThreshold; svg.append(makeSvg("path", { class: `plot-line-svg-path plot-line-svg-segment ${isDormantGap ? "plot-line-svg-segment-dormant" : ""} plotline-state-${state}`, d: `M ${point.x} ${point.y} L ${nextPoint.x} ${nextPoint.y}`, stroke: colour })); }); } }); rowData.forEach(({ points, colour }) => { points.forEach((point) => { if (point.type === "branch" || point.type === "merge") { drawConnector(point, rowLookup.get(point.targetPlotLineId), colour, point.type); } if (point.type === "split" && point.targetPlotLineIds.length >= 2) { point.targetPlotLineIds.forEach((targetPlotLineId) => { drawConnector(point, rowLookup.get(targetPlotLineId), colour, point.type); }); } else if (point.type === "split" && point.targetPlotLineIds.length > 0 && window.console?.warn) { window.console.warn("Plot line split connector skipped because fewer than two targets are available."); } }); }); rowData.forEach((item) => { const markerPoint = item.lineEndPoint || item.points[item.points.length - 1]; const healthWarnings = []; if (item.health?.isNeglected && markerPoint) { healthWarnings.push({ point: markerPoint, kind: "neglected", label: "Dormant Thread", reason: "No activity for 10+ scenes.", action: "Add a Progress, Reveal or Resolve event." }); } if (item.health?.isDangling && markerPoint) { healthWarnings.push({ point: markerPoint, kind: "dangling", label: "Unresolved Thread", reason: "Thread contains no Resolve or Abandon event by the end of the current timeline.", action: "Review ending scenes and add Resolve or Abandon where appropriate." }); } if (item.health?.isQuestionable && item.health.resolvePoint) { healthWarnings.push({ point: item.health.resolvePoint, kind: "questionable", label: "Missing Closure", reason: "Resolved without merge, split, or branch.", action: "Verify this thread has sufficient payoff." }); } if (item.health?.isAbandoned && item.health.abandonPoint) { healthWarnings.push({ point: item.health.abandonPoint, kind: "abandoned", label: "Abandoned Thread", reason: "This plot line was abandoned.", action: "Check this is intentional." }); } const groupedWarnings = new Map(); healthWarnings.forEach((warning) => { const key = `${Math.round(warning.point.x)}:${Math.round(warning.point.y)}`; if (!groupedWarnings.has(key)) { groupedWarnings.set(key, { point: warning.point, warnings: [] }); } groupedWarnings.get(key).warnings.push(warning); }); groupedWarnings.forEach(({ point, warnings }) => drawHealthMarker(point, warnings)); }); }); }; const setCollapsed = (id, shouldCollapse) => { document.querySelectorAll(`[data-collapse-body='${CSS.escape(id)}']`).forEach((body) => { body.classList.toggle("timeline-section-collapsed", shouldCollapse); }); document.querySelectorAll(`[data-collapse-toggle='${CSS.escape(id)}']`).forEach((button) => { button.classList.toggle("is-collapsed", shouldCollapse); button.setAttribute("aria-expanded", String(!shouldCollapse)); }); }; document.querySelectorAll("[data-collapse-toggle]").forEach((button) => { const id = button.dataset.collapseToggle; if (!hasCollapsedPreference && button.dataset.collapseDefault === "collapsed") { collapsed.add(id); } const isCollapsed = collapsed.has(id); setCollapsed(id, isCollapsed); button.addEventListener("click", () => { const nextCollapsed = !collapsed.has(id); if (nextCollapsed) { collapsed.add(id); } else { collapsed.delete(id); } setCollapsed(id, nextCollapsed); saveCollapsed(); renderPlotLineSvg(); window.dispatchEvent(new Event("plotline:timeline-layout-changed")); }); }); densitySelect?.addEventListener("change", () => applyDensity(densitySelect.value)); zoomSelect?.addEventListener("change", () => applyZoom(zoomSelect.value)); applyDensity(localStorage.getItem(densityKey) || "comfortable"); applyZoom(localStorage.getItem(zoomKey) || "chapter"); const hadPendingScrollRestore = (() => { try { return sessionStorage.getItem(scrollKey) !== null; } catch { return false; } })(); window.requestAnimationFrame(() => { renderPlotLineSvg(); scrollActiveChapterIntoView("auto"); if (hadPendingScrollRestore) { restoreScrollPosition(); } }); window.addEventListener("resize", renderPlotLineSvg); window.addEventListener("plotline:timeline-layout-changed", renderPlotLineSvg); const saveScrollForSceneSelection = (event) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } const target = event.target instanceof Element ? event.target : null; const link = target?.closest("a[href]"); if (!link || !root.contains(link)) { return; } const url = new URL(link.href, window.location.href); if ([...url.searchParams.keys()].some((key) => key.toLowerCase() === "selectedsceneid")) { saveScrollPosition(); } }; root.addEventListener("click", saveScrollForSceneSelection, { capture: true }); const runSearch = () => { const query = searchInput?.value.trim().toLowerCase() || ""; let firstMatch = null; sceneCards.forEach((card) => { const matches = !query || (card.dataset.timelineSearchText || "").toLowerCase().includes(query); card.classList.toggle("search-hit", Boolean(query && matches)); card.classList.toggle("search-dim", Boolean(query && !matches)); if (!firstMatch && query && matches) { firstMatch = card; } }); return firstMatch; }; searchInput?.addEventListener("input", runSearch); searchInput?.addEventListener("keydown", (event) => { if (event.key !== "Enter") { return; } const firstMatch = runSearch(); if (firstMatch) { event.preventDefault(); scrollToScene(firstMatch.dataset.sceneId); } }); jumpSelects.forEach((select) => { select.addEventListener("change", () => { if (select.value) { scrollToScene(select.value); } }); }); document.querySelectorAll("[data-jump-scene]").forEach((button) => { button.addEventListener("click", () => { if (button.dataset.jumpScene) { scrollToScene(button.dataset.jumpScene); } }); }); document.addEventListener("keydown", (event) => { const activeTag = document.activeElement?.tagName?.toLowerCase(); const isTyping = ["input", "select", "textarea"].includes(activeTag) || document.activeElement?.isContentEditable; if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && searchInput) { event.preventDefault(); searchInput.focus(); searchInput.select(); return; } if (event.key === "Escape") { const url = new URL(window.location.href); const hadFocus = url.searchParams.has("FocusType") || url.searchParams.has("FocusID"); url.searchParams.delete("FocusType"); url.searchParams.delete("FocusID"); if (hadFocus) { window.location.href = url.toString(); } else if (searchInput?.value) { searchInput.value = ""; runSearch(); } return; } if (isTyping) { return; } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); workspace.scrollBy({ left: event.key === "ArrowLeft" ? -260 : 260, behavior: "smooth" }); return; } if (event.key === "+" || event.key === "=" || event.key === "-") { const currentIndex = zoomValues.indexOf(zoomSelect?.value || "chapter"); const direction = event.key === "-" ? -1 : 1; const nextIndex = Math.min(zoomValues.length - 1, Math.max(0, currentIndex + direction)); applyZoom(zoomValues[nextIndex]); } }); })(); (() => { const root = document.querySelector("[data-timeline-root]"); const panel = document.querySelector("[data-drag-drop-panel]"); if (!panel) { return; } const forms = new Map([...document.querySelectorAll("[data-drag-form]")].map((form) => [form.dataset.dragForm, form])); const title = panel.querySelector("[data-drag-panel-title]"); const kicker = panel.querySelector("[data-drag-panel-kicker]"); const summary = panel.querySelector("[data-drag-panel-summary]"); const sceneMoveModalElement = document.querySelector("[data-scene-move-modal]"); const sceneMoveModal = sceneMoveModalElement && window.bootstrap?.Modal ? window.bootstrap.Modal.getOrCreateInstance(sceneMoveModalElement) : null; const sceneMoveTitle = sceneMoveModalElement?.querySelector("[data-scene-move-title]"); const sceneMoveSummary = sceneMoveModalElement?.querySelector("[data-scene-move-summary]"); const sceneMoveSubmit = sceneMoveModalElement?.querySelector("[data-scene-move-submit]"); const scrollKey = "plotline.timeline.scroll"; let currentDrag = null; let suppressSceneClickUntil = 0; const setValue = (form, name, value) => { const field = form?.querySelector(`[name='${CSS.escape(name)}']`); if (field) { field.value = value ?? ""; } }; const selectText = (select) => select?.selectedOptions?.[0]?.textContent?.trim() || ""; const quoteScene = (label) => `\u201c${label || "this scene"}\u201d`; const buildSceneMoveSummary = (payload, sourceChapter, targetChapter, position, anchorLabel) => { if (sourceChapter && targetChapter && sourceChapter === targetChapter) { return `Move ${quoteScene(payload.label)} to this new position within ${targetChapter}?`; } if (sourceChapter && targetChapter) { return `Move ${quoteScene(payload.label)} from ${sourceChapter} to ${targetChapter}?`; } return `Move ${quoteScene(payload.label)} ${position.toLowerCase()} ${anchorLabel}?`; }; const showForm = (key, heading, body, actionLabel) => { if (key === "scene") { panel.classList.add("d-none"); panel.querySelectorAll("[data-drag-form]").forEach((form) => form.classList.add("d-none")); forms.get("scene")?.classList.remove("d-none"); if (sceneMoveTitle) { sceneMoveTitle.textContent = heading; } if (sceneMoveSummary) { sceneMoveSummary.textContent = body; } if (sceneMoveSubmit) { sceneMoveSubmit.classList.toggle("d-none", heading.toLowerCase().startsWith("cannot") || heading.toLowerCase().startsWith("scene already")); } if (sceneMoveModal) { sceneMoveModal.show(); } else { panel.classList.remove("d-none"); } return; } forms.forEach((form, formKey) => form.classList.toggle("d-none", formKey !== key)); panel.classList.remove("d-none"); title.textContent = heading; kicker.textContent = actionLabel; summary.textContent = body; panel.scrollIntoView({ behavior: "smooth", block: "center" }); }; const hidePanel = () => { panel.classList.add("d-none"); forms.forEach((form) => form.classList.add("d-none")); }; const clearDropHighlights = () => { document .querySelectorAll(".drop-target-active, .drop-before, .drop-after, .chapter-drop-active, .drop-chapter-end") .forEach((target) => target.classList.remove("drop-target-active", "drop-before", "drop-after", "chapter-drop-active", "drop-chapter-end")); }; const saveTimelineScroll = () => { const workspace = root?.querySelector(".timeline-shell"); try { sessionStorage.setItem(scrollKey, JSON.stringify({ left: workspace?.scrollLeft || 0, top: window.scrollY || 0 })); } catch { // Scroll restoration is a convenience; blocked storage should not stop the move. } }; try { const storedScroll = JSON.parse(sessionStorage.getItem(scrollKey) || "null"); if (storedScroll) { sessionStorage.removeItem(scrollKey); requestAnimationFrame(() => { const workspace = root?.querySelector(".timeline-shell"); if (workspace) { workspace.scrollLeft = storedScroll.left || 0; } window.scrollTo({ top: storedScroll.top || 0 }); }); } } catch { sessionStorage.removeItem(scrollKey); } const readPayload = (event) => { try { const payload = JSON.parse(event.dataTransfer.getData("application/json") || "{}"); return payload.type ? payload : currentDrag || {}; } catch { return currentDrag || {}; } }; const writePayload = (event, element) => { const payload = { type: element.dataset.dragType, id: element.dataset.dragId, label: element.dataset.dragLabel || element.textContent.trim() }; event.dataTransfer.effectAllowed = "copyMove"; event.dataTransfer.setData("application/json", JSON.stringify(payload)); event.dataTransfer.setData("text/plain", `${payload.type}:${payload.id}`); currentDrag = payload; document.body.classList.add("drag-active", `dragging-${payload.type}`); element.classList.add("is-dragging"); }; document.querySelectorAll("[data-drag-type][draggable='true']").forEach((item) => { item.addEventListener("dragstart", (event) => writePayload(event, item)); item.addEventListener("dragend", () => { item.classList.remove("is-dragging"); document.body.classList.remove("drag-active", "dragging-scene", "dragging-character", "dragging-asset", "dragging-location"); currentDrag = null; clearDropHighlights(); }); }); panel.querySelectorAll("[data-drag-cancel]").forEach((button) => button.addEventListener("click", hidePanel)); forms.get("scene")?.addEventListener("submit", saveTimelineScroll); root?.querySelectorAll(".timeline-scene-card[data-scene-id]").forEach((card) => { card.addEventListener("click", (event) => { if (Date.now() < suppressSceneClickUntil) { event.preventDefault(); event.stopPropagation(); } }); card.addEventListener("dragover", (event) => { const payload = readPayload(event); if (["scene", "character", "asset"].includes(payload.type)) { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = payload.type === "scene" ? "move" : "copy"; card.classList.add("drop-target-active"); card.classList.toggle("drop-before", payload.type === "scene" && event.offsetX < card.clientWidth / 2); card.classList.toggle("drop-after", payload.type === "scene" && event.offsetX >= card.clientWidth / 2); } }); card.addEventListener("dragleave", () => card.classList.remove("drop-target-active", "drop-before", "drop-after")); card.addEventListener("drop", (event) => { const payload = readPayload(event); if (!["scene", "character", "asset"].includes(payload.type)) { return; } event.preventDefault(); event.stopPropagation(); suppressSceneClickUntil = Date.now() + 800; card.classList.remove("drop-target-active", "drop-before", "drop-after"); const sceneId = card.dataset.sceneId; if (payload.type === "scene") { if (payload.id === sceneId) { showForm("scene", "Cannot move scene", "Drop a scene before or after a different scene.", "Scene move"); return; } const form = forms.get("scene"); const position = event.offsetX < card.clientWidth / 2 ? "Before" : "After"; const sourceCard = root.querySelector(`.timeline-scene-card[data-scene-id='${CSS.escape(payload.id)}']`); const sourceChapter = sourceCard?.dataset.chapterLabel || ""; const targetChapter = card.dataset.chapterLabel || ""; setValue(form, "SceneID", payload.id); setValue(form, "AnchorSceneID", sceneId); setValue(form, "TargetChapterID", ""); setValue(form, "Position", position); showForm("scene", "Move Scene?", buildSceneMoveSummary(payload, sourceChapter, targetChapter, position, card.dataset.dragLabel), "Scene move"); return; } if (payload.type === "character") { const form = forms.get("character"); setValue(form, "SceneID", sceneId); setValue(form, "CharacterID", payload.id); showForm("character", "Add character to scene", `Add ${payload.label} to ${card.dataset.dragLabel}.`, "Character drop"); return; } const form = forms.get("asset-event"); setValue(form, "SceneID", sceneId); setValue(form, "StoryAssetID", payload.id); setValue(form, "EventTitle", ""); showForm("asset-event", "What happens to this asset?", `Add an event for ${payload.label} in ${card.dataset.dragLabel}.`, "Asset drop"); }); }); root?.querySelectorAll(".timeline-scenes[data-chapter-drop-target]").forEach((chapterBody) => { const chapter = chapterBody.closest("[data-chapter-drop-target]"); const chapterId = chapterBody.dataset.chapterId || chapter?.dataset.chapterId; const chapterLabel = chapterBody.dataset.chapterLabel || chapter?.dataset.chapterLabel || "this chapter"; chapterBody.addEventListener("dragover", (event) => { const payload = readPayload(event); if (payload.type !== "scene" || !chapterId) { return; } if (event.target.closest(".timeline-scene-card[data-scene-id]")) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "move"; chapterBody.classList.add("drop-target-active", "drop-chapter-end"); chapter?.classList.add("chapter-drop-active"); }); chapterBody.addEventListener("dragleave", (event) => { if (chapterBody.contains(event.relatedTarget)) { return; } chapterBody.classList.remove("drop-target-active", "drop-chapter-end"); chapter?.classList.remove("chapter-drop-active"); }); chapterBody.addEventListener("drop", (event) => { const payload = readPayload(event); if (payload.type !== "scene" || !chapterId) { return; } if (event.target.closest(".timeline-scene-card[data-scene-id]")) { return; } event.preventDefault(); event.stopPropagation(); suppressSceneClickUntil = Date.now() + 800; clearDropHighlights(); const sourceCard = root.querySelector(`.timeline-scene-card[data-scene-id='${CSS.escape(payload.id)}']`); if (sourceCard?.dataset.chapterId === chapterId && chapterBody.lastElementChild?.dataset?.sceneId === payload.id) { showForm("scene", "Scene already here", `${payload.label} is already at the end of ${chapterLabel}.`, "Scene move"); return; } const form = forms.get("scene"); setValue(form, "SceneID", payload.id); setValue(form, "AnchorSceneID", ""); setValue(form, "TargetChapterID", chapterId); setValue(form, "Position", "End"); showForm("scene", "Move Scene?", buildSceneMoveSummary(payload, sourceCard?.dataset.chapterLabel || "", chapterLabel, "After", chapterLabel), "Scene move"); }); }); const assetDependencyForm = forms.get("asset-dependency"); const updateAssetPreview = () => { const source = assetDependencyForm?.dataset.sourceLabel || "Source asset"; const target = assetDependencyForm?.dataset.targetLabel || "Target asset"; const dependency = selectText(assetDependencyForm?.querySelector("[name='AssetDependencyTypeID']")) || "Requires"; const sourceState = selectText(assetDependencyForm?.querySelector("[name='SourceRequiredStateID']")) || "the required state"; const targetState = selectText(assetDependencyForm?.querySelector("[name='TargetBlockedStateID']")) || "the blocked state"; const preview = panel.querySelector("[data-asset-dependency-preview]"); if (preview) { preview.textContent = `${source} ${dependency.toLowerCase()} ${target}. ${source} should be ${sourceState} before ${target} can be ${targetState}.`; } }; assetDependencyForm?.querySelectorAll("select").forEach((select) => select.addEventListener("change", updateAssetPreview)); document.querySelectorAll("[data-asset-drop-target], .drag-chip.asset").forEach((target) => { target.addEventListener("dragover", (event) => { const payload = readPayload(event); if (payload.type === "asset") { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; target.classList.add("drop-target-active"); } }); target.addEventListener("dragleave", () => target.classList.remove("drop-target-active")); target.addEventListener("drop", (event) => { const payload = readPayload(event); const targetId = target.dataset.assetId || target.dataset.dragId; const targetLabel = target.dataset.assetLabel || target.dataset.dragLabel || target.textContent.trim(); if (payload.type !== "asset" || !targetId) { return; } event.preventDefault(); target.classList.remove("drop-target-active"); if (payload.id === targetId) { showForm("asset-dependency", "Cannot create dependency", "Drop one asset onto a different asset.", "Asset dependency"); return; } setValue(assetDependencyForm, "SourceAssetID", payload.id); setValue(assetDependencyForm, "TargetAssetID", targetId); assetDependencyForm.dataset.sourceLabel = payload.label; assetDependencyForm.dataset.targetLabel = targetLabel; updateAssetPreview(); showForm("asset-dependency", "Create asset dependency", `${payload.label} -> ${targetLabel}`, "Asset dependency"); }); }); document.querySelectorAll("[data-drag-type='location']").forEach((target) => { target.addEventListener("dragover", (event) => { const payload = readPayload(event); if (payload.type === "location") { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; target.classList.add("drop-target-active"); } }); target.addEventListener("dragleave", () => target.classList.remove("drop-target-active")); target.addEventListener("drop", (event) => { const payload = readPayload(event); const targetId = target.dataset.dragId; const targetLabel = target.dataset.dragLabel || target.textContent.trim(); if (payload.type !== "location") { return; } event.preventDefault(); target.classList.remove("drop-target-active"); if (payload.id === targetId) { showForm("location-relationship", "Cannot create relationship", "Drop one location onto a different location.", "Location relationship"); return; } const form = forms.get("location-relationship"); setValue(form, "FromLocationID", payload.id); setValue(form, "ToLocationID", targetId); showForm("location-relationship", "Create location relationship", `${payload.label} -> ${targetLabel}`, "Location drop"); }); }); })();