murtaza-2007
Aurelius improvement pass: domain-aware recs, finance/research surfaces, 2D graph
658d200 | /* 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('<br>'); | |
| } | |
| 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; | |
| })(); | |