// The graph: every note a point, every [[link]] a line, laid out by a // small force simulation — notes push each other apart, links pull their // ends together, and the whole drifts to the middle. Drag a note to move // it, drag the space to pan, scroll or pinch to zoom, click to open. // A canvas with data-graph="local" and data-focus="" draws only that // note and its neighbours. Colours are the tokens', read from the page. const charts = new WeakSet(); function data() { try { return JSON.parse(document.getElementById("graph")?.textContent ?? ""); } catch { return { nodes: [], edges: [] }; } } function colours() { const css = getComputedStyle(document.documentElement); const v = (name) => css.getPropertyValue(name).trim(); return { node: v("--color-graph-node"), edge: v("--color-graph-edge"), accent: v("--color-accent"), label: v("--color-fg"), quiet: v("--color-muted"), bg: v("--color-bg"), }; } /** A small deterministic spread, so the same vault opens the same way. */ const jitter = (id, k) => { const x = Math.sin(id * 12.9898 + k * 78.233) * 43758.5453; return x - Math.floor(x) - 0.5; }; function chart(canvas) { if (charts.has(canvas)) return; charts.add(canvas); const all = data(); const focus = canvas.dataset.focus ? Number(canvas.dataset.focus) : null; let nodes = all.nodes; let edges = all.edges; if (focus !== null) { const near = new Set([focus]); for (const [a, b] of edges) { if (a === focus) near.add(b); if (b === focus) near.add(a); } nodes = nodes.filter((n) => near.has(n.id)); edges = edges.filter(([a, b]) => near.has(a) && near.has(b)); } const local = focus !== null; const byId = new Map(); const ps = nodes.map((n, i) => { const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2; const r = 40 + Math.sqrt(nodes.length) * 18; const p = { ...n, x: n.id === focus ? 0 : Math.cos(angle) * r + jitter(n.id, 1) * 30, y: n.id === focus ? 0 : Math.sin(angle) * r + jitter(n.id, 2) * 30, vx: 0, vy: 0, r: (local ? 4 : 3) + Math.sqrt(n.links) * 1.7, fixed: false, }; byId.set(n.id, p); return p; }); const links = edges.map(([a, b]) => [byId.get(a), byId.get(b)]).filter(([a, b]) => a && b); const neighbours = new Map(ps.map((p) => [p.id, new Set()])); for (const [a, b] of links) { neighbours.get(a.id).add(b.id); neighbours.get(b.id).add(a.id); } const ctx = canvas.getContext("2d"); let c = colours(); let w = 0; let h = 0; let view = { x: 0, y: 0, k: local ? 1.1 : 1 }; let hover = null; let alpha = 1; let running = false; const still = matchMedia("(prefers-reduced-motion: reduce)").matches; function size() { const r = canvas.getBoundingClientRect(); const dpr = devicePixelRatio || 1; w = r.width; h = r.height; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); } function tick() { const n = ps.length; const repel = local ? 900 : 1400; for (let i = 0; i < n; i++) { const a = ps[i]; for (let j = i + 1; j < n; j++) { const b = ps[j]; let dx = a.x - b.x; let dy = a.y - b.y; let d2 = dx * dx + dy * dy; if (d2 < 0.01) { dx = jitter(a.id, j); dy = jitter(b.id, i); d2 = 0.01; } if (d2 > 250000) continue; const f = (repel * alpha) / d2; const d = Math.sqrt(d2); a.vx += (dx / d) * f; a.vy += (dy / d) * f; b.vx -= (dx / d) * f; b.vy -= (dy / d) * f; } } const rest = local ? 70 : 60; for (const [a, b] of links) { const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const f = (d - rest) * 0.04 * alpha; a.vx += (dx / d) * f; a.vy += (dy / d) * f; b.vx -= (dx / d) * f; b.vy -= (dy / d) * f; } for (const p of ps) { p.vx -= p.x * 0.012 * alpha; p.vy -= p.y * 0.012 * alpha; if (p.fixed || (local && p.id === focus)) { p.vx = p.vy = 0; if (local && p.id === focus && !p.fixed) p.x = p.y = 0; continue; } p.vx *= 0.82; p.vy *= 0.82; p.x += Math.max(-30, Math.min(30, p.vx)); p.y += Math.max(-30, Math.min(30, p.vy)); } alpha = Math.max(0, alpha * 0.985 - 0.0005); } // Once the layout has mostly settled, frame it: the whole vault in view, // never blown up past a comfortable size. Only once — after that the // view is yours. let framed = false; function fit() { framed = true; if (ps.length === 0 || w === 0) return; let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; for (const p of ps) { x0 = Math.min(x0, p.x - p.r); y0 = Math.min(y0, p.y - p.r); x1 = Math.max(x1, p.x + p.r); y1 = Math.max(y1, p.y + p.r + 18); } const k = Math.min((w * 0.8) / Math.max(1, x1 - x0), (h * 0.75) / Math.max(1, y1 - y0), local ? 1.6 : 2.4); view = { x: -(x0 + x1) / 2, y: -(y0 + y1) / 2, k: Math.max(0.25, k) }; } function loop() { if (!framed && alpha < 0.3 && !drag) fit(); if (alpha <= 0.002) { running = false; draw(); return; } tick(); draw(); requestAnimationFrame(loop); } function heat(to = 0.6) { alpha = Math.max(alpha, to); if (still) { for (let i = 0; i < 400 && alpha > 0.002; i++) tick(); if (!framed) fit(); draw(); return; } if (!running) { running = true; requestAnimationFrame(loop); } } const screen = (p) => ({ x: w / 2 + (p.x + view.x) * view.k, y: h / 2 + (p.y + view.y) * view.k }); const world = (x, y) => ({ x: (x - w / 2) / view.k - view.x, y: (y - h / 2) / view.k - view.y }); function draw() { ctx.clearRect(0, 0, w, h); const lit = hover ?? (local ? byId.get(focus) : null); const near = lit ? neighbours.get(lit.id) : null; ctx.lineWidth = 1; for (const [a, b] of links) { const on = lit && (a === lit || b === lit); ctx.strokeStyle = on ? c.accent : c.edge; ctx.globalAlpha = lit && !on ? 0.35 : on ? 0.9 : 0.75; const p = screen(a); const q = screen(b); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); } const labels = local || view.k > 1.15 || ps.length <= 30; ctx.font = `${local ? 11 : 12}px system-ui, -apple-system, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; for (const p of ps) { const s = screen(p); const r = p.r * Math.sqrt(view.k); const on = lit && (p === lit || near.has(p.id)); ctx.globalAlpha = lit && !on ? 0.35 : 1; ctx.fillStyle = p === lit || p.id === focus ? c.accent : c.node; ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.fill(); if (labels || on) { const t = p.title.length > 28 ? `${p.title.slice(0, 27)}…` : p.title; ctx.globalAlpha = lit && !on ? 0.3 : 1; ctx.fillStyle = p === lit ? c.label : c.quiet; ctx.fillText(t, s.x, s.y + r + 4); } } ctx.globalAlpha = 1; } function at(x, y) { let best = null; let bestD = Infinity; for (const p of ps) { const s = screen(p); const d = Math.hypot(s.x - x, s.y - y); const reach = Math.max(10, p.r * Math.sqrt(view.k) + 4); if (d < reach && d < bestD) { best = p; bestD = d; } } return best; } // Pointers: one drags a note or pans; two pinch. const pointers = new Map(); let drag = null; let moved = 0; let pinch = null; const local2 = (e) => { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; canvas.addEventListener("pointerdown", (e) => { canvas.setPointerCapture(e.pointerId); const p = local2(e); pointers.set(e.pointerId, p); moved = 0; if (pointers.size === 2) { const [a, b] = [...pointers.values()]; pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), k: view.k }; drag = null; return; } const node = at(p.x, p.y); drag = node ? { node, ...p } : { pan: true, ...p, vx: view.x, vy: view.y }; if (node) { node.fixed = true; heat(0.3); } }); canvas.addEventListener("pointermove", (e) => { const p = local2(e); if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p); if (pinch && pointers.size === 2) { const [a, b] = [...pointers.values()]; view.k = Math.max(0.2, Math.min(5, (pinch.k * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d)); draw(); return; } if (drag) { moved += Math.abs(e.movementX) + Math.abs(e.movementY); if (drag.node) { const q = world(p.x, p.y); drag.node.x = q.x; drag.node.y = q.y; heat(0.3); } else { view.x = drag.vx + (p.x - drag.x) / view.k; view.y = drag.vy + (p.y - drag.y) / view.k; } draw(); return; } const over = at(p.x, p.y); if (over !== hover) { hover = over; canvas.style.cursor = over ? "pointer" : "grab"; draw(); } }); const up = (e) => { pointers.delete(e.pointerId); if (pointers.size < 2) pinch = null; if (!drag) return; if (drag.node) { drag.node.fixed = false; if (moved < 6) location.href = `/n/${drag.node.id}`; } drag = null; }; canvas.addEventListener("pointerup", up); canvas.addEventListener("pointercancel", up); canvas.addEventListener("pointerleave", () => { if (hover && !drag) { hover = null; draw(); } }); canvas.addEventListener( "wheel", (e) => { e.preventDefault(); const p = local2(e); const before = world(p.x, p.y); view.k = Math.max(0.2, Math.min(5, view.k * Math.exp(-e.deltaY * 0.0015))); const after = world(p.x, p.y); view.x += after.x - before.x; view.y += after.y - before.y; draw(); }, { passive: false }, ); // The keyboard reaches what the mouse does: Tab focuses the canvas, arrows // pan, + and - zoom. canvas.tabIndex = 0; canvas.addEventListener("keydown", (e) => { const step = 40 / view.k; const moves = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; if (moves[e.key]) { view.x += moves[e.key][0]; view.y += moves[e.key][1]; } else if (e.key === "+" || e.key === "=") view.k = Math.min(5, view.k * 1.2); else if (e.key === "-") view.k = Math.max(0.2, view.k / 1.2); else return; e.preventDefault(); draw(); }); canvas.style.cursor = "grab"; new ResizeObserver(size).observe(canvas); document.addEventListener("theme", () => { c = colours(); draw(); }); matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { c = colours(); draw(); }); size(); heat(1); } const start = () => document.querySelectorAll("canvas[data-graph]").forEach(chart); start(); document.addEventListener("live:swapped", start);