/* Aurelius — 2D graph explorer (HTML canvas + d3-force). * * Replaces the old WebGL/three.js renderer. A canvas draws hundreds of * nodes smoothly; d3-force lays them out; d3-zoom drives pan/zoom. The * design goals, borrowed from Obsidian / Kumu / Observable graph views: * * · minimalist ink-on-dark palette — the found path is the only thing * that shouts; the exploration cloud recedes to faint context * · curved edges (quadratic beziers) so parallel links don't overlap * · gentle kind-based clustering (typed sources group by what nodes ARE) * · progressive expansion — click a node to pull its real neighbours in * · focus mode — click a node to spotlight its neighbourhood, dim the rest * · auto-fit that follows the graph as it grows and backs off the instant * the user grabs the camera (resume with GV.fit()) * · label level-of-detail — text only on important / hovered nodes * * It runs the simulation directly over the SAME node objects app.js keeps * in its `nodes` map (positions live on those objects), so the WebSocket * flow is unchanged. One global, GV, matching the old renderer's surface: * GV.available() always true (canvas is universal) * GV.init(el, opts) build the renderer; opts.onExpand(id) * GV.sync(nodes, edges, ctx) reconcile with current state * GV.focus(id) ease the camera to a node * GV.fit() frame everything + resume auto-follow * GV.reset() clear the graph */ (function () { 'use strict'; const PALETTE = { centre: '#e2a45c', target: '#4aab79', start: '#e4b254', open: '#c99a4e', closed: '#5f5a4a', gated: '#7a3b3b', pathLive: '#e0864a', pathFound: '#5cd6a0', dim: 'rgba(150,140,110,0.28)', ring: '#0c0b08', link: 'rgba(201,158,90,0.22)', linkDim: 'rgba(140,124,84,0.09)', linkPath: 'rgba(224,134,74,0.9)', linkPathFound: 'rgba(92,214,160,0.95)', label: '#c8bd98', labelDim: 'rgba(150,140,110,0.5)', labelKey: '#efe9d6', }; // Node kind → colour: typed graphs (finance/biology/news) cluster and // colour by what a node IS, not just its search state. const KIND_COLORS = { company: '#c98a2e', etf: '#8b7cf7', sector: '#4aab79', executive: '#e0995c', country: '#5b8dd6', macro: '#d65b8d', person: '#e0995c', organization: '#8b7cf7', place: '#5b8dd6', gene: '#4aab79', protein: '#8b7cf7', disease: '#d65b8d', drug: '#c98a2e', pathway: '#5b8dd6', article: '#8a86a8', paper: '#8a86a8', entity: '#c98a2e', }; let canvas = null, g = null, container = null; let sim = null, onExpand = null; let dpr = 1, width = 0, height = 0; let zoomBehavior = null, transform = null; // d3.zoomIdentity at init let nodesArr = [], linksArr = []; const nodeById = new Map(); let lastCtx = {}; let hoverNode = null, hoverEdge = null, focusId = null; let userNav = false, lastCount = -1, fitCooldown = 0; let raf = 0, fitTarget = null; // desired transform we ease toward // ── helpers ────────────────────────────────────────────────────────── // Display name: prefer a human title (papers carry an opaque id like // "W2100837269"); Wikipedia/finance nodes fall back to the id, which IS // their title. const nameOf = (n) => n.title || n.id; const found = (c) => !!(c && c.foundPath && c.foundPath.length); const onPath = (id, c) => !!(c && c.pathNodes && c.pathNodes.has(id)); const isKey = (n, c) => n.state === 'centre' || n.state === 'target' || n.state === 'start' || onPath(n.id, c); function nodeColor(n, c) { if (n.state === 'centre') return PALETTE.centre; if (n.state === 'target') return PALETTE.target; if (n.state === 'start') return PALETTE.start; if (onPath(n.id, c)) return found(c) ? PALETTE.pathFound : PALETTE.pathLive; if ((n.state === 'open') && (n.kind || n.expanded)) { return KIND_COLORS[n.kind] || PALETTE.open; } if (found(c)) return PALETTE.dim; if (n.state === 'closed') return PALETTE.closed; if (n.state === 'gated') return PALETTE.gated; return PALETTE.open; } function nodeRadius(n, c) { if (n.state === 'centre') return 11; if (n.state === 'target' || n.state === 'start') return 9.5; if (onPath(n.id, c)) return 7.5; if (n.kind || n.expanded) return 5.5; return 4.2; } function labelWorthy(n, c) { return isKey(n, c) || n === hoverNode || (focusId && (n.id === focusId || isNeighborOfFocus(n.id))); } function passesFilter(n, c) { const f = (c && c.filter) || 'all'; if (isKey(n, c)) return true; if (f === 'all') return true; if (f === 'path') return false; if (f === 'explored') return n.state !== 'open'; return true; } // ── focus mode neighbourhood ───────────────────────────────────────── let focusNeighbors = new Set(); function recomputeFocusNeighbors() { focusNeighbors = new Set(); if (!focusId) return; for (const l of linksArr) { const s = l.source.id || l.source, t = l.target.id || l.target; if (s === focusId) focusNeighbors.add(t); else if (t === focusId) focusNeighbors.add(s); } } const isNeighborOfFocus = (id) => focusNeighbors.has(id); function dimmedByFocus(id) { return focusId && id !== focusId && !focusNeighbors.has(id); } // ── clustering anchors (typed sources group by kind) ───────────────── const kindAnchors = new Map(); function anchorFor(kind) { if (!kind) return null; if (!kindAnchors.has(kind)) { // deterministic angle from the kind string so a kind keeps its side let h = 0; for (let i = 0; i < kind.length; i++) h = (h * 31 + kind.charCodeAt(i)) | 0; const ang = (Math.abs(h) % 360) * Math.PI / 180; kindAnchors.set(kind, { x: Math.cos(ang), y: Math.sin(ang) }); } return kindAnchors.get(kind); } // ── canvas plumbing ────────────────────────────────────────────────── function resize() { if (!container || !canvas) return; width = container.clientWidth || 800; height = container.clientHeight || 600; dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; draw(); } function kick() { // Single rAF loop drives everything: it eases the camera toward // `fitTarget` with a cheap lerp (NO d3 transitions — those stacked up // and pegged the main thread), re-frames on a throttle while the layout // is warm, and self-stops once the sim is cool AND the camera arrived. if (raf) return; const step = () => { // periodically recompute the follow target while warm (unless the // user grabbed the camera) if (!userNav && sim && sim.alpha() > 0.02 && Date.now() >= fitCooldown) { fitCooldown = Date.now() + 450; fitTarget = computeFitTarget(); } let easing = false; if (fitTarget) { const cur = transform; const k = cur.k + (fitTarget.k - cur.k) * 0.16; const x = cur.x + (fitTarget.x - cur.x) * 0.16; const y = cur.y + (fitTarget.y - cur.y) * 0.16; if (Math.abs(k - fitTarget.k) < 1e-3 && Math.abs(x - fitTarget.x) < 0.5 && Math.abs(y - fitTarget.y) < 0.5) { applyTransform(fitTarget); fitTarget = null; } else { applyTransform(d3.zoomIdentity.translate(x, y).scale(k)); easing = true; } } draw(); const warm = sim && sim.alpha() > 0.004; if (warm || easing) raf = requestAnimationFrame(step); else raf = 0; }; raf = requestAnimationFrame(step); } // set the zoom transform instantly, keeping d3-zoom's internal state in // sync so the next user gesture doesn't jump. Programmatic (no sourceEvent) // so the zoom handler won't flag it as user navigation. function applyTransform(t) { transform = t; if (zoomBehavior) d3.select(canvas).property('__zoom', t); } // ── draw ───────────────────────────────────────────────────────────── function draw() { if (!g) return; g.save(); g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, width, height); const t = transform; g.translate(t.x, t.y); g.scale(t.k, t.k); const c = lastCtx; const isFound = found(c); const now = performance.now(); // edges first g.lineCap = 'round'; for (const l of linksArr) { const a = l.source, b = l.target; if (a.x == null || b.x == null) continue; const path = !!l.isPath; const dim = dimmedByFocus(a.id) && dimmedByFocus(b.id); let col; if (path) col = (isFound ? PALETTE.linkPathFound : PALETTE.linkPath); else col = (isFound || dim) ? PALETTE.linkDim : PALETTE.link; g.strokeStyle = col; g.lineWidth = (path ? 2.4 : 1) / t.k; // curved edge: perpendicular offset at the midpoint const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; const dx = b.x - a.x, dy = b.y - a.y; const curve = path ? 0 : 0.12; const cx = mx - dy * curve, cy = my + dx * curve; g.beginPath(); g.moveTo(a.x, a.y); g.quadraticCurveTo(cx, cy, b.x, b.y); g.stroke(); } // nodes for (const n of nodesArr) { if (n.x == null) continue; const r = nodeRadius(n, c); const appear = n.__appear ? Math.min(1, (now - n.__appear) / 380) : 1; const rr = r * (0.4 + 0.6 * appear); let col = nodeColor(n, c); let alpha = 1; if (dimmedByFocus(n.id)) alpha = 0.22; else if (isFound && !onPath(n.id, c) && !isKey(n, c)) alpha = 0.5; g.globalAlpha = alpha * appear; // subtle glow for key nodes if (isKey(n, c) || n === hoverNode) { g.shadowColor = col; g.shadowBlur = 14 / Math.sqrt(t.k); } else { g.shadowBlur = 0; } g.beginPath(); g.arc(n.x, n.y, rr, 0, 2 * Math.PI); g.fillStyle = col; g.fill(); g.shadowBlur = 0; // ring g.lineWidth = 1.6 / t.k; g.strokeStyle = PALETTE.ring; g.stroke(); if (n === hoverNode) { g.beginPath(); g.arc(n.x, n.y, rr + 3 / t.k, 0, 2 * Math.PI); g.strokeStyle = 'rgba(240,232,210,0.7)'; g.lineWidth = 1.4 / t.k; g.stroke(); } } g.globalAlpha = 1; // labels (LOD) — only key / hovered / focused nodes, and only when // zoomed in enough to be readable const showAll = t.k > 1.35; g.font = `500 ${12 / t.k}px Outfit, system-ui, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'top'; for (const n of nodesArr) { if (n.x == null) continue; const key = isKey(n, c); if (!key && !showAll && n !== hoverNode && !(focusId && (n.id === focusId || focusNeighbors.has(n.id)))) continue; if (dimmedByFocus(n.id)) continue; const r = nodeRadius(n, c); const label = truncate(nameOf(n), key ? 30 : 22); g.globalAlpha = key || n === hoverNode ? 1 : 0.75; g.fillStyle = n.state === 'target' ? PALETTE.target : (n.state === 'centre' || n.state === 'start') ? PALETTE.labelKey : PALETTE.label; // legibility: dark pill behind key labels if (key || n === hoverNode) { const w = g.measureText(label).width; g.globalAlpha = 0.55; g.fillStyle = 'rgba(8,7,5,0.72)'; roundRect(g, n.x - w / 2 - 5 / t.k, n.y + r + 3 / t.k, w + 10 / t.k, 16 / t.k, 4 / t.k); g.fill(); g.globalAlpha = 1; g.fillStyle = n.state === 'target' ? PALETTE.target : PALETTE.labelKey; } g.fillText(label, n.x, n.y + r + 5 / t.k); } g.globalAlpha = 1; // edge tooltip label on hover if (hoverEdge && hoverEdge.display) { const a = hoverEdge.source, b = hoverEdge.target; if (a.x != null && b.x != null) { const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; g.font = `500 ${11 / t.k}px Outfit, system-ui, sans-serif`; const w = g.measureText(hoverEdge.display).width; g.fillStyle = 'rgba(8,7,5,0.85)'; roundRect(g, mx - w / 2 - 6 / t.k, my - 9 / t.k, w + 12 / t.k, 18 / t.k, 4 / t.k); g.fill(); g.fillStyle = '#e8dcbc'; g.textBaseline = 'middle'; g.fillText(hoverEdge.display, mx, my); g.textBaseline = 'top'; } } g.restore(); } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } // ── hit testing ────────────────────────────────────────────────────── function nodeAt(sx, sy) { const [wx, wy] = transform.invert([sx, sy]); let best = null, bestD = Infinity; for (const n of nodesArr) { if (n.x == null) continue; const r = nodeRadius(n, lastCtx) + 4; const d = (n.x - wx) ** 2 + (n.y - wy) ** 2; if (d < r * r && d < bestD) { best = n; bestD = d; } } return best; } function edgeAt(sx, sy) { const [wx, wy] = transform.invert([sx, sy]); const tol = 6 / transform.k; let best = null, bestD = tol * tol; for (const l of linksArr) { const a = l.source, b = l.target; if (a.x == null || b.x == null || !l.display) continue; const d = distToSeg(wx, wy, a.x, a.y, b.x, b.y); if (d < bestD) { best = l; bestD = d; } } return best; } function distToSeg(px, py, x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; const l2 = dx * dx + dy * dy || 1; let t = ((px - x1) * dx + (py - y1) * dy) / l2; t = Math.max(0, Math.min(1, t)); const cx = x1 + t * dx, cy = y1 + t * dy; return (px - cx) ** 2 + (py - cy) ** 2; } // ── auto-fit ───────────────────────────────────────────────────────── function bbox() { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, n = 0; for (const nd of nodesArr) { if (nd.x == null) continue; minX = Math.min(minX, nd.x); maxX = Math.max(maxX, nd.x); minY = Math.min(minY, nd.y); maxY = Math.max(maxY, nd.y); n++; } if (!n) return null; return { minX, minY, maxX, maxY }; } // desired transform that frames the whole graph (does NOT apply it — the // rAF loop eases `transform` toward it) function computeFitTarget() { const bb = bbox(); if (!bb) return null; const pad = 90; const w = (bb.maxX - bb.minX) || 1, h = (bb.maxY - bb.minY) || 1; const k = Math.max(0.15, Math.min(1.6, Math.min((width - pad) / w, (height - pad) / h))); const cx = (bb.minX + bb.maxX) / 2, cy = (bb.minY + bb.maxY) / 2; return d3.zoomIdentity.translate(width / 2 - k * cx, height / 2 - k * cy).scale(k); } function scheduleFit() { if (userNav) return; fitCooldown = Date.now() + 450; fitTarget = computeFitTarget(); kick(); } // ── the GV surface ─────────────────────────────────────────────────── const GV = { available() { return typeof d3 !== 'undefined'; }, init(el, opts) { if (!this.available()) return false; container = el; onExpand = (opts && opts.onExpand) || null; transform = d3.zoomIdentity; canvas = document.createElement('canvas'); canvas.style.display = 'block'; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.cursor = 'grab'; el.innerHTML = ''; el.appendChild(canvas); g = canvas.getContext('2d'); zoomBehavior = d3.zoom() .scaleExtent([0.08, 5]) .on('start', (e) => { if (e.sourceEvent) { userNav = true; fitTarget = null; } canvas.style.cursor = 'grabbing'; }) .on('zoom', (e) => { // a genuine user gesture (wheel/drag) pauses auto-follow if (e.sourceEvent) { userNav = true; fitTarget = null; } transform = e.transform; draw(); }) .on('end', () => { canvas.style.cursor = 'grab'; }); d3.select(canvas).call(zoomBehavior) .on('dblclick.zoom', null); // pointer interactions canvas.addEventListener('mousemove', onMove); canvas.addEventListener('mouseleave', () => { hoverNode = null; hoverEdge = null; hideTip(); draw(); }); canvas.addEventListener('click', onClick); sim = d3.forceSimulation(nodesArr) .force('link', d3.forceLink(linksArr).id(d => d.id) .distance(l => l.isPath ? 64 : 92).strength(0.3)) .force('charge', d3.forceManyBody().strength(-240).distanceMax(520)) .force('collide', d3.forceCollide(d => nodeRadius(d, lastCtx) + 10)) .force('cluster', clusterForce(0.05)) .force('x', d3.forceX(() => centerWorld().x).strength(0.02)) .force('y', d3.forceY(() => centerWorld().y).strength(0.02)) .alphaDecay(0.025) .velocityDecay(0.4) .on('tick', () => { kick(); }) .stop(); window.addEventListener('resize', resize); resize(); window.__aureliusGraph = { GV, sim }; return true; }, sync(nodesObj, edgesArr, c) { if (!sim) return; lastCtx = c || {}; const lens = lastCtx.lens || null; const visible = Object.values(nodesObj).filter(n => passesFilter(n, lastCtx)); const visSet = new Set(visible.map(n => n.id)); // links: honour the relationship lens; path edges always survive const connected = new Set(); linksArr = []; for (const e of edgesArr) { if (!visSet.has(e.from) || !visSet.has(e.to)) continue; if (lens && !e.isPath && (!e.type || !lens.has(e.type))) continue; linksArr.push({ source: e.from, target: e.to, isPath: !!e.isPath, type: e.type, display: e.display }); connected.add(e.from); connected.add(e.to); } // nodes: a lens hides the nodes it orphans, except anchors const now = performance.now(); nodesArr.length = 0; nodeById.clear(); for (const n of visible) { if (lens && !isKey(n, lastCtx) && !connected.has(n.id)) continue; if (n.__appear == null) n.__appear = now; // fade-in stamp nodesArr.push(n); nodeById.set(n.id, n); } sim.nodes(nodesArr); sim.force('link').links(linksArr); recomputeFocusNeighbors(); // reheat so new nodes settle; harder when the graph changed size const changed = nodesArr.length !== lastCount; sim.alpha(changed ? 0.6 : 0.3).restart(); kick(); if (changed) { lastCount = nodesArr.length; scheduleFit(); } }, focus(id) { const n = nodeById.get(id); if (!n || n.x == null) return; userNav = true; // taking the camera; auto-follow yields const k = Math.max(transform.k, 1.1); fitTarget = d3.zoomIdentity .translate(width / 2 - k * n.x, height / 2 - k * n.y).scale(k); kick(); }, fit() { userNav = false; focusId = null; recomputeFocusNeighbors(); fitCooldown = 0; fitTarget = computeFitTarget(); kick(); }, reset() { userNav = false; lastCount = -1; focusId = null; hoverNode = null; hoverEdge = null; focusNeighbors = new Set(); fitTarget = null; nodesArr.length = 0; linksArr = []; nodeById.clear(); if (sim) { sim.nodes([]); sim.force('link').links([]); sim.stop(); } if (raf) { cancelAnimationFrame(raf); raf = 0; } if (g) { g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, width, height); } }, graph() { return { sim, nodesArr, linksArr }; }, }; // gentle pull of each node toward its kind's anchor direction function clusterForce(strength) { let nodes = []; function force(alpha) { const cw = centerWorld(); const spread = Math.min(width, height) * 0.32; for (const n of nodes) { const a = anchorFor(n.kind); if (!a || onPath(n.id, lastCtx) || n.state === 'centre') continue; n.vx += (cw.x + a.x * spread - n.x) * strength * alpha; n.vy += (cw.y + a.y * spread - n.y) * strength * alpha; } } force.initialize = (n) => { nodes = n; }; return force; } // world-space centre = screen centre un-projected (keeps layout stable // under zoom/pan instead of snapping to a fixed origin) function centerWorld() { if (!transform) return { x: width / 2, y: height / 2 }; const [x, y] = transform.invert([width / 2, height / 2]); return { x, y }; } // ── pointer handlers ───────────────────────────────────────────────── function onMove(e) { const rect = canvas.getBoundingClientRect(); const sx = e.clientX - rect.left, sy = e.clientY - rect.top; const n = nodeAt(sx, sy); const prevN = hoverNode, prevE = hoverEdge; hoverNode = n; hoverEdge = n ? null : edgeAt(sx, sy); canvas.style.cursor = n ? 'pointer' : 'grab'; if (n) showNodeTip(n, e.clientX, e.clientY); else hideTip(); if (n !== prevN || hoverEdge !== prevE) draw(); } function onClick(e) { const rect = canvas.getBoundingClientRect(); const n = nodeAt(e.clientX - rect.left, e.clientY - rect.top); if (!n) { // click empty space clears focus if (focusId) { focusId = null; recomputeFocusNeighbors(); draw(); } return; } // toggle focus spotlight on the node's neighbourhood focusId = (focusId === n.id) ? null : n.id; recomputeFocusNeighbors(); GV.focus(n.id); draw(); if (onExpand && !lastCtx.running) onExpand(n.id); } // reuse the app's existing tooltip element function showNodeTip(n, px, py) { const tip = document.getElementById('tooltip'); if (!tip) return; const title = document.getElementById('tt-title'); const scores = document.getElementById('tt-scores'); if (title) title.textContent = nameOf(n); if (scores) { const rows = []; if (n.kind) rows.push(cap(n.kind)); if (n.g != null) rows.push(`g = ${n.g} · hops from start`); if (n.h != null) rows.push(`h = ${n.h} · heuristic`); if (n.f != null) rows.push(`f = ${n.f} · total`); if (onExpand && !lastCtx.running) rows.push('click to expand'); scores.innerHTML = rows.join('
'); } tip.style.left = (px + 14) + 'px'; tip.style.top = (py - 32) + 'px'; tip.classList.add('visible'); } function hideTip() { const tip = document.getElementById('tooltip'); if (tip) tip.classList.remove('visible'); } function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; } function cap(s) { return String(s).replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase()); } window.GV = GV; })();