igneum/site/live-steps.js
igneum-labs bbb22eb986 Home scene: the step view returns, with real blocks, animated transitions and a one-line caption
The owner, 6 October 2026: "I liked it when we could see each step of the algo working." The home page's scene is again the
lane chart where a square moves through mined, shards being proven, proven and locked checkpoint, now as site/live-steps.js on
the shared feed (site/scenes/feed.js, which gains onData):
- every square is a real block from /api/live (a 300 s window so late proofs still reach the caption), released one every
  2.5 s so a square crosses the scene in about 34 s; the chain merges a pending block in about 10 s, so its step changes
  while it is still on screen (at the original 10 s crossing most transitions landed after the square had left). The feed is
  sampled for the stream; item 0's counters read the chain. The first reply seeds the scene across its width, so nothing
  starts empty;
- each step is drawn as before: an outline (grey while pending, ember once included), molten quarter cells filling as shards
  are verified or paid, an ember fill when proven, a ring on a locked checkpoint with the dashed "final" line to its left,
  excluded blocks dimmed; every transition glows for a second and a lock ripples;
- one caption line under the scene for the newest block that changed step, e.g. "block 144,564: mined 21:16:03, on the
  selected chain; 1 shard planned", or with its shards "8 shards on 3 cards; proven in 48 s" and "locked at checkpoint 7,084".
  /api/live now carries each block's chain number for it. When no block changes step for a minute the caption says why in
  the ledger's words: finality paused (under two thirds of the weight signing), no proof landed in the last 10 minutes
  (with the median lag), or no block changed step;
- the wheel is never touched: the module has no wheel handler, so the page scrolls through the scene;
- light and dark from the tokens, 390 px, a still frame under reduced motion. The DAG module stays as /live's scene and the
  three scenes stay unlinked on /scenes.

Proved headless over 40 s against the live feed: thirteen captioned transitions, no console errors at 1440 or 390, no
overflow. Captures: docs/plans/site-ui-3-shots/after/index-steps-1440-dark-clip.jpg, index-steps-390-dark-clip.jpg and
index-steps-1440-dark.webm (ten seconds of blocks moving through the steps). Checks: identity grep 0 hits, link check 931
links 0 broken, ledger text 43 of 43, contrast 32 pairs 0 under 4.5:1, api tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:17:47 +00:00

115 lines
11 KiB
JavaScript

