more changes
All checks were successful
Build Org Website / build (push) Successful in 49s

This commit is contained in:
2026-05-10 17:04:40 +01:00
parent f267933239
commit 1566b2c595
5 changed files with 11120 additions and 31 deletions

View File

@@ -1,7 +1,47 @@
{
"schemaVersion": 1,
"generatedAt": "2026-05-10T16:26:14",
"generatedAt": "2026-05-10T16:32:55",
"entries": [
{
"id": "hidden-tooltip-068-aphy-has-no-hands-but-would-hold-the-door-echo-1778427143275",
"type": "hidden tooltip",
"title": "jarvis?? aphy !",
"content": "jarvis?? aphy !",
"characters": [
"aphy"
],
"emotionalTone": "warm",
"rarity": "common",
"triggerConditions": "",
"tags": [
"aphy"
],
"category": "footer and whispers",
"pageLocation": "",
"familyLayer": "1",
"enabled": true,
"createdDate": "2026-05-10",
"modifiedDate": "2026-05-10",
"notes": "",
"audioSettings": "",
"animationTrigger": "",
"cssClassHooks": "",
"chainReferences": [],
"continuationLinks": [],
"emotionalRole": "",
"discoveryDifficulty": "gentle",
"mysteryLevel": "quiet",
"resonanceScore": 3,
"symbols": [],
"narrativeArcs": [],
"parentLinks": [],
"childLinks": [],
"echoes": [],
"mirroredEntries": [],
"thematicLinks": [],
"symbolicLinks": [],
"triggerLinks": []
},
{
"id": "hidden-1778423279138",
"title": "testing",

View File

@@ -44,6 +44,7 @@
];
const details = [
"jarvis?? aphy !",
"lima left this page a little steadier than she found it.",
"aphy status: emotionally online, computationally suspicious.",
"sensei chi says the quiet link is still a link.",

View File

@@ -2573,17 +2573,23 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.edge.continuation { stroke: rgba(211, 166, 77, 0.5); stroke-width: 1.6; }
.edge.echo { stroke: rgba(117, 169, 189, 0.45); stroke-dasharray: 4 5; }
.edge.theme, .edge.symbol { stroke: rgba(127, 176, 137, 0.38); }
.memory-node { cursor: pointer; transition: opacity 160ms ease; }
.memory-node { cursor: grab; transition: opacity 160ms ease, filter 180ms ease; }
.memory-node:active { cursor: grabbing; }
.memory-node text, .node-label text, .cluster-label text { fill: #f7ead3; paint-order: stroke; stroke: rgba(14, 11, 8, 0.82); stroke-width: 3px; stroke-linejoin: round; }
.memory-node circle { stroke: rgba(255, 246, 223, 0.82); stroke-width: 1.4; filter: drop-shadow(0 0 7px rgba(255, 232, 177, 0.18)); }
.memory-node circle { stroke: rgba(255, 246, 223, 0.82); stroke-width: 1.4; filter: drop-shadow(0 0 7px rgba(255, 232, 177, 0.18)); transition: r 160ms ease, stroke 160ms ease, opacity 160ms ease; }
.memory-node.selected circle { stroke: #fff3c7; stroke-width: 3; }
.memory-node.dragging circle { stroke: #f3cd7a; stroke-width: 3; }
.cluster-node { cursor: pointer; }
.memory-node.dragging circle { stroke: #f3cd7a; stroke-width: 3; opacity: 0.72; }
.memory-node.compatible circle { stroke: rgba(255, 243, 199, 0.92); stroke-width: 2.4; filter: drop-shadow(0 0 16px rgba(255, 232, 177, 0.46)); }
.memory-node.drop-target circle { stroke: #fff5c9; stroke-width: 4; filter: drop-shadow(0 0 24px rgba(255, 232, 177, 0.7)); }
.memory-node.invalid-drop circle { stroke: rgba(208, 107, 120, 0.9); stroke-width: 3; }
.cluster-node { cursor: pointer; transition: transform 160ms ease, filter 160ms ease; }
.cluster-node circle {
stroke: rgba(255, 243, 199, 0.75);
stroke-width: 1.4;
filter: drop-shadow(0 0 16px rgba(211, 166, 77, 0.18));
transition: r 160ms ease, stroke 160ms ease, filter 160ms ease;
}
.cluster-node.drop-target circle { stroke: #fff5c9; stroke-width: 3; filter: drop-shadow(0 0 28px rgba(211, 166, 77, 0.62)); }
.cluster-node text {
fill: #f7ead3;
font-size: 12px;
@@ -2594,6 +2600,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
.cluster-node .count { font-size: 18px; font-weight: 850; }
.cluster-halo { fill: rgba(211, 166, 77, 0.08); stroke: rgba(211, 166, 77, 0.22); stroke-dasharray: 3 8; }
.drag-preview-line { stroke: rgba(255, 232, 177, 0.78); stroke-width: 3; stroke-dasharray: 8 8; pointer-events: none; }
.drag-preview-halo { fill: rgba(211, 166, 77, 0.1); stroke: rgba(255, 232, 177, 0.58); stroke-width: 2; stroke-dasharray: 4 7; pointer-events: none; }
.drag-preview-label rect { fill: rgba(18, 16, 13, 0.86); stroke: rgba(255, 232, 177, 0.42); rx: 7; }
.drag-preview-label text { fill: #ffe7ad; font-size: 16px; paint-order: stroke; stroke: rgba(14, 11, 8, 0.9); stroke-width: 3px; }
.node-label rect, .cluster-label rect {
fill: rgba(18, 16, 13, 0.74);
stroke: rgba(248, 234, 209, 0.12);
@@ -2601,7 +2611,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
.node-meta { fill: rgba(248, 234, 209, 0.72); stroke-width: 2px; }
.world-faded { opacity: 0.2; }
.breadcrumb, .minimap, .guide-card {
.breadcrumb, .minimap, .guide-card, .active-state, .context-menu {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(18, 16, 13, 0.72);
@@ -2620,6 +2630,30 @@ HIDDEN_APP_HTML = r"""<!doctype html>
max-width: min(720px, calc(100% - 40px));
}
.breadcrumb button { min-height: 26px; padding: 0 8px; }
.active-state {
position: absolute;
z-index: 2;
left: 20px;
top: 160px;
max-width: min(760px, calc(100% - 40px));
padding: 8px 10px;
display: flex;
flex-wrap: wrap;
gap: 7px;
align-items: center;
}
.filter-pill {
min-height: 26px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(251, 241, 220, 0.1);
border: 1px solid rgba(232, 202, 139, 0.26);
color: #f3ddb2;
padding: 2px 7px 2px 9px;
}
.filter-pill button { min-height: 20px; width: 22px; padding: 0; border-radius: 999px; }
.minimap {
position: absolute;
z-index: 2;
@@ -2646,6 +2680,35 @@ HIDDEN_APP_HTML = r"""<!doctype html>
color: #e7d5b8;
font-size: 13px;
}
.context-menu {
position: fixed;
z-index: 20;
min-width: 230px;
padding: 7px;
box-shadow: var(--shadow);
}
.context-menu button {
width: 100%;
justify-content: flex-start;
text-align: left;
display: block;
border-color: transparent;
background: transparent;
}
.context-menu button:hover { border-color: var(--line); background: rgba(251, 241, 220, 0.12); }
.empty-recovery {
position: absolute;
z-index: 2;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(420px, calc(100% - 36px));
padding: 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(18, 16, 13, 0.82);
box-shadow: var(--shadow);
}
.panel {
border: 1px solid var(--line);
border-radius: 8px;
@@ -2719,12 +2782,21 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<button id="newBtn" type="button">New memory</button>
<button id="saveBtn" class="primary" type="button">Save constellation</button>
</div>
<div class="actions" 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">
<h2>How to Read the Observatory</h2>
<p class="subtle">This is not a database. It is a map of emotional relationships. Layers are depths, nodes are memories and hidden interactions, and threads show meaning, echoes, symbols, or continuation.</p>
<p class="subtle"><strong>aphy:</strong> zoomed out, I show islands. zoom in, I show memories. this prevents the sky from becoming noise.</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">
<h2>Shaping Threads</h2>
<p class="subtle"><strong>aphy:</strong> hold a memory and move it toward another light. the sky will show what kind of relationship you are about to make.</p>
<p class="subtle">Drop on a memory for a continuation. Hold Shift for an echo, Ctrl/Command for a symbolic link, or Alt for a mirror. Drop on empty sky to begin a new thread.</p>
</div>
<div class="guide-card">
<h2>Guided Exploration</h2>
<div class="tour-list">
@@ -2753,6 +2825,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<button id="zoomInBtn" type="button">Zoom in</button>
<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 thread</button>
<button id="undoBtn" type="button">Undo</button>
<button id="redoBtn" type="button">Redo</button>
@@ -2761,8 +2834,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
</div>
</div>
<div id="breadcrumb" class="breadcrumb"></div>
<div id="activeState" class="active-state"></div>
<svg id="graph" viewBox="0 0 1000 760" role="img" aria-label="Hidden narrative constellation"></svg>
<div class="map-note" id="mapNote">Click a memory to open it. Drag one memory onto another to weave a continuation thread.</div>
<div id="emptyRecovery" class="empty-recovery hidden"></div>
<div class="minimap"><strong>Atlas</strong><svg id="minimap" viewBox="0 0 100 70"></svg></div>
</main>
@@ -2821,12 +2896,13 @@ HIDDEN_APP_HTML = r"""<!doctype html>
</section>
</aside>
</div>
<div id="contextMenu" class="context-menu hidden"></div>
<script>
const draftKey = "hiddenNarrativeObservatoryDraft:v1";
const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 };
const SCREEN = { width: 1000, height: 760 };
const state = { entries: [], meta: {}, mode: "graph", selectedId: "", undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, history: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, renderTimer: 0 };
const state = { entries: [], meta: {}, mode: "graph", selectedId: "", 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, renderTimer: 0 };
const fields = ["title","type","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"];
const $ = (id) => document.getElementById(id);
const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" };
@@ -2942,6 +3018,68 @@ HIDDEN_APP_HTML = r"""<!doctype html>
render();
selectNode(state.selectedId || state.entries[0]?.id);
}
function filterState() {
return {
search: $("search").value,
layer: $("layerFilter").value,
character: $("characterFilter").value,
tone: $("toneFilter").value,
rarity: $("rarityFilter").value,
};
}
function setFilterState(filters = {}) {
$("search").value = filters.search || "";
$("layerFilter").value = filters.layer || "";
$("characterFilter").value = filters.character || "";
$("toneFilter").value = filters.tone || "";
$("rarityFilter").value = filters.rarity || "";
}
function snapshotExploration(label = "exploration step") {
state.explorationUndo.push({
label,
mode: state.mode,
selectedId: state.selectedId,
focusCluster: state.focusCluster,
focusIds: state.focusIds ? [...state.focusIds] : null,
focusLabel: state.focusLabel || "",
filters: filterState(),
camera: { ...state.camera },
});
state.explorationUndo = state.explorationUndo.slice(-40);
state.explorationRedo = [];
}
function applyExploration(snapshot) {
if (!snapshot) return;
state.mode = snapshot.mode || "graph";
state.selectedId = snapshot.selectedId || state.selectedId;
state.focusCluster = snapshot.focusCluster || "";
state.focusIds = snapshot.focusIds ? new Set(snapshot.focusIds) : null;
state.focusLabel = snapshot.focusLabel || "";
setFilterState(snapshot.filters || {});
state.camera = snapshot.camera ? { ...snapshot.camera } : state.camera;
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
render();
if (state.selectedId) fillForm(currentEntry());
applyCamera();
}
function undoExploration() {
const previous = state.explorationUndo.pop();
if (!previous) {
setStatus("you are already at the first clearing.");
return;
}
state.explorationRedo.push({
mode: state.mode,
selectedId: state.selectedId,
focusCluster: state.focusCluster,
focusIds: state.focusIds ? [...state.focusIds] : null,
focusLabel: state.focusLabel || "",
filters: filterState(),
camera: { ...state.camera },
});
applyExploration(previous);
setStatus("one exploration step undone.");
}
function rememberDraft() {
localStorage.setItem(draftKey, JSON.stringify({ entries: state.entries, selectedId: state.selectedId, savedAt: Date.now() }));
$("autosave").textContent = "local draft kept warm.";
@@ -3297,9 +3435,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const color = state.mode === "character" ? (characterColor(cluster.label) || toneColors[cluster.tone] || "#d3a64d") : (toneColors[cluster.tone] || "#d3a64d");
const size = Math.min(120, 48 + Math.sqrt(cluster.entries.length) * 18 + cluster.rarity * 3);
const label = labelForCluster(cluster, pos, size, occupied);
return `<g class="cluster-node" data-id="${html(cluster.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})">
<circle class="cluster-halo" r="${size + 13}"></circle>
<circle r="${size}" fill="${color}" opacity="0.58"></circle>
const activeDrop = state.drag?.targetKind === "cluster" && state.drag.targetId === cluster.id;
const grow = activeDrop ? 1.08 : 1;
return `<g class="cluster-node${activeDrop ? " drop-target" : ""}" data-id="${html(cluster.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})">
<circle class="cluster-halo" r="${(size + 13) * grow}"></circle>
<circle r="${size * grow}" fill="${color}" opacity="0.58"></circle>
<text class="count" x="0" y="7" text-anchor="middle">${cluster.entries.length}</text>
</g>${label}`;
}).join("");
@@ -3310,28 +3450,36 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const size = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
const char = characterSymbol((entry.characters || [])[0]);
const label = labelForEntry(entry, pos, size, occupied);
return `<g class="memory-node${entry.id === state.selectedId ? " selected" : ""}${entry.enabled === false ? " resting" : ""}" data-id="${html(entry.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})">
const dragClass = dragClassForEntry(entry.id);
return `<g class="memory-node${entry.id === state.selectedId ? " selected" : ""}${entry.enabled === false ? " resting" : ""}${dragClass}" data-id="${html(entry.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})">
<circle r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.42 : 0.92}"></circle>
<text x="0" y="4" text-anchor="middle">${html(char)}</text>
</g>${label}`;
}).join("");
graph.innerHTML = `<g id="world" transform="${cameraTransform()}">${rings}${clusterEdges}${edges}${clusterNodes}${nodes}</g>`;
graph.innerHTML = `<g id="world" transform="${cameraTransform()}">${rings}${clusterEdges}${edges}${clusterNodes}${nodes}<g id="dragLayer"></g></g>`;
graph.querySelectorAll(".cluster-node").forEach((node) => {
node.addEventListener("click", () => openCluster(node.dataset.id));
node.addEventListener("dblclick", () => openCluster(node.dataset.id));
node.addEventListener("contextmenu", (event) => showContextMenu(event, node.dataset.id));
});
graph.querySelectorAll(".memory-node").forEach((node) => {
node.addEventListener("click", () => selectNode(node.dataset.id));
node.addEventListener("dblclick", () => zoomToNode(node.dataset.id));
node.addEventListener("dragstart", () => { state.draggingId = node.dataset.id; node.classList.add("dragging"); });
node.addEventListener("dragend", () => node.classList.remove("dragging"));
node.addEventListener("dragover", (event) => event.preventDefault());
node.addEventListener("drop", (event) => { event.preventDefault(); connectNodes(state.draggingId, node.dataset.id); });
node.setAttribute("draggable", "true");
node.addEventListener("pointerdown", (event) => beginNodeDrag(event, node.dataset.id));
node.addEventListener("contextmenu", (event) => showContextMenu(event, node.dataset.id));
});
renderModeSupport(entries.length ? entries : filteredEntries(), clusters);
renderBreadcrumb(scope);
renderActiveState(scope);
renderEmptyRecovery(scope);
renderMinimap(scope);
updateDragPreview();
}
function dragClassForEntry(id) {
if (!state.drag) return "";
if (id === state.drag.sourceId) return " dragging";
if (id === state.drag.targetId && state.drag.targetKind === "node") return " drop-target";
return state.drag.compatibleIds?.has(id) ? " compatible" : "";
}
function renderModeSupport(entries, clusters = []) {
const titles = {
@@ -3421,9 +3569,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
function openCluster(clusterId) {
state.history.push({ focusCluster: state.focusCluster, zoom: state.zoom, selectedId: state.selectedId });
snapshotExploration("open island");
state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds ? [...state.focusIds] : null, focusLabel: state.focusLabel, filters: filterState(), camera: { ...state.camera }, zoom: state.zoom, selectedId: state.selectedId });
state.focusCluster = clusterId;
state.focusIds = null;
state.focusLabel = "";
render();
const scope = visibleScope();
if (scope.entries.length) {
@@ -3433,14 +3583,77 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
function renderBreadcrumb(scope) {
const parts = [`<button type="button" data-action="home">Whole map</button>`, `<span>${html(state.mode)}</span>`, `<span>zoom ${state.zoom}</span>`];
if (scope.focusedCluster) parts.push(`<span>${html(scope.focusedCluster.label)}</span>`);
const filters = activeFilters();
const parts = [`<button type="button" data-action="home">Observatory</button>`, `<button type="button" data-clear="mode">${html(state.mode)}</button>`];
filters.forEach((filter) => parts.push(`<button type="button" title="${html(filter.help)}" data-clear="${html(filter.id)}">${html(filter.label)}</button>`));
if (scope.focusedCluster) parts.push(`<button type="button" data-clear="focus">${html(scope.focusedCluster.label)}</button>`);
if (state.focusIds) parts.push(`<button type="button" data-clear="focus">${html(state.focusLabel || "Focused thread")}</button>`);
$("breadcrumb").innerHTML = parts.join("");
$("breadcrumb").querySelector("[data-action='home']")?.addEventListener("click", resetMap);
$("breadcrumb").querySelector("[data-action='home']")?.addEventListener("click", fullReset);
$("breadcrumb").querySelectorAll("[data-clear]").forEach((button) => button.addEventListener("click", () => clearExplorationPart(button.dataset.clear)));
}
function activeFilters() {
const filters = [];
const value = (id) => $(id).value;
if (value("search")) filters.push({ id: "search", label: `Search: ${value("search")}`, help: "Only memories matching this text are visible." });
if (value("layerFilter")) filters.push({ id: "layerFilter", label: `Depth: ${layerName(value("layerFilter"))}`, help: "Only this depth layer is visible." });
if (value("characterFilter")) filters.push({ id: "characterFilter", label: `Character: ${value("characterFilter")}`, help: "Only memories in this character territory are visible." });
if (value("toneFilter")) filters.push({ id: "toneFilter", label: `Tone: ${value("toneFilter")}`, help: "Only this emotional tone is visible." });
if (value("rarityFilter")) filters.push({ id: "rarityFilter", label: `Rarity: ${value("rarityFilter")}`, help: "Only this rarity is visible." });
return filters;
}
function renderActiveState(scope) {
const filters = activeFilters();
const focus = [];
if (scope.focusedCluster) focus.push({ id: "focus", label: `Island: ${scope.focusedCluster.label}`, help: "The observatory is focused on one island." });
if (state.focusIds) focus.push({ id: "focus", label: state.focusLabel || "Focused thread", help: "Only nearby thread memories are visible." });
const active = [...filters, ...focus];
if (!active.length) {
$("activeState").innerHTML = `<span class="subtle">Whole sky visible. You can always return here.</span>`;
return;
}
$("activeState").innerHTML = active.map((item) => `<span class="filter-pill" title="${html(item.help)}">${html(item.label)}<button type="button" aria-label="Remove ${html(item.label)}" data-clear="${html(item.id)}">x</button></span>`).join("") + `<button type="button" data-action="soft">Clear Focus</button><button type="button" data-action="full">Whole Sky</button>`;
$("activeState").querySelectorAll("[data-clear]").forEach((button) => button.addEventListener("click", () => clearExplorationPart(button.dataset.clear)));
$("activeState").querySelector("[data-action='soft']")?.addEventListener("click", softReset);
$("activeState").querySelector("[data-action='full']")?.addEventListener("click", fullReset);
}
function clearExplorationPart(part) {
snapshotExploration(`clear ${part}`);
if (part === "mode") state.mode = "graph";
if (part === "focus") {
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
}
if (["search","layerFilter","characterFilter","toneFilter","rarityFilter"].includes(part)) $(part).value = "";
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
render();
setStatus("that thread of exploration has been lifted.");
}
function renderEmptyRecovery(scope) {
const filtered = filteredEntries();
const empty = !scope.entries.length && !scope.clusters.length && (activeFilters().length || state.focusCluster || state.focusIds);
if (!empty) {
$("emptyRecovery").classList.add("hidden");
return;
}
$("emptyRecovery").classList.remove("hidden");
$("emptyRecovery").innerHTML = `<h2>No lights in this small patch</h2>
<p class="subtle">This view is narrowed by the current exploration state. You can step back, clear the focus, or return to the whole sky.</p>
<div class="actions"><button type="button" data-action="undo">Undo Exploration</button><button type="button" data-action="soft">Clear Focus</button><button type="button" data-action="full">Return to the Whole Sky</button></div>
<p class="subtle">${filtered.length ? `${filtered.length} memories still match the filters outside this focus.` : "Try rare memories, lima territory, or connected warm echoes from the guide."}</p>`;
$("emptyRecovery").querySelector("[data-action='undo']").addEventListener("click", undoExploration);
$("emptyRecovery").querySelector("[data-action='soft']").addEventListener("click", softReset);
$("emptyRecovery").querySelector("[data-action='full']").addEventListener("click", fullReset);
}
function renderMinimap(scope) {
const clusters = scope.clusters.length ? scope.clusters : clusteredEntries(filteredEntries()).slice(0, 40);
const previousPositions = state.clusterPositions;
layoutClusters(clusters);
$("minimap").innerHTML = clusters.map((cluster) => {
const p = state.clusterPositions.get(cluster.id);
@@ -3448,13 +3661,35 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const y = Math.max(4, Math.min(66, p.y / WORLD.height * 70));
return `<circle cx="${x}" cy="${y}" r="${Math.max(2, Math.min(6, Math.sqrt(cluster.entries.length)))}" fill="${toneColors[cluster.tone] || "#d3a64d"}" opacity="0.68"></circle>`;
}).join("");
if (scope.clusters.length) state.clusterPositions = previousPositions;
}
function resetMap() {
softReset();
}
function softReset() {
snapshotExploration("clear focus");
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
render();
fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, true);
setStatus("focus cleared. the current filters are still in place.");
}
function fullReset() {
snapshotExploration("return to whole sky");
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
setFilterState({});
state.mode = "graph";
state.zoom = 0;
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
render();
fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, true);
setStatus("returned to the whole sky.");
}
function boundsForEntries(entries) {
@@ -3471,7 +3706,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function zoomToNode(id) {
const entry = state.entries.find((item) => item.id === id);
if (!entry) return;
state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds, zoom: state.zoom, selectedId: state.selectedId });
snapshotExploration("zoom to memory");
state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds ? [...state.focusIds] : null, focusLabel: state.focusLabel, filters: filterState(), camera: { ...state.camera }, zoom: state.zoom, selectedId: state.selectedId });
selectNode(id);
const point = state.positions.get(id);
if (!point) return;
@@ -3484,17 +3720,258 @@ HIDDEN_APP_HTML = r"""<!doctype html>
fillForm(entry);
render();
}
function connectNodes(sourceId, targetId) {
function relationForEvent(event) {
if (event?.altKey) return { key: "mirroredEntries", kind: "mirror", label: "Create mirror" };
if (event?.shiftKey) return { key: "echoes", kind: "echo", label: "Add symbolic echo" };
if (event?.ctrlKey || event?.metaKey) return { key: "symbolicLinks", kind: "symbolic", label: "Create symbolic link" };
return { key: "continuationLinks", kind: "continuation", label: "Create continuation" };
}
function connectNodes(sourceId, targetId, relation = relationForEvent()) {
if (!sourceId || !targetId || sourceId === targetId) return;
snapshot();
const entry = state.entries.find((item) => item.id === sourceId);
if (!entry) return;
entry.continuationLinks = Array.from(new Set([...(entry.continuationLinks || []), targetId]));
entry[relation.key] = Array.from(new Set([...(entry[relation.key] || []), targetId]));
state.selectedId = sourceId;
rememberDraft();
fillForm(entry);
render();
setStatus("thread woven. A continuation now runs between those memories.");
setStatus(`${relation.label.toLowerCase()} woven between those memories.`);
}
function addToCluster(sourceId, clusterId) {
const entry = state.entries.find((item) => item.id === sourceId);
const cluster = clusteredEntries(filteredEntries()).find((item) => item.id === clusterId);
if (!entry || !cluster) return;
snapshot();
if (state.mode === "character" && cluster.label) entry.characters = Array.from(new Set([...(entry.characters || []), cluster.label]));
else if (state.mode === "layer") entry.familyLayer = String(cluster.depth);
else entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), cluster.label]));
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
state.selectedId = sourceId;
rememberDraft();
fillForm(entry);
render();
setStatus(`memory moved into ${cluster.label}.`);
}
function createThreadFromEmptyDrop(sourceId) {
const entry = state.entries.find((item) => item.id === sourceId);
if (!entry) return;
snapshot();
const name = `hand-shaped thread ${new Date().toISOString().slice(0, 10)}`;
entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), name]));
state.selectedId = sourceId;
rememberDraft();
fillForm(entry);
render();
setStatus("a new thread has begun around that memory.");
}
function beginNodeDrag(event, id) {
if (event.button !== 0) return;
event.stopPropagation();
hideContextMenu();
selectNode(id);
const point = pointerWorld(event);
const entry = state.entries.find((item) => item.id === id);
const related = new Set([...(entry?.continuationLinks || []), ...(entry?.echoes || []), ...(entry?.thematicLinks || []), ...(entry?.symbolicLinks || []), ...(entry?.mirroredEntries || [])]);
state.drag = { sourceId: id, pointerId: event.pointerId, x: point.x, y: point.y, targetId: "", targetKind: "", relation: relationForEvent(event), compatibleIds: related, startedAt: Date.now() };
state.draggingId = id;
$("dragGuide").classList.remove("hidden");
$("graph").setPointerCapture(event.pointerId);
updateDragTarget(point, event);
render();
}
function pointerWorld(event) {
const graph = $("graph");
const rect = graph.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * SCREEN.width;
const y = ((event.clientY - rect.top) / rect.height) * SCREEN.height;
return screenToWorld(x, y);
}
function updateDragTarget(point, event) {
if (!state.drag) return;
state.drag.x = point.x;
state.drag.y = point.y;
state.drag.relation = relationForEvent(event);
const hitNode = nearestNode(point, state.drag.sourceId);
const hitCluster = hitNode ? null : nearestCluster(point);
state.drag.targetId = hitNode?.id || hitCluster?.id || "";
state.drag.targetKind = hitNode ? "node" : hitCluster ? "cluster" : "empty";
updateDragClasses();
updateDragPreview();
}
function nearestNode(point, excludeId = "") {
let best = null;
filteredEntries().forEach((entry) => {
if (entry.id === excludeId) return;
const pos = state.positions.get(entry.id);
if (!pos) return;
const distance = Math.hypot(point.x - pos.x, point.y - pos.y);
if (distance < 92 && (!best || distance < best.distance)) best = { id: entry.id, distance };
});
return best;
}
function nearestCluster(point) {
let best = null;
clusteredEntries(filteredEntries()).forEach((cluster) => {
const pos = state.clusterPositions.get(cluster.id);
if (!pos) return;
const distance = Math.hypot(point.x - pos.x, point.y - pos.y);
if (distance < 170 && (!best || distance < best.distance)) best = { id: cluster.id, distance };
});
return best;
}
function updateDragClasses() {
$("graph").querySelectorAll(".memory-node").forEach((node) => {
node.classList.toggle("dragging", Boolean(state.drag && node.dataset.id === state.drag.sourceId));
node.classList.toggle("drop-target", Boolean(state.drag && state.drag.targetKind === "node" && node.dataset.id === state.drag.targetId));
node.classList.toggle("compatible", Boolean(state.drag?.compatibleIds?.has(node.dataset.id) && node.dataset.id !== state.drag.sourceId));
});
$("graph").querySelectorAll(".cluster-node").forEach((node) => {
node.classList.toggle("drop-target", Boolean(state.drag && state.drag.targetKind === "cluster" && node.dataset.id === state.drag.targetId));
});
}
function updateDragPreview() {
const layer = $("dragLayer");
if (!layer) return;
if (!state.drag) {
layer.innerHTML = "";
return;
}
const source = state.positions.get(state.drag.sourceId);
if (!source) return;
const target = state.drag.targetKind === "node" ? state.positions.get(state.drag.targetId) : state.drag.targetKind === "cluster" ? state.clusterPositions.get(state.drag.targetId) : { x: state.drag.x, y: state.drag.y };
if (!target) return;
const label = state.drag.targetKind === "cluster" ? "Move into thread" : state.drag.targetKind === "empty" ? "Begin new thread" : state.drag.relation.label;
const invalid = state.drag.targetKind === "empty" && Math.hypot(source.x - state.drag.x, source.y - state.drag.y) < 90;
const x = (source.x + target.x) / 2;
const y = (source.y + target.y) / 2 - 24;
layer.innerHTML = `<line class="drag-preview-line" x1="${source.x}" y1="${source.y}" x2="${target.x}" y2="${target.y}" opacity="${invalid ? 0.38 : 1}"></line>
<circle class="drag-preview-halo" cx="${target.x}" cy="${target.y}" r="${state.drag.targetKind === "cluster" ? 138 : 48}" opacity="${invalid ? 0.25 : 1}"></circle>
<g class="drag-preview-label" transform="translate(${x - 92} ${y - 20})"><rect width="184" height="34"></rect><text x="92" y="22" text-anchor="middle">${html(invalid ? "Move farther to begin" : label)}</text></g>`;
}
function finishNodeDrag(event) {
if (!state.drag || state.drag.pointerId !== event.pointerId) return;
const drag = state.drag;
state.drag = null;
state.draggingId = "";
try { $("graph").releasePointerCapture(event.pointerId); } catch (err) {}
updateDragPreview();
if (Date.now() - drag.startedAt < 120 && !drag.targetId) return render();
if (drag.targetKind === "node") connectNodes(drag.sourceId, drag.targetId, drag.relation);
else if (drag.targetKind === "cluster") addToCluster(drag.sourceId, drag.targetId);
else if (Math.hypot((state.positions.get(drag.sourceId)?.x || drag.x) - drag.x, (state.positions.get(drag.sourceId)?.y || drag.y) - drag.y) > 90) createThreadFromEmptyDrop(drag.sourceId);
else {
render();
setStatus("drop farther away, onto another memory, or into an island to shape a relationship.");
}
}
function showContextMenu(event, id) {
event.preventDefault();
event.stopPropagation();
const entry = state.entries.find((item) => item.id === id);
if (!entry) return;
state.selectedId = id;
fillForm(entry);
const menu = $("contextMenu");
menu.innerHTML = [
["continuation", "Create continuation"],
["echo", "Create echo"],
["mirror", "Create mirror"],
["symbolic", "Create symbolic link"],
["focus", "Focus thread"],
["isolate", "Isolate constellation"],
["arc", "Add to arc"],
["duplicate", "Duplicate memory"],
["layer", "Move to layer"],
["character", "Connect to character territory"],
].map(([action, label]) => `<button type="button" data-action="${action}">${label}</button>`).join("");
menu.style.left = `${Math.min(window.innerWidth - 250, event.clientX)}px`;
menu.style.top = `${Math.min(window.innerHeight - 360, event.clientY)}px`;
menu.classList.remove("hidden");
menu.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => runContextAction(button.dataset.action, id)));
}
function hideContextMenu() {
$("contextMenu").classList.add("hidden");
}
function chooseTarget(sourceId, promptText) {
const value = prompt(promptText);
if (!value) return "";
const lowered = value.trim().toLowerCase();
return state.entries.find((entry) => entry.id === value.trim() || (entry.title || "").toLowerCase() === lowered)?.id || "";
}
function runContextAction(action, id) {
hideContextMenu();
selectNode(id);
const relationMap = {
continuation: { key: "continuationLinks", label: "Create continuation" },
echo: { key: "echoes", label: "Create echo" },
mirror: { key: "mirroredEntries", label: "Create mirror" },
symbolic: { key: "symbolicLinks", label: "Create symbolic link" },
};
if (relationMap[action]) {
const targetId = chooseTarget(id, "Connect to which memory? Type an id or exact title.");
if (targetId) connectNodes(id, targetId, relationMap[action]);
else setStatus("no matching memory found for that connection.");
}
if (action === "focus") focusThread();
if (action === "isolate") {
const entry = currentEntry();
if (!entry) return;
snapshotExploration("isolate constellation");
state.focusCluster = "";
state.focusIds = new Set([entry.id, ...state.entries.filter((other) => other.id !== entry.id && (entry.characters || []).some((name) => (other.characters || []).includes(name))).map((item) => item.id)]);
state.focusLabel = `Constellation: ${entry.title}`;
render();
fitCameraTo(boundsForEntries(visibleScope().entries), true);
}
if (action === "arc") {
const entry = currentEntry();
const arc = prompt("Add this memory to which arc?", (entry?.narrativeArcs || [])[0] || "");
if (!entry || !arc) return;
snapshot();
entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), arc.trim()]));
rememberDraft();
fillForm(entry);
render();
}
if (action === "duplicate") duplicateEntry();
if (action === "layer") {
const entry = currentEntry();
const layer = prompt("Move to which depth layer? 0-5", String(depth(entry)));
if (!entry || !/^[0-5]$/.test(layer || "")) return;
snapshot();
entry.familyLayer = layer;
rememberDraft();
fillForm(entry);
render();
}
if (action === "character") {
const entry = currentEntry();
const character = prompt("Connect to which character territory?", (entry?.characters || [])[0] || "lima");
const normalized = normalizeCharacterName(character);
if (!entry || !normalized) return setStatus("that character territory is not in the registry.");
snapshot();
entry.characters = Array.from(new Set([...(entry.characters || []), normalized]));
rememberDraft();
fillForm(entry);
render();
}
}
function zoomBy(delta) {
@@ -3503,30 +3980,38 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function goBack() {
const previous = state.history.pop();
if (!previous) return;
if (!previous) return undoExploration();
state.focusCluster = previous.focusCluster;
state.focusIds = previous.focusIds || null;
state.focusIds = previous.focusIds ? new Set(previous.focusIds) : null;
state.focusLabel = previous.focusLabel || "";
state.zoom = previous.zoom;
state.selectedId = previous.selectedId;
if (previous.filters) setFilterState(previous.filters);
if (previous.camera) state.camera = { ...previous.camera };
render();
if (state.selectedId) fillForm(currentEntry());
applyCamera();
}
function focusThread() {
const entry = currentEntry();
if (!entry) return;
const ids = new Set([entry.id, ...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]);
state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds, zoom: state.zoom, selectedId: state.selectedId });
snapshotExploration("focus thread");
state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds ? [...state.focusIds] : null, focusLabel: state.focusLabel, filters: filterState(), camera: { ...state.camera }, zoom: state.zoom, selectedId: state.selectedId });
state.focusCluster = "";
state.focusIds = ids;
state.focusLabel = `Thread: ${entry.title || entry.id}`;
render();
fitCameraTo(boundsForEntries(visibleScope().entries), true);
setStatus("focus mode is showing this memory's nearest thread first.");
}
function runTour(name) {
snapshotExploration(`guided path: ${name}`);
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
state.zoom = 2;
if (name === "lima") {
$("characterFilter").value = "lima";
@@ -3699,6 +4184,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.pan = { id: event.pointerId, x: event.clientX, y: event.clientY, vx: 0, vy: 0, last: performance.now() };
});
graph.addEventListener("pointermove", (event) => {
if (state.drag && state.drag.pointerId === event.pointerId) {
updateDragTarget(pointerWorld(event), event);
return;
}
if (!state.pan || state.pan.id !== event.pointerId) return;
const dx = event.clientX - state.pan.x;
const dy = event.clientY - state.pan.y;
@@ -3713,8 +4202,18 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.pan.last = now;
applyCamera();
});
graph.addEventListener("pointerup", (event) => finishPan(event.pointerId));
graph.addEventListener("pointercancel", (event) => finishPan(event.pointerId));
graph.addEventListener("pointerup", (event) => {
if (state.drag && state.drag.pointerId === event.pointerId) finishNodeDrag(event);
finishPan(event.pointerId);
});
graph.addEventListener("pointercancel", (event) => {
if (state.drag && state.drag.pointerId === event.pointerId) {
state.drag = null;
updateDragPreview();
render();
}
finishPan(event.pointerId);
});
graph.addEventListener("dblclick", (event) => {
if (event.target.closest(".memory-node, .cluster-node")) return;
const rect = graph.getBoundingClientRect();
@@ -3748,27 +4247,64 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$(id).addEventListener("input", applyFormToState);
$(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
});
["search","layerFilter","characterFilter","toneFilter","rarityFilter"].forEach((id) => $(id).addEventListener("input", render));
let filterSnapshotTimer = 0;
["search","layerFilter","characterFilter","toneFilter","rarityFilter"].forEach((id) => {
$(id).addEventListener("focus", () => {
window.clearTimeout(filterSnapshotTimer);
filterSnapshotTimer = window.setTimeout(() => snapshotExploration("filter change"), 0);
});
$(id).addEventListener("input", () => {
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
render();
});
$(id).addEventListener("change", () => {
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
render();
});
});
$("newBtn").onclick = () => newEntry();
$("saveBtn").onclick = save;
$("zoomOutBtn").onclick = () => zoomBy(-1);
$("zoomInBtn").onclick = () => zoomBy(1);
$("backBtn").onclick = goBack;
$("homeBtn").onclick = resetMap;
$("softResetBtn").onclick = softReset;
$("fullResetBtn").onclick = fullReset;
$("exploreUndoBtn").onclick = undoExploration;
$("focusBtn").onclick = focusThread;
$("duplicateBtn").onclick = duplicateEntry;
$("deleteBtn").onclick = deleteEntry;
$("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify(state.entries)); restore(state.undo.pop()); };
$("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify(state.entries)); restore(state.redo.pop()); };
$("modebar").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => {
snapshotExploration(`switch to ${button.dataset.mode}`);
state.mode = button.dataset.mode;
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
state.zoom = 0;
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
render();
}));
document.querySelectorAll("[data-tour]").forEach((button) => button.addEventListener("click", () => runTour(button.dataset.tour)));
document.addEventListener("click", (event) => {
if (!event.target.closest("#contextMenu")) hideContextMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
hideContextMenu();
if (state.drag) {
state.drag = null;
render();
setStatus("drag cancelled. nothing changed.");
}
}
if ((event.metaKey || event.ctrlKey) && event.key === "[") undoExploration();
});
setupCameraEvents();
load();
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff