This commit is contained in:
@@ -2396,7 +2396,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 { 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; }
|
.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; }
|
.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.echo { stroke: rgba(117, 169, 189, 0.45); stroke-dasharray: 4 5; }
|
||||||
.edge.theme, .edge.symbol { stroke: rgba(127, 176, 137, 0.38); }
|
.edge.theme, .edge.symbol { stroke: rgba(127, 176, 137, 0.38); }
|
||||||
.memory-node { cursor: pointer; transition: opacity 160ms ease; }
|
.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 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.selected circle { stroke: #fff3c7; stroke-width: 3; }
|
||||||
.memory-node.dragging circle { stroke: #f3cd7a; 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-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; }
|
.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 {
|
.breadcrumb, .minimap, .guide-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -2643,7 +2654,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const draftKey = "hiddenNarrativeObservatoryDraft:v1";
|
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 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 $ = (id) => document.getElementById(id);
|
||||||
const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" };
|
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;
|
$("status").textContent = message;
|
||||||
$("autosave").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() {
|
function snapshot() {
|
||||||
state.undo.push(JSON.stringify(state.entries));
|
state.undo.push(JSON.stringify(state.entries));
|
||||||
state.undo = state.undo.slice(-60);
|
state.undo = state.undo.slice(-60);
|
||||||
@@ -2890,8 +2965,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function layoutEntries(entries) {
|
function layoutEntries(entries) {
|
||||||
const cx = 500;
|
const cx = WORLD.cx;
|
||||||
const cy = 400;
|
const cy = WORLD.cy;
|
||||||
const byLayer = new Map();
|
const byLayer = new Map();
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const layer = state.mode === "layer" ? depth(entry) : state.mode === "character" ? characterBucket(entry) : depth(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();
|
state.positions = new Map();
|
||||||
[...byLayer.entries()].forEach(([layer, items]) => {
|
[...byLayer.entries()].forEach(([layer, items]) => {
|
||||||
const layerNum = Number(layer);
|
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) => {
|
items.forEach((entry, index) => {
|
||||||
const salt = hash(entry.id) / 9999;
|
const salt = hash(entry.id) / 9999;
|
||||||
const angle = ((Math.PI * 2) / Math.max(1, items.length)) * index + salt;
|
const angle = ((Math.PI * 2) / Math.max(1, items.length)) * index + salt;
|
||||||
let x = cx + Math.cos(angle) * radius;
|
const lane = (index % ringSpread) * 42;
|
||||||
let y = cy + Math.sin(angle) * radius * 0.72;
|
let x = cx + Math.cos(angle) * (radius + lane);
|
||||||
|
let y = cy + Math.sin(angle) * (radius + lane) * 0.72;
|
||||||
if (state.mode === "timeline") {
|
if (state.mode === "timeline") {
|
||||||
x = 120 + (index % 7) * 125;
|
x = 420 + (index % 7) * 420;
|
||||||
y = 140 + Math.floor(index / 7) * 72;
|
y = 520 + Math.floor(index / 7) * 190;
|
||||||
}
|
}
|
||||||
if (state.mode === "flow") {
|
if (state.mode === "flow") {
|
||||||
x = 120 + depth(entry) * 145;
|
x = 430 + depth(entry) * 560;
|
||||||
y = 120 + (index % 9) * 62;
|
y = 420 + (index % 9) * 175;
|
||||||
}
|
}
|
||||||
state.positions.set(entry.id, { x, y });
|
state.positions.set(entry.id, { x, y });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
relaxPositions(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutClusters(clusters) {
|
function layoutClusters(clusters) {
|
||||||
const cx = 500;
|
const cx = WORLD.cx;
|
||||||
const cy = 400;
|
const cy = WORLD.cy;
|
||||||
state.clusterPositions = new Map();
|
state.clusterPositions = new Map();
|
||||||
const byDepth = new Map();
|
const byDepth = new Map();
|
||||||
clusters.forEach((cluster) => {
|
clusters.forEach((cluster) => {
|
||||||
@@ -2931,20 +3009,20 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
byDepth.get(layer).push(cluster);
|
byDepth.get(layer).push(cluster);
|
||||||
});
|
});
|
||||||
[...byDepth.entries()].forEach(([layer, items]) => {
|
[...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) => {
|
items.forEach((cluster, index) => {
|
||||||
let x;
|
let x;
|
||||||
let y;
|
let y;
|
||||||
if (state.mode === "timeline") {
|
if (state.mode === "timeline") {
|
||||||
x = 125 + (index % 4) * 215;
|
x = 520 + (index % 4) * 860;
|
||||||
y = 145 + Math.floor(index / 4) * 112;
|
y = 480 + Math.floor(index / 4) * 360;
|
||||||
} else if (state.mode === "flow") {
|
} else if (state.mode === "flow") {
|
||||||
x = 130 + Math.min(5, index % 6) * 145;
|
x = 420 + Math.min(5, index % 6) * 700;
|
||||||
y = 145 + Math.floor(index / 6) * 105;
|
y = 500 + Math.floor(index / 6) * 330;
|
||||||
} else if (state.mode === "character") {
|
} else if (state.mode === "character") {
|
||||||
const angle = (Math.PI * 2 * index) / Math.max(1, items.length);
|
const angle = (Math.PI * 2 * index) / Math.max(1, items.length);
|
||||||
x = cx + Math.cos(angle) * 285;
|
x = cx + Math.cos(angle) * 1180;
|
||||||
y = cy + Math.sin(angle) * 205;
|
y = cy + Math.sin(angle) * 840;
|
||||||
} else {
|
} else {
|
||||||
const angle = (Math.PI * 2 * index) / Math.max(1, items.length) + hash(cluster.id) / 8000;
|
const angle = (Math.PI * 2 * index) / Math.max(1, items.length) + hash(cluster.id) / 8000;
|
||||||
x = cx + Math.cos(angle) * radius;
|
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) {
|
function characterBucket(entry) {
|
||||||
const first = (entry.characters || [])[0] || "Other";
|
const first = (entry.characters || [])[0] || "Other";
|
||||||
const index = Math.max(0, state.meta.characters.indexOf(first));
|
const index = Math.max(0, state.meta.characters.indexOf(first));
|
||||||
@@ -2965,6 +3067,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
function render() {
|
function render() {
|
||||||
|
updateSemanticZoom();
|
||||||
const scope = visibleScope();
|
const scope = visibleScope();
|
||||||
const entries = scope.entries;
|
const entries = scope.entries;
|
||||||
const clusters = scope.clusters;
|
const clusters = scope.clusters;
|
||||||
@@ -2972,8 +3075,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
if (entries.length) layoutEntries(entries);
|
if (entries.length) layoutEntries(entries);
|
||||||
const graph = $("graph");
|
const graph = $("graph");
|
||||||
const rings = state.mode === "timeline" || state.mode === "flow" ? "" : [0,1,2,3,4,5].map((layer) => {
|
const rings = state.mode === "timeline" || state.mode === "flow" ? "" : [0,1,2,3,4,5].map((layer) => {
|
||||||
const radius = 80 + (5 - layer) * 55;
|
const radius = 280 + (5 - layer) * 235;
|
||||||
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>`;
|
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("");
|
}).join("");
|
||||||
const clusterEdges = relationshipPairsForClusters(clusters).map((pair) => {
|
const clusterEdges = relationshipPairsForClusters(clusters).map((pair) => {
|
||||||
const a = state.clusterPositions.get(pair.source);
|
const a = state.clusterPositions.get(pair.source);
|
||||||
@@ -2988,34 +3091,38 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
if (!a || !b) return "";
|
if (!a || !b) return "";
|
||||||
return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`;
|
return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
const occupied = [];
|
||||||
const clusterNodes = clusters.map((cluster) => {
|
const clusterNodes = clusters.map((cluster) => {
|
||||||
const pos = state.clusterPositions.get(cluster.id);
|
const pos = state.clusterPositions.get(cluster.id);
|
||||||
const color = toneColors[cluster.tone] || "#d3a64d";
|
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})">
|
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 class="cluster-halo" r="${size + 13}"></circle>
|
||||||
<circle r="${size}" fill="${color}" opacity="0.58"></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 class="count" x="0" y="7" text-anchor="middle">${cluster.entries.length}</text>
|
||||||
<text x="0" y="${size + 18}" text-anchor="middle">${html(cluster.label).slice(0, 34)}</text>
|
</g>${label}`;
|
||||||
</g>`;
|
|
||||||
}).join("");
|
}).join("");
|
||||||
const nodes = entries.map((entry) => {
|
const nodes = entries.map((entry) => {
|
||||||
const pos = state.positions.get(entry.id);
|
const pos = state.positions.get(entry.id);
|
||||||
|
if (!isWorldVisible(pos.x, pos.y, 220)) return "";
|
||||||
const color = toneColors[entry.emotionalTone] || "#d3a64d";
|
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 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})">
|
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>
|
<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="0" y="4" text-anchor="middle">${html(char)}</text>
|
||||||
<text x="${size + 5}" y="-7">${html(entry.title).slice(0, 34)}</text>
|
</g>${label}`;
|
||||||
</g>`;
|
|
||||||
}).join("");
|
}).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) => {
|
graph.querySelectorAll(".cluster-node").forEach((node) => {
|
||||||
node.addEventListener("click", () => openCluster(node.dataset.id));
|
node.addEventListener("click", () => openCluster(node.dataset.id));
|
||||||
|
node.addEventListener("dblclick", () => openCluster(node.dataset.id));
|
||||||
});
|
});
|
||||||
graph.querySelectorAll(".memory-node").forEach((node) => {
|
graph.querySelectorAll(".memory-node").forEach((node) => {
|
||||||
node.addEventListener("click", () => selectNode(node.dataset.id));
|
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("dragstart", () => { state.draggingId = node.dataset.id; node.classList.add("dragging"); });
|
||||||
node.addEventListener("dragend", () => node.classList.remove("dragging"));
|
node.addEventListener("dragend", () => node.classList.remove("dragging"));
|
||||||
node.addEventListener("dragover", (event) => event.preventDefault());
|
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.`;
|
$("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) {
|
function openCluster(clusterId) {
|
||||||
state.history.push({ focusCluster: state.focusCluster, zoom: state.zoom, selectedId: state.selectedId });
|
state.history.push({ focusCluster: state.focusCluster, zoom: state.zoom, selectedId: state.selectedId });
|
||||||
state.focusCluster = clusterId;
|
state.focusCluster = clusterId;
|
||||||
state.zoom = 3;
|
state.focusIds = null;
|
||||||
render();
|
render();
|
||||||
const first = visibleScope().entries[0];
|
const scope = visibleScope();
|
||||||
if (first) selectNode(first.id);
|
if (scope.entries.length) {
|
||||||
|
fitCameraTo(boundsForEntries(scope.entries), true);
|
||||||
|
selectNode(scope.entries[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBreadcrumb(scope) {
|
function renderBreadcrumb(scope) {
|
||||||
@@ -3061,8 +3235,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
layoutClusters(clusters);
|
layoutClusters(clusters);
|
||||||
$("minimap").innerHTML = clusters.map((cluster) => {
|
$("minimap").innerHTML = clusters.map((cluster) => {
|
||||||
const p = state.clusterPositions.get(cluster.id);
|
const p = state.clusterPositions.get(cluster.id);
|
||||||
const x = Math.max(4, Math.min(96, p.x / 10));
|
const x = Math.max(4, Math.min(96, p.x / WORLD.width * 100));
|
||||||
const y = Math.max(4, Math.min(66, p.y / 11));
|
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>`;
|
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("");
|
}).join("");
|
||||||
}
|
}
|
||||||
@@ -3070,8 +3244,29 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
function resetMap() {
|
function resetMap() {
|
||||||
state.focusCluster = "";
|
state.focusCluster = "";
|
||||||
state.focusIds = null;
|
state.focusIds = null;
|
||||||
state.zoom = 0;
|
|
||||||
render();
|
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) {
|
function selectNode(id) {
|
||||||
const entry = state.entries.find((item) => item.id === id) || state.entries[0];
|
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) {
|
function zoomBy(delta) {
|
||||||
state.zoom = Math.max(0, Math.min(3, state.zoom + delta));
|
zoomAt(SCREEN.width / 2, SCREEN.height / 2, delta > 0 ? 1.35 : 1 / 1.35);
|
||||||
if (state.zoom < 2) state.focusCluster = "";
|
|
||||||
render();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
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.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds, zoom: state.zoom, selectedId: state.selectedId });
|
||||||
state.focusCluster = "";
|
state.focusCluster = "";
|
||||||
state.focusIds = ids;
|
state.focusIds = ids;
|
||||||
state.zoom = 3;
|
|
||||||
render();
|
render();
|
||||||
|
fitCameraTo(boundsForEntries(visibleScope().entries), true);
|
||||||
setStatus("focus mode is showing this memory's nearest thread first.");
|
setStatus("focus mode is showing this memory's nearest thread first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3157,6 +3350,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
$("toneFilter").value = "";
|
$("toneFilter").value = "";
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
|
window.setTimeout(() => fitCameraTo(boundsForEntries(visibleScope().entries), true), 0);
|
||||||
setStatus("guided path opened. adjust the filters when you want to wander differently.");
|
setStatus("guided path opened. adjust the filters when you want to wander differently.");
|
||||||
}
|
}
|
||||||
function renderPreview(entry) {
|
function renderPreview(entry) {
|
||||||
@@ -3270,10 +3464,74 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
populateControls();
|
populateControls();
|
||||||
setStatus(data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open.");
|
setStatus(data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open.");
|
||||||
selectNode(state.selectedId || state.entries[0]?.id);
|
selectNode(state.selectedId || state.entries[0]?.id);
|
||||||
|
fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, false);
|
||||||
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(err.message);
|
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) => {
|
fields.forEach((id) => {
|
||||||
$(id).addEventListener("input", applyFormToState);
|
$(id).addEventListener("input", applyFormToState);
|
||||||
$(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
|
$(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
|
||||||
@@ -3299,6 +3557,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
|||||||
render();
|
render();
|
||||||
}));
|
}));
|
||||||
document.querySelectorAll("[data-tour]").forEach((button) => button.addEventListener("click", () => runTour(button.dataset.tour)));
|
document.querySelectorAll("[data-tour]").forEach((button) => button.addEventListener("click", () => runTour(button.dataset.tour)));
|
||||||
|
setupCameraEvents();
|
||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user