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

This commit is contained in:
2026-05-10 16:07:51 +01:00
parent 5dc0b93416
commit 52a0160198

View File

@@ -2396,7 +2396,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
inset: 0;
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
user-select: none;
}
#graph.is-panning { cursor: grabbing; }
.ring { fill: none; stroke: rgba(232, 202, 139, 0.14); stroke-width: 1; }
.ring-label { fill: rgba(248, 234, 209, 0.45); font-size: 10px; letter-spacing: 0; }
.edge { stroke: rgba(248, 234, 209, 0.18); stroke-width: 1; }
@@ -2404,7 +2408,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.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 text { fill: #f7ead3; font-size: 11px; paint-order: stroke; stroke: rgba(14, 11, 8, 0.82); stroke-width: 3px; stroke-linejoin: round; }
.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.selected circle { stroke: #fff3c7; stroke-width: 3; }
.memory-node.dragging circle { stroke: #f3cd7a; stroke-width: 3; }
@@ -2424,6 +2428,13 @@ 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; }
.node-label rect, .cluster-label rect {
fill: rgba(18, 16, 13, 0.74);
stroke: rgba(248, 234, 209, 0.12);
rx: 7;
}
.node-meta { fill: rgba(248, 234, 209, 0.72); stroke-width: 2px; }
.world-faded { opacity: 0.2; }
.breadcrumb, .minimap, .guide-card {
border: 1px solid var(--line);
border-radius: 8px;
@@ -2643,7 +2654,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<script>
const draftKey = "hiddenNarrativeObservatoryDraft:v1";
const state = { entries: [], meta: {}, mode: "graph", selectedId: "", undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, history: [] };
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 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" };
@@ -2668,6 +2681,68 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("status").textContent = message;
$("autosave").textContent = message;
}
function cameraTransform() {
return `translate(${state.camera.x} ${state.camera.y}) scale(${state.camera.k})`;
}
function applyCamera() {
const world = document.getElementById("world");
if (world) world.setAttribute("transform", cameraTransform());
updateSemanticZoom();
renderMinimap(visibleScope());
}
function updateSemanticZoom() {
const k = state.camera.k;
state.zoom = k < 0.3 ? 0 : k < 0.62 ? 1 : k < 1.15 ? 2 : 3;
}
function screenToWorld(x, y) {
return { x: (x - state.camera.x) / state.camera.k, y: (y - state.camera.y) / state.camera.k };
}
function worldToScreen(x, y) {
return { x: x * state.camera.k + state.camera.x, y: y * state.camera.k + state.camera.y };
}
function fitCameraTo(bounds, animate = true) {
const pad = 110;
const width = Math.max(260, bounds.maxX - bounds.minX + pad * 2);
const height = Math.max(220, bounds.maxY - bounds.minY + pad * 2);
const nextK = Math.max(0.14, Math.min(2.8, Math.min(SCREEN.width / width, SCREEN.height / height)));
const next = {
k: nextK,
x: SCREEN.width / 2 - ((bounds.minX + bounds.maxX) / 2) * nextK,
y: SCREEN.height / 2 - ((bounds.minY + bounds.maxY) / 2) * nextK,
};
if (!animate) {
state.camera = next;
return;
}
animateCamera(next);
}
function animateCamera(next) {
const start = { ...state.camera };
const started = performance.now();
const duration = 420;
function step(now) {
const t = Math.min(1, (now - started) / duration);
const eased = 1 - Math.pow(1 - t, 3);
state.camera = {
x: start.x + (next.x - start.x) * eased,
y: start.y + (next.y - start.y) * eased,
k: start.k + (next.k - start.k) * eased,
};
applyCamera();
if (t < 1) requestAnimationFrame(step);
else render();
}
requestAnimationFrame(step);
}
function zoomAt(screenX, screenY, factor) {
const before = screenToWorld(screenX, screenY);
state.camera.k = Math.max(0.13, Math.min(3.8, state.camera.k * factor));
state.camera.x = screenX - before.x * state.camera.k;
state.camera.y = screenY - before.y * state.camera.k;
applyCamera();
window.clearTimeout(state.renderTimer);
state.renderTimer = window.setTimeout(render, 90);
}
function snapshot() {
state.undo.push(JSON.stringify(state.entries));
state.undo = state.undo.slice(-60);
@@ -2890,8 +2965,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
});
}
function layoutEntries(entries) {
const cx = 500;
const cy = 400;
const cx = WORLD.cx;
const cy = WORLD.cy;
const byLayer = new Map();
entries.forEach((entry) => {
const layer = state.mode === "layer" ? depth(entry) : state.mode === "character" ? characterBucket(entry) : depth(entry);
@@ -2901,28 +2976,31 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.positions = new Map();
[...byLayer.entries()].forEach(([layer, items]) => {
const layerNum = Number(layer);
const radius = state.mode === "layer" ? 72 + (5 - layerNum) * 54 : 80 + (5 - depth({ familyLayer: String(layerNum) })) * 55;
const radius = state.mode === "layer" ? 210 + (5 - layerNum) * 250 : 280 + (5 - depth({ familyLayer: String(layerNum) })) * 235;
const ringSpread = Math.max(1, Math.ceil(Math.sqrt(items.length)));
items.forEach((entry, index) => {
const salt = hash(entry.id) / 9999;
const angle = ((Math.PI * 2) / Math.max(1, items.length)) * index + salt;
let x = cx + Math.cos(angle) * radius;
let y = cy + Math.sin(angle) * radius * 0.72;
const lane = (index % ringSpread) * 42;
let x = cx + Math.cos(angle) * (radius + lane);
let y = cy + Math.sin(angle) * (radius + lane) * 0.72;
if (state.mode === "timeline") {
x = 120 + (index % 7) * 125;
y = 140 + Math.floor(index / 7) * 72;
x = 420 + (index % 7) * 420;
y = 520 + Math.floor(index / 7) * 190;
}
if (state.mode === "flow") {
x = 120 + depth(entry) * 145;
y = 120 + (index % 9) * 62;
x = 430 + depth(entry) * 560;
y = 420 + (index % 9) * 175;
}
state.positions.set(entry.id, { x, y });
});
});
relaxPositions(entries);
}
function layoutClusters(clusters) {
const cx = 500;
const cy = 400;
const cx = WORLD.cx;
const cy = WORLD.cy;
state.clusterPositions = new Map();
const byDepth = new Map();
clusters.forEach((cluster) => {
@@ -2931,20 +3009,20 @@ HIDDEN_APP_HTML = r"""<!doctype html>
byDepth.get(layer).push(cluster);
});
[...byDepth.entries()].forEach(([layer, items]) => {
const radius = state.mode === "timeline" || state.mode === "flow" ? 0 : 90 + (5 - Number(layer)) * 58;
const radius = state.mode === "timeline" || state.mode === "flow" ? 0 : 340 + (5 - Number(layer)) * 265;
items.forEach((cluster, index) => {
let x;
let y;
if (state.mode === "timeline") {
x = 125 + (index % 4) * 215;
y = 145 + Math.floor(index / 4) * 112;
x = 520 + (index % 4) * 860;
y = 480 + Math.floor(index / 4) * 360;
} else if (state.mode === "flow") {
x = 130 + Math.min(5, index % 6) * 145;
y = 145 + Math.floor(index / 6) * 105;
x = 420 + Math.min(5, index % 6) * 700;
y = 500 + Math.floor(index / 6) * 330;
} else if (state.mode === "character") {
const angle = (Math.PI * 2 * index) / Math.max(1, items.length);
x = cx + Math.cos(angle) * 285;
y = cy + Math.sin(angle) * 205;
x = cx + Math.cos(angle) * 1180;
y = cy + Math.sin(angle) * 840;
} else {
const angle = (Math.PI * 2 * index) / Math.max(1, items.length) + hash(cluster.id) / 8000;
x = cx + Math.cos(angle) * radius;
@@ -2954,6 +3032,30 @@ HIDDEN_APP_HTML = r"""<!doctype html>
});
});
}
function relaxPositions(entries) {
const ids = entries.map((entry) => entry.id);
const minDistance = state.focusCluster || state.focusIds ? 112 : 86;
for (let pass = 0; pass < 3; pass += 1) {
for (let i = 0; i < ids.length; i += 1) {
for (let j = i + 1; j < ids.length; j += 1) {
const a = state.positions.get(ids[i]);
const b = state.positions.get(ids[j]);
if (!a || !b) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
if (dist >= minDistance) continue;
const push = (minDistance - dist) / 2;
const ux = dx / dist;
const uy = dy / dist;
a.x -= ux * push;
a.y -= uy * push;
b.x += ux * push;
b.y += uy * push;
}
}
}
}
function characterBucket(entry) {
const first = (entry.characters || [])[0] || "Other";
const index = Math.max(0, state.meta.characters.indexOf(first));
@@ -2965,6 +3067,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
return h;
}
function render() {
updateSemanticZoom();
const scope = visibleScope();
const entries = scope.entries;
const clusters = scope.clusters;
@@ -2972,8 +3075,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
if (entries.length) layoutEntries(entries);
const graph = $("graph");
const rings = state.mode === "timeline" || state.mode === "flow" ? "" : [0,1,2,3,4,5].map((layer) => {
const radius = 80 + (5 - layer) * 55;
return `<ellipse class="ring ${layer === 5 ? "core-glow" : ""}" cx="500" cy="400" rx="${radius}" ry="${radius * 0.72}"></ellipse><text class="ring-label" x="${505 + radius}" y="${400}">Layer ${layer} - ${html(layerName(layer))}</text>`;
const radius = 280 + (5 - layer) * 235;
return `<ellipse class="ring ${layer === 5 ? "core-glow" : ""}" cx="${WORLD.cx}" cy="${WORLD.cy}" rx="${radius}" ry="${radius * 0.72}"></ellipse><text class="ring-label" x="${WORLD.cx + radius + 28}" y="${WORLD.cy}">Layer ${layer} - ${html(layerName(layer))}</text>`;
}).join("");
const clusterEdges = relationshipPairsForClusters(clusters).map((pair) => {
const a = state.clusterPositions.get(pair.source);
@@ -2988,34 +3091,38 @@ HIDDEN_APP_HTML = r"""<!doctype html>
if (!a || !b) return "";
return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`;
}).join("");
const occupied = [];
const clusterNodes = clusters.map((cluster) => {
const pos = state.clusterPositions.get(cluster.id);
const color = toneColors[cluster.tone] || "#d3a64d";
const size = Math.min(54, 20 + Math.sqrt(cluster.entries.length) * 6 + cluster.rarity);
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>
<text class="count" x="0" y="5" text-anchor="middle">${cluster.entries.length}</text>
<text x="0" y="${size + 18}" text-anchor="middle">${html(cluster.label).slice(0, 34)}</text>
</g>`;
<text class="count" x="0" y="7" text-anchor="middle">${cluster.entries.length}</text>
</g>${label}`;
}).join("");
const nodes = entries.map((entry) => {
const pos = state.positions.get(entry.id);
if (!isWorldVisible(pos.x, pos.y, 220)) return "";
const color = toneColors[entry.emotionalTone] || "#d3a64d";
const size = 5 + Math.min(10, Number(entry.resonanceScore || 3)) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 3 : 0);
const size = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
const char = characterSymbols[(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})">
<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>
<text x="${size + 5}" y="-7">${html(entry.title).slice(0, 34)}</text>
</g>`;
</g>${label}`;
}).join("");
graph.innerHTML = `${rings}${clusterEdges}${edges}${clusterNodes}${nodes}`;
graph.innerHTML = `<g id="world" transform="${cameraTransform()}">${rings}${clusterEdges}${edges}${clusterNodes}${nodes}</g>`;
graph.querySelectorAll(".cluster-node").forEach((node) => {
node.addEventListener("click", () => openCluster(node.dataset.id));
node.addEventListener("dblclick", () => openCluster(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());
@@ -3040,13 +3147,80 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("mapNote").textContent = `${scopeText} Zoom in or open an island to reveal detail. Drag memories to weave continuation threads.`;
}
function isWorldVisible(x, y, pad = 0) {
const p = worldToScreen(x, y);
return p.x >= -pad && p.x <= SCREEN.width + pad && p.y >= -pad && p.y <= SCREEN.height + pad;
}
function labelScale() {
return Math.max(0.42, Math.min(1.6, 1 / Math.max(0.42, state.camera.k)));
}
function overlaps(box, boxes) {
return boxes.some((item) => !(box.x + box.w < item.x || item.x + item.w < box.x || box.y + box.h < item.y || item.y + item.h < box.y));
}
function reserveLabel(pos, width, height, occupied) {
const screen = worldToScreen(pos.x, pos.y);
const box = { x: screen.x, y: screen.y, w: width * state.camera.k, h: height * state.camera.k };
if (overlaps(box, occupied)) return false;
occupied.push(box);
return true;
}
function labelForCluster(cluster, pos, size, occupied) {
if (state.camera.k < 0.16) return "";
const scale = labelScale();
const text = truncate(cluster.label, state.camera.k > 0.35 ? 46 : 28);
const width = Math.max(180, text.length * 8 + 34);
const height = state.camera.k > 0.34 ? 68 : 40;
const labelPos = { x: pos.x - width * scale / 2, y: pos.y + size + 28 };
if (!reserveLabel(labelPos, width * scale, height * scale, occupied)) return "";
const detail = state.camera.k > 0.34 ? `<text x="14" y="45" font-size="15">${cluster.entries.length} memories / ${html(layerName(cluster.depth))}</text>` : "";
return `<g class="cluster-label" transform="translate(${labelPos.x} ${labelPos.y}) scale(${scale})">
<rect width="${width}" height="${height}"></rect>
<text x="14" y="25" font-size="20">${html(text)}</text>
${detail}
</g>`;
}
function labelForEntry(entry, pos, size, occupied) {
const selected = entry.id === state.selectedId;
const important = selected || Number(entry.resonanceScore || 0) >= 7 || ["rare", "very rare", "seasonal", "timed"].includes(entry.rarity);
if (!selected && state.camera.k < 0.68 && !important) return "";
if (!isWorldVisible(pos.x, pos.y, 260)) return "";
const scale = labelScale();
const deep = state.camera.k > 1.25 || selected;
const text = truncate(entry.title, deep ? 72 : state.camera.k > 0.85 ? 42 : 24);
const meta = `${entry.type || "memory"} / ${entry.emotionalTone || "warm"}`;
const width = Math.max(190, Math.min(420, text.length * 8 + 42));
const height = deep ? 92 : 44;
const labelPos = { x: pos.x + size + 18, y: pos.y - height * scale / 2 };
if (!selected && !reserveLabel(labelPos, width * scale, height * scale, occupied)) return "";
const snippet = deep && entry.content ? `<text class="node-meta" x="14" y="72" font-size="14">${html(truncate(entry.content.replace(/\s+/g, " "), 58))}</text>` : "";
const metaLine = deep ? `<text class="node-meta" x="14" y="48" font-size="14">${html(meta)}</text>` : "";
return `<g class="node-label${selected ? " selected" : ""}" transform="translate(${labelPos.x} ${labelPos.y}) scale(${scale})">
<rect width="${width}" height="${height}"></rect>
<text x="14" y="27" font-size="19">${html(text)}</text>
${metaLine}${snippet}
</g>`;
}
function truncate(value, length) {
const text = String(value || "");
return text.length > length ? `${text.slice(0, Math.max(0, length - 3))}...` : text;
}
function openCluster(clusterId) {
state.history.push({ focusCluster: state.focusCluster, zoom: state.zoom, selectedId: state.selectedId });
state.focusCluster = clusterId;
state.zoom = 3;
state.focusIds = null;
render();
const first = visibleScope().entries[0];
if (first) selectNode(first.id);
const scope = visibleScope();
if (scope.entries.length) {
fitCameraTo(boundsForEntries(scope.entries), true);
selectNode(scope.entries[0].id);
}
}
function renderBreadcrumb(scope) {
@@ -3061,8 +3235,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
layoutClusters(clusters);
$("minimap").innerHTML = clusters.map((cluster) => {
const p = state.clusterPositions.get(cluster.id);
const x = Math.max(4, Math.min(96, p.x / 10));
const y = Math.max(4, Math.min(66, p.y / 11));
const x = Math.max(4, Math.min(96, p.x / WORLD.width * 100));
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("");
}
@@ -3070,8 +3244,29 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function resetMap() {
state.focusCluster = "";
state.focusIds = null;
state.zoom = 0;
render();
fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, true);
}
function boundsForEntries(entries) {
const points = entries.map((entry) => state.positions.get(entry.id)).filter(Boolean);
if (!points.length) return { minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 };
return {
minX: Math.min(...points.map((p) => p.x)),
minY: Math.min(...points.map((p) => p.y)),
maxX: Math.max(...points.map((p) => p.x)),
maxY: Math.max(...points.map((p) => p.y)),
};
}
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 });
selectNode(id);
const point = state.positions.get(id);
if (!point) return;
animateCamera({ k: 1.75, x: SCREEN.width / 2 - point.x * 1.75, y: SCREEN.height / 2 - point.y * 1.75 });
}
function selectNode(id) {
const entry = state.entries.find((item) => item.id === id) || state.entries[0];
@@ -3094,9 +3289,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
function zoomBy(delta) {
state.zoom = Math.max(0, Math.min(3, state.zoom + delta));
if (state.zoom < 2) state.focusCluster = "";
render();
zoomAt(SCREEN.width / 2, SCREEN.height / 2, delta > 0 ? 1.35 : 1 / 1.35);
}
function goBack() {
@@ -3117,8 +3310,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds, zoom: state.zoom, selectedId: state.selectedId });
state.focusCluster = "";
state.focusIds = ids;
state.zoom = 3;
render();
fitCameraTo(boundsForEntries(visibleScope().entries), true);
setStatus("focus mode is showing this memory's nearest thread first.");
}
@@ -3157,6 +3350,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("toneFilter").value = "";
}
render();
window.setTimeout(() => fitCameraTo(boundsForEntries(visibleScope().entries), true), 0);
setStatus("guided path opened. adjust the filters when you want to wander differently.");
}
function renderPreview(entry) {
@@ -3270,10 +3464,74 @@ HIDDEN_APP_HTML = r"""<!doctype html>
populateControls();
setStatus(data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open.");
selectNode(state.selectedId || state.entries[0]?.id);
fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, false);
render();
} catch (err) {
setStatus(err.message);
}
}
function setupCameraEvents() {
const graph = $("graph");
graph.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = graph.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * SCREEN.width;
const y = ((event.clientY - rect.top) / rect.height) * SCREEN.height;
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;
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() };
});
graph.addEventListener("pointermove", (event) => {
if (!state.pan || state.pan.id !== event.pointerId) return;
const dx = event.clientX - state.pan.x;
const dy = event.clientY - state.pan.y;
state.camera.x += dx * (SCREEN.width / graph.clientWidth);
state.camera.y += dy * (SCREEN.height / graph.clientHeight);
const now = performance.now();
const dt = Math.max(16, now - state.pan.last);
state.pan.vx = dx / dt;
state.pan.vy = dy / dt;
state.pan.x = event.clientX;
state.pan.y = event.clientY;
state.pan.last = now;
applyCamera();
});
graph.addEventListener("pointerup", (event) => finishPan(event.pointerId));
graph.addEventListener("pointercancel", (event) => finishPan(event.pointerId));
graph.addEventListener("dblclick", (event) => {
if (event.target.closest(".memory-node, .cluster-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;
zoomAt(x, y, 1.55);
});
}
function finishPan(pointerId) {
const graph = $("graph");
if (!state.pan || state.pan.id !== pointerId) return;
graph.classList.remove("is-panning");
const vx = state.pan.vx * 180;
const vy = state.pan.vy * 180;
state.pan = null;
let decay = 1;
function glide() {
if (decay < 0.04 || state.pan) return;
state.camera.x += vx * decay;
state.camera.y += vy * decay;
applyCamera();
decay *= 0.82;
requestAnimationFrame(glide);
}
requestAnimationFrame(glide);
window.clearTimeout(state.renderTimer);
state.renderTimer = window.setTimeout(render, 120);
}
fields.forEach((id) => {
$(id).addEventListener("input", applyFormToState);
$(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
@@ -3299,6 +3557,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
render();
}));
document.querySelectorAll("[data-tour]").forEach((button) => button.addEventListener("click", () => runTour(button.dataset.tour)));
setupCameraEvents();
load();
</script>
</body>