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).
377 lines
13 KiB
JavaScript
377 lines
13 KiB
JavaScript
// 3D Merkle lattice via six.js (fox's three.js fork).
|
|
//
|
|
// Layers stack on the Y axis: leaves at y=0, root at top. Each node is a
|
|
// small sphere; child→parent edges are line segments. A proof "pulse"
|
|
// recolors leaf → siblings → running-hash path → root in waves.
|
|
//
|
|
// Render loop pauses when no animation is in flight (ticket §10
|
|
// "Pause render loop when static if possible"). Reset wipes selection.
|
|
|
|
import * as THREE from "/static/six/six.module.js";
|
|
|
|
const COLORS = {
|
|
leaf: 0x4dd1c1, // mint
|
|
interior: 0x7aa2ff, // soft blue
|
|
root: 0xf5b455, // amber
|
|
edge: 0x1f2934, // dark gray
|
|
selected: 0xffffff, // bright white
|
|
usedLeaf: 0xffd166, // gold — chunk the answer actually cited (glow)
|
|
primaryLeaf: 0x4dd1c1, // mint — primary candidate, retrieved but not cited
|
|
pulseLeaf: 0x4dd1c1,
|
|
pulseSib: 0xf5b455, // siblings = amber
|
|
pulsePath: 0x7aa2ff, // running-hash path = blue
|
|
pulseRoot: 0xe96b6b, // root flash = red on arrival
|
|
};
|
|
|
|
export async function mountLattice(container, layers) {
|
|
// Container sizing.
|
|
const width = () => container.clientWidth || 600;
|
|
const height = () => container.clientHeight || 400;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x0b0e12);
|
|
|
|
// Camera — adapts to layer count + max layer width.
|
|
const maxWidth = Math.max(1, ...layers.map((l) => l.length));
|
|
const layerCount = layers.length;
|
|
const camera = new THREE.PerspectiveCamera(45, width() / height(), 0.1, 1000);
|
|
camera.position.set(0, layerCount * 1.2, Math.max(maxWidth * 1.4, layerCount * 2.2, 8));
|
|
camera.lookAt(0, layerCount * 0.5, 0);
|
|
|
|
// WebGL context creation can fail (no GPU, blocklisted driver, a
|
|
// browser without a software fallback). Only this panel needs 3D — the
|
|
// inclusion proof, leaves strip, and audit chain do not. Degrade to a
|
|
// message and hand back a no-op API so the caller's sceneApi.* calls
|
|
// stay safe instead of throwing and blanking the whole proof page.
|
|
let renderer;
|
|
try {
|
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
} catch (err) {
|
|
console.warn("merkle-lattice: WebGL unavailable — 3D view disabled.", err);
|
|
container.innerHTML =
|
|
`<div class="lattice-fallback muted">3D lattice unavailable — this browser could not create a WebGL context. The inclusion proof, leaves, and audit chain still work.</div>`;
|
|
const noop = () => {};
|
|
return {
|
|
highlightLeaf: noop,
|
|
markUsage: noop,
|
|
setProofPath: noop,
|
|
pulseProofPath: noop,
|
|
reset: noop,
|
|
onLeafSelect: noop,
|
|
onNodeSelect: noop,
|
|
};
|
|
}
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.setSize(width(), height());
|
|
container.innerHTML = "";
|
|
container.appendChild(renderer.domElement);
|
|
|
|
// Lights — soft ambient + a key from above.
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
|
|
const key = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
key.position.set(2, layerCount + 2, 4);
|
|
scene.add(key);
|
|
|
|
// Build node meshes per layer. Spread leaves widest, root smallest.
|
|
const nodesByLayer = []; // nodesByLayer[layerIdx][nodeIdx] = mesh
|
|
const allNodeMeshes = [];
|
|
for (let li = 0; li < layers.length; li++) {
|
|
const layer = layers[li];
|
|
const isRoot = li === layers.length - 1;
|
|
const isLeaf = li === 0;
|
|
const layerNodes = [];
|
|
const spacing = isRoot ? 0 : 1.4;
|
|
const total = (layer.length - 1) * spacing;
|
|
for (let ni = 0; ni < layer.length; ni++) {
|
|
const radius = isRoot ? 0.5 : (isLeaf ? 0.3 : 0.36);
|
|
const geom = new THREE.SphereGeometry(radius, 24, 16);
|
|
const baseColor = isRoot ? COLORS.root : (isLeaf ? COLORS.leaf : COLORS.interior);
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: baseColor,
|
|
emissive: baseColor,
|
|
emissiveIntensity: 0.25,
|
|
roughness: 0.4,
|
|
metalness: 0.1,
|
|
});
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
const x = (ni * spacing) - total / 2;
|
|
const y = li * 1.4;
|
|
mesh.position.set(x, y, 0);
|
|
mesh.userData = {
|
|
layer: li, idx: ni, baseColor, hash: layer[ni], isLeaf, isRoot,
|
|
baseIntensity: 0.25,
|
|
};
|
|
scene.add(mesh);
|
|
layerNodes.push(mesh);
|
|
allNodeMeshes.push(mesh);
|
|
}
|
|
nodesByLayer.push(layerNodes);
|
|
}
|
|
|
|
// Child→parent edges. Each parent at layer l has children at layer l-1
|
|
// (indices 2*idx and 2*idx+1; odd self-dup means right==left when
|
|
// 2*idx+1 >= len(child layer)).
|
|
const edgeMaterial = new THREE.LineBasicMaterial({ color: COLORS.edge });
|
|
for (let li = 1; li < nodesByLayer.length; li++) {
|
|
const child = nodesByLayer[li - 1];
|
|
const parent = nodesByLayer[li];
|
|
for (let pi = 0; pi < parent.length; pi++) {
|
|
const left = child[2 * pi];
|
|
const right = child[(2 * pi + 1) < child.length ? (2 * pi + 1) : (2 * pi)];
|
|
if (!left) continue;
|
|
const p = parent[pi].position;
|
|
const segL = lineSegment(p, left.position, edgeMaterial);
|
|
scene.add(segL);
|
|
parent[pi].userData.edgeLeft = segL;
|
|
if (right && right !== left) {
|
|
const segR = lineSegment(p, right.position, edgeMaterial);
|
|
scene.add(segR);
|
|
parent[pi].userData.edgeRight = segR;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render-loop control. We only animate when a pulse is running.
|
|
let pulseQueue = [];
|
|
let pulseAt = 0;
|
|
let highlightedLeaf = null;
|
|
let proofPath = null; // {leaf_index, sibling_hashes, left_right_flags}
|
|
|
|
function render() {
|
|
renderer.render(scene, camera);
|
|
}
|
|
function frame() {
|
|
if (pulseQueue.length === 0) {
|
|
render();
|
|
return;
|
|
}
|
|
const now = performance.now();
|
|
while (pulseQueue.length && now >= pulseQueue[0].at) {
|
|
const step = pulseQueue.shift();
|
|
if (step.kind === "color") {
|
|
recolor(step.mesh, step.color, step.intensity);
|
|
} else if (step.kind === "restore") {
|
|
restore(step.mesh);
|
|
}
|
|
}
|
|
render();
|
|
if (pulseQueue.length) requestAnimationFrame(frame);
|
|
else render();
|
|
}
|
|
render();
|
|
|
|
// Drag-to-rotate (no OrbitControls dependency).
|
|
let isDragging = false;
|
|
let lastX = 0;
|
|
let yaw = 0;
|
|
let pitch = 0;
|
|
function applyCamera() {
|
|
const r = Math.max(maxWidth * 1.4, layerCount * 2.2, 8);
|
|
camera.position.set(
|
|
Math.sin(yaw) * Math.cos(pitch) * r,
|
|
layerCount * 0.6 + Math.sin(pitch) * r,
|
|
Math.cos(yaw) * Math.cos(pitch) * r,
|
|
);
|
|
camera.lookAt(0, layerCount * 0.5, 0);
|
|
render();
|
|
}
|
|
renderer.domElement.addEventListener("pointerdown", (ev) => {
|
|
isDragging = true; lastX = ev.clientX; renderer.domElement.setPointerCapture(ev.pointerId);
|
|
});
|
|
renderer.domElement.addEventListener("pointermove", (ev) => {
|
|
if (!isDragging) return;
|
|
const dx = ev.clientX - lastX;
|
|
lastX = ev.clientX;
|
|
yaw += dx * 0.01;
|
|
applyCamera();
|
|
});
|
|
renderer.domElement.addEventListener("pointerup", () => { isDragging = false; });
|
|
|
|
// Click-to-select.
|
|
const raycaster = new THREE.Raycaster();
|
|
const ndc = new THREE.Vector2();
|
|
let onLeafSelect = null;
|
|
let onNodeSelect = null;
|
|
renderer.domElement.addEventListener("click", (ev) => {
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
|
ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
|
raycaster.setFromCamera(ndc, camera);
|
|
const hits = raycaster.intersectObjects(allNodeMeshes, false);
|
|
if (!hits.length) return;
|
|
const hit = hits[0].object;
|
|
const ud = hit.userData;
|
|
if (ud.isLeaf && onLeafSelect) {
|
|
onLeafSelect(ud.idx);
|
|
}
|
|
// Surface every node (leaves included) for inspectors that want the
|
|
// hash + layer + idx — proof-page renders it in a side panel so
|
|
// interior + root spheres no longer feel inert.
|
|
if (onNodeSelect) onNodeSelect({ hash: ud.hash, layer: ud.layer, idx: ud.idx, isLeaf: !!ud.isLeaf });
|
|
});
|
|
|
|
// Window resize.
|
|
window.addEventListener("resize", () => {
|
|
renderer.setSize(width(), height());
|
|
camera.aspect = width() / height();
|
|
camera.updateProjectionMatrix();
|
|
render();
|
|
});
|
|
|
|
// ----- public API ------
|
|
|
|
return {
|
|
highlightLeaf(idx) {
|
|
restoreAll();
|
|
highlightedLeaf = idx;
|
|
const leaf = nodesByLayer[0][idx];
|
|
if (leaf) recolor(leaf, COLORS.selected, 1.2);
|
|
render();
|
|
},
|
|
|
|
markUsage(usage) {
|
|
// Persistent provenance glow on layer-0 leaves:
|
|
// used (cited in the answer) → bright gold;
|
|
// primary candidate (retrieved, not cited) → soft mint glow;
|
|
// background → default. Written into each node's BASE appearance
|
|
// so proof-pulse restores keep the glow. `usage` is indexed by
|
|
// leaf position (sorted by document_root — matches the lattice,
|
|
// the leaves strip, and the inclusion-proof leaf_index).
|
|
if (!Array.isArray(usage)) return;
|
|
const leafLayer = nodesByLayer[0] || [];
|
|
usage.forEach((u, idx) => {
|
|
const mesh = leafLayer[idx];
|
|
if (!mesh || !u) return;
|
|
if (u.used) {
|
|
mesh.userData.baseColor = COLORS.usedLeaf;
|
|
mesh.userData.baseIntensity = 1.3;
|
|
} else if (u.role === "primary_answer_source") {
|
|
mesh.userData.baseColor = COLORS.primaryLeaf;
|
|
mesh.userData.baseIntensity = 0.7;
|
|
} else {
|
|
return;
|
|
}
|
|
restore(mesh);
|
|
});
|
|
render();
|
|
},
|
|
|
|
setProofPath(proof) {
|
|
proofPath = proof;
|
|
},
|
|
|
|
pulseProofPath() {
|
|
restoreAll();
|
|
if (!proofPath || proofPath.error) return;
|
|
const leafIdx = proofPath.leaf_index;
|
|
const flags = proofPath.left_right_flags || [];
|
|
|
|
// Build the running-hash path: at each layer, the node we're
|
|
// climbing through (leaf → parent → … → root).
|
|
const runningPath = [];
|
|
let idx = leafIdx;
|
|
for (let li = 0; li < nodesByLayer.length; li++) {
|
|
runningPath.push({ layer: li, idx });
|
|
idx = Math.floor(idx / 2);
|
|
}
|
|
|
|
// Sibling path: at each layer below the root, the sibling we hash with.
|
|
const siblingPath = [];
|
|
idx = leafIdx;
|
|
for (let li = 0; li < flags.length; li++) {
|
|
const is_left = flags[li]; // sibling is left of running
|
|
const sib = is_left ? idx - 1 : idx + 1;
|
|
// odd-element self-duplication: if sib out of range, the sibling
|
|
// IS the running node — we render no extra highlight (the sphere
|
|
// is already highlighted by running path).
|
|
const layer = nodesByLayer[li];
|
|
if (layer && sib >= 0 && sib < layer.length) {
|
|
siblingPath.push({ layer: li, idx: sib });
|
|
}
|
|
idx = Math.floor(idx / 2);
|
|
}
|
|
|
|
pulseQueue = [];
|
|
pulseAt = performance.now();
|
|
const STEP = 260; // ms between pulses
|
|
|
|
// Wave 1 — light the leaf.
|
|
schedule(0, "color", nodesByLayer[0][leafIdx], COLORS.pulseLeaf, 1.5);
|
|
|
|
// Wave 2 — light each sibling in order.
|
|
siblingPath.forEach((p, i) => {
|
|
const mesh = nodesByLayer[p.layer][p.idx];
|
|
schedule(STEP * (i + 1), "color", mesh, COLORS.pulseSib, 1.4);
|
|
});
|
|
|
|
// Wave 3 — climb running-hash path.
|
|
runningPath.forEach((p, i) => {
|
|
if (p.layer === 0) return; // leaf already lit
|
|
const mesh = nodesByLayer[p.layer][p.idx];
|
|
const isRoot = p.layer === nodesByLayer.length - 1;
|
|
const t = STEP * (siblingPath.length + 1 + i);
|
|
schedule(
|
|
t,
|
|
"color",
|
|
mesh,
|
|
isRoot ? COLORS.pulseRoot : COLORS.pulsePath,
|
|
isRoot ? 2.0 : 1.4,
|
|
);
|
|
});
|
|
|
|
requestAnimationFrame(frame);
|
|
},
|
|
|
|
reset() {
|
|
restoreAll();
|
|
pulseQueue = [];
|
|
proofPath = null;
|
|
highlightedLeaf = null;
|
|
render();
|
|
},
|
|
|
|
onLeafSelect(callback) {
|
|
onLeafSelect = callback;
|
|
},
|
|
|
|
onNodeSelect(callback) {
|
|
onNodeSelect = callback;
|
|
},
|
|
};
|
|
|
|
// ----- helpers -----
|
|
|
|
function schedule(deltaMs, kind, mesh, color, intensity) {
|
|
pulseQueue.push({ at: pulseAt + deltaMs, kind, mesh, color, intensity });
|
|
}
|
|
|
|
function recolor(mesh, color, intensity) {
|
|
if (!mesh) return;
|
|
mesh.material.color.setHex(color);
|
|
mesh.material.emissive.setHex(color);
|
|
mesh.material.emissiveIntensity = intensity != null ? intensity : 0.5;
|
|
}
|
|
|
|
function restore(mesh) {
|
|
if (!mesh) return;
|
|
const c = mesh.userData.baseColor;
|
|
mesh.material.color.setHex(c);
|
|
mesh.material.emissive.setHex(c);
|
|
// baseIntensity carries the persistent provenance glow (markUsage) so
|
|
// a proof-pulse restore returns to the glow, not the flat default.
|
|
mesh.material.emissiveIntensity =
|
|
mesh.userData.baseIntensity != null ? mesh.userData.baseIntensity : 0.25;
|
|
}
|
|
|
|
function restoreAll() {
|
|
for (const m of allNodeMeshes) restore(m);
|
|
}
|
|
}
|
|
|
|
function lineSegment(a, b, material) {
|
|
const geom = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(a.x, a.y, a.z),
|
|
new THREE.Vector3(b.x, b.y, b.z),
|
|
]);
|
|
return new THREE.Line(geom, material);
|
|
}
|