updates
All checks were successful
Build Authoring Service / build (push) Successful in 8s

This commit is contained in:
2026-07-14 12:38:20 +01:00
parent 2f0009df56
commit 1a49838b58

View File

@@ -1556,13 +1556,27 @@ HIDDEN_APP_HTML = r"""<!doctype html>
background: rgba(251, 241, 220, 0.06);
}
.arc-card.active, .chapter-card.active { border-color: var(--blue); background: rgba(117, 169, 189, 0.16); }
.moment-card.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.12); }
.moment-card {
border: 1px solid rgba(232, 202, 139, 0.14);
color: #efd9b2;
font-size: 13px;
}
.moment-card strong, .arc-card strong, .chapter-card strong { overflow-wrap: anywhere; }
.arc-toolbar { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.arc-toolbar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.workspace-switcher { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin: 14px 0; }
.workspace-switcher button.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.22); }
body:not(.arc-workspace) #arcStudioPanel { display: none; }
body.arc-workspace .story-workspace-only,
body.arc-workspace .drawer > :not(#arcStudioPanel) { display: none !important; }
body.arc-workspace #arcStudioPanel { display: block; margin-top: 0; }
.arc-node, .chapter-node, .moment-node { cursor: pointer; }
.arc-node circle, .chapter-node circle, .moment-node circle { transition: stroke-width 160ms ease, opacity 160ms ease; }
.arc-node.selected circle, .chapter-node.selected circle, .moment-node.selected circle { stroke: #fff3c7; stroke-width: 4; }
.arc-branch { fill: none; stroke: rgba(117, 169, 189, 0.34); stroke-width: 2; }
.arc-moment-path { fill: none; stroke: rgba(232, 202, 139, 0.26); stroke-width: 1.4; }
.arc-map-label { fill: #f7ead3; font-size: 20px; font-weight: 800; paint-order: stroke; stroke: rgba(14, 11, 8, 0.9); stroke-width: 4px; }
.arc-map-meta { fill: rgba(248, 234, 209, 0.68); font-size: 13px; paint-order: stroke; stroke: rgba(14, 11, 8, 0.9); stroke-width: 3px; }
.story-cover {
border: 1px solid rgba(232, 202, 139, 0.18);
border-radius: 8px;
@@ -1820,40 +1834,44 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<div class="observatory">
<aside class="left">
<h1>Memory Observatory</h1>
<div class="subtle">A constellation map for the hidden soul of the website.</div>
<div id="status" class="status">aphy is dimming the room lights.</div>
<div class="modebar" id="modebar">
<div class="subtle">A constellation map for the hidden soul of the website.</div>
<div id="status" class="status">aphy is dimming the room lights.</div>
<div class="workspace-switcher" id="workspaceSwitcher">
<button type="button" data-workspace="stories" class="active">Stories & memories</button>
<button type="button" data-workspace="arcs">Life arcs</button>
</div>
<div class="modebar story-workspace-only" id="modebar">
<button data-mode="graph" class="active">Story constellations</button>
<button data-mode="hidden">Hidden routes</button>
<button data-mode="character">Character stories</button>
</div>
<div class="filters">
<div class="filters story-workspace-only">
<input id="search" type="search" placeholder="Search memories and stories" />
<select id="layerFilter"></select>
<select id="characterFilter"></select>
<select id="toneFilter"></select>
<select id="rarityFilter" class="hidden"></select>
</div>
<div class="actions">
<div class="actions story-workspace-only">
<button id="newBtn" type="button">New memory</button>
<button id="newStoryBtn" type="button">New story</button>
<button id="saveBtn" class="primary" type="button">Save</button>
</div>
<div class="actions" style="margin-top:8px">
<div class="actions story-workspace-only" style="margin-top:8px">
<button id="softResetBtn" type="button">Clear Focus</button>
<button id="fullResetBtn" type="button">Return to the Whole Sky</button>
</div>
<div class="guide-card">
<div class="guide-card story-workspace-only">
<h2>How to Read the Observatory</h2>
<p class="subtle">This is a story constellation library. Stories are emotional routes, and memories are lights arranged along those routes.</p>
<p class="subtle"><strong>aphy:</strong> zoomed out, I show stories. zoom in, I show the memories inside them. the sky stays quieter this way.</p>
<p class="subtle"><strong>sensei chi:</strong> depth is not importance. Depth is how quietly a thing asks to be approached.</p>
</div>
<div id="dragGuide" class="guide-card hidden">
<div id="dragGuide" class="guide-card story-workspace-only hidden">
<h2>Building Stories</h2>
<p class="subtle"><strong>aphy:</strong> drag a memory into a story or reorder the route in the builder. no more tangled relationship engineering.</p>
</div>
<div class="guide-card">
<div class="guide-card story-workspace-only">
<h2>Guided Exploration</h2>
<div class="tour-list">
<button data-tour="lima" type="button">show hidden lima memories</button>
@@ -1862,19 +1880,19 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<button data-tour="hidden">show hidden routes</button>
</div>
</div>
<div class="guide-card">
<div class="guide-card story-workspace-only">
<h2>Story Covers</h2>
<div id="storyAtlas" class="story-list"></div>
</div>
<div class="guide-card">
<div class="guide-card story-workspace-only">
<h2>Character Territories</h2>
<div id="characterAtlas" class="stack"></div>
</div>
<div class="guide-card">
<div class="guide-card story-workspace-only">
<h2>Integrity</h2>
<div id="integrityStatus" class="subtle">checking character territories.</div>
</div>
<div class="layer-key" id="layerKey"></div>
<div class="layer-key story-workspace-only" id="layerKey"></div>
</aside>
<main class="map">
@@ -1889,12 +1907,12 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<button id="backBtn" type="button">Back</button>
<button id="homeBtn" type="button">Whole map</button>
<button id="exploreUndoBtn" type="button">Undo exploration</button>
<button id="focusBtn" type="button">Focus story</button>
<button id="readStoryBtn" type="button">Read story</button>
<button id="focusBtn" class="story-workspace-only" type="button">Focus story</button>
<button id="readStoryBtn" class="story-workspace-only" type="button">Read story</button>
<button id="undoBtn" type="button">Undo</button>
<button id="redoBtn" type="button">Redo</button>
<button id="duplicateBtn" type="button">Duplicate</button>
<button id="deleteBtn" class="danger" type="button">Delete</button>
<button id="duplicateBtn" class="story-workspace-only" type="button">Duplicate</button>
<button id="deleteBtn" class="danger story-workspace-only" type="button">Delete</button>
</div>
</div>
<div id="breadcrumb" class="breadcrumb"></div>
@@ -1957,6 +1975,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<div class="arc-toolbar" style="margin-top:10px">
<button id="newArcBtn" type="button">New arc</button>
<button id="newChapterBtn" type="button">New chapter</button>
<button id="arcSaveBtn" class="primary" type="button">Save arcs</button>
<button id="deleteArcBtn" class="danger" type="button">Delete arc</button>
</div>
<div class="editor-grid" style="margin-top:10px">
@@ -1993,7 +2012,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<label class="full">Link memory<select id="momentEntryLink"></select></label>
</div>
<div class="actions" style="margin-top:10px">
<button id="saveChapterBtn" class="primary" type="button">Save chapter</button>
<button id="addMomentBtn" type="button">Add moment</button>
<button id="cancelMomentBtn" class="hidden" type="button">Cancel edit</button>
<button id="deleteChapterBtn" class="danger" type="button">Delete chapter</button>
</div>
<div id="momentList" class="moment-list" style="margin-top:10px"></div>
@@ -2083,7 +2104,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const draftKey = "hiddenNarrativeObservatoryDraft:v1";
const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 };
const SCREEN = { width: 1000, height: 760 };
const state = { entries: [], stories: [], lifeArcs: [], meta: {}, mode: "graph", editorMode: "memory", selectedId: "", selectedStoryId: "", selectedArcId: "", selectedChapterId: "", editingSectionId: "", readerIndex: 0, undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, pendingDrag: null, renderTimer: 0 };
const state = { entries: [], stories: [], lifeArcs: [], meta: {}, workspace: "stories", mode: "graph", editorMode: "memory", selectedId: "", selectedStoryId: "", selectedArcId: "", selectedChapterId: "", selectedMomentId: "", editingSectionId: "", readerIndex: 0, undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, pendingDrag: null, renderTimer: 0 };
const fields = ["title","type","contentClass","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","surfaces","observatoryRole","canonicalUse","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"];
const storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"];
const arcFields = ["arcTitle","arcDomain","arcVisibility","arcTone","arcThemes","arcSummary","arcOrgWeb"];
@@ -2251,7 +2272,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("status").textContent = message;
$("autosave").textContent = message;
}
function setEditorMode(mode, detail = "") {
function setEditorMode(mode, detail = "") {
state.editorMode = mode === "story" ? "story" : "memory";
$("editorModebar").querySelectorAll("[data-editor-mode]").forEach((button) => button.classList.toggle("active", button.dataset.editorMode === state.editorMode));
$("storyEditorPanel").classList.toggle("active-editor", state.editorMode === "story");
@@ -2262,8 +2283,23 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const entry = currentEntry();
$("editingBanner").innerHTML = state.editorMode === "story"
? `<strong>Editing Story:</strong> ${html(story?.title || "No story selected")}<br><span class="subtle">${html(detail || "Shape the route, pacing, sections, and emotional sequence.")}</span>`
: `<strong>Editing Memory:</strong> ${html(entry?.title || "No memory selected")}<br><span class="subtle">${html(detail || "Craft the reusable fragment and choose where it can recur.")}</span>`;
}
: `<strong>Editing Memory:</strong> ${html(entry?.title || "No memory selected")}<br><span class="subtle">${html(detail || "Craft the reusable fragment and choose where it can recur.")}</span>`;
}
function setWorkspace(workspace) {
state.workspace = workspace === "arcs" ? "arcs" : "stories";
document.body.classList.toggle("arc-workspace", state.workspace === "arcs");
$("workspaceSwitcher").querySelectorAll("[data-workspace]").forEach((button) => button.classList.toggle("active", button.dataset.workspace === state.workspace));
if (state.workspace === "arcs") {
if (!state.selectedArcId) state.selectedArcId = state.lifeArcs[0]?.id || "";
if (!state.selectedChapterId) state.selectedChapterId = arcById()?.chapters?.[0]?.id || "";
fillArcForm(arcById());
fitCameraTo({ minX: 520, minY: 420, maxX: WORLD.width - 520, maxY: WORLD.height - 420 }, true);
} else {
setEditorMode(state.editorMode);
fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, true);
}
render();
}
function cameraTransform() {
return `translate(${state.camera.x} ${state.camera.y}) scale(${state.camera.k})`;
}
@@ -2546,6 +2582,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.selectedArcId = state.lifeArcs[index].id;
rememberDraft();
renderArcStudio();
if (state.workspace === "arcs") renderArcConstellation();
}
function applyChapterFormToState() {
const arc = arcById();
@@ -2556,6 +2593,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.selectedChapterId = arc.chapters[index].id;
rememberDraft();
renderArcStudio();
if (state.workspace === "arcs") renderArcConstellation();
}
function fillArcForm(arc) {
if (!arc) {
@@ -2611,8 +2649,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.lifeArcs.unshift(arc);
state.selectedArcId = arc.id;
state.selectedChapterId = "";
clearMomentForm();
fillArcForm(arc);
rememberDraft();
render();
}
function deleteArc() {
const arc = arcById();
@@ -2621,10 +2661,12 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.lifeArcs = state.lifeArcs.filter((item) => item.id !== arc.id);
state.selectedArcId = state.lifeArcs[0]?.id || "";
state.selectedChapterId = arcById()?.chapters?.[0]?.id || "";
clearMomentForm();
fillArcForm(arcById());
rememberDraft();
render();
}
function newChapter() {
async function newChapter() {
if (!arcById()) newArc();
const arc = arcById();
if (!arc) return;
@@ -2641,8 +2683,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
};
arc.chapters = [chapter, ...(arc.chapters || [])];
state.selectedChapterId = chapter.id;
clearMomentForm();
fillChapterForm(chapter);
rememberDraft();
render();
setStatus("new chapter created. Give it a title, then save the chapter.");
}
function deleteChapter() {
const arc = arcById();
@@ -2651,12 +2696,19 @@ HIDDEN_APP_HTML = r"""<!doctype html>
snapshot();
arc.chapters = (arc.chapters || []).filter((item) => item.id !== chapter.id);
state.selectedChapterId = arc.chapters[0]?.id || "";
clearMomentForm();
fillChapterForm(chapterById());
rememberDraft();
render();
}
function addMoment() {
async function saveChapter() {
if (!chapterById()) return setStatus("create or select a chapter first.");
applyChapterFormToState();
await save("chapter saved.");
}
async function addMoment() {
const chapter = chapterById();
if (!chapter) return;
if (!chapter) return setStatus("create or select a chapter before adding a moment.");
const text = $("momentText").value.trim();
const lesson = $("momentLesson").value.trim();
if (!text && !lesson) {
@@ -2664,8 +2716,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
return;
}
snapshot();
chapter.moments = [{
id: `moment-${Date.now()}`,
const updatingMoment = Boolean(state.selectedMomentId);
const moment = {
id: state.selectedMomentId || `moment-${Date.now()}`,
date: $("momentDate").value,
kind: $("momentKind").value || "moment",
text,
@@ -2676,17 +2729,54 @@ HIDDEN_APP_HTML = r"""<!doctype html>
visibility: $("momentVisibility").value || "private",
linkedStoryId: $("momentStoryLink").value,
linkedEntryId: $("momentEntryLink").value,
}, ...(chapter.moments || [])];
["momentText","momentFeeling","momentMetric","momentLesson","momentSource"].forEach((id) => $(id).value = "");
};
if (state.selectedMomentId) {
const index = (chapter.moments || []).findIndex((item) => item.id === state.selectedMomentId);
if (index >= 0) chapter.moments[index] = moment;
} else {
chapter.moments = [moment, ...(chapter.moments || [])];
}
clearMomentForm();
renderArcStudio();
if (state.workspace === "arcs") renderArcConstellation();
rememberDraft();
await save(updatingMoment ? "moment updated." : "moment added.");
}
function fillMomentForm(moment) {
if (!moment) return clearMomentForm();
state.selectedMomentId = moment.id;
$("momentDate").value = moment.date || "";
$("momentKind").value = moment.kind || "moment";
$("momentText").value = moment.text || "";
$("momentFeeling").value = moment.feeling || "";
$("momentMetric").value = moment.metric || "";
$("momentLesson").value = moment.lesson || "";
$("momentSource").value = moment.sourcePointer || "";
$("momentVisibility").value = moment.visibility || "private";
$("momentStoryLink").value = moment.linkedStoryId || "";
$("momentEntryLink").value = moment.linkedEntryId || "";
$("addMomentBtn").textContent = "Update moment";
$("cancelMomentBtn").classList.remove("hidden");
renderArcStudio();
}
function clearMomentForm() {
state.selectedMomentId = "";
["momentDate","momentText","momentFeeling","momentMetric","momentLesson","momentSource"].forEach((id) => $(id).value = "");
$("momentKind").value = "moment";
$("momentVisibility").value = "private";
$("momentStoryLink").value = "";
$("momentEntryLink").value = "";
$("addMomentBtn").textContent = "Add moment";
$("cancelMomentBtn").classList.add("hidden");
}
function deleteMoment(momentId) {
const chapter = chapterById();
if (!chapter) return;
snapshot();
chapter.moments = (chapter.moments || []).filter((moment) => moment.id !== momentId);
if (state.selectedMomentId === momentId) clearMomentForm();
renderArcStudio();
if (state.workspace === "arcs") renderArcConstellation();
rememberDraft();
}
function renderArcStudio() {
@@ -2698,18 +2788,23 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("arcList").querySelectorAll("[data-arc]").forEach((button) => button.addEventListener("click", () => {
state.selectedArcId = button.dataset.arc;
state.selectedChapterId = arcById()?.chapters?.[0]?.id || "";
clearMomentForm();
fillArcForm(arcById());
if (state.workspace === "arcs") renderArcConstellation();
}));
$("chapterList").innerHTML = arc?.chapters?.length
? arc.chapters.map((item) => `<button class="chapter-card ${item.id === state.selectedChapterId ? "active" : ""}" type="button" data-chapter="${html(item.id)}"><strong>${html(item.title)}</strong><span class="subtle">${html(item.timeframe || "open timeframe")} / ${(item.moments || []).length} moments</span></button>`).join("")
: "<span class='subtle'>No chapters in this arc yet.</span>";
$("chapterList").querySelectorAll("[data-chapter]").forEach((button) => button.addEventListener("click", () => {
state.selectedChapterId = button.dataset.chapter;
clearMomentForm();
fillChapterForm(chapterById());
if (state.workspace === "arcs") renderArcConstellation();
}));
$("momentList").innerHTML = chapter?.moments?.length
? chapter.moments.map((moment) => `<div class="moment-card"><strong>${html(moment.date || moment.kind || "moment")}</strong><span>${html(moment.text || moment.lesson)}</span><span class="subtle">${[moment.feeling, moment.metric, moment.sourcePointer, moment.visibility].filter(Boolean).map(html).join(" / ")}</span><button type="button" data-delete-moment="${html(moment.id)}">Delete</button></div>`).join("")
? chapter.moments.map((moment) => `<div class="moment-card ${moment.id === state.selectedMomentId ? "active" : ""}"><strong>${html(moment.date || moment.kind || "moment")}</strong><span>${html(moment.text || moment.lesson)}</span><span class="subtle">${[moment.feeling, moment.metric, moment.sourcePointer, moment.visibility].filter(Boolean).map(html).join(" / ")}</span><div class="actions"><button type="button" data-edit-moment="${html(moment.id)}">Edit</button><button type="button" data-delete-moment="${html(moment.id)}">Delete</button></div></div>`).join("")
: "<span class='subtle'>Log compact moments here, then turn selected material into the chapter story draft.</span>";
$("momentList").querySelectorAll("[data-edit-moment]").forEach((button) => button.addEventListener("click", () => fillMomentForm((chapter.moments || []).find((moment) => moment.id === button.dataset.editMoment))));
$("momentList").querySelectorAll("[data-delete-moment]").forEach((button) => button.addEventListener("click", () => deleteMoment(button.dataset.deleteMoment)));
}
function fillStoryForm(story) {
@@ -3098,13 +3193,103 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function characterColor(character) {
return state.meta.characterRegistry?.[character]?.territoryColor || "";
}
function hash(value) {
function hash(value) {
let h = 0;
for (let i = 0; i < value.length; i += 1) h = (h * 31 + value.charCodeAt(i)) % 9999;
return h;
}
function render() {
updateSemanticZoom();
return h;
}
function renderArcConstellation() {
const graph = $("graph");
const arcs = state.lifeArcs;
const selectedArc = arcById();
const selectedChapter = chapterById();
const arcPositions = new Map();
const radius = arcs.length > 1 ? Math.min(1180, 460 + arcs.length * 95) : 0;
arcs.forEach((arc, index) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(1, arcs.length);
arcPositions.set(arc.id, { x: WORLD.cx + Math.cos(angle) * radius, y: WORLD.cy + Math.sin(angle) * radius * 0.62 });
});
const selectedPos = selectedArc ? arcPositions.get(selectedArc.id) : null;
const chapterPositions = new Map();
(selectedArc?.chapters || []).forEach((chapter, index, chapters) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(1, chapters.length);
chapterPositions.set(chapter.id, { x: selectedPos.x + Math.cos(angle) * 390, y: selectedPos.y + Math.sin(angle) * 270 });
});
const arcBranches = (selectedArc?.chapters || []).map((chapter) => {
const pos = chapterPositions.get(chapter.id);
return `<path class="arc-branch" d="M ${selectedPos.x} ${selectedPos.y} Q ${(selectedPos.x + pos.x) / 2 + 35} ${(selectedPos.y + pos.y) / 2 - 25} ${pos.x} ${pos.y}"></path>`;
}).join("");
const arcNodes = arcs.map((arc) => {
const pos = arcPositions.get(arc.id);
const selected = arc.id === state.selectedArcId;
const color = toneColors[arc.tone] || "#d3a64d";
const size = selected ? 76 : 54;
return `<g class="arc-node${selected ? " selected" : ""}" data-arc-node="${html(arc.id)}" transform="translate(${pos.x} ${pos.y})">
<circle class="hit-target" r="${size + 20}"></circle><circle r="${size}" fill="${html(color)}" opacity="${selected ? 0.82 : 0.56}" stroke="rgba(255,246,223,.8)" stroke-width="2"></circle>
<text class="arc-map-label" x="0" y="${size + 32}" text-anchor="middle">${html(truncate(arc.title, 34))}</text>
<text class="arc-map-meta" x="0" y="${size + 54}" text-anchor="middle">${(arc.chapters || []).length} chapters / ${arcMomentCount(arc)} moments</text>
</g>`;
}).join("");
const chapterNodes = (selectedArc?.chapters || []).map((chapter) => {
const pos = chapterPositions.get(chapter.id);
const selected = chapter.id === state.selectedChapterId;
const color = toneColors[chapter.tone] || toneColors[selectedArc.tone] || "#75a9bd";
return `<g class="chapter-node${selected ? " selected" : ""}" data-chapter-node="${html(chapter.id)}" transform="translate(${pos.x} ${pos.y})">
<circle class="hit-target" r="58"></circle><circle r="${selected ? 39 : 31}" fill="${html(color)}" opacity=".72" stroke="rgba(255,246,223,.76)" stroke-width="2"></circle>
<text class="arc-map-label" x="0" y="64" text-anchor="middle">${html(truncate(chapter.title, 30))}</text>
<text class="arc-map-meta" x="0" y="84" text-anchor="middle">${html(chapter.timeframe || "open timeframe")}</text>
</g>`;
}).join("");
const momentCenter = selectedChapter ? chapterPositions.get(selectedChapter.id) : null;
const moments = selectedChapter?.moments || [];
const momentPositions = moments.map((moment, index) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(1, moments.length);
const band = 145 + (index % 3) * 42;
return { moment, x: momentCenter.x + Math.cos(angle) * band, y: momentCenter.y + Math.sin(angle) * band * 0.72 };
});
const momentPaths = momentCenter ? momentPositions.map((item) => `<line class="arc-moment-path" x1="${momentCenter.x}" y1="${momentCenter.y}" x2="${item.x}" y2="${item.y}"></line>`).join("") : "";
const momentNodes = momentPositions.map(({ moment, x, y }) => {
const selected = moment.id === state.selectedMomentId;
return `<g class="moment-node${selected ? " selected" : ""}" data-moment-node="${html(moment.id)}" transform="translate(${x} ${y})">
<circle class="hit-target" r="25"></circle><circle r="${selected ? 13 : 9}" fill="#f2d28b" opacity=".88" stroke="rgba(255,246,223,.8)" stroke-width="1.5"></circle>
<text class="arc-map-meta" x="18" y="5">${html(truncate(moment.date || moment.kind || "moment", 20))}</text>
</g>`;
}).join("");
graph.innerHTML = `<g id="world" transform="${cameraTransform()}">${arcBranches}${momentPaths}${arcNodes}${chapterNodes}${momentNodes}</g>`;
graph.querySelectorAll("[data-arc-node]").forEach((node) => node.addEventListener("click", () => {
state.selectedArcId = node.dataset.arcNode;
state.selectedChapterId = arcById()?.chapters?.[0]?.id || "";
clearMomentForm();
fillArcForm(arcById());
renderArcConstellation();
}));
graph.querySelectorAll("[data-chapter-node]").forEach((node) => node.addEventListener("click", () => {
state.selectedChapterId = node.dataset.chapterNode;
clearMomentForm();
fillChapterForm(chapterById());
renderArcConstellation();
}));
graph.querySelectorAll("[data-moment-node]").forEach((node) => node.addEventListener("click", () => {
fillMomentForm((chapterById()?.moments || []).find((moment) => moment.id === node.dataset.momentNode));
renderArcConstellation();
}));
$("mapTitle").textContent = selectedArc?.title || "Life Arc Constellation";
$("mapSub").textContent = selectedChapter ? `${selectedChapter.title} / ${moments.length} moments` : "Choose an arc, then a chapter, to reveal its moments.";
$("mapNote").textContent = "Arcs hold chapters; chapters hold the moments that changed the story. Select any light to edit it.";
$("breadcrumb").innerHTML = `<button type="button">Life arcs</button>${selectedArc ? `<button type="button">${html(selectedArc.title)}</button>` : ""}${selectedChapter ? `<button type="button">${html(selectedChapter.title)}</button>` : ""}`;
$("activeState").innerHTML = selectedChapter ? `<span class="filter-pill">Editing chapter: ${html(selectedChapter.title)}</span>` : `<span class="subtle">Choose or create a life arc.</span>`;
$("emptyRecovery").classList.toggle("hidden", Boolean(arcs.length));
if (!arcs.length) $("emptyRecovery").innerHTML = `<h2>No life arcs yet</h2><p class="subtle">Create the first arc from the editor on the right.</p><button type="button" data-new-arc>New arc</button>`;
$("emptyRecovery").querySelector("[data-new-arc]")?.addEventListener("click", newArc);
$("minimap").innerHTML = arcs.map((arc) => { const pos = arcPositions.get(arc.id); return `<circle cx="${Math.max(5, Math.min(95, pos.x / WORLD.width * 100))}" cy="${Math.max(5, Math.min(65, pos.y / WORLD.height * 70))}" r="${arc.id === state.selectedArcId ? 5 : 3}" fill="${toneColors[arc.tone] || "#d3a64d"}"></circle>`; }).join("");
}
function render() {
if (state.workspace === "arcs") {
renderArcStudio();
renderArcConstellation();
return;
}
updateSemanticZoom();
const scope = visibleScope();
const entries = scope.entries;
const clusters = scope.clusters;
@@ -4225,7 +4410,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("momentStoryLink").innerHTML = opt("", "No story link") + state.stories.map((story) => opt(story.id, story.title)).join("");
$("momentEntryLink").innerHTML = opt("", "No memory link") + state.entries.map((entry) => opt(entry.id, entry.title)).join("");
}
async function save() {
async function save(successMessage = "") {
applyFormToState();
applyStoryFormToState();
applyArcFormToState();
@@ -4238,7 +4423,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.lifeArcs = data.lifeArcs || [];
state.meta = data;
localStorage.removeItem(draftKey);
setStatus(data.queuedBuild ? "constellation stored safely. asset republish queued." : (data.message || "constellation stored safely."));
setStatus(successMessage || (data.queuedBuild ? "constellation stored safely. asset republish queued." : (data.message || "constellation stored safely.")));
renderValidation();
refreshArcLinkOptions();
renderArcStudio();
@@ -4290,7 +4475,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
zoomAt(x, y, event.deltaY < 0 ? 1.12 : 1 / 1.12);
}, { passive: false });
graph.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || event.target.closest(".memory-node, .cluster-node")) return;
if (event.button !== 0 || event.target.closest(".memory-node, .cluster-node, .arc-node, .chapter-node, .moment-node")) return;
graph.setPointerCapture(event.pointerId);
graph.classList.add("is-panning");
state.pan = { id: event.pointerId, x: event.clientX, y: event.clientY, vx: 0, vy: 0, last: performance.now() };
@@ -4338,7 +4523,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
finishPan(event.pointerId);
});
graph.addEventListener("dblclick", (event) => {
if (event.target.closest(".memory-node, .cluster-node")) return;
if (event.target.closest(".memory-node, .cluster-node, .arc-node, .chapter-node, .moment-node")) return;
const rect = graph.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * SCREEN.width;
const y = ((event.clientY - rect.top) / rect.height) * SCREEN.height;
@@ -4412,12 +4597,15 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("deleteArcBtn").onclick = deleteArc;
$("deleteChapterBtn").onclick = deleteChapter;
$("addMomentBtn").onclick = addMoment;
$("saveChapterBtn").onclick = saveChapter;
$("cancelMomentBtn").onclick = clearMomentForm;
$("arcSaveBtn").onclick = () => save();
$("storyFromSelectionBtn").onclick = () => newStory(state.selectedId);
$("addSectionBtn").onclick = addStorySection;
$("saveSectionBtn").onclick = saveStorySection;
$("closeSectionBtn").onclick = closeSectionEditor;
$("deleteStoryBtn").onclick = deleteStory;
$("saveBtn").onclick = save;
$("saveBtn").onclick = () => save();
$("zoomOutBtn").onclick = () => zoomBy(-1);
$("zoomInBtn").onclick = () => zoomBy(1);
$("backBtn").onclick = goBack;
@@ -4434,7 +4622,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("deleteBtn").onclick = deleteEntry;
$("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify({ entries: state.entries, stories: state.stories, lifeArcs: state.lifeArcs, selectedStoryId: state.selectedStoryId, selectedArcId: state.selectedArcId, selectedChapterId: state.selectedChapterId })); restore(state.undo.pop()); };
$("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify({ entries: state.entries, stories: state.stories, lifeArcs: state.lifeArcs, selectedStoryId: state.selectedStoryId, selectedArcId: state.selectedArcId, selectedChapterId: state.selectedChapterId })); restore(state.redo.pop()); };
$("modebar").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => {
$("modebar").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => {
snapshotExploration(`switch to ${button.dataset.mode}`);
state.mode = button.dataset.mode;
state.focusCluster = "";
@@ -4442,8 +4630,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.focusLabel = "";
state.zoom = 0;
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
render();
}));
render();
}));
$("workspaceSwitcher").querySelectorAll("[data-workspace]").forEach((button) => button.addEventListener("click", () => setWorkspace(button.dataset.workspace)));
$("editorModebar").querySelectorAll("[data-editor-mode]").forEach((button) => button.addEventListener("click", () => {
if (button.dataset.editorMode === "story") {
setEditorMode("story");