diff --git a/docs/plans/site-ui-3-shots/scenes/scene-a-1440-dark.jpg b/docs/plans/site-ui-3-shots/scenes/scene-a-1440-dark.jpg new file mode 100644 index 000000000..e42a2dd53 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-a-1440-dark.jpg differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-a-1440-dark.webm b/docs/plans/site-ui-3-shots/scenes/scene-a-1440-dark.webm new file mode 100644 index 000000000..13d8cf194 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-a-1440-dark.webm differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-a-390-dark.jpg b/docs/plans/site-ui-3-shots/scenes/scene-a-390-dark.jpg new file mode 100644 index 000000000..160700725 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-a-390-dark.jpg differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-b-1440-dark.jpg b/docs/plans/site-ui-3-shots/scenes/scene-b-1440-dark.jpg new file mode 100644 index 000000000..37b32c6c4 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-b-1440-dark.jpg differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-b-1440-dark.webm b/docs/plans/site-ui-3-shots/scenes/scene-b-1440-dark.webm new file mode 100644 index 000000000..d6bfdedd4 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-b-1440-dark.webm differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-b-390-dark.jpg b/docs/plans/site-ui-3-shots/scenes/scene-b-390-dark.jpg new file mode 100644 index 000000000..456acc600 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-b-390-dark.jpg differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-c-1440-dark.jpg b/docs/plans/site-ui-3-shots/scenes/scene-c-1440-dark.jpg new file mode 100644 index 000000000..8ca64e0a0 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-c-1440-dark.jpg differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-c-1440-dark.webm b/docs/plans/site-ui-3-shots/scenes/scene-c-1440-dark.webm new file mode 100644 index 000000000..11d1083b7 Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-c-1440-dark.webm differ diff --git a/docs/plans/site-ui-3-shots/scenes/scene-c-390-dark.jpg b/docs/plans/site-ui-3-shots/scenes/scene-c-390-dark.jpg new file mode 100644 index 000000000..7fb76fe9a Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scene-c-390-dark.jpg differ diff --git a/docs/plans/site-ui-3-shots/scenes/scenes-1440-dark.jpg b/docs/plans/site-ui-3-shots/scenes/scenes-1440-dark.jpg new file mode 100644 index 000000000..2d6bb1f8d Binary files /dev/null and b/docs/plans/site-ui-3-shots/scenes/scenes-1440-dark.jpg differ diff --git a/site/build.mjs b/site/build.mjs index 5595c6297..83257d385 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -345,7 +345,9 @@ function renderEvidence(html) { const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', ''], // the DAG explorer (5 Oct 2026): /explorer, /block/ and /address/ (vercel.json rewrites the last two) - ['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live']]; + ['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live'], + // the scenes lab (6 Oct 2026): three animated views of the live feed, unlinked, noindex + ['scenes.html', '']]; for (const [file, active] of PAGES) { const p = join(here, file); if (!existsSync(p)) throw new Error(`missing page ${file}`); diff --git a/site/scenes.html b/site/scenes.html new file mode 100644 index 000000000..f6a51b043 --- /dev/null +++ b/site/scenes.html @@ -0,0 +1,212 @@ + + + + + +Igneum scenes, a lab + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
A lab, not linked
+

Three scenes, one feed

+

The same live devnet reply, drawn three ways. Every mark is a block the observer stored; the words are the ledger's.

+
+
connecting to the devnet observer
+
+
+
+

A · Forge

embers rise
+ +

Blocks arrive as embers from the hearth, one column per miner, and cool as they go: pending molten, included ember, proven bone. A lock is the dark band sweeping up; everything under it is final.

+
+
+

B · Lattice

a slow isometric DAG
+ +

Time runs left to right, each miner one step deeper, so parents stand behind their children. The selected chain is the lit spine; a checkpoint is a ring that closes around it when it locks. The camera drifts at chain speed.

+
+
+

C · Pulse

the tip at the centre
+ +

The tip is the centre and time runs outward, a ring every 30 seconds. Each miner is a ray; a block is a bead on it that drifts outward, filling when proven. A lock is a ring that hardens from dashed to solid.

+
+
+
+ pending + included + excluded + proven + locked checkpoint +
+

One node is read every 2 s; the window is the last 120 s. Under reduced motion each scene draws a still frame on every reply. Add ?only=a, b or c to the address for one scene full width.

+
+
+
+ + + + + + + + + + + + diff --git a/site/scenes/feed.js b/site/scenes/feed.js new file mode 100644 index 000000000..178353b0b --- /dev/null +++ b/site/scenes/feed.js @@ -0,0 +1,40 @@ +/* One poll of /api/live every 2 s, pushed to every scene on the page (docs/plans/site-ui-3.md, the scenes lab, 6 Oct 2026). + IgneumFeed.start({ url, window, onState, subs: [scene, ...] }); each scene has push(reply). The state words are the site's: + connecting, live, stale (observer offline, last update N ago), failed (live feed unavailable). */ +(function (root) { + 'use strict'; + 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 start(opts) { + var subs = opts.subs || [], failures = 0, state = 'connecting', reason = 'connecting'; + function set(s, r) { if (s === state && r === reason) return; state = s; reason = r; if (opts.onState) opts.onState(s, r); } + function poll() { + if (document.hidden) return; + fetch((opts.url || '/api/live') + '?window=' + (opts.window || 120), { 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) set('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown')); else set('live', null); + subs.forEach(function (s) { s.push(d); }); + }).catch(function () { failures++; if (failures >= 2) set('failed', 'live feed unavailable'); }); + } + poll(); var t = setInterval(poll, 2000); + document.addEventListener('visibilitychange', function () { if (!document.hidden) poll(); }); + return { stop: function () { clearInterval(t); }, state: function () { return { state: state, reason: reason }; } }; + } + // shared helpers for the three scenes + function cssVar(name, fallback) { try { var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; } } + function theme() { return { ember: cssVar('--ember', '#F2541B'), molten: cssVar('--molten', '#FFB35C'), bone: cssVar('--bone', '#F4F1EC'), ash: cssVar('--ash', '#9A9A9E'), line: cssVar('--line', '#2A2A30'), row: cssVar('--row', '#111114'), ink2: cssVar('--ink-2', '#C9C7C2') }; } + 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 + ')'; } + // the ledger's word for a block + function word(b) { return b.locked ? 'locked' : b.proven ? 'proven' : (b.chain || b.color === 'blue') ? 'included' : b.color === 'red' ? 'excluded' : 'pending'; } + function hash32(s) { var h = 2166136261; for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967296; } + function sizer(canvas, ctx) { var dpr = Math.min(root.devicePixelRatio || 1, 2); return function () { var 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); return { W: W, H: H }; }; } + function loop(draw, canvas) { + var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches, running = false, inView = true; + function frame(t) { if (!running) return; draw(t); if (reduce || document.hidden || !inView) { running = false; return; } requestAnimationFrame(frame); } + function kick() { if (!running) { running = true; requestAnimationFrame(frame); } } + 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) kick(); }); + return { kick: kick, reduce: reduce }; + } + root.IgneumFeed = { start: start, theme: theme, rgba: rgba, word: word, hash32: hash32, sizer: sizer, loop: loop }; +})(window); diff --git a/site/scenes/forge.js b/site/scenes/forge.js new file mode 100644 index 000000000..eb509c8f4 --- /dev/null +++ b/site/scenes/forge.js @@ -0,0 +1,61 @@ +/* Scene A, Forge. Blocks arrive as embers rising from the bottom, one column per miner. A block cools as its word changes: + pending is molten, included is ember, proven is bone. A lock is a dark band that sweeps up the scene at the newest locked + checkpoint; everything below it is final and settles to ash. Every ember is a block the observer stored. */ +(function (root) { + 'use strict'; + var F = root.IgneumFeed; + function mount(canvas, opts) { + opts = opts || {}; + var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme(); + var blocks = [], byHash = {}, lanes = {}, laneN = 0, lockBlue = null, lockY = null, lockTarget = null, lastT = 0; + var WIN = opts.window || 120; // seconds from the hearth to the top; a block's height is its header age, its glow is its arrival + function vnow() { return Date.now() - 2500; } + root.addEventListener('resize', function () { dims = size(); L.kick(); }); + function laneX(id) { if (!(id in lanes)) lanes[id] = laneN++; var n = Math.max(1, laneN); var i = lanes[id]; return 24 + ((i * 0.618034) % 1) * (dims.W - 48); } + function push(d) { + var now = performance.now(); + d.blocks.forEach(function (b) { + var e = byHash[b.hash]; + if (e) { e.src = b; e.word = F.word(b); return; } + e = { hash: b.hash, src: b, word: F.word(b), x: laneX(b.miner || '?') + (F.hash32(b.hash) - 0.5) * 10, born: now, ts: b.ts, glow: 1, settle: 0 }; + byHash[b.hash] = e; blocks.push(e); + }); + var cps = (d.finality && d.finality.checkpoints) || [], locked = cps.filter(function (c) { return c.state === 'locked'; }); + lockBlue = locked.length ? locked[locked.length - 1].blue_score : null; + blocks.forEach(function (e) { e.final = lockBlue !== null && e.src.blue_score !== null && e.src.blue_score <= lockBlue; }); + var cut = vnow() - WIN * 1000 - 8000; + blocks = blocks.filter(function (e) { if (e.ts < cut) { delete byHash[e.hash]; return false; } return true; }); + L.kick(); + } + function yOf(e) { return dims.H - 16 - ((vnow() - e.ts) / 1000) * ((dims.H - 32) / WIN); } + function draw(t) { + var W = dims.W, H = dims.H; T = F.theme(); + ctx.clearRect(0, 0, W, H); + // the hearth: a faint warm floor + var g = ctx.createLinearGradient(0, H, 0, H - 90); g.addColorStop(0, F.rgba(T.ember, 0.16)); g.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = g; ctx.fillRect(0, H - 90, W, 90); + // the lock band: the lowest final block's height, eased + var target = null; blocks.forEach(function (e) { if (e.final) { var y = yOf(e); if (target === null || y > target) target = y; } }); + if (target !== null) { lockY = lockY === null ? target : lockY + (target - lockY) * 0.08; } + if (lockY !== null) { var band = ctx.createLinearGradient(0, lockY - 70, 0, lockY); band.addColorStop(0, F.rgba(T.row, 0)); band.addColorStop(1, F.rgba(T.row, 0.9)); ctx.fillStyle = band; ctx.fillRect(0, lockY - 70, W, 70); ctx.fillStyle = F.rgba(T.row, 0.9); ctx.fillRect(0, 0, W, Math.max(0, lockY - 70)); + ctx.strokeStyle = F.rgba(T.ember, 0.6); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, lockY); ctx.lineTo(W, lockY); ctx.stroke(); + ctx.fillStyle = T.ash; ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('locked', W - 10, lockY - 4); } + blocks.forEach(function (e) { + var y = yOf(e); if (y < -20) return; + var age = (t - e.born) / 1000, w = e.word; + var col = w === 'proven' ? T.bone : w === 'included' || w === 'locked' ? T.ember : w === 'excluded' ? T.ash : T.molten; + var r = w === 'excluded' ? 2.2 : w === 'proven' ? 3.6 : 3; + var a = e.final ? 0.45 : 1; + if (age < 1.2) { var gl = 1 - age / 1.2; var rg = ctx.createRadialGradient(e.x, y, 0, e.x, y, 18); rg.addColorStop(0, F.rgba(T.molten, 0.5 * gl)); rg.addColorStop(1, F.rgba(T.molten, 0)); ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(e.x, y, 18, 0, Math.PI * 2); ctx.fill(); } + var flicker = e.final ? 0 : Math.sin(t / 420 + e.x) * 0.6; + ctx.globalAlpha = a; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(e.x + flicker, y, r, 0, Math.PI * 2); ctx.fill(); + if (w === 'locked') { ctx.strokeStyle = F.rgba(T.ember, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(e.x, y, r + 4, 0, Math.PI * 2); ctx.stroke(); } + // a thin trail: the ember's rise + ctx.strokeStyle = F.rgba(col, 0.12); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(e.x, y + r); ctx.lineTo(e.x, Math.min(H - 16, y + 14)); ctx.stroke(); + ctx.globalAlpha = 1; + }); + } + var L = F.loop(draw, canvas); L.kick(); + return { push: push, redraw: L.kick }; + } + root.IgneumForge = { mount: mount }; +})(window); diff --git a/site/scenes/lattice.js b/site/scenes/lattice.js new file mode 100644 index 000000000..1ff35cea3 --- /dev/null +++ b/site/scenes/lattice.js @@ -0,0 +1,77 @@ +/* Scene B, Lattice. A slow isometric DAG: time runs left to right, each miner lane sits one step deeper, so parents stand + behind their children. The selected chain is a lit spine; every other edge is a thin thread. Checkpoints are rings that + close around the spine where they lock. The camera drifts at chain speed. */ +(function (root) { + 'use strict'; + var F = root.IgneumFeed; + function mount(canvas, opts) { + opts = opts || {}; + var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme(); + var blocks = [], byHash = {}, lanes = {}, laneN = 0, cps = [], windowS = opts.window || 120, rank = {}; + var DEPTH = 8; // up to DEPTH lanes deep; the isometric step scales with the canvas (about 4.5% of its height per lane) + // the step scales with the canvas; the whole shear never takes more than a third of the width, so a narrow card keeps its time axis + function stepY() { return Math.max(7, Math.min(dims.H * 0.045, (dims.W * 0.33) / (DEPTH * 1.4))); } function stepX() { return stepY() * 1.4; } + root.addEventListener('resize', function () { dims = size(); L.kick(); }); + function lane(id) { if (!(id in lanes)) lanes[id] = laneN++; return lanes[id]; } + function vnow() { return Date.now() - 2500; } + function push(d) { + var now = performance.now(); + d.blocks.forEach(function (b) { var e = byHash[b.hash]; if (e) { e.src = b; e.word = F.word(b); return; } e = { hash: b.hash, src: b, word: F.word(b), lane: lane(b.miner || '?'), born: now }; byHash[b.hash] = e; blocks.push(e); }); + var cut = vnow() - windowS * 1000 - 20000; + blocks = blocks.filter(function (e) { if (e.src.ts < cut) { delete byHash[e.hash]; return false; } return true; }); + blocks.sort(function (a, b) { return a.src.ts - b.src.ts; }); + // lanes ranked: the miners of the selected chain nearest the front, then by blocks in the window + var score = {}; blocks.forEach(function (e) { var m = e.src.miner || '?'; score[m] = (score[m] || 0) + 1 + (e.src.chain ? 3 : 0); }); + var ids = Object.keys(score).sort(function (a, b) { return score[b] - score[a] || (a < b ? -1 : 1); }); rank = {}; ids.forEach(function (id, i) { rank[id] = i; }); + cps = (d.finality && d.finality.checkpoints) || []; + L.kick(); + } + function project(e) { + // the selected chain is the front lane; the other blocks sit one step deeper per miner rank, the many small miners sharing the back + var W = dims.W, H = dims.H, r = rank[e.src.miner || '?'] || 0, z = e.src.chain ? 0 : (1 + (r % (DEPTH - 1))) / DEPTH; + var SX = stepX(), SY = stepY(); + var x = 40 + (W - 80 - DEPTH * SX) * (1 - (vnow() - e.src.ts) / (windowS * 1000)) + z * DEPTH * SX; + var y = H * 0.74 - z * DEPTH * SY; + return { x: x, y: y, z: z }; + } + function draw(t) { + var W = dims.W, H = dims.H; T = F.theme(); + ctx.clearRect(0, 0, W, H); + // the ground plane: three faint isometric rails + ctx.strokeStyle = F.rgba(T.line, 0.9); ctx.lineWidth = 1; + for (var k = 0; k <= DEPTH; k += 2) { var yy = H * 0.74 - k * stepY(), xx = k * stepX(); ctx.beginPath(); ctx.moveTo(xx, yy); ctx.lineTo(W, yy); ctx.stroke(); } + var pos = {}; blocks.forEach(function (e) { pos[e.hash] = project(e); }); + // threads, back to front + var ordered = blocks.slice().sort(function (a, b) { return pos[b.hash].z - pos[a.hash].z; }); + ordered.forEach(function (e) { + var p = pos[e.hash]; if (p.x < -20) return; + (e.src.parents || []).forEach(function (h) { var q = byHash[h]; if (!q) return; var pq = pos[h]; var spine = e.src.chain && q.src.chain; + ctx.strokeStyle = spine ? F.rgba(T.ember, 0.9) : F.rgba(T.ash, e.word === 'excluded' ? 0.1 : 0.2); ctx.lineWidth = spine ? 2.2 : 1; + ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(pq.x, pq.y); ctx.stroke(); + if (spine) { ctx.strokeStyle = F.rgba(T.ember, 0.16); ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(pq.x, pq.y); ctx.stroke(); } }); + }); + // checkpoint rings around the spine + cps.forEach(function (c) { + var ref = null, best = Infinity; blocks.forEach(function (e) { if (!e.src.chain || e.src.blue_score === null) return; var dd = Math.abs(e.src.blue_score - c.blue_score); if (dd < best) { best = dd; ref = e; } }); + if (!ref || best > 3) return; var p = pos[ref.hash]; if (p.x < 0 || p.x > W) return; + var locked = c.state === 'locked', age = (performance.now() - ref.born) / 1000, close = locked ? Math.min(1, age / 1.5) : 0.35; + ctx.strokeStyle = F.rgba(T.ember, locked ? 0.85 : 0.35); ctx.lineWidth = locked ? 1.8 : 1; ctx.setLineDash(locked ? [] : [3, 5]); + ctx.beginPath(); ctx.ellipse(p.x, p.y, 22 - 8 * close, 9 - 3 * close, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); + if (locked && !opts.compact) { ctx.fillStyle = T.ash; ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText(Math.round((c.fraction_total || 0) * 100) + '%', p.x, p.y - 16); } + }); + // blocks: small isometric tiles, lit by word + ordered.forEach(function (e) { + var p = pos[e.hash]; if (p.x < -20 || p.x > W + 20) return; var w = e.word, age = (t - e.born) / 1000; + var col = w === 'proven' ? T.bone : (w === 'included' || w === 'locked') ? T.ember : w === 'excluded' ? T.ash : T.molten; + var s = e.src.chain ? 6 : 4.5, a = w === 'excluded' ? 0.45 : 1 - p.z * 0.35; + if (age < 1.4) { var gl = 1 - age / 1.4; var rg = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 20); rg.addColorStop(0, F.rgba(T.ember, 0.45 * gl)); rg.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(p.x, p.y, 20, 0, Math.PI * 2); ctx.fill(); } + ctx.globalAlpha = a; ctx.fillStyle = w === 'pending' ? T.row : col; ctx.strokeStyle = col; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(p.x, p.y - s); ctx.lineTo(p.x + s * 1.3, p.y); ctx.lineTo(p.x, p.y + s); ctx.lineTo(p.x - s * 1.3, p.y); ctx.closePath(); ctx.fill(); ctx.stroke(); + ctx.globalAlpha = 1; + }); + } + var L = F.loop(draw, canvas); L.kick(); + return { push: push, redraw: L.kick }; + } + root.IgneumLattice = { mount: mount }; +})(window); diff --git a/site/scenes/pulse.js b/site/scenes/pulse.js new file mode 100644 index 000000000..06b10faff --- /dev/null +++ b/site/scenes/pulse.js @@ -0,0 +1,60 @@ +/* Scene C, Pulse. A radial view: the tip at the centre, time outward in rings, each miner a ray. A block is a bead on its + miner's ray at the radius of its age; a proven bead fills; a locked checkpoint is a ring that hardens from dashed to + solid at the radius where it locked. Beads drift outward as time passes. */ +(function (root) { + 'use strict'; + var F = root.IgneumFeed; + function mount(canvas, opts) { + opts = opts || {}; + var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme(); + var blocks = [], byHash = {}, rays = {}, rayN = 0, cps = [], windowS = opts.window || 120; + root.addEventListener('resize', function () { dims = size(); L.kick(); }); + function vnow() { return Date.now() - 2500; } + function ray(id) { if (!(id in rays)) rays[id] = rayN++; return rays[id]; } + function push(d) { + var now = performance.now(); + d.blocks.forEach(function (b) { var e = byHash[b.hash]; if (e) { e.src = b; e.word = F.word(b); return; } e = { hash: b.hash, src: b, word: F.word(b), ray: ray(b.miner || '?'), born: now }; byHash[b.hash] = e; blocks.push(e); }); + var cut = vnow() - windowS * 1000 - 20000; + blocks = blocks.filter(function (e) { if (e.src.ts < cut) { delete byHash[e.hash]; return false; } return true; }); + cps = (d.finality && d.finality.checkpoints) || []; + L.kick(); + } + function polar(e) { var R = Math.min(dims.W, dims.H) / 2 - 14, cx = dims.W / 2, cy = dims.H / 2; var r = 10 + (R - 10) * ((vnow() - e.src.ts) / (windowS * 1000)); var ang = ((e.ray * 0.618034) % 1) * Math.PI * 2 - Math.PI / 2; return { x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r, r: r, ang: ang }; } + function draw(t) { + var W = dims.W, H = dims.H; T = F.theme(); var cx = W / 2, cy = H / 2, R = Math.min(W, H) / 2 - 14; + ctx.clearRect(0, 0, W, H); + // time rings every 30 s + ctx.strokeStyle = F.rgba(T.line, 0.9); ctx.lineWidth = 1; + for (var s = 30; s < windowS; s += 30) { var rr = 10 + (R - 10) * (s / windowS); ctx.beginPath(); ctx.arc(cx, cy, rr, 0, Math.PI * 2); ctx.stroke(); } + // the tip + var pulse = 0.5 + 0.5 * Math.sin(t / 900); var tg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 26); tg.addColorStop(0, F.rgba(T.ember, 0.35 + 0.2 * pulse)); tg.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = tg; ctx.beginPath(); ctx.arc(cx, cy, 26, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = T.ember; ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); ctx.fill(); + // checkpoint rings, hardening when locked + cps.forEach(function (c) { + var ref = null, best = Infinity; blocks.forEach(function (e) { if (e.src.blue_score === null) return; var dd = Math.abs(e.src.blue_score - c.blue_score); if (dd < best) { best = dd; ref = e; } }); + if (!ref || best > 3) return; var p = polar(ref); if (p.r > R) return; var locked = c.state === 'locked', age = (performance.now() - ref.born) / 1000, hard = locked ? Math.min(1, age / 2) : 0; + ctx.strokeStyle = F.rgba(T.ember, locked ? 0.35 + 0.5 * hard : 0.3); ctx.lineWidth = locked ? 1 + hard : 1; ctx.setLineDash(locked && hard > 0.9 ? [] : [3, 6]); + ctx.beginPath(); ctx.arc(cx, cy, p.r, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); + }); + // the selected chain as a soft spiral thread through its beads + var chain = blocks.filter(function (e) { return e.src.chain; }).sort(function (a, b) { return a.src.ts - b.src.ts; }); + // the selected chain as short arcs between consecutive chain beads (the arc follows the ring between the two radii, never a chord through the centre) + if (chain.length > 1) { ctx.strokeStyle = F.rgba(T.ember, 0.28); ctx.lineWidth = 1; for (var i = 1; i < chain.length; i++) { var p0 = polar(chain[i - 1]), p1 = polar(chain[i]); if (p0.r > R || p1.r > R) continue; var a0 = p0.ang, a1 = p1.ang, da = ((a1 - a0 + Math.PI * 3) % (Math.PI * 2)) - Math.PI; var steps = 10; ctx.beginPath(); for (var k = 0; k <= steps; k++) { var f = k / steps, rr = p0.r + (p1.r - p0.r) * f, aa = a0 + da * f; var x = cx + Math.cos(aa) * rr, y = cy + Math.sin(aa) * rr; if (k) ctx.lineTo(x, y); else ctx.moveTo(x, y); } ctx.stroke(); } } + // beads + blocks.forEach(function (e) { + var p = polar(e); if (p.r > R + 6) return; var w = e.word, age = (t - e.born) / 1000; + var col = w === 'proven' ? T.bone : (w === 'included' || w === 'locked') ? T.ember : w === 'excluded' ? T.ash : T.molten; + var rad = e.src.chain ? 4 : 3; + if (age < 1.2) { var gl = 1 - age / 1.2; var rg = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 16); rg.addColorStop(0, F.rgba(T.ember, 0.5 * gl)); rg.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(p.x, p.y, 16, 0, Math.PI * 2); ctx.fill(); } + ctx.globalAlpha = w === 'excluded' ? 0.45 : 1; + ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, Math.PI * 2); + if (w === 'proven' || w === 'included' || w === 'locked') { ctx.fillStyle = col; ctx.fill(); } else { ctx.fillStyle = T.row; ctx.fill(); ctx.strokeStyle = col; ctx.lineWidth = 1.2; ctx.stroke(); } + if (w === 'locked') { ctx.strokeStyle = F.rgba(T.ember, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, rad + 4, 0, Math.PI * 2); ctx.stroke(); } + ctx.globalAlpha = 1; + }); + } + var L = F.loop(draw, canvas); L.kick(); + return { push: push, redraw: L.kick }; + } + root.IgneumPulse = { mount: mount }; +})(window);