Nick Beckley 4c8f97d048 Phase 1R
2026-06-01 14:32:42 +01:00

753 lines
27 KiB
JavaScript

(() => {
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 = `<span>${title}</span><span class="accordion-count">${count || ""}</span>`;
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 globalDefaultMetrics = 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 defaultMetrics = new Set(payload.defaultMetrics?.length ? payload.defaultMetrics : globalDefaultMetrics);
const labels = payload.scenes.map((scene) => scene.axisLabel);
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 = `<input type="checkbox" id="${id}" ${dataset.hidden ? "" : "checked"}><span>${dataset.label}</span>`;
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];
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;
}
}
}
}
}
});
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]);
}
});
})();
(() => {
const root = document.querySelector("[data-timeline-root]");
const panel = document.querySelector("[data-drag-drop-panel]");
if (!panel) {
return;
}
const forms = new Map([...panel.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]");
let currentDrag = null;
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 showForm = (key, heading, body, actionLabel) => {
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 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;
document.querySelectorAll(".drop-target-active, .drop-before, .drop-after").forEach((target) => {
target.classList.remove("drop-target-active", "drop-before", "drop-after");
});
});
});
panel.querySelectorAll("[data-drag-cancel]").forEach((button) => button.addEventListener("click", hidePanel));
root?.querySelectorAll(".timeline-scene-card[data-scene-id]").forEach((card) => {
card.addEventListener("dragover", (event) => {
const payload = readPayload(event);
if (["scene", "character", "asset"].includes(payload.type)) {
event.preventDefault();
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();
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";
setValue(form, "SceneID", payload.id);
setValue(form, "AnchorSceneID", sceneId);
setValue(form, "Position", position);
showForm("scene", "Move scene?", `Move ${payload.label} ${position.toLowerCase()} ${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");
});
});
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");
});
});
})();