+
-
+
-
+
Integrity
checking character territories.
-
+
@@ -1889,12 +1907,12 @@ HIDDEN_APP_HTML = r"""
-
-
+
+
-
-
+
+
@@ -1957,6 +1975,7 @@ HIDDEN_APP_HTML = r"""
+
@@ -1993,7 +2012,9 @@ HIDDEN_APP_HTML = r"""
+
+
@@ -2083,7 +2104,7 @@ HIDDEN_APP_HTML = r"""
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"""
$("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"""
const entry = currentEntry();
$("editingBanner").innerHTML = state.editorMode === "story"
? `
Editing Story: ${html(story?.title || "No story selected")}
${html(detail || "Shape the route, pacing, sections, and emotional sequence.")}`
- : `
Editing Memory: ${html(entry?.title || "No memory selected")}
${html(detail || "Craft the reusable fragment and choose where it can recur.")}`;
- }
+ : `
Editing Memory: ${html(entry?.title || "No memory selected")}
${html(detail || "Craft the reusable fragment and choose where it can recur.")}`;
+ }
+ 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"""
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"""
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"""
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"""
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"""
};
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"""
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"""
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"""
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"""
$("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) => `
`).join("")
: "
No chapters in this arc yet.";
$("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) => `
${html(moment.date || moment.kind || "moment")}${html(moment.text || moment.lesson)}${[moment.feeling, moment.metric, moment.sourcePointer, moment.visibility].filter(Boolean).map(html).join(" / ")}
`).join("")
+ ? chapter.moments.map((moment) => `
${html(moment.date || moment.kind || "moment")}${html(moment.text || moment.lesson)}${[moment.feeling, moment.metric, moment.sourcePointer, moment.visibility].filter(Boolean).map(html).join(" / ")} `).join("")
: "
Log compact moments here, then turn selected material into the chapter story draft.";
+ $("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"""
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 `
`;
+ }).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 `
+
+ ${html(truncate(arc.title, 34))}
+ ${(arc.chapters || []).length} chapters / ${arcMomentCount(arc)} moments
+ `;
+ }).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 `
+
+ ${html(truncate(chapter.title, 30))}
+ ${html(chapter.timeframe || "open timeframe")}
+ `;
+ }).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) => `
`).join("") : "";
+ const momentNodes = momentPositions.map(({ moment, x, y }) => {
+ const selected = moment.id === state.selectedMomentId;
+ return `
+
+ ${html(truncate(moment.date || moment.kind || "moment", 20))}
+ `;
+ }).join("");
+ graph.innerHTML = `
${arcBranches}${momentPaths}${arcNodes}${chapterNodes}${momentNodes}`;
+ 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 = `
${selectedArc ? `
` : ""}${selectedChapter ? `
` : ""}`;
+ $("activeState").innerHTML = selectedChapter ? `
Editing chapter: ${html(selectedChapter.title)}` : `
Choose or create a life arc.`;
+ $("emptyRecovery").classList.toggle("hidden", Boolean(arcs.length));
+ if (!arcs.length) $("emptyRecovery").innerHTML = `
No life arcs yet
Create the first arc from the editor on the right.
`;
+ $("emptyRecovery").querySelector("[data-new-arc]")?.addEventListener("click", newArc);
+ $("minimap").innerHTML = arcs.map((arc) => { const pos = arcPositions.get(arc.id); return `
`; }).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"""
$("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"""
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"""
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"""
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"""
$("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"""
$("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"""
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");