/* Igneum live steps (6 October 2026, the owner: "I liked it when we could see each step of the algo working").
The original step view with real blocks: a lane chart where every square is a block the observer stored, released at a
calm pace (the feed is sampled, the counters elsewhere read the chain), and every square shows its step: mined (an outline,
grey while pending, ember once included), shards being proven (molten quarter cells fill as shards are verified or paid),
proven (ember fill), locked checkpoint (a ring; a dashed "final" line marks that everything to its left is final).
Each transition is animated as it happens (a glow and a ring pulse) and captioned in one line for the newest block that
changed. The wheel is never touched: the page scrolls through the scene. Light and dark from the CSS tokens; a still frame
under reduced motion. Needs /scenes/feed.js (IgneumFeed) on the page.
Usage: var steps = IgneumSteps.mount(canvas, { caption: el, window: 300 }); then IgneumFeed.start({ subs: [steps], window: 300 }). */
(function (root) {
'use strict';
var F = root.IgneumFeed;
function pad(n) { return String(n).padStart(2, '0'); }
function hms(ms) { var d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); }
function fmt(n) { return n === null || n === undefined ? null : Number(n).toLocaleString('en-GB'); }
function stepOf(b) {
var sh = b.shards || [], done = function (x) { return x.state === 'verified' || x.state === 'paid'; };
if (b.proven || (sh.length && sh.every(done))) return 'proven';
if (sh.some(function (x) { return x.state === 'proving' || done(x); })) return 'proving';
return 'mined';
}
function mount(canvas, opts) {
opts = opts || {};
var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme();
var lanes = 5, S, GAP, speed, blocks = [], byHash = {}, queue = [], laneOf = {}, laneN = 0, last = 0, prevT = 0;
// one block released every 2.5 s and a crossing of about 34 s at 1440: the chain merges a pending block in about 10 s, so a
// square is still on screen when its step changes (the original pace of 10 s lost most transitions off the left edge)
var RELEASE_MS = 2500;
var caption = opts.caption || null, lastChange = 0, latest = null, finality = null, proving = null, serverNow = 0;
function metrics() { S = dims.W < 600 ? 18 : 26; GAP = dims.W < 600 ? S * 2.5 : S * 3.1; speed = GAP / RELEASE_MS; }
metrics();
root.addEventListener('resize', function () { dims = size(); metrics(); L.kick(); });
function laneY(l) { return dims.H * 0.14 + l * (dims.H * 0.72 / (lanes - 1)); }
function laneFor(m) { if (!(m in laneOf)) laneOf[m] = laneN++ % lanes; return laneOf[m]; }
function cpIndexOf(hash) { var cps = (finality && finality.checkpoints) || []; for (var i = cps.length - 1; i >= 0; i--) if (cps[i].hash === hash) return cps[i].index; return null; }
// the record of a block for the caption: its words with their times
function describe(e) {
var b = e.src, parts = [], name = b.number !== null && b.number !== undefined ? 'block ' + fmt(b.number) : 'block ' + b.hash.slice(0, 8);
parts.push('mined ' + hms(b.ts) + (b.color === 'red' ? ', excluded' : b.chain ? ', on the selected chain' : b.color === 'blue' ? ', included' : ', pending'));
var sh = b.shards || [];
if (sh.length) {
var proving = sh.filter(function (x) { return x.state === 'proving'; }), done = sh.filter(function (x) { return x.state === 'verified' || x.state === 'paid'; });
var provers = {}; sh.forEach(function (x) { if (x.prover) provers[x.prover] = 1; }); var np = Object.keys(provers).length;
if (e.step === 'proven') { var lag = Math.max.apply(null, sh.map(function (x) { return x.lag || 0; })); parts.push(sh.length + (sh.length === 1 ? ' shard' : ' shards') + (np ? ' on ' + np + (np === 1 ? ' card' : ' cards') : '')); parts.push('proven in ' + (lag > 0 ? lag : Math.max(1, Math.round((Date.now() - b.ts) / 1000))) + ' s'); }
else if (proving.length || done.length) parts.push((done.length ? done.length + ' of ' + sh.length + ' shards proven' : sh.length + (sh.length === 1 ? ' shard' : ' shards') + ' proving') + (np ? ' on ' + np + (np === 1 ? ' card' : ' cards') : ''));
else parts.push(sh.length + (sh.length === 1 ? ' shard planned' : ' shards planned'));
}
if (b.locked) { var idx = cpIndexOf(b.hash); parts.push('locked at checkpoint' + (idx !== null ? ' ' + fmt(idx) : '')); }
else if (b.final) parts.push('final under the newest lock');
return name + ': ' + parts.join('; ');
}
function setCaption(text, muted) { if (!caption) return; caption.textContent = text; caption.classList.toggle('muted', !!muted); }
function apply(e, src, now, announce) {
var before = e.step + '|' + e.src.color + '|' + e.src.locked + '|' + e.src.final + '|' + (e.src.chain ? 1 : 0);
e.src = src; e.step = stepOf(src);
var after = e.step + '|' + src.color + '|' + src.locked + '|' + src.final + '|' + (src.chain ? 1 : 0);
if (announce && after !== before) { e.glow = 1; if (src.locked && !e.ringed) { e.ringed = true; e.ripple = 1; } lastChange = now; latest = e; setCaption(describe(e)); if (opts.onChange) opts.onChange(e, before, after); }
}
var seeded = false;
function push(d) {
var now = performance.now(); finality = d.finality || null; proving = d.proving || null; serverNow = new Date(d.now).getTime();
if (!seeded) {
// the first reply fills the scene: the newest blocks placed across the width, oldest at the left, so nothing starts empty
seeded = true; var n = Math.ceil(dims.W / GAP) + 1, recent = d.blocks.slice(-n);
recent.forEach(function (b, i) { var e = { hash: b.hash, src: b, step: stepOf(b), x: dims.W - S - (recent.length - 1 - i) * GAP, y: laneY(laneFor(b.miner || '?')), glow: 0, ripple: 0, ringed: !!b.locked, born: now }; byHash[b.hash] = e; blocks.push(e); });
}
d.blocks.forEach(function (b) {
var e = byHash[b.hash];
if (e) { apply(e, b, now, true); return; }
e = { hash: b.hash, src: b, step: stepOf(b), x: null, y: null, glow: 0, ripple: 0, ringed: !!b.locked, born: 0 };
byHash[b.hash] = e; queue.push(e); if (queue.length > 6) { var dropped = queue.shift(); delete byHash[dropped.hash]; }
});
var seen = {}; d.blocks.forEach(function (b) { seen[b.hash] = 1; });
// a block the feed no longer carries keeps its last words on screen until it leaves at the left
if (!latest || now - lastChange > 60000) {
var why = finality && finality.supported && !finality.active ? 'finality paused: under two thirds of the weight is signing, so no checkpoint locks' : proving && proving.supported && proving.active && !(proving.shards_paid_10m > 0) ? 'no proof landed in the last 10 minutes; proofs land about ' + (proving.median_proof_lag_s ? Math.round(proving.median_proof_lag_s) : 380) + ' s behind the tip' : 'no block changed step in the last minute';
setCaption('Blocks arrive every second. ' + why.charAt(0).toUpperCase() + why.slice(1) + '.', true);
}
L.kick();
}
function rr(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(); }
function spawn(e, t) { e.x = dims.W + S; e.y = laneY(laneFor(e.src.miner || '?')); e.born = t; blocks.push(e); if (blocks.length > 80) { var old = blocks.shift(); if (!queue.some(function (q) { return q === old; })) delete byHash[old.hash]; } }
function draw(t) {
var W = dims.W, H = dims.H; T = F.theme();
var dt = prevT ? Math.min(50, t - prevT) : 16; prevT = t;
if (queue.length && t - last > RELEASE_MS) { spawn(queue.pop(), t); queue.length = 0; last = t; } // the newest waiting block; the rest are sampled out
blocks.forEach(function (b) { b.x -= speed * dt; if (b.glow > 0) b.glow -= 0.02; if (b.ripple > 0) b.ripple -= 0.012; });
blocks = blocks.filter(function (b) { if (b.x < -S * 2) { if (byHash[b.hash] === b && queue.indexOf(b) < 0) delete byHash[b.hash]; return false; } return true; });
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = F.rgba(T.line, 0.9); ctx.lineWidth = 1; for (var l = 0; l < lanes; l++) { ctx.beginPath(); ctx.moveTo(0, laneY(l)); ctx.lineTo(W, laneY(l)); ctx.stroke(); }
// parent edges between blocks on screen
blocks.forEach(function (b) { (b.src.parents || []).forEach(function (h) { var q = byHash[h]; if (!q || q.x === null || blocks.indexOf(q) < 0) return; var both = b.step === 'proven' && q.step === 'proven'; ctx.strokeStyle = both ? F.rgba(T.ember, 0.45) : F.rgba(T.ash, 0.22); ctx.lineWidth = both ? 1.5 : 1; ctx.beginPath(); ctx.moveTo(b.x, b.y); var mx = (b.x + q.x) / 2; ctx.bezierCurveTo(mx, b.y, mx, q.y, q.x, q.y); ctx.stroke(); }); });
// the lock line: everything left of the newest locked block is final
var lk = null; blocks.forEach(function (b) { if (b.src.locked && (!lk || b.x > lk.x)) lk = b; });
if (lk) { ctx.strokeStyle = F.rgba(T.ember, 0.55); ctx.lineWidth = 1.5; ctx.setLineDash([5, 6]); ctx.beginPath(); ctx.moveTo(lk.x, 6); ctx.lineTo(lk.x, H - 6); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = T.ash; ctx.font = '500 ' + Math.max(10, Math.round(S * 0.42)) + 'px IBM Plex Mono, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'alphabetic'; ctx.fillText('final', lk.x - 8, 14); }
blocks.forEach(function (b) {
var h = S / 2, w = b.step, src = b.src, ex = src.color === 'red';
if (b.glow > 0) { var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, S * 1.6); g.addColorStop(0, F.rgba(T.ember, 0.45 * b.glow)); g.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, S * 1.6, 0, Math.PI * 2); ctx.fill(); }
if (ex) ctx.globalAlpha = 0.45;
if (w === 'proven') { ctx.fillStyle = T.ember; rr(b.x - h, b.y - h, S, S, S * 0.24); ctx.fill(); }
else {
ctx.fillStyle = T.row; rr(b.x - h, b.y - h, S, S, S * 0.24); ctx.fill();
ctx.strokeStyle = w === 'proving' ? F.rgba(T.molten, 0.9) : (src.chain || src.color === 'blue') ? F.rgba(T.ember, 0.8) : F.rgba(T.ash, 0.7); ctx.lineWidth = 2; rr(b.x - h, b.y - h, S, S, S * 0.24); ctx.stroke();
if (w === 'proving') { var sh = src.shards || [], n = Math.min(4, sh.length), q = S / 2 - 3; for (var i = 0; i < n; i++) { var st = sh[i].state; if (st === 'verified' || st === 'paid' || st === 'proving') { ctx.fillStyle = st === 'proving' ? F.rgba(T.molten, 0.45) : T.molten; var qx = b.x - h + 3 + (i % 2) * q, qy = b.y - h + 3 + Math.floor(i / 2) * q; rr(qx, qy, q - 1, q - 1, 2); ctx.fill(); } } }
}
if (src.locked) { var pulse = 0.8 + 0.2 * Math.sin(t / 350); ctx.strokeStyle = F.rgba(T.ember, pulse); ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(b.x, b.y, S * 1.15, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = T.bone; ctx.beginPath(); ctx.arc(b.x, b.y, S * 0.16, 0, Math.PI * 2); ctx.fill(); }
if (b.ripple > 0) { ctx.strokeStyle = F.rgba(T.ember, b.ripple); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(b.x, b.y, S * (1.3 + (1 - b.ripple) * 2), 0, Math.PI * 2); ctx.stroke(); }
ctx.globalAlpha = 1;
});
}
var L = F.loop(draw, canvas); L.kick();
return { push: push, redraw: L.kick };
}
root.IgneumSteps = { mount: mount };
})(window);