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