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).
140 lines
4.1 KiB
JavaScript
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";
|
|
}
|
|
}
|