igneum/site/live-dag.js
igneum-labs aa71405d92 Site UI 3 on master e69117a: the inlined journey regenerated from master's log, no owner name in a served file
The rebase onto e69117a kept master's captions and build scrub and this branch's wheel handler, chip and note. The build
re-inlined the journey feed from master's journey.json. A code comment in site/live-dag.js named the owner; master's
identity grep now covers every served file and caught it, so the comment says the owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:42:07 +00:00

236 lines
20 KiB
JavaScript

/* Igneum live DAG view (docs/plans/site-ui-3.md section 6, 6 October 2026).
One module for /live and the home page. Reads /api/live only; every pixel is a block, edge or checkpoint the observer
stored. Blocks are placed by header time on x (the axis is labelled in DAA score) and by miner lane on y. Parent edges
are drawn; the edge between two selected-chain blocks is heavier and the chain glows as one path. Colour words are the
ledger's: pending, included (blue), excluded (red), proven (every shard verified or paid), locked (a checkpoint block).
Checkpoints are vertical bands at their blue score with the lock weight; a locked band is solid, a pending one dashed.
The view scrolls at chain speed, new blocks arrive from the right with a brief glow. The page's scroll passes through the
scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene (engaged, released by Escape or a click
outside; opts.chip is the "scroll to zoom, Esc to release" element); window 30 to 300 s (the API's ?window=); a pause button;
hover and tap show a block's details.
Usage: var dag = IgneumDag.mount(canvas, { compact, window, url, poll, mine, onState, onData, onWindow, onEngage, pauseButton, tooltip, chip })
- url: the /api/live endpoint (default '/api/live'); poll: false stops the module's own 2 s fetch, the host then calls
dag.push(reply) with a reply of the same shape ({ok, state:{stale, age_s, height, ...}, blocks:[...], finality:{checkpoints:[...]}, proving})
whenever it has new data (every poll or every new block, either is fine; blocks already on screen are updated in place).
- block record (the /api/live shape): hash (string, 16 hex), ts (ms, header time), blue_score, daa (numbers), parents ([hash]),
chain (bool, selected chain), color ('blue' | 'red' | 'pending'), miner (string lane key, 8 hex of the vote key hash),
locked (bool, a locked checkpoint block), final (bool, at or before the newest lock), shards ([{state: 'planned' | 'proving' |
'verified' | 'paid'}]), proven (bool, every shard verified or paid). Checkpoints: {index, blue_score, state: 'locked' | 'pending', fraction_total}.
- mine: a miner lane key (string) or function(block) -> bool; the user's own blocks take a molten ring and the lane is labelled "you".
- The legend is the host's; the module draws no HTML. Colours come from the CSS custom properties on :root (--ember, --ember-hi,
--molten, --bone, --ash, --line, --line-2, --row or --obsidian), re-read on data-theme and prefers-color-scheme changes.
- Sizing: the canvas fills its CSS box (width and height from the stylesheet); 280 px wide works, 600 or more shows lane labels well.
onState(state, reason): 'connecting' | 'live' | 'stale' | 'failed'; onData(d) with every fresh reply. */
(function (root) {
'use strict';
var COL = {
ember: '#F2541B', emberHi: '#FF6A2B', molten: '#FFB35C', bone: '#F4F1EC', ash: '#9A9A9E', line: '#2A2A30', line2: '#3A3A42', obsidian: '#0C0C0E',
red: '#B0362B'
};
function cssVar(name, fallback) { try { var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; } }
function readTheme() {
COL.ember = cssVar('--ember', COL.ember); COL.emberHi = cssVar('--ember-hi', COL.emberHi); COL.molten = cssVar('--molten', COL.molten);
COL.bone = cssVar('--bone', COL.bone); COL.ash = cssVar('--ash', COL.ash); COL.line = cssVar('--line', COL.line); COL.line2 = cssVar('--line-2', COL.line2);
COL.obsidian = cssVar('--row', cssVar('--obsidian', COL.obsidian));
}
function rgba(hex, a) { var h = hex.replace('#', ''); if (h.length !== 6) return hex; return 'rgba(' + parseInt(h.slice(0, 2), 16) + ',' + parseInt(h.slice(2, 4), 16) + ',' + parseInt(h.slice(4, 6), 16) + ',' + a + ')'; }
function fmt(n) { return n === null || n === undefined ? 'pending' : Number(n).toLocaleString('en-GB'); }
function fmtAge(sec) { sec = Math.round(sec); return sec < 90 ? sec + ' s' : sec < 5400 ? Math.round(sec / 60) + ' min' : Math.round(sec / 3600) + ' h'; }
function mount(canvas, opts) {
opts = opts || {};
var compact = !!opts.compact, windowS = Math.max(30, Math.min(300, opts.window || (compact ? 90 : 120)));
var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isMine = typeof opts.mine === 'function' ? opts.mine : opts.mine ? function (b) { return b.miner === opts.mine; } : null;
var ctx = canvas.getContext('2d'), dpr = Math.min(root.devicePixelRatio || 1, 2), W = 0, H = 0;
var blocks = [], byHash = {}, lanes = [], laneOf = {}, cps = [], data = null, state = 'connecting', reason = 'connecting';
var paused = false, offsetMs = 0, hover = null, pinned = null, lastPoll = 0, failures = 0, running = false, inView = true, lastT = 0;
var vnow = function () { return Date.now() - 2500 - offsetMs; }; // the right edge sits 2.5 s behind the clock so a block has arrived before it is drawn
var padL = compact ? 10 : 56, padR = 14, padT = compact ? 12 : 22, padB = compact ? 10 : 26;
function size() { W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); layout(); }
root.addEventListener('resize', size);
// ---- data ----
function apply(d) {
data = d;
var seen = {}, now = performance.now();
d.blocks.forEach(function (b) {
seen[b.hash] = 1;
var e = byHash[b.hash];
if (e) { e.chain = !!b.chain; e.color = b.color || 'pending'; e.locked = !!b.locked; e.final = !!b.final; e.shards = b.shards || []; e.proven = !!b.proven; e.blue = b.blue_score; e.daa = b.daa; return; }
e = { hash: b.hash, ts: b.ts, blue: b.blue_score, daa: b.daa, parents: b.parents || [], chain: !!b.chain, color: b.color || 'pending', miner: b.miner || '?', locked: !!b.locked, final: !!b.final, shards: b.shards || [], proven: !!b.proven, born: now, x: 0, y: 0 };
byHash[b.hash] = e; blocks.push(e);
});
var cut = vnow() - windowS * 1000 - 20000;
blocks = blocks.filter(function (b) { if (b.ts < cut && !seen[b.hash]) { delete byHash[b.hash]; return false; } return true; });
blocks.sort(function (a, b) { return a.ts - b.ts; });
cps = (d.finality && d.finality.checkpoints) || [];
layout();
}
function layout() {
// lanes: miners of the visible window ordered by blocks, as many as fit at 22 px, the rest in one "others" lane
var vis = vnow() - windowS * 1000, count = {};
blocks.forEach(function (b) { if (b.ts >= vis) count[b.miner] = (count[b.miner] || 0) + 1; });
var ids = Object.keys(count).sort(function (a, b) { return count[b] - count[a] || (a < b ? -1 : 1); });
var usable = H - padT - padB, laneH = compact ? 26 : 22, maxLanes = Math.max(2, Math.floor(usable / laneH));
var shown = ids.length > maxLanes ? ids.slice(0, maxLanes - 1) : ids;
lanes = shown.slice(); if (ids.length > shown.length) lanes.push('others');
laneOf = {}; shown.forEach(function (id, i) { laneOf[id] = i; });
}
function laneY(b) { var i = b.miner in laneOf ? laneOf[b.miner] : lanes.length - 1; var n = Math.max(1, lanes.length); return padT + (i + 0.5) * ((H - padT - padB) / n); }
function xOf(ts) { return padL + (W - padL - padR) * (1 - (vnow() - ts) / (windowS * 1000)); }
// the host can feed replies itself (poll: false); the same path the fetch takes
function push(d) {
if (!d || !d.ok || !d.state || !d.blocks) { setState('failed', 'live feed unavailable'); return; }
failures = 0;
if (d.state.stale) setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown'));
else setState('live', null);
apply(d); if (opts.onData) opts.onData(d); kick();
}
function poll() {
if (opts.poll === false || document.hidden) return;
var u = (opts.url || '/api/live') + '?window=' + windowS;
fetch(u, { cache: 'no-store' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (d) {
if (!d || !d.ok || !d.state || !d.blocks) throw new Error('bad reply');
failures = 0;
if (d.state.stale) { setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown')); }
else setState('live', null);
apply(d); if (opts.onData) opts.onData(d);
kick();
}).catch(function () { failures++; if (failures >= 2) setState('failed', 'live feed unavailable'); });
}
function setState(s, r) { if (s === state && r === reason) return; state = s; reason = r; if (opts.onState) opts.onState(s, r); }
// ---- drawing ----
function draw(t) {
if (!W || !H) return;
var dt = lastT ? Math.min(50, t - lastT) : 16; lastT = t;
ctx.clearRect(0, 0, W, H);
var vis = vnow() - windowS * 1000, n = Math.max(1, lanes.length), lh = (H - padT - padB) / n;
// lane rails and labels
ctx.lineWidth = 1;
for (var i = 0; i < n; i++) {
var y = padT + (i + 0.5) * lh;
ctx.strokeStyle = rgba(COL.line, 0.9); ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(W - padR, y); ctx.stroke();
if (!compact) { var mineLane = isMine && typeof opts.mine === 'string' && lanes[i] === opts.mine; ctx.fillStyle = mineLane ? COL.molten : COL.ash; ctx.font = (mineLane ? '500' : '400') + ' 10px IBM Plex Mono, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(mineLane ? 'you' : lanes[i] === 'others' ? 'others' : String(lanes[i]).slice(0, 8), 6, y); }
}
// checkpoint bands at their blue score (x from the chain block carrying that score, else the nearest)
cps.forEach(function (c) {
var ref = null, best = Infinity;
for (var k = blocks.length - 1; k >= 0; k--) { var b = blocks[k]; if (b.blue === null || b.blue === undefined) continue; var dd = Math.abs(b.blue - c.blue_score); if (dd < best) { best = dd; ref = b; } }
if (!ref || best > 3 || ref.ts < vis) return;
var x = xOf(ref.ts), locked = c.state === 'locked';
ctx.strokeStyle = rgba(COL.ember, locked ? 0.55 : 0.3); ctx.lineWidth = locked ? 1.5 : 1; ctx.setLineDash(locked ? [] : [4, 6]);
ctx.beginPath(); ctx.moveTo(x, padT - 6); ctx.lineTo(x, H - padB + 6); ctx.stroke(); ctx.setLineDash([]);
if (!compact) { ctx.fillStyle = rgba(COL.ember, 0.9); ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText((locked ? 'locked ' : 'pending ') + (c.fraction_total !== null && c.fraction_total !== undefined ? Math.round(c.fraction_total * 100) + '%' : ''), x, H - 8); }
});
// positions
blocks.forEach(function (b) { b.x = xOf(b.ts); b.y = laneY(b); b.on = b.ts >= vis && b.x >= padL - 10; });
// edges: merge edges thin, chain-to-chain heavier; the chain as one glowing path
blocks.forEach(function (b) {
if (!b.on) return;
b.parents.forEach(function (h) {
var p = byHash[h]; if (!p || !p.on) return;
var chainEdge = b.chain && p.chain;
ctx.strokeStyle = chainEdge ? rgba(COL.ember, 0.75) : rgba(COL.ash, b.color === 'red' ? 0.12 : 0.22);
ctx.lineWidth = chainEdge ? 2 : 1;
ctx.beginPath(); ctx.moveTo(b.x, b.y); var mx = (b.x + p.x) / 2; ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke();
if (chainEdge) { ctx.strokeStyle = rgba(COL.ember, 0.18); ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke(); }
});
});
// blocks
var S = compact ? 9 : 8, now = performance.now();
blocks.forEach(function (b) {
if (!b.on) return;
var age = now - b.born, glow = age < 1600 ? 1 - age / 1600 : 0, ex = b.color === 'red';
var r = b.chain ? S + 1 : S;
if (glow > 0) { var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, r * 3); g.addColorStop(0, rgba(COL.ember, 0.4 * glow)); g.addColorStop(1, rgba(COL.ember, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, r * 3, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = ex ? 0.45 : 1;
// fill: proven = ember, otherwise the row surface; outline: included (blue or chain) = ember, pending = grey, excluded = dim
var proving = !b.proven && b.shards.some(function (s) { return s.state === 'proving' || s.state === 'verified' || s.state === 'paid'; });
ctx.beginPath(); rr(b.x - r, b.y - r, 2 * r, 2 * r, 3);
ctx.fillStyle = b.proven ? COL.ember : COL.obsidian; ctx.fill();
ctx.lineWidth = b.chain ? 2 : 1.5;
// chain blocks in ember (the path), included blocks lit in bone, pending in ash, excluded dim
ctx.strokeStyle = b.proven ? COL.ember : proving ? COL.molten : b.chain ? rgba(COL.ember, 0.9) : b.color === 'blue' ? rgba(COL.bone, 0.8) : ex ? rgba(COL.ash, 0.5) : rgba(COL.ash, 0.75);
ctx.stroke();
if (b.locked) { ctx.strokeStyle = rgba(COL.ember, 0.8 + 0.2 * Math.sin(t / 350)); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.9, 0, Math.PI * 2); ctx.stroke(); }
if (isMine && isMine(b)) { ctx.strokeStyle = rgba(COL.molten, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.5, 0, Math.PI * 2); ctx.stroke(); }
ctx.globalAlpha = 1;
});
// hover or pinned ring
var f = pinned || hover; if (f && f.on) { ctx.strokeStyle = COL.bone; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.arc(f.x, f.y, S * 2.4, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); }
// axis in DAA at the left and right edges, and the lock line label
if (!compact) {
ctx.fillStyle = COL.ash; ctx.font = '400 10px IBM Plex Mono, monospace'; ctx.textBaseline = 'alphabetic';
var first = null, last = null; blocks.forEach(function (b) { if (!b.on) return; if (!first || b.ts < first.ts) first = b; if (!last || b.ts > last.ts) last = b; });
if (first && last) { ctx.textAlign = 'left'; ctx.fillText('daa ' + fmt(first.daa), padL, padT - 8); ctx.textAlign = 'right'; ctx.fillText('daa ' + fmt(last.daa) + (paused ? ' paused' : ''), W - padR, padT - 8); }
}
if (paused) { ctx.fillStyle = rgba(COL.obsidian, 0.0); }
}
function rr(x, y, w, h, r) { 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(); }
function frame(t) { if (!running) return; if (!paused) draw(t); if (reduce || document.hidden || !inView) { running = false; return; } requestAnimationFrame(frame); }
function start() { if (running) return; running = true; requestAnimationFrame(frame); }
function kick() { if (!running) requestAnimationFrame(function (t) { draw(t); if (!reduce && !document.hidden && inView) start(); }); }
// ---- interaction ----
function hit(ev) {
var rect = canvas.getBoundingClientRect(), px = ev.clientX - rect.left, py = ev.clientY - rect.top, best = null, bd = 14 * 14;
blocks.forEach(function (b) { if (!b.on) return; var dx = b.x - px, dy = b.y - py, d = dx * dx + dy * dy; if (d < bd) { bd = d; best = b; } });
return best;
}
function words(b) {
var state = b.locked ? 'locked checkpoint' : b.proven ? 'proven' : b.chain ? 'included, selected chain' : b.color === 'blue' ? 'included' : b.color === 'red' ? 'excluded' : 'pending';
var sh = b.shards.length ? b.shards.map(function (s) { return s.state; }).join(', ') : 'no shard plan yet';
return { title: b.hash + ' ' + state, lines: [(isMine && isMine(b) ? 'your block, miner ' : 'miner ') + b.miner + (b.miner in laneOf ? '' : ' (others lane)'), 'blue score ' + fmt(b.blue) + ' daa ' + fmt(b.daa), b.parents.length + (b.parents.length === 1 ? ' parent' : ' parents'), 'proof: ' + sh + (b.final ? ' final' : '')] };
}
var tip = opts.tooltip || null;
function showTip(b, ev) {
if (!tip) return;
if (!b) { tip.hidden = true; return; }
var w = words(b); tip.hidden = false; tip.innerHTML = '<b>' + w.title + '</b>' + w.lines.map(function (l) { return '<span>' + l + '</span>'; }).join('');
var rect = canvas.getBoundingClientRect(); var x = Math.min(rect.width - tip.offsetWidth - 8, Math.max(8, b.x + 12)), y = Math.max(8, b.y - tip.offsetHeight - 10); if (y < 8) y = b.y + 14;
tip.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';
}
canvas.addEventListener('mousemove', function (ev) { hover = hit(ev); canvas.style.cursor = hover ? 'pointer' : 'default'; if (!pinned) showTip(hover, ev); kick(); });
canvas.addEventListener('mouseleave', function () { hover = null; if (!pinned) showTip(null); kick(); });
canvas.addEventListener('click', function (ev) { engage(true); var b = hit(ev); pinned = (pinned && b && pinned.hash === b.hash) ? null : b; showTip(pinned || hover); kick(); });
// the wheel is never captured by default (6 Oct 2026, the owner: scrolling past the scene zoomed it): the page scrolls through the
// scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene, which engages it (opts.chip shows
// "scroll to zoom, Esc to release"); Escape, a click outside or leaving the scene releases it
var engaged = false, chip = opts.chip || null;
function engage(on) { if (on === engaged) return; engaged = on; canvas.classList.toggle('engaged', on); if (chip) chip.hidden = !on; if (opts.onEngage) opts.onEngage(on); }
canvas.addEventListener('wheel', function (ev) {
var zoomKey = ev.ctrlKey || ev.metaKey;
if (!zoomKey && !engaged) return; // a plain wheel or a two-finger scroll passes through to the page
if (Math.abs(ev.deltaY) < 1) return;
ev.preventDefault(); zoom(ev.deltaY > 0 ? 1.15 : 1 / 1.15);
}, { passive: false });
document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && engaged) engage(false); });
document.addEventListener('pointerdown', function (ev) { if (engaged && !canvas.contains(ev.target) && !(chip && chip.contains(ev.target))) engage(false); });
var pinch = null;
canvas.addEventListener('touchstart', function (ev) { if (ev.touches.length === 2) pinch = dist(ev.touches); }, { passive: true });
canvas.addEventListener('touchmove', function (ev) { if (ev.touches.length === 2 && pinch) { var d = dist(ev.touches); if (Math.abs(d - pinch) > 12) { zoom(d > pinch ? 1 / 1.1 : 1.1); pinch = d; } } }, { passive: true });
canvas.addEventListener('touchend', function () { pinch = null; }, { passive: true });
function dist(t) { var dx = t[0].clientX - t[1].clientX, dy = t[0].clientY - t[1].clientY; return Math.sqrt(dx * dx + dy * dy); }
function zoom(f) { var w = Math.round(Math.max(30, Math.min(300, windowS * f))); if (w === windowS) return; windowS = w; layout(); if (opts.onWindow) opts.onWindow(w); poll(); kick(); }
function setPaused(p) { paused = !!p; if (paused) { offsetMs = 0; pausedAt = Date.now(); } else { offsetMs = 0; } if (opts.pauseButton) { opts.pauseButton.setAttribute('aria-pressed', paused ? 'true' : 'false'); opts.pauseButton.textContent = paused ? 'Resume' : 'Pause'; } kick(); }
var pausedAt = 0;
if (opts.pauseButton) opts.pauseButton.addEventListener('click', function () { setPaused(!paused); });
// while paused the clock stops: offsetMs grows so the scene holds still but keeps its blocks
setInterval(function () { if (paused) offsetMs = Date.now() - pausedAt; }, 250);
if ('IntersectionObserver' in root) { new IntersectionObserver(function (es) { es.forEach(function (e) { inView = e.isIntersecting; if (inView) kick(); }); }, { threshold: 0.05 }).observe(canvas); }
document.addEventListener('visibilitychange', function () { if (!document.hidden) { poll(); kick(); } });
if (root.matchMedia) { try { root.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { readTheme(); kick(); }); } catch (e) { } }
new MutationObserver(function () { readTheme(); kick(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
readTheme(); size(); if (opts.poll !== false) { poll(); setInterval(poll, 2000); } kick();
return { push: push, setPaused: setPaused, zoom: zoom, engage: engage, getWindow: function () { return windowS; }, getState: function () { return { state: state, reason: reason }; }, redraw: kick, words: words };
}
root.IgneumDag = { mount: mount };
})(window);