This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user