(() => { 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.querySelector("[name='SceneID']")?.value || "new"; const storagePrefix = `plotline.inspector.${sceneId}.`; const defaultOpen = new Set(["overview", "timing", "characters"]); const sections = [...root.querySelectorAll(".inspector-section")]; 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"); const count = section.querySelectorAll(".asset-event-item, .thread-event-item, .warning-mini-card, .dependency-card, .metric-slider").length; button.innerHTML = `${title}${count || ""}`; section.append(button, body); 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 defaultMetrics = new Set(["Overall Intensity", "Tension", "Emotional Weight", "Action"]); 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"; let previousChapter = null; scenes.forEach((scene, index) => { if (scene.chapterLabel === previousChapter) { return; } const x = scales.x.getPixelForValue(index); ctx.beginPath(); ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom); ctx.stroke(); ctx.fillText(scene.chapterLabel, Math.min(x + 4, chartArea.right - 70), chartArea.bottom + 22); previousChapter = scene.chapterLabel; }); 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]"); 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 labels = payload.scenes.map((scene) => scene.axisLabel); const sceneIndexById = new Map(payload.scenes.map((scene, index) => [scene.sceneId, index])); 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, data: payload.scenes.map((scene) => pointMap.get(scene.sceneId) ?? null), 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) }; }); 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); chart.update(); }); }); const chart = new Chart(canvas, { type: "line", data: { labels, 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: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 16 }, grid: { display: false } } }, plugins: { legend: { display: false }, plotlineScenes: payload.scenes, tooltip: { callbacks: { title(items) { const scene = payload.scenes[items[0].dataIndex]; return `${scene.chapterLabel}, Scene ${scene.sceneNumber}: ${scene.sceneTitle}`; }, label(item) { return `${item.dataset.label}: ${item.formattedValue}`; }, afterBody(items) { const scene = payload.scenes[items[0].dataIndex]; return scene.bookTitle ? scene.bookTitle : ""; } } } } } }); canvas.addEventListener("click", (event) => { const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true); if (!points.length) { return; } const scene = payload.scenes[points[0].index]; if (scene?.timelineUrl) { window.location.href = scene.timelineUrl; } }); }); })(); (() => { 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 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 densityValues = ["compact", "comfortable", "expanded"]; const zoomValues = ["book", "chapter", "scene-detail"]; const readSet = (key) => { try { return new Set(JSON.parse(localStorage.getItem(key) || "[]")); } catch { return new Set(); } }; const collapsed = readSet(collapsedKey); 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); }; const applyZoom = (value) => { const nextValue = zoomValues.includes(value) ? value : "chapter"; applyMode("zoom", nextValue, zoomValues); if (zoomSelect) { zoomSelect.value = nextValue; } localStorage.setItem(zoomKey, nextValue); }; 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 saveCollapsed = () => localStorage.setItem(collapsedKey, JSON.stringify([...collapsed])); 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; 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(); }); }); densitySelect?.addEventListener("change", () => applyDensity(densitySelect.value)); zoomSelect?.addEventListener("change", () => applyZoom(zoomSelect.value)); applyDensity(localStorage.getItem(densityKey) || "comfortable"); applyZoom(localStorage.getItem(zoomKey) || "chapter"); 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]); } }); })();