arborist-viz/arborist_viz/static/viz/claim-graph.js
russell@unturf.com 0cd96ab1d7
Initial commit: arborist VIZ — Merkle command center
Pyramid web app for inspecting arborist Merkle trees over sharded SQLite:
document roots, inclusion proofs, an interactive 3D Merkle lattice
(six.js / WebGL with graceful fallback), audit-chain linkage, and a live
SSE event stream.

Generated artifacts (env/, vendored static/six/ bundles, demo databases)
are gitignored and rebuilt via the Makefile (venv, six, init-db, serve).
2026-05-28 09:22:59 -04:00

140 lines
4.1 KiB
JavaScript

// Phase 6 — small claim-graph canvas renderer.
//
// Vanilla 2D <canvas> + simple Verlet-style force-directed layout. Used
// for claim-warrant + claim-graph widgets. No React, no D3, no
// Cosmograph until a measured need surfaces (ticket §3-corrected).
export function mountClaimGraph(container, { nodes, edges }, opts = {}) {
const width = container.clientWidth || 480;
const height = container.clientHeight || 360;
const canvas = document.createElement("canvas");
canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;
canvas.style.width = width + "px";
canvas.style.height = height + "px";
container.innerHTML = "";
container.appendChild(canvas);
const ctx = canvas.getContext("2d");
ctx.scale(devicePixelRatio, devicePixelRatio);
// Place nodes on a circle then settle with a few hundred force ticks.
const items = nodes.map((n, i) => ({
...n,
x: width / 2 + Math.cos((2 * Math.PI * i) / Math.max(nodes.length, 1)) * Math.min(width, height) / 3,
y: height / 2 + Math.sin((2 * Math.PI * i) / Math.max(nodes.length, 1)) * Math.min(width, height) / 3,
vx: 0,
vy: 0,
}));
const byId = new Map(items.map((n) => [n.id, n]));
const edgeRefs = edges.map((e) => ({ s: byId.get(e.source), t: byId.get(e.target), kind: e.kind })).filter((e) => e.s && e.t);
const REPEL = 1800;
const SPRING = 0.04;
const DAMPING = 0.85;
const MIN_DIST = 30;
for (let step = 0; step < 250; step++) {
// Pairwise repulsion.
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const dx = items[j].x - items[i].x;
const dy = items[j].y - items[i].y;
const d2 = dx * dx + dy * dy + 0.01;
const d = Math.sqrt(d2);
const f = REPEL / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
items[i].vx -= fx;
items[i].vy -= fy;
items[j].vx += fx;
items[j].vy += fy;
}
}
// Edge spring.
for (const e of edgeRefs) {
const dx = e.t.x - e.s.x;
const dy = e.t.y - e.s.y;
e.s.vx += dx * SPRING;
e.s.vy += dy * SPRING;
e.t.vx -= dx * SPRING;
e.t.vy -= dy * SPRING;
}
// Integrate + damp + clamp to canvas.
for (const n of items) {
n.vx *= DAMPING; n.vy *= DAMPING;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(MIN_DIST, Math.min(width - MIN_DIST, n.x));
n.y = Math.max(MIN_DIST, Math.min(height - MIN_DIST, n.y));
}
}
// One-shot render. Re-call mountClaimGraph for live updates.
ctx.clearRect(0, 0, width, height);
// Edges.
ctx.lineWidth = 1;
for (const e of edgeRefs) {
ctx.strokeStyle = edgeColor(e.kind);
ctx.beginPath();
ctx.moveTo(e.s.x, e.s.y);
ctx.lineTo(e.t.x, e.t.y);
ctx.stroke();
}
// Nodes.
ctx.font = "11px ui-monospace, monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const n of items) {
ctx.fillStyle = statusColor(n.status);
ctx.beginPath();
ctx.arc(n.x, n.y, 8, 0, 2 * Math.PI);
ctx.fill();
ctx.strokeStyle = "#1f2934";
ctx.stroke();
ctx.fillStyle = "#d6e0ec";
ctx.fillText(n.label || (n.id || "").slice(0, 6), n.x, n.y + 18);
}
// Click → callback with node id.
canvas.addEventListener("click", (ev) => {
const rect = canvas.getBoundingClientRect();
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
for (const n of items) {
const dx = n.x - x;
const dy = n.y - y;
if (dx * dx + dy * dy < 100 && opts.onSelect) {
opts.onSelect(n.id);
return;
}
}
});
return { items };
}
function statusColor(s) {
switch (s) {
case "live": return "#4dd1c1";
case "stale": return "#f5b455";
case "failed":
case "quarantined": return "#e96b6b";
case "speculative": return "#7aa2ff";
case "formally_proved": return "#ffffff";
default: return "#8aa1b8";
}
}
function edgeColor(kind) {
switch (kind) {
case "supports": return "#4dd1c1";
case "contradicts":
case "falsifies": return "#e96b6b";
case "depends_on":
case "derived_from": return "#7aa2ff";
default: return "#1f2934";
}
}