Aurelius / graph2d.js
murtaza-2007
Aurelius improvement pass: domain-aware recs, finance/research surfaces, 2D graph
658d200
Raw
History Blame Contribute Delete
23.8 kB
/* 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;
})();