blockyard 0.0.1 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,1138 @@
1
+ // The block flow: past blocks, the tip, and the block being built right now -- plus the
2
+ // Goggles-style views of what is inside the block under construction.
3
+ //
4
+ // Layout follows the thing it is imitating, for a reason: mempool.space puts past blocks
5
+ // on the left, the tip in the middle, and the block being assembled on the right, with the
6
+ // eye moving left-to-right because that is the direction blocks leave. A bar chart of
7
+ // "weight used per block" carries the same numbers and communicates none of it, so the
8
+ // cards show what a block *is* -- who mined it, how long the gap was, how full it came
9
+ // out, what it paid -- and the next-block card shows a block that does not exist yet.
10
+ //
11
+ // The honest part: an unlabelled coinbase is drawn with its raw text and a grey rail, the
12
+ // next-block card states its own age and what it cost the node to answer, and the package
13
+ // view is labelled as the block-under-construction's ancestor graph -- the one dependency
14
+ // graph this node publishes. Until 2026-09-13 that meant getblocktemplate; measured against
15
+ // Bitcoin Core, getrawmempool(true) carries `depends`, the ancestor sizes and fees, and
16
+ // fees.chunk/chunkweight -- so the block being built is assembled from the mempool the
17
+ // monitor already reads, and costs the node no call at all (server/collect/gbt.js).
18
+
19
+ import { paint, COL } from './charts.js';
20
+ import { blockTreemap, mempoolTreemap, rateColor as rateBucketColor } from './goggles.js';
21
+ import { loadSettings, spaceOptions } from './settings.js';
22
+ // 2026-09-10: the block and the pool now draw as lit solids on a square-packed
23
+ // grid (the look of mempool.space's block view, our own packer in blockpack.js) with feerate carried
24
+ // in HEIGHT as well as colour, and refreshes that lift, fly and land. The flat
25
+ // treemap entry points stay imported above so a fallback is one edit away.
26
+ import { block3d, mempool3d } from './details3d.js';
27
+ import { feeColor } from './feepalette.js';
28
+
29
+ // ONE viewer, two pages. The operator asked for the mempool viewer to be
30
+ // IDENTICAL on Overview and Mining, so both call this and nothing else --
31
+ // same data, same options. A "smaller copy" that quietly diverges is exactly
32
+ // the failure this replaces.
33
+ export function poolViewerArgs(s, state) {
34
+ const mp = state?.mempoolDist ?? s?.mempool ?? {};
35
+ return { cells: mp.cells ?? [], totalVsize: mp.totalVsize ?? mp.usage ?? null };
36
+ }
37
+ // the same viewer, for the Mempool page (2026-09-11: "add the mempool display here too")
38
+ export function poolViewer(canvas, s, state) { return drawPoolViewer(canvas, s, state); }
39
+
40
+ // THE COUNTDOWN TO THE NEXT REFRESH (operator, 2026-09-11: "we need to add a
41
+ // countdown to refresh somewhere in that panel"). The viewer's picture changes
42
+ // every 30 s; this says when. `frac` is the share of the wait already gone,
43
+ // drawn as a fill behind the text. No refresh scheduled yet: nothing to show.
44
+ export function refreshLabel(nextAt, now, { paused = false, period = 60_000 } = {}) {
45
+ if (!Number.isFinite(nextAt)) return { text: '', frac: 0 };
46
+ const rem = nextAt - now;
47
+ const frac = Math.max(0, Math.min(1, 1 - rem / period));
48
+ if (paused) return { text: 'refresh paused', frac };
49
+ if (rem <= 0) return { text: 'refreshing…', frac: 1 };
50
+ const s = Math.ceil(rem / 1000);
51
+ return { text: `next refresh ${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`, frac };
52
+ }
53
+
54
+ // VIEWER MODES (operator, 2026-09-11: "we need to save off the current viewer as "Viewer Mode 1"
55
+ // or something, as we add more viewer variants"). Mode 1 is the viewer as it was: the richest
56
+ // 400 transactions as cubes, the rest as equal pieces, on a 44-unit board. Mode 2 is the
57
+ // high-density view asked for beside mempool.space's Goggles: every transaction in the next
58
+ // block's worth (denseBlock on the server), one square each on a 128-unit board, standing as low
59
+ // slabs so thousands stay readable and fast. Same board, camera, lighting and effects.
60
+ export const VIEWER_MODES = [
61
+ // named Simple and Detailed (operator, 2026-09-11: "We should not call it Goggles Mode. We should call it
62
+ // Detailed. Mode 1 as Simple"); the ids stay '1' and '2' so a remembered choice survives the rename
63
+ { id: '1', label: 'Simple', title: 'Simple: the richest 400 transactions as cubes, the rest as equal pieces coloured by feerate' },
64
+ { id: '2', label: 'Detailed', title: 'Detailed: every transaction in the next block, one square each' },
65
+ ];
66
+ // 96 UNITS, MEASURED (2026-09-11, the live next block: 3,051 transactions, p10/50/90 139/140/141
67
+ // vB). The packer rounds a square's side (round(sqrt(1.1 vsize / vbytes-per-unit))), and at 128
68
+ // units a 140 vB transaction rounds to 2 x 2: the block overflowed to 172 rows, the fit shrank
69
+ // everything back to 1 x 1, and 30 of the 128 rows stood empty -- the scattered, holed board. At
70
+ // 96 a typical transaction is exactly one unit: every one fits with no shrinking, 2,943 of them
71
+ // 1 x 1, the board 94% full, the largest still 26 units a side.
72
+ // dither: area-true square sides (blockpack.js ditheredSide), so a full block fills the board
73
+ export const DENSE_OPTS = { resolution: 96, slab: 1.2, order: 'diagonal', gridStep: 8, neonCell: 'rgba(50,190,125,0.22)', dither: true };
74
+
75
+ // SIMPLE IS THE DEFAULT VIEW, so its board has to pack clean (operator, 2026-09-12: "Simple viewer
76
+ // mode is what I use as default. It needs to be fucking perfect"). It is NOT clean yet, and the
77
+ // reason is recorded here so the next attempt does not repeat the one that failed.
78
+ //
79
+ // 48 was tried, on the grounds that the tail's equal squares come out 3 units a side and
80
+ // 44 = 14x3 + 2, leaving a 2-wide strip no piece could enter down the whole board. That much is
81
+ // true, and 48 did remove the side strip (interior gap rows 33 -> 8). It was still reverted,
82
+ // because it is worse where it shows: measured across four pools, the TOP row came out 8%, 8%,
83
+ // 67% and 4% full against 55%, 61%, 61% and 45% at 44, and leftover cells rose from 92 to 160 on
84
+ // the live pool. Interior gap-rows were the wrong thing to optimise; the torn top edge is what
85
+ // the eye reads as a mess.
86
+ //
87
+ // No resolution fixes it: the best top row across 40/44/48/50/56/60 swings from 4% to 98%
88
+ // depending on the pool. The lever is not the board size. The tail is cut into N equal squares of
89
+ // a side chosen in advance (aggregatePieces), and the scale is then shrunk in 5% steps until the
90
+ // result happens to fit -- so the last row is partial by construction. A flush board needs the
91
+ // scale SOLVED so the block fills the grid exactly, and the remainder tiled out to the edge.
92
+ export function viewerSetup(s, state) {
93
+ const d = state?.denseBlock;
94
+ if (state?.viewerMode === '2' && d?.v?.length) {
95
+ const cells = d.v.map((v, i) => ({ vbytes: v, rate: d.r[i], txid: d.id?.[i] ?? `d${i}` }));
96
+ return { mode: '2', args: { cells, totalVsize: d.vsize ?? null }, opts: DENSE_OPTS };
97
+ }
98
+ // mode 2 before its first dense read: the mode 1 picture, not a blank board
99
+ // ONE ID FORMAT FOR BOTH MODES: full txids -- a transaction on both boards is the same tile, so
100
+ // switching modes flies the richest few hundred to their new places instead of emptying the
101
+ // board and refilling it; and a click on any of them opens it in the explorer
102
+ return { mode: state?.viewerMode === '2' ? '2-waiting' : '1', args: poolViewerArgs(s, state), opts: {} };
103
+ }
104
+ function modeSwitch(canvas, state) {
105
+ // THE CONTROLS SIT IN THE PANEL'S TITLE ROW, not over the board (operator, 2026-09-11: "move the
106
+ // buttons out of the 3D display. It's covering the blocks at the top of the scene"). The bar is
107
+ // authored inside the viewer's wrapper; the first render moves it into the card's heading and
108
+ // remembers its board (app.js finds a refresh button's board through the bar).
109
+ const ctl = canvas?.__viewerCtl ?? canvas?.parentElement?.querySelector?.('.viewer-ctl');
110
+ if (!ctl) return;
111
+ if (canvas && !canvas.__viewerCtl) {
112
+ canvas.__viewerCtl = ctl;
113
+ ctl.__canvas = canvas;
114
+ const head = canvas.closest?.('.card, .kpanel')?.querySelector?.('h3, .khead');
115
+ if (head && ctl.parentElement !== head && typeof head.appendChild === 'function') { head.appendChild(ctl); ctl.classList?.add('in-head'); }
116
+ }
117
+ let el = ctl.querySelector?.('.viewer-mode');
118
+ if (!el && typeof document?.createElement === 'function') {
119
+ el = document.createElement('div');
120
+ el.className = 'viewer-mode';
121
+ el.innerHTML = VIEWER_MODES.map((m) => `<button type="button" data-vmode="${m.id}" title="${m.title}">${m.label}</button>`).join('');
122
+ ctl.prepend?.(el);
123
+ }
124
+ for (const b of el?.querySelectorAll?.('button') ?? []) b.classList?.toggle('on', b.dataset.vmode === (state?.viewerMode ?? '1'));
125
+ }
126
+
127
+ function drawPoolViewer(canvas, s, state) {
128
+ // Scaled to the POOL, so the packing fills a SQUARE grid and every block
129
+ // lands inside it (operator: "the grid needs to take up the entire
130
+ // viewspace. Every block needs to fit within the grid"). Against one
131
+ // block's 1,000,000 vB a 3 MB pool packs three times taller than it is
132
+ // wide, and no square grid could hold it. Where one block's worth ends is
133
+ // drawn as a brighter line inside the grid instead.
134
+ modeSwitch(canvas, state);
135
+ const v = viewerSetup(s, state);
136
+ // the operator's preferences last, over the mode's own options (settings.js)
137
+ return mempool3d(canvas, v.args, { ...v.opts, ...spaceOptions(loadSettings()) });
138
+ }
139
+
140
+ const WU_CAP_FALLBACK = 4_000_000;
141
+
142
+ const POOL_COLOURS = [COL.accent, COL.info, COL.ok, COL.purple, COL.cyan, COL.pink,
143
+ COL.warn, '#8bd450', '#d98b5f', '#5fb0c9', '#c78bd4', '#c9b45f'];
144
+
145
+ function poolColor(key) {
146
+ const k = String(key ?? '');
147
+ let h = 0;
148
+ for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) | 0;
149
+ return POOL_COLOURS[Math.abs(h) % POOL_COLOURS.length];
150
+ }
151
+
152
+ /** Palette entry per pool: index only, resolved to a colour by our own CSSOM code. */
153
+ function who(row) {
154
+ if (row.poolLabel) return { name: row.poolLabel, idx: poolIndex(row.poolLabelKey ?? row.poolKey), labelled: true };
155
+ if (row.poolKey && !String(row.poolKey).startsWith('unknown:')) {
156
+ return { name: row.poolKey, idx: poolIndex(row.poolKey), labelled: false };
157
+ }
158
+ return { name: row.tagText ? trunc(row.tagText, 12) : 'unknown', idx: -1, labelled: false };
159
+ }
160
+
161
+ /** Thousands, through whatever fmt the page handed us; never a bare Number(). */
162
+ function fmtNum(n, h) {
163
+ const f = h?.fmt?.num;
164
+ return typeof f === 'function' ? f(n) : (n == null ? '–' : String(n));
165
+ }
166
+
167
+ // Fees in a two-column card grid: the unit lives in the LABEL ("fees ₿"), because
168
+ // "0.01193 BTC" does not fit a half-card column and wrapped onto a second line --
169
+ // a whole extra row of height per card for three letters.
170
+ const btcNum = (sats) => (sats == null ? '–' : (sats / 1e8).toFixed(sats > 1e7 ? 3 : 5));
171
+ const trunc = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t || 'unknown'; };
172
+ const btc = (sats) => (sats == null ? '–' : `${(sats / 1e8).toFixed(sats > 1e7 ? 3 : 5)} BTC`);
173
+ const gapText = (sec) => (sec == null ? '–' : sec >= 600 ? `${Math.floor(sec / 600)}m${Math.floor((sec % 600) / 60)}s` : sec >= 60 ? `${Math.floor(sec / 60)}s${sec % 60 ? ` ${(sec % 60)}s` : ''}` : `${sec}s`);
174
+
175
+ /**
176
+ * Which motions this data has earned, as opposed to decoration.
177
+ *
178
+ * The pipeline must animate when a block ARRIVES and stay still when a frame merely
179
+ * repaints -- and this page repaints once a second. Compared against the previous render,
180
+ * not against a timer: without this, every second of every day would replay the arrival
181
+ * animation until the motion meant nothing at all.
182
+ */
183
+ export function flowMotion(prevNewest, newest) {
184
+ const has = (v) => Number.isFinite(v);
185
+ if (!has(prevNewest) || !has(newest) || newest <= prevNewest) return { shift: false, arrived: null };
186
+ return { shift: true, arrived: newest };
187
+ }
188
+
189
+ /** Rail speed: the measured average block gap, clamped to something a screen can show. */
190
+ export function railSeconds(avgGapSec) {
191
+ const g = Number.isFinite(avgGapSec) && avgGapSec > 0 ? avgGapSec : 600;
192
+ return +Math.min(240, Math.max(12, g / 25)).toFixed(1);
193
+ }
194
+
195
+ /**
196
+ * The block meter: the block being built, filling up, in ONE row.
197
+ *
198
+ * Replaces a row of bobbing ticks whose count was sqrt(txCount) -- decoration
199
+ * shaped like a number, which also wrapped onto a second line and bobbed out of
200
+ * step (operator, 2026-09-11: "disjointed and not contiguous across the line").
201
+ *
202
+ * Each of METER_SEGMENTS segments is an equal share of the weight cap. A lit
203
+ * segment is weight the node has selected, coloured by the feerate of the
204
+ * transaction sitting at that point of the template when it is laid out richest
205
+ * first -- so the row reads hot-to-cool left to right, exactly as a miner fills
206
+ * a block. The frontier segment is filled fractionally. Segments the latest
207
+ * reading added over `prevLit` (same height only) are marked `new` and light
208
+ * up once. Everything is a reading: weight, cap, and the template's own cells.
209
+ */
210
+ export const METER_SEGMENTS = 40;
211
+ export function growthMeter(nb, prevLit = null, now = Date.now()) {
212
+ if (!nb || nb.unavailable) return { html: '', lit: 0 };
213
+ const cap = Number(nb.weightLimit) || WU_CAP_FALLBACK;
214
+ const frac = Math.max(0, Math.min(1, (Number(nb.weight) || 0) / cap));
215
+ const n = METER_SEGMENTS;
216
+ const full = Math.min(n, Math.floor(frac * n + 1e-9));
217
+ const part = frac * n - full;
218
+ const cells = (nb.visual?.cells ?? [])
219
+ .map((c) => ({ vb: Math.max(0, Number(c.vbytes ?? c.vsize) || 0), rate: Number(c.rate) }))
220
+ .filter((c) => c.vb > 0 && Number.isFinite(c.rate))
221
+ .sort((a, b) => b.rate - a.rate);
222
+ const totalVb = cells.reduce((s, c) => s + c.vb, 0);
223
+ // the rate of the transaction at fraction f (0..1) of the template's own size
224
+ const rateAt = (f) => {
225
+ if (!totalVb) return null;
226
+ const target = Math.max(0, Math.min(1, f)) * totalVb;
227
+ let acc = 0;
228
+ for (const c of cells) { acc += c.vb; if (acc >= target) return c.rate; }
229
+ return cells[cells.length - 1].rate;
230
+ };
231
+ const fee = (r) => (r == null ? '' : ` data-fee="${Math.round(r * 100) / 100}"`);
232
+ const grew = Number.isFinite(prevLit) && full > prevLit;
233
+ const segs = [];
234
+ for (let i = 0; i < n; i++) {
235
+ if (i < full) {
236
+ segs.push(`<i class="on${grew && i >= prevLit ? ' new' : ''}"${fee(rateAt(frac > 0 ? ((i + 0.5) / n) / frac : 0))}></i>`);
237
+ } else if (i === full && part > 0.001) {
238
+ segs.push(`<i class="edge"><span data-w="${(part * 100).toFixed(1)}"${fee(rateAt(1))} data-phase="${((now % 2400) / 1000).toFixed(2)}"></span></i>`);
239
+ } else if (i === full) {
240
+ segs.push(`<i class="next" data-phase="${((now % 2400) / 1000).toFixed(2)}"></i>`);
241
+ } else {
242
+ segs.push('<i></i>');
243
+ }
244
+ }
245
+ const title = `${(frac * 100).toFixed(1)}% of the ${cap.toLocaleString('en-US')} WU cap selected. `
246
+ + `Each segment is ${(100 / n).toFixed(1)}% of the cap; lit segments are coloured by the feerate of the transactions that fill them, richest first.`;
247
+ return { html: `<div class="bmeter" title="${title}">${segs.join('')}</div>`, lit: full };
248
+ }
249
+
250
+ /**
251
+ * Where each card comes from.
252
+ *
253
+ * The TIP HEIGHT -- `getblockchaininfo.blocks`, polled every second -- is the only thing
254
+ * allowed to decide what sits in the centre. The attribution rows lag it: one block per
255
+ * poll, two RPC reads each, and nothing at all during initial download. If the newest
256
+ * attributed row were treated as the tip, the centre card would quietly show a block from
257
+ * four minutes ago and call it the current one, which is the bug this exists to prevent.
258
+ * So heights are generated from the tip and the rows are matched to them by height; a
259
+ * height with no row yet is drawn as a placeholder that says so, not skipped -- skipping
260
+ * hides the lag, and a row that hides its lag is a lie with better typography.
261
+ */
262
+ export function flowFrame({ tipHeight, recent = [], stats = [], history = 8 } = {}) {
263
+ const rows = new Map((recent ?? []).filter((r) => r && Number.isFinite(r.height)).map((r) => [r.height, r]));
264
+ // A HEIGHT WITHOUT ITS COINBASE IS NOT A HEIGHT WITHOUT DATA (operator, 2026-09-12: "It's not
265
+ // showing any data at all for unattributed blocks. How is that possible?"). Attribution reads one
266
+ // coinbase per poll, so it trails the tip -- but getblockstats has been in hand the whole time:
267
+ // size, weight, transactions, fees, feerate. The card used to be built only from the attribution
268
+ // row, so a height the reader had not reached yet was drawn as an empty dashed box, throwing away
269
+ // everything already known about it to report the one thing that was missing.
270
+ const statRows = new Map((stats ?? []).filter((r) => r && Number.isFinite(r.height)).map((r) => [r.height, r]));
271
+ const rowFor = (h) => {
272
+ const known = rows.get(h);
273
+ if (known) return known;
274
+ const st = statRows.get(h);
275
+ // `t` is the stats row's timestamp; the card reads `at`. Marked so the plate can say the miner
276
+ // is not known YET rather than claiming the block has no miner.
277
+ return st ? { ...st, at: st.at ?? st.t, coinbaseUnread: true } : null;
278
+ };
279
+ const tip = { height: Number.isFinite(tipHeight) ? tipHeight : null, row: rowFor(tipHeight) };
280
+ const past = [];
281
+ if (Number.isFinite(tipHeight)) {
282
+ for (let h = tipHeight - 1; h > tipHeight - 1 - history && h >= 0; h--) {
283
+ past.push({ height: h, row: rowFor(h) });
284
+ }
285
+ }
286
+ // still counted against ATTRIBUTION, not against the joined row: the note is about how far the
287
+ // coinbase reader is behind, and filling the cards in must not make that lag disappear
288
+ const heights = [tipHeight, ...past.map((p) => p.height)].filter((h) => Number.isFinite(h));
289
+ const unattributed = heights.filter((h) => !rows.has(h)).length;
290
+ return { tip, history: past, unattributed };
291
+ }
292
+
293
+ /**
294
+ * Does the template actually describe the block that comes NEXT?
295
+ *
296
+ * The template is fetched on demand and can be seconds or minutes old while the chain
297
+ * moves; showing a stale one under the label "being built" implies the node is assembling
298
+ * a block that is already three heights back.
299
+ */
300
+ export function templateDrift(templateHeight, tipHeight) {
301
+ if (!Number.isFinite(templateHeight) || !Number.isFinite(tipHeight)) return { known: false };
302
+ const behind = templateHeight - (tipHeight + 1);
303
+ return { known: true, behind, ok: behind === 0, stale: behind < 0 };
304
+ }
305
+
306
+ /**
307
+ * How overdue the next block is, and when the answer must not be given at all.
308
+ *
309
+ * The clock that matters is ARRIVAL -- seconds since the tip height last changed --
310
+ * because "we are expecting a new tip at any moment" is a statement about the chain's
311
+ * behaviour, not about a timestamp a miner chose. Block time is the fallback on a page
312
+ * load, where no arrival has been witnessed yet, and in that case the note says so.
313
+ *
314
+ * Suppressed entirely during initial download, while the node is not answering, and while
315
+ * updates are paused: a tip that is six hours old in the middle of a reindex is not a
316
+ * warning, and a red card that means nothing is worse than no card -- it teaches people to
317
+ * ignore the colour that is supposed to be the most important one on the screen.
318
+ */
319
+ export function tipFreshness({ arrivalSec = null, ageSec = null, avgGapSec = null, ibd = false, online = true, paused = false } = {}) {
320
+ if (ibd || !online || paused) return { level: 'n/a', seconds: null, basis: null, why: ibd ? 'initial download' : (!online ? 'node not answering' : 'updates paused') };
321
+ if (!Number.isFinite(arrivalSec) && !Number.isFinite(ageSec)) return { level: 'n/a', seconds: null, basis: null, why: 'no tip reading yet' };
322
+ // Arrival is used only when the caller actually WITNESSED the transition (blockFlow does
323
+ // the witnessing, and refuses to on a first paint). That is what makes it authoritative:
324
+ // a page that has watched the height change knows when this block landed, whatever the
325
+ // timestamp says -- timestamps can run minutes late, and the node accepts blocks up to
326
+ // two hours in the past by rule. Where nothing was witnessed, the block timestamp is the
327
+ // only honest clock, and the legend says so rather than quietly mixing the two.
328
+ const seconds = Number.isFinite(arrivalSec) ? arrivalSec : (Number.isFinite(ageSec) ? ageSec : null);
329
+ const basis = Number.isFinite(arrivalSec) ? 'arrival' : (Number.isFinite(ageSec) ? 'block time' : null);
330
+ // Judged against THIS chain's measured interval, floored at the agreed 4/8 minutes.
331
+ // Amber past one average gap — it is taking longer than it has been taking. Red at 1.5x:
332
+ // "two average gaps" on an 11-minute chain means waiting 22 minutes to be told a
333
+ // 22-minute-old tip is overdue, and by then the colour has stopped meaning anything.
334
+ // No measurement, no allowance: with no observed interval the marks stay at the absolute
335
+ // 4/8 minutes rather than granting a healthy 10-minute chain we never saw.
336
+ const gap = Number.isFinite(avgGapSec) && avgGapSec > 0 ? avgGapSec : 0;
337
+ const lateAt = Math.max(240, gap);
338
+ const overdueAt = Math.max(480, gap * 1.5);
339
+ const level = seconds < lateAt ? 'fresh' : seconds < overdueAt ? 'late' : 'overdue';
340
+ return { level, seconds, basis, lateAt, overdueAt, gap: Number.isFinite(avgGapSec) ? avgGapSec : null };
341
+ }
342
+
343
+ /** Rank of each block by the feerate its miner achieved, within the window shown. */
344
+ function feeRank(rows) {
345
+ const ranked = rows.filter((r) => r?.avgFeerate != null).sort((a, b) => b.avgFeerate - a.avgFeerate);
346
+ return new Map(ranked.map((r, i) => [r.height, i + 1]));
347
+ }
348
+
349
+ /** "12m ago", "4h ago", "just now" -- coarse on purpose; a train card is read at a glance. */
350
+ export function agoText(ms, now = Date.now()) {
351
+ if (!Number.isFinite(ms)) return null;
352
+ const sec = Math.max(0, Math.round((now - ms) / 1000));
353
+ if (sec < 75) return 'just now';
354
+ if (sec < 5400) return `${Math.round(sec / 60)}m ago`;
355
+ if (sec < 172800) return `${(sec / 3600).toFixed(1)}h ago`;
356
+ return `${Math.round(sec / 86400)}d ago`;
357
+ }
358
+
359
+ /** Everything a card can say from the row alone, derived or read. */
360
+ function blockFacts(row) {
361
+ const vB = row.weight != null ? Math.round(row.weight / 4) : null;
362
+ const capPct = row.weight != null ? (100 * row.weight) / (row.weightLimit ?? WU_CAP_FALLBACK) : null;
363
+ const fillVb = row.size != null ? row.size : vB;
364
+ const satsPerTx = row.totalfee != null && row.txs ? Math.round(row.totalfee / row.txs) : null;
365
+ return { vB, capPct, fillVb, satsPerTx };
366
+ }
367
+
368
+ /** One mined block, as a card. */
369
+ function blockCard(row, prev, fmt, arrived = false, isTip = false, extraClass = '', ctx = {}) {
370
+ const w = who(row);
371
+ const f = blockFacts(row);
372
+ const age = agoText(row.at ?? row.seenAt, ctx.now);
373
+ const gapToPrev = prev?.time && row.time ? row.time - prev.time : null;
374
+ const tooltip = [
375
+ row.tagText ? `coinbase: ${row.tagText}` : 'coinbase: no readable text',
376
+ row.matchedTag ? `label matched "${row.matchedTag}"` : 'no curated label matched',
377
+ row.tagSource ? `tag read by ${row.tagSource === 'scan' ? 'printable-run scan (the scriptSig push lengths do not describe their own bytes)' : 'push parsing'}` : '',
378
+ f.vB != null ? `${fmt.num(f.vB)} vB / ${fmt.num(row.weight)} WU` : '',
379
+ row.strippedSize != null ? `stripped size ${fmt.bytes(row.strippedSize, 0)}` : '',
380
+ f.satsPerTx != null ? `${fmt.num(f.satsPerTx)} sat/tx` : '',
381
+ row.p50 != null ? `block sat/vB p50 ${row.p50}${row.p75 != null ? ` · p75 ${row.p75}` : ''}${row.p99 != null ? ` · p99 ${row.p99}` : ''}` : '',
382
+ row.commitment ? `witness commitment ${row.commitment}` : '',
383
+ row.extraNonce ? `extranonce ${row.extraNonce.slice(0, 16)}` : '',
384
+ row.hash ? `hash ${row.hash.slice(0, 24)}…` : '',
385
+ row.at != null ? `block time ${new Date(row.at).toISOString().slice(0, 19)}Z` : '',
386
+ row.seenAt != null ? `attributed ${new Date(row.seenAt).toISOString().slice(11, 19)}Z` : '',
387
+ ].filter(Boolean).join('\n');
388
+
389
+ return `<div class="bcard ${w.labelled ? '' : 'unlabelled'}${isTip ? ' tip' : ''}${arrived ? ' arrive' : ''}${extraClass ? ' ' + extraClass : ''}" data-pool="${w.idx}"
390
+ title="${fmt.esc(tooltip)}">
391
+ <i class="bstack" data-h="${f.capPct == null ? 0 : f.capPct.toFixed(1)}" data-pool="${w.idx}" aria-hidden="true"></i>
392
+ <div class="bh"><span class="bdot" data-pool="${w.idx}"></span>${Number.isFinite(row.height) ? `<a class="bxlink" href="#explorer/block/${row.height}" title="open block ${row.height} in the explorer"><b>#${row.height}</b></a>` : '<b>#?</b>'}
393
+ ${ctx.tipHeight && Number.isFinite(row.height) ? `<i class="delt">−${ctx.tipHeight - row.height}</i>` : ''}</div>
394
+ <div class="bwhen ${gapToPrev != null && gapToPrev > 1200 ? 'longgap' : ''}">${age ? `mined ${age}` : 'mined –'}${gapToPrev != null ? ` · gap ${gapText(gapToPrev)}` : ''}</div>
395
+ <div class="bgrid">
396
+ <div><i>size</i><span>${f.fillVb != null ? fmt.bytes(f.fillVb, 0) : '–'}</span></div>
397
+ <div><i>txs</i><span>${row.txs != null ? fmt.num(row.txs) : '–'}</span></div>
398
+ <div><i>fees ₿</i><span>${btcNum(row.totalfee)}</span></div>
399
+ <div><i>sat/tx</i><span>${f.satsPerTx != null ? fmt.num(f.satsPerTx) : '–'}</span></div>
400
+ <div><i>avg/vB</i><span>${row.avgFeerate ?? '–'}</span></div>
401
+ <div><i>p50/vB</i><span>${row.p50 ?? '–'}</span></div>
402
+ </div>
403
+ <div class="bfill" title="${f.capPct != null ? `${f.capPct.toFixed(1)}% of the 4,000,000 WU cap` : 'weight unknown'}">
404
+ <span data-w="${f.capPct == null ? 0 : f.capPct.toFixed(1)}" data-pool="${w.idx}"></span>
405
+ </div>
406
+ <div class="bcap">${f.capPct != null ? `${f.capPct.toFixed(1)}% of 4M WU` : 'weight –'}${ctx.rank && ctx.rank.get(row.height) ? ` · feerate #${ctx.rank.get(row.height)}` : ''}</div>
407
+ <div class="bplaque"
408
+ title="${row.coinbaseUnread ? 'the coinbase for this height has not been read yet — attribution is one block per poll' : fmt.esc(row.tagText ? `coinbase: ${row.tagText}` : 'no readable coinbase text')}">
409
+ <span class="pill${row.coinbaseUnread ? ' waiting' : w.labelled ? '' : ' raw'}" data-pool-fg="${w.idx}">${row.coinbaseUnread ? 'reading coinbase' : fmt.esc(trunc(w.name, 22))}</span>
410
+ </div>
411
+ </div>`;
412
+ }
413
+
414
+ /** The block that does not exist yet: how full it is, what it paid, what is queued behind it. */
415
+ function nextCard(nb, mempool, drift = {}, fmt, freshClass = '', fresh = null, meter = '') {
416
+ const ec = nb?.economy ?? null;
417
+ const ring = freshClass ? ' ' + freshClass : '';
418
+ const since = fresh && Number.isFinite(fresh.seconds) ? Math.round(fresh.seconds / 60) : null;
419
+ if (!nb) return `<div class="bcard next empty${ring}">The block being built.<br><span class="tiny">No template yet — it is assembled from the node\u2019s mempool, which is read every twenty seconds. <a href="#mining">Mining</a> shows the packages inside it.</span></div>`;
420
+ if (nb.unavailable) return `<div class="bcard next empty${ring}">No block template.<br><span class="tiny">${fmt.esc(nb.unavailable)}</span></div>`;
421
+ const pct = nb.weightPct ?? 0;
422
+ const ageSec = nb.at ? Math.round((Date.now() - nb.at) / 1000) : null;
423
+ const cap = Math.min(100, Math.max(pct, 0.6));
424
+ const q = mempool?.bytes;
425
+ return `<div class="bcard next${ring}" title="${fmt.esc(nb.note ?? '')}">
426
+ <i class="bstack" data-h="${Math.max(0, Math.min(100, pct)).toFixed(1)}" aria-hidden="true"></i>
427
+ <div class="bh"><span class="live"></span><b>#${nb.height ?? '?'}</b><span class="bpool">${drift.stale ? 'stale template' : 'being built'}</span>${since != null ? `<span class="bage">${since}m</span>` : ''}</div>
428
+ ${drift.stale ? `<div class="note tiny warn">this reading is ${Math.abs(drift.behind)} height(s) behind the tip — the node has since mined a block</div>` : ''}
429
+ <div class="bgap">${ageSec != null ? `${ageSec}s old · assembled in ${nb.ms ?? '?'}ms` : ''}</div>
430
+ ${meter}
431
+ <div class="bgrid" title="${fmt.num(nb.weight)} of ${fmt.num(nb.weightLimit ?? WU_CAP_FALLBACK)} WU selected${q != null ? ` · ${fmt.bytes(q)} queued in the mempool` : ''}">
432
+ <div><i>txs</i><span>${nb.txCount != null ? fmt.num(nb.txCount) : '–'}</span></div>
433
+ <div><i>full</i><span>${pct.toFixed(1)}%</span></div>
434
+ <div><i>fees ₿</i><span>${btcNum(nb.totalFeesSat)}</span></div>
435
+ <div><i>free</i><span>${ec?.remainingPct != null ? `${ec.remainingPct}%` : '–'}</span></div>
436
+ <div><i>med/vB</i><span>${nb.feeRate?.p50 ?? '–'}</span></div>
437
+ <div><i>max/vB</i><span>${nb.feeRate?.max ?? '–'}</span></div>
438
+ </div>
439
+ <div class="bnextfill"><span data-h="${cap.toFixed(1)}"></span></div>
440
+ ${ec ? `<div class="chips">
441
+ <span class="chip ${ec.marginal?.rate != null ? 'hot' : 'cold'}" title="the lowest feerate the node still had room for">${ec.marginal?.rate != null ? `marginal ~${ec.marginal.rate}/vB` : 'not full'}</span>
442
+ ${ec.spillCount ? `<span class="chip warn" title="selected transactions in and below the marginal band, plus the queue that did not fit">${fmt.num(ec.spillCount)} spill</span>` : ''}
443
+ ${ec.poolFitsNextPct != null ? `<span class="chip" title="getmempoolinfo.bytes against the weight still free: this share of the pool fits in the block">${ec.poolFitsNextPct}% fits</span>` : ''}
444
+ </div>
445
+ <div class="bcap" title="${ec.spillCount ? `~${fmt.num(Math.round(ec.spillWeight / 4))} vB of selected and queued weight waits beyond this block` : ''}">${ec.backlogBlocks != null ? `≈ ${ec.backlogBlocks} blocks queued` : 'queue depth unknown'}</div>` : ''}
446
+ ${nb.lastError ? `<div class="note tiny bad">last refresh failed: ${fmt.esc(nb.lastError)}</div>` : ''}
447
+ </div>`;
448
+ }
449
+
450
+ /**
451
+ * The block being assembled on the LEFT, the latest confirmed block in the CENTRE, history
452
+ * receding to the RIGHT. Three motions, each tied to a fact:
453
+ * - the rail runs at a speed derived from the measured average block gap, so a faster
454
+ * chain visibly moves faster, and the figure driving it is stated below;
455
+ * - when the TIP HEIGHT advances, the centre card plays the arrival (it is the new
456
+ * block) and the history row slides one step right. It is keyed on the chain's own tip
457
+ * from getblockchaininfo, never on the newest attributed row -- attribution runs one
458
+ * block per poll and would otherwise decide the animation, so the centre would step
459
+ * late and show the wrong height in between;
460
+ * - the under-construction card eases its fill toward the weight the node reported, and
461
+ * the inflow boxes stand for the transactions already selected, with the age of that
462
+ * reading on the card so it is never mistaken for a live per-transaction feed.
463
+ * With prefers-reduced-motion, every number stays and nothing moves.
464
+ */
465
+ // DRAG THE TRAIN (operator, 2026-09-11: "in block Flow, I should be able to mouse drag the blocks
466
+ // left and right, instead of using the drag bar"). Press anywhere on the row and drag to scroll it,
467
+ // with a little momentum on release. A drag never counts as a click on the block links it started
468
+ // over, snap scrolling is off while it moves, and touch keeps the browser's own scrolling. Bound
469
+ // once per row.
470
+ export function dragScroll(el) {
471
+ if (!el || el.__drag || typeof el.addEventListener !== 'function') return;
472
+ el.__drag = true;
473
+ const now = () => (globalThis.performance && performance.now()) || Date.now();
474
+ let down = null, moved = false, v = 0, raf = null;
475
+ const settle = () => { el.classList?.remove('dragging'); };
476
+ const stopGlide = () => { if (raf != null && globalThis.cancelAnimationFrame) cancelAnimationFrame(raf); raf = null; };
477
+ el.addEventListener('pointerdown', (e) => {
478
+ if (e.button !== 0 || e.pointerType === 'touch') return;
479
+ stopGlide();
480
+ down = { x: e.clientX, left: el.scrollLeft, lx: e.clientX, t: now() };
481
+ moved = false; v = 0;
482
+ });
483
+ el.addEventListener('pointermove', (e) => {
484
+ if (!down) return;
485
+ const dx = e.clientX - down.x;
486
+ if (!moved && Math.abs(dx) < 4) return;
487
+ if (!moved) { moved = true; el.setPointerCapture?.(e.pointerId); el.classList?.add('dragging'); }
488
+ const t = now();
489
+ v = (e.clientX - down.lx) / Math.max(1, t - down.t); // px per ms over the last stretch
490
+ down.lx = e.clientX; down.t = t;
491
+ el.scrollLeft = down.left - dx;
492
+ });
493
+ const up = (e) => {
494
+ if (!down) return;
495
+ down = null;
496
+ el.releasePointerCapture?.(e.pointerId);
497
+ if (!moved) return;
498
+ if (!(Math.abs(v) > 0.05) || !globalThis.requestAnimationFrame) { settle(); return; }
499
+ let last = now();
500
+ const glide = (t) => {
501
+ const dt = Math.max(0, t - last); last = t;
502
+ el.scrollLeft -= v * dt;
503
+ v *= Math.pow(0.94, dt / 16);
504
+ if (Math.abs(v) > 0.02) raf = requestAnimationFrame(glide); else { raf = null; settle(); }
505
+ };
506
+ raf = requestAnimationFrame(glide);
507
+ };
508
+ el.addEventListener('pointerup', up);
509
+ el.addEventListener('pointercancel', up);
510
+ el.addEventListener('dragstart', (e) => e.preventDefault()); // no link-drag ghost
511
+ el.addEventListener('click', (e) => { if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; } }, true);
512
+ }
513
+
514
+ export function blockFlow(el, { tipHeight = null, recent = [], stats = [], next = null, mempool = null, avgGapSec = null, tipAgeSec = null, ibd = false, online = true, paused = false } = {}, fmt) {
515
+ dragScroll(el);
516
+ if (!el) return;
517
+ // `stats` too, or a height whose coinbase has not been read yet arrives here with its
518
+ // getblockstats row already in hand and still draws as an empty box
519
+ const frame = flowFrame({ tipHeight, recent, stats });
520
+ const motion = flowMotion(el.__tip, frame.tip.height);
521
+ // Arrival is WITNESSED: the timer starts only when the height changes under our eyes,
522
+ // never on the first paint. A page that opened on a 14-minute-old tip has no idea when
523
+ // the chain found it, and pretending otherwise coloured a stale tip green.
524
+ if (Number.isFinite(frame.tip.height)) {
525
+ if (el.__tip === undefined) { el.__tip = frame.tip.height; el.__tipAt = null; }
526
+ else if (frame.tip.height !== el.__tip) { el.__tip = frame.tip.height; el.__tipAt = Date.now(); el.__witnessed = true; }
527
+ }
528
+ if (Number.isFinite(frame.tip.height)) el.__tip = frame.tip.height;
529
+ const arrivalSec = el.__witnessed && el.__tipAt && el.__tip === frame.tip.height
530
+ ? Math.round((Date.now() - el.__tipAt) / 1000) : null;
531
+ const fresh = tipFreshness({ arrivalSec, ageSec: tipAgeSec, avgGapSec, ibd, online, paused });
532
+ const drift = templateDrift(next?.height, frame.tip.height);
533
+ if (!frame.tip.height && !next) {
534
+ el.innerHTML = `<div class="note">No blocks drawn yet. The centre card needs the chain tip; the cards behind it need attribution, which is two small reads per block on the node's own RPC lane, skipped entirely during initial download.</div>`;
535
+ return;
536
+ }
537
+ const gapMin = Math.round((Number.isFinite(avgGapSec) && avgGapSec > 0 ? avgGapSec : 600) / 60);
538
+ const freshClass = fresh.level === 'n/a' ? '' : fresh.level;
539
+ const shown = [frame.tip, ...frame.history].map((p) => p.row).filter(Boolean);
540
+ const ctx = { now: Date.now(), tipHeight: frame.tip.height, rank: feeRank(shown) };
541
+ // The coloured ring says HOW LONG THIS BLOCK HAS BEEN BUILDING, which is a fact
542
+ // about the block under construction, not about the one that already landed
543
+ // (operator: "the current block being built does not have a colored outline like
544
+ // it's supposed to for it's time"). The tip keeps its own accent ring, which means
545
+ // something else: this is the height the chain reports.
546
+ const centre = frame.tip.row
547
+ ? blockCard(frame.tip.row, frame.history[0]?.row, fmt, motion.arrived === frame.tip.height, true, '', ctx)
548
+ : `<div class="bcard tip pending ${freshClass}"><div class="bh"><b>#${frame.tip.height ?? '?'}</b><span class="bpool warn">awaiting attribution</span></div>
549
+ <div class="bgap">this is the chain tip; its coinbase has not been read yet</div>
550
+ <div class="brows"><div><i>why</i><span>1 block per poll</span></div>${frame.unattributed ? `<div><i>heights behind</i><span>${frame.unattributed}</span></div>` : ''}</div></div>`;
551
+
552
+ // The row scrolls horizontally and shows what it shows in flex; the cap that used to
553
+ // live here (reserve 400 px for next+tip+arrows, fit history in the rest) broke the
554
+ // timeline on wide panels — the mined side stopped short of the divider while the
555
+ // forecast slivers were jammed against it. Every block the frame carries is rendered;
556
+ // the horizontal scroll, parked at the divider on first paint (below), is what shows
557
+ // more. This is the order the operator asked for: forecasts far left, the assembled
558
+ // block immediately left of the line, the chain receding right of it.
559
+ const hist = frame.history;
560
+ // The meter remembers how many segments it lit for THIS height, so a reading
561
+ // that adds weight lights only the new ones; a new height starts over.
562
+ const same = el.__meter && next && el.__meter.height === next.height;
563
+ const meter = growthMeter(next, same ? el.__meter.lit : null);
564
+ if (next && !next.unavailable) el.__meter = { height: next.height, lit: meter.lit };
565
+ el.innerHTML = `
566
+ <div class="rail" data-rail="${railSeconds(avgGapSec)}" title="rail speed derived from the measured average block gap"><span></span></div>
567
+ <div class="flow${motion.shift ? ' shift' : ''}">
568
+ <div class="flowside todo">
569
+ ${projectedCards(mempool?.dist?.projected, { avgGapSec, tipAgeSec }, fmt)}${nextCard(next, mempool, drift, fmt, freshClass, fresh, meter.html)}
570
+ </div>
571
+ <div class="flowsep" title="the divider: left is the work in progress, right is work the network accepted">
572
+ <span class="flowsep-arrow up" aria-hidden="true"></span>
573
+ <span class="flowsep-line" aria-hidden="true"></span>
574
+ <span class="flowsep-arrow down" aria-hidden="true"></span>
575
+ <span class="flowsep-tag">pending &#8646; done</span>
576
+ </div>
577
+ <div class="flowside done">
578
+ ${centre}
579
+ <div class="flowpast">${hist.map((p, i) => LINK + (p.row
580
+ ? blockCard(p.row, hist[i + 1]?.row ?? null, fmt, false, false, '', ctx)
581
+ : `<div class="bcard pending"><div class="bh"><b>#${p.height}</b></div><div class="bgap">not attributed</div></div>`)).join('')}</div>
582
+ </div>
583
+ </div>
584
+ ${frame.unattributed ? `<div class="flownote"><b class="warn">${frame.unattributed} height(s) here have no coinbase reading yet</b> — attribution is one block per poll, so it trails the tip.</div>` : ''}
585
+ <div class="tiplegend">${fresh.level === 'n/a'
586
+ ? `<span class="tl na">tip age not judged — ${fmt.esc(fresh.why ?? 'no reading')}</span>`
587
+ : `<span class="tl fresh" title="green: found within this">≤ ${Math.round((fresh.lateAt ?? 240) / 60)} min</span><span class="tl late" title="amber: later than the chain's average gap">≤ ${Math.round((fresh.overdueAt ?? 480) / 60)} min</span><span class="tl overdue" title="red: past 1.5x the average gap -- a block is due">due</span>
588
+ <span class="tl now" title="${fresh.basis === 'block time' ? 'from the block timestamp: this page has not watched a block arrive yet' : 'since this page watched the tip change'}">${Math.round((fresh.seconds ?? 0) / 60)} min since the last block${fresh.gap != null ? ` · avg ${Math.round(fresh.gap / 60)} min` : ''}</span>`}</div>`;
589
+
590
+ // First paint parks the scroll so the DIVIDER sits a quarter of the way in: the
591
+ // interesting edge is what is being built against what landed, and both sides of
592
+ // it have to be on screen for that to be an edge at all. Parking the divider
593
+ // hard against the left margin was right when the pending side was three
594
+ // forecast slivers plus the assembled block, roughly a third of the row. With
595
+ // the forecasts gone the pending side is one card, so the same rule pushed the
596
+ // block being built — the one card carrying the countdown ring — off the left
597
+ // edge, and the panel opened on nothing but history. Subsequent repaints leave
598
+ // the operator's scroll position alone; reading history should not snap back on
599
+ // every SSE tick.
600
+ // With PROJECTED BLOCKS on the pending side (2026-09-11) that rule opened the row on the
601
+ // block being built with every projected card off the left edge -- measured on the
602
+ // Overview: five cards in the page, none on screen. So when they are there the row parks
603
+ // on the second-nearest (+2), showing +2, +1, the block being built and the chain after the
604
+ // divider -- as long as the divider stays in the left 70% of the panel; a narrow panel
605
+ // keeps the rule above. Cards that arrive after the first paint re-park once, never again.
606
+ const projCards = typeof el.querySelectorAll === 'function' ? [...el.querySelectorAll('.bcard.proj')] : [];
607
+ if ((!el.__divParked || (projCards.length && !el.__projParked)) && !el.hidden && el.clientWidth) {
608
+ const sep = typeof el.querySelector === 'function' ? el.querySelector('.flowsep') : null;
609
+ if (sep && typeof sep.getBoundingClientRect === 'function' && typeof el.getBoundingClientRect === 'function'
610
+ && typeof el.scrollTo === 'function') {
611
+ const sepR = sep.getBoundingClientRect();
612
+ const elR = el.getBoundingClientRect();
613
+ const lead = Math.max(12, Math.round(el.clientWidth * 0.28));
614
+ let target = (el.scrollLeft ?? 0) + (sepR.left - elR.left) - lead;
615
+ // the nearest projected card that still leaves the block being built and the divider
616
+ // on screen: +2 if +2, +1, the block being built and the divider fit, else +1, else
617
+ // the divider rule (an earlier cut anchored +2 whatever the width, and a narrow
618
+ // Overview opened on forecasts with the block being built off the right edge)
619
+ for (const anchor of [projCards[projCards.length - 2], projCards[projCards.length - 1]]) {
620
+ if (!anchor || typeof anchor.getBoundingClientRect !== 'function') continue;
621
+ const aR = anchor.getBoundingClientRect();
622
+ if (sepR.left + 40 - aR.left <= el.clientWidth) { target = (el.scrollLeft ?? 0) + (aR.left - elR.left) - 12; break; }
623
+ }
624
+ el.scrollTo({ left: Math.max(0, target), behavior: 'auto' });
625
+ el.__divParked = true;
626
+ if (projCards.length) el.__projParked = true;
627
+ }
628
+ }
629
+ }
630
+
631
+ /**
632
+ * A block that nobody is assembling yet.
633
+ *
634
+ * Only ONE candidate is ever assembled -- the next block -- so anything beyond tip+1 is
635
+ * inference. Those cards say so in their own border and text rather than being drawn like
636
+ * the real one: an estimate that looks like a measurement is the same category of error
637
+ * as a number that was never measured. What can honestly be shown is how much of the
638
+ * queue is expected to reach that far, from getmempoolinfo.bytes.
639
+ */
640
+ // The link between two blocks in the train. It is a picture of the thing the cards
641
+ // are actually describing -- each block commits to the one before it -- and it is
642
+ // markup rather than a ::before so the row's flex gaps stay predictable.
643
+ // Drawn as two INTERLOCKED links of a real chain (operator, 2026-09-14: "That chain graphic between
644
+ // the blocks looks bad on a black background. Re-do it to be much more stylized and visible"): each
645
+ // link is a dark accent tube with a bright highlight along it, and the left link's top strand is
646
+ // painted again over the right link so the pair weaves -- over at the top, under at the bottom.
647
+ const CHAIN_SVG = (() => {
648
+ const a = '<rect x="2" y="4.5" width="20" height="11" rx="5.5"/>';
649
+ const b = '<rect x="16" y="4.5" width="20" height="11" rx="5.5"/>';
650
+ const weave = '<path d="M16.5 4.5a5.5 5.5 0 0 1 5.5 5.5"/>';
651
+ const tube = (shape) => `<g class="cl-base">${shape}</g><g class="cl-hi">${shape}</g>`;
652
+ return `<svg viewBox="0 0 38 20" aria-hidden="true">${tube(a)}${tube(b)}${tube(weave)}</svg>`;
653
+ })();
654
+ const LINK = `<span class="chainlink" aria-hidden="true">${CHAIN_SVG}</span>`;
655
+
656
+ /*
657
+ * THE FORECAST SLIVERS ARE GONE (2026-09-11, operator: "too much space is
658
+ * wasted on the left side ... empty forecasts that never have activity").
659
+ *
660
+ * `economy.ahead` reports a fixed set of offsets beyond tip+1 with a byte
661
+ * estimate for each, and the row drew one dashed card per offset. They opened
662
+ * the train, so the left third of the panel was permanently inference -- and
663
+ * inference that says nothing new: every one of them is a restatement of the
664
+ * queue depth, which the assembled block's own card already gives as
665
+ * "queue ~ N blocks deep", measured, in one line, from the same figure.
666
+ *
667
+ * Filtering them to the ones with bytes behind them was tried first and did
668
+ * nothing: a live mempool always has bytes reaching three blocks out, so all
669
+ * three cards stayed. The honest reading is that they were never carrying
670
+ * their width. The number they were built on is still on the page.
671
+ */
672
+
673
+ /*
674
+ * PROJECTED BLOCKS (operator, 2026-09-11: "Why can't we forecast at least one block ahead of
675
+ * current work, like mempool space app does?"). Not the slivers above, which were a byte
676
+ * estimate per fixed offset and read "nothing queued" on a quiet chain: these are the mempool
677
+ * itself (projectBlocks, server side) -- sorted by feerate, the block being built skipped, the
678
+ * next ones cut at 1,000,000 vB, each with its fee range, median, fees and count, and an ETA
679
+ * from the measured average gap. Furthest future on the far left, as on mempool.space; the
680
+ * card being built stays against the divider. Drawn as inference: dashed, no depth. A child
681
+ * paying for its parent can be projected a block late (no ancestor data in this node's pool).
682
+ */
683
+ function projectedCards(proj, { avgGapSec, tipAgeSec } = {}, fmt) {
684
+ if (!proj?.blocks?.length) return '';
685
+ const gap = Number.isFinite(avgGapSec) && avgGapSec > 0 ? avgGapSec : 600;
686
+ const since = Number.isFinite(tipAgeSec) && tipAgeSec > 0 ? tipAgeSec : 0;
687
+ const eta = (k) => Math.max(1, Math.round(((k + 1) * gap - since) / 60));
688
+ const r = (v) => (v == null ? '–' : v >= 10 ? String(Math.round(v)) : v.toFixed(2));
689
+ const vcap = Number(proj.blockVsize) > 0 ? Number(proj.blockVsize) : 1_000_000;
690
+ const fillPct = (v) => Math.max(0, Math.min(100, ((Number(v) || 0) / vcap) * 100));
691
+ // READABLE (operator, 2026-09-11: "The forecast block text is unreadable. Do better !!!"):
692
+ // light text on a dark card, the fee colour as a bar and a band, one fact per line and no
693
+ // line wrapping -- the first cut put grey 10.5 px text on a saturated fill
694
+ const card = (b, k) => `<div class="bcard proj" data-pfee="${Number(b.medianRate ?? 0)}" title="projected from the mempool by feerate: ${fmt.num(b.n)} transactions, ${fmt.bytes(b.vsize)}">
695
+ <i class="bstack" data-h="${fillPct(b.vsize).toFixed(1)}" aria-hidden="true"></i>
696
+ <div class="ph">+${k}</div>
697
+ <div class="pm">~${r(b.medianRate)} sat/vB</div>
698
+ <div class="pr">${r(b.minRate)} – ${r(b.maxRate)}</div>
699
+ <div class="pf">${(b.feeSat / 1e8).toFixed(3)} ₿</div>
700
+ <div class="pf">${fmt.num(b.n)} tx</div>
701
+ <div class="pe">in ~${eta(k)} min</div>
702
+ </div>`;
703
+ const cards = proj.blocks.map((b, i) => card(b, i + 1));
704
+ if (proj.rest?.n) {
705
+ const k = proj.blocks.length + 1;
706
+ cards.push(`<div class="bcard proj rest" data-pfee="${Number(proj.rest.maxRate ?? 0)}" title="the rest of the mempool, beyond the projected blocks">
707
+ <div class="ph">+${k}…</div>
708
+ <div class="pm">≤ ${r(proj.rest.maxRate)} sat/vB</div>
709
+ <div class="pf">${fmt.num(proj.rest.n)} tx</div>
710
+ <div class="pe">≈ ${fmt.num(proj.rest.blocks)} more block${proj.rest.blocks === 1 ? '' : 's'}</div>
711
+ </div>`);
712
+ }
713
+ return cards.reverse().join('');
714
+ }
715
+
716
+ /** Ancestor packages: what the miner grouped, and who is paying for whom. */
717
+ export function packagesView(el, packages, fmt) {
718
+ if (!el) return;
719
+ if (!packages) {
720
+ el.innerHTML = `<div class="note">No packages yet — this needs a block template, which the page asks for only while it is open.</div>`;
721
+ return;
722
+ }
723
+ if (!packages.multiTx) {
724
+ el.innerHTML = `<div class="note">Every one of the <b>${packages.total}</b> transactions the node would include is on its own — no child paying for a parent in this template. That is a real reading, not a missing chart.</div>`;
725
+ return;
726
+ }
727
+ const hist = Object.entries(packages.sizeHistogram ?? {}).sort((a, b) => Number(a[0]) - Number(b[0]));
728
+ const maxN = Math.max(...hist.map(([, n]) => n), 1);
729
+ el.innerHTML = `
730
+ <div class="pkghist">${hist.map(([size, n]) => `<div class="pkbar" title="${n} package(s) of ${size} tx"><span data-w="${(100 * n / maxN).toFixed(1)}"></span><i>${size} tx</i><b>${n}</b></div>`).join('')}</div>
731
+ <table class="t pkgtable"><thead><tr><th>package</th><th class="r">fees</th><th class="r">weight</th><th class="r">package sat/vB</th><th class="r">child</th><th class="r">parent</th><th>shape</th></tr></thead><tbody>
732
+ ${(packages.top ?? []).map((p) => `<tr class="${p.cpfp ? 'cpfp' : ''}">
733
+ <td>${p.size} tx</td>
734
+ <td class="r">${fmt.num(p.feesSat)} sat</td>
735
+ <td class="r">${fmt.num(p.weight)} WU</td>
736
+ <td class="r"><b>${p.packageFeeRate ?? '–'}</b></td>
737
+ <td class="r">${p.childRate ?? '–'}</td>
738
+ <td class="r">${p.parentRate ?? '–'}</td>
739
+ <td class="pkshape" title="${fmt.esc((p.txids ?? []).join(' '))}">${rateBoxes(p)}</td>
740
+ </tr>`).join('')}
741
+ </tbody></table>
742
+ <div class="note tiny faint">${packages.txsInPackages} of the template's transactions sit inside a package; ${packages.cpfpCandidates} packages have a child paying at least twice its parent's rate — that is the child-pays-for-parent shape. The ancestor graph comes from <span class="mono">getrawmempool … depends</span> — Bitcoin Core publishes the whole graph in the mempool, along with <span class="mono">ancestorcount</span> and <span class="mono">fees.chunk</span>, so the block being built is assembled here and costs your node no call of its own. This is that block, not the whole pool.</div>`;
743
+ }
744
+
745
+ /** A tiny picture of a package: box size = weight, colour = that transaction's own rate. */
746
+ function rateBoxes(p) {
747
+ const rates = [p.childRate, p.parentRate].filter((v) => v != null);
748
+ if (!rates.length) return '–';
749
+ const top = Math.max(...rates, 1);
750
+ return rates.map((r) => `<span class="pkgbox" data-rate="${r}" title="${r} sat/vB"></span>`).join('');
751
+ }
752
+
753
+ export function poolIndex(key) {
754
+ const k = String(key ?? '');
755
+ let h = 0;
756
+ for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) | 0;
757
+ return Math.abs(h) % POOL_COLOURS.length;
758
+ }
759
+
760
+ /**
761
+ * Resolve `data-pool` / `data-pool-fg` / `data-rate` / `data-delay` / `data-rail`
762
+ * through the CSSOM.
763
+ *
764
+ * CSP `style-src 'self'` refuses `style="..."` inside injected markup, and the fix is
765
+ * not to widen the policy: the markup carries an INDEX into our own palette, and the
766
+ * colour string never comes from the payload at all. A node that invented a pool name
767
+ * therefore cannot become CSS, and a name we do know still cannot inject markup.
768
+ */
769
+ /**
770
+ * data-w / data-h -> CSSOM width/height, for the markup this module writes.
771
+ *
772
+ * app.js owns the same helper for the sync hero. It is not imported: app.js runs boot()
773
+ * at module scope, so importing it from here dragged a browser-only startup path into
774
+ * every Node test that touches mining.js (window is not defined). Duplicated on purpose,
775
+ * numbers-only for the same reason, and small enough that the two cannot drift far.
776
+ */
777
+ function applySizes(root) {
778
+ const q = (sel) => { try { return root.querySelectorAll(sel) ?? []; } catch { return []; } };
779
+ const pct = (n) => `${Math.max(0, Math.min(100, n))}%`;
780
+ for (const el of q('[data-w]')) {
781
+ const n = Number(el.dataset.w);
782
+ if (Number.isFinite(n)) { try { el.style.width = pct(n); } catch { /* no CSSOM in the stub */ } }
783
+ }
784
+ for (const el of q('[data-h]')) {
785
+ const n = Number(el.dataset.h);
786
+ if (Number.isFinite(n)) { try { el.style.height = pct(n); } catch { /* no CSSOM in the stub */ } }
787
+ }
788
+ }
789
+
790
+ export function applyMiningStyles(root = document) {
791
+ // data-w / data-h land on the CSSOM in app.js; the flow and the maps are written here,
792
+ // so the size pass has to be invoked on this markup too. It used to run only on the
793
+ // sync hero, so every weight bar in the block train stayed at its default and the
794
+ // cards looked identically empty whatever the node had mined.
795
+ const q = (sel) => { try { return root.querySelectorAll(sel) ?? []; } catch { return []; } };
796
+ // Custom properties must go through setProperty: `el.style['--pool'] = …` is accepted
797
+ // without complaint and does nothing, which is how the block cards lost their pool rail
798
+ // while the dots beside them kept theirs -- a half-applied style pass that only a real
799
+ // browser shows you, because nothing throws.
800
+ const set = (el, prop, val) => {
801
+ try {
802
+ if (prop.startsWith('--')) el.style.setProperty(prop, val);
803
+ else el.style[prop] = val;
804
+ } catch { /* a stub without a CSSOM has nothing to set */ }
805
+ };
806
+ for (const el of q('[data-pool]')) {
807
+ const n = Number(el.dataset.pool);
808
+ if (!Number.isFinite(n) || n < 0) continue;
809
+ const c = POOL_COLOURS[n % POOL_COLOURS.length];
810
+ // A card carries its pool as a rail and an accent; only the small marks (dot,
811
+ // fill bar) get filled with the colour itself. Painting a whole card in the pool
812
+ // colour would turn the legend into a patchwork quilt and hide its own text.
813
+ if (!el.classList.contains('bcard')) set(el, 'background', c);
814
+ set(el, '--pool', c);
815
+ }
816
+ // a projected block is tinted by its median feerate: a NUMBER in the markup (the CSP
817
+ // allows no style attributes), the colour from our own fee palette here. Its own
818
+ // attribute: [data-fee] is the fee swatches', whose pass paints the whole background
819
+ // inline -- which is what filled the first projected cards solid green
820
+ for (const el of q('[data-pfee]')) {
821
+ const n = Number(el.dataset.pfee);
822
+ if (Number.isFinite(n) && n >= 0) set(el, '--pc', feeColor(n));
823
+ }
824
+ for (const el of q('[data-pool-key]')) {
825
+ const c = POOL_COLOURS[poolIndex(el.dataset.poolKey) % POOL_COLOURS.length];
826
+ set(el, 'background', c); set(el, '--pool', c);
827
+ }
828
+ for (const el of q('[data-pool-fg]')) {
829
+ const n = Number(el.dataset.poolFg);
830
+ if (Number.isFinite(n) && n >= 0) set(el, 'color', POOL_COLOURS[n % POOL_COLOURS.length]);
831
+ }
832
+ for (const el of q('[data-rate]')) {
833
+ const r = Number(el.dataset.rate);
834
+ if (Number.isFinite(r)) set(el, 'background', rateBucketColor(r));
835
+ }
836
+ // our feerate palette (feepalette.js), so the block meter and the Block space stones agree
837
+ for (const el of q('[data-fee]')) {
838
+ const r = Number(el.dataset.fee);
839
+ if (Number.isFinite(r)) set(el, 'background', feeColor(r));
840
+ }
841
+ // A NEGATIVE delay: the animation resumes at the phase it would have reached,
842
+ // so a loop survives the once-a-second innerHTML repaint without restarting.
843
+ for (const el of q('[data-phase]')) {
844
+ const d = Number(el.dataset.phase);
845
+ if (Number.isFinite(d)) set(el, 'animationDelay', `-${Math.min(10, Math.max(0, d))}s`);
846
+ }
847
+ for (const el of q('[data-delay]')) {
848
+ const d = Number(el.dataset.delay);
849
+ if (Number.isFinite(d)) set(el, 'animationDelay', `${Math.min(3, Math.max(0, d))}s`);
850
+ }
851
+ for (const el of q('[data-rail]')) {
852
+ const d = Number(el.dataset.rail);
853
+ if (Number.isFinite(d)) set(el, 'animationDuration', `${Math.min(240, Math.max(12, d))}s`);
854
+ }
855
+ applySizes(root);
856
+ return root;
857
+ }
858
+
859
+ function rateColor(r, top) {
860
+ const t = Math.min(1, r / top);
861
+ return t > 0.66 ? COL.ok : t > 0.33 ? COL.warn : COL.bad;
862
+ }
863
+
864
+ /** Feerate landscape of the block being built: 1,496 transactions, 15 buckets. */
865
+ export function feeLandscape(canvas, nb, fmt) {
866
+ const h = nb?.feeRateHistogram ?? [];
867
+ paint(canvas, {
868
+ when: h.some((b) => b.n > 0),
869
+ placeholder: nb?.unavailable ? `no template: ${nb.unavailable}` : 'no block template requested yet',
870
+ draw: (c) => {
871
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
872
+ const w = c.clientWidth || 600; const hh = c.clientHeight || 140;
873
+ c.width = Math.round(w * dpr); c.height = Math.round(hh * dpr);
874
+ const ctx = c.getContext('2d');
875
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
876
+ ctx.clearRect(0, 0, w, hh);
877
+ const max = Math.max(...h.map((b) => b.n), 1);
878
+ const bw = w / h.length;
879
+ ctx.font = '9px ui-monospace, monospace'; ctx.textBaseline = 'middle';
880
+ h.forEach((b, i) => {
881
+ const bh = Math.max(1, (b.n / max) * (hh - 30));
882
+ const x = i * bw + 2;
883
+ ctx.fillStyle = rateColor(b.hi, 20);
884
+ ctx.fillRect(x, hh - 18 - bh, bw - 4, bh);
885
+ ctx.fillStyle = COL.text; ctx.textAlign = 'center';
886
+ ctx.fillText(`${b.hi}`, x + (bw - 4) / 2, hh - 9);
887
+ if (b.n) { ctx.fillStyle = COL.textDim; ctx.fillText(`${b.n}`, x + (bw - 4) / 2, hh - 22 - bh); }
888
+ });
889
+ ctx.textAlign = 'left'; ctx.fillStyle = COL.textDim;
890
+ ctx.fillText('sat/vB →', 2, 8);
891
+ c.__hasData = true;
892
+ },
893
+ });
894
+ }
895
+
896
+ export const flowArgs = (s, state) => ({
897
+ tipHeight: s?.tip?.height ?? null,
898
+ recent: s?.attribution?.recent ?? [],
899
+ // getblockstats rows, keyed by height: what a block says about itself before its coinbase has
900
+ // been read. Without these a height the attribution reader has not reached draws as an empty box.
901
+ stats: s?.blocks?.recent ?? [],
902
+ next: s?.attribution?.nextBlock ?? null,
903
+ mempool: s?.mempool,
904
+ avgGapSec: s?.avgBlockGapSec ?? null,
905
+ tipAgeSec: s?.tip?.ageSec ?? null,
906
+ ibd: s?.ibd === true || s?.sync?.state === 'ibd',
907
+ online: s?.online !== false,
908
+ paused: !!state?.paused,
909
+ });
910
+
911
+ export function renderMiningOverview(s, state, h) {
912
+ const a = s?.attribution;
913
+ // The same map as the Mining page, same data, same rules -- a smaller copy must
914
+ // not quietly become a different claim. The box used to break that promise: a
915
+ // fixed w4/168px square gave a 34%-full block an 84px band of transactions in a
916
+ // dead square (measured 2026-09-10). The card is now w8 and the canvas's height
917
+ // follows the FILL, from the template's own weightPct — the width always carries
918
+ // the whole block, so the canvas is width x (width x fill) with a 240px floor
919
+ // that keeps an empty block's hatch legible. The box still IS the block; it is
920
+ // just no longer a square that hides how empty the block is.
921
+ const nbOv = a?.nextBlock ?? null;
922
+ h.mempoolDetail?.(); // the overview draws the same pool viewer, so it needs the same data
923
+ const ovCanvas = h.canvas('ovGnTreemap');
924
+ // No inline height any more. It used to scale the canvas by the template's
925
+ // fill, which fought the square card and left the canvas ZERO-SIZED whenever
926
+ // there was no template yet (the browser check read zeroSized). The card and
927
+ // its wrapper are square in CSS; the canvas fills them.
928
+ if (ovCanvas?.style?.height) ovCanvas.style.height = '';
929
+ drawPoolViewer(ovCanvas, s, state);
930
+ const ovNote = document.getElementById('ovGnTreemapNote');
931
+ if (ovNote) {
932
+ const v = nbOv?.visual;
933
+ ovNote.textContent = v?.cells?.length
934
+ ? `${fmtNum(v.cells.length, h)} selected transactions, ${fmtNum(v.totalVbytes, h)} vB`
935
+ : (nbOv?.unavailable ?? 'no block template yet');
936
+ }
937
+ h.nextBlock?.();
938
+ blockFlow(document.getElementById('ovTrain'), flowArgs(s, state), h.fmt);
939
+ poolTable(document.getElementById('ovMiningPools'), a, h.fmt);
940
+ applyMiningStyles(document);
941
+ }
942
+
943
+ // --------------------------------------------------------------- block space page
944
+ //
945
+ // The viewer at full size, with the block being built and the chain tip in the
946
+ // same panel (operator, 2026-09-11). The viewer is drawPoolViewer -- the SAME
947
+ // call Overview and Mining make, per the "one viewer, identical everywhere" ask --
948
+ // and the two side panels are built from the same readings the flow cards use.
949
+
950
+ /** The block being built, as the side panel shows it. Pure: markup from readings. */
951
+ export function nextHud(nb, { meter = '', fresh = null, mempool = null } = {}, fmt) {
952
+ const head = (k) => `<div class="hudh"><span class="live"></span><b>Being built</b>${k}</div>`;
953
+ if (!nb) return `${head('')}<div class="note tiny">No block template yet. It is assembled here from the node's mempool, which is read every twenty seconds; if it stays empty, the node's RPC is slow or the mempool read is being dropped -- <span class="mono">npm run check</span> times the node alone.</div>`;
954
+ if (nb.unavailable) return `${head('')}<div class="note tiny">No block template: ${fmt.esc(nb.unavailable)}</div>`;
955
+ const cap = Number(nb.weightLimit) || WU_CAP_FALLBACK;
956
+ const pct = Number.isFinite(nb.weightPct) ? nb.weightPct : 100 * (Number(nb.weight) || 0) / cap;
957
+ const lvl = fresh && fresh.level !== 'n/a' ? fresh.level : '';
958
+ const mins = fresh && Number.isFinite(fresh.seconds) ? Math.floor(fresh.seconds / 60) : null;
959
+ const ageSec = nb.at ? Math.max(0, Math.round((Date.now() - nb.at) / 1000)) : null;
960
+ const ec = nb.economy ?? null;
961
+ const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
962
+ return `${head(`<span class="hudk">#${nb.height ?? '?'}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
963
+ ${meter}
964
+ <div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${ec.marginal.rate}/vB</span>` : '<span class="chip">not full</span>'}</div>
965
+ <dl class="hudkv">
966
+ ${kv('transactions', nb.txCount != null ? fmt.num(nb.txCount) : '–')}
967
+ ${kv('fees', btc(nb.totalFeesSat))}
968
+ ${kv('weight', `${fmt.num(nb.weight)} / ${fmt.num(cap)}`)}
969
+ ${kv('sat/vB', `${nb.feeRate?.p50 ?? '–'} med · ${nb.feeRate?.max ?? '–'} max`)}
970
+ ${mempool?.bytes != null ? kv('queued', fmt.bytes(mempool.bytes)) : ''}
971
+ ${ec?.backlogBlocks != null ? kv('queue depth', `≈ ${ec.backlogBlocks} blocks`) : ''}
972
+ ${kv('template', `${ageSec != null ? `${ageSec}s old` : '–'} · ${nb.ms ?? '?'} ms to assemble`)}
973
+ </dl>`;
974
+ }
975
+
976
+ /** The chain tip, as the side panel shows it. Pure. */
977
+ export function tipHud(tipHeight, row, prev, { avgGapSec = null, now = Date.now() } = {}, fmt) {
978
+ const head = `<div class="hudh"><b>Chain tip</b><span class="hudk">${Number.isFinite(tipHeight) ? `#${tipHeight}` : '–'}</span></div>`;
979
+ if (!Number.isFinite(tipHeight)) return `${head}<div class="note tiny">No tip reading yet.</div>`;
980
+ const avg = Number.isFinite(avgGapSec) && avgGapSec > 0 ? `${(avgGapSec / 60).toFixed(1)} min` : '–';
981
+ if (!row) return `${head}<div class="note tiny">This height's coinbase has not been read yet: attribution runs one block per poll, so it trails the tip.</div><dl class="hudkv"><dt>average gap</dt><dd>${avg}</dd></dl>`;
982
+ const w = who(row);
983
+ const f = blockFacts(row);
984
+ const gap = prev?.time && row.time ? row.time - prev.time : null;
985
+ const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
986
+ return `${head}
987
+ <div class="hudpool"><span class="bdot" data-pool="${w.idx}"></span><b data-pool-fg="${w.idx}">${fmt.esc(trunc(w.name, 22))}</b><span class="faint">${agoText(row.at ?? row.seenAt, now) ? `mined ${agoText(row.at ?? row.seenAt, now)}` : ''}</span></div>
988
+ <div class="hudbar" title="${f.capPct != null ? `${f.capPct.toFixed(1)}% of the 4,000,000 WU cap` : 'weight unknown'}"><span data-w="${f.capPct == null ? 0 : f.capPct.toFixed(1)}" data-pool="${w.idx}"></span></div>
989
+ <dl class="hudkv">
990
+ ${kv('full', f.capPct != null ? `${f.capPct.toFixed(1)}%` : '–')}
991
+ ${kv('size', f.fillVb != null ? fmt.bytes(f.fillVb, 0) : '–')}
992
+ ${kv('transactions', row.txs != null ? fmt.num(row.txs) : '–')}
993
+ ${kv('fees', row.totalfee != null ? btc(row.totalfee) : '–')}
994
+ ${kv('sat/vB', `${row.avgFeerate ?? '–'} avg · ${row.p50 ?? '–'} p50`)}
995
+ ${kv('gap before it', gap != null ? gapText(gap) : '–')}
996
+ ${kv('average gap', avg)}
997
+ </dl>`;
998
+ }
999
+
1000
+ // the rates a reader actually meets; each swatch is the colour of the band that rate falls in
1001
+ // from 0.1 sat/vB, where the palette starts and this chain's blocks spend most of their space
1002
+ const LEGEND_RATES = [0, 0.1, 0.2, 0.3, 0.5, 1, 2, 3, 5, 10, 20, 50, 100, 200, 500];
1003
+ export function feeLegend() {
1004
+ return `<div class="hudh"><b>Feerate</b><span class="hudk">sat/vB</span></div>
1005
+ <div class="feelegend">${LEGEND_RATES.map((r) => `<span><i data-fee="${r}"></i>${r === 0 ? '&lt;0.1' : `${r}+`}</span>`).join('')}</div>
1006
+ <div class="note tiny faint">Our own feerate banding, the same colours as the stones.</div>`;
1007
+ }
1008
+
1009
+ export function renderBlockSpace(s, state, h) {
1010
+ const a = s?.attribution;
1011
+ h.mempoolDetail?.();
1012
+ h.nextBlock?.();
1013
+ drawPoolViewer(h.canvas('spTreemap'), s, state);
1014
+ const nb = a?.nextBlock ?? null;
1015
+ const nextEl = document.getElementById('spNext');
1016
+ if (nextEl) {
1017
+ const same = nextEl.__meter && nb && nextEl.__meter.height === nb.height;
1018
+ const m = growthMeter(nb, same ? nextEl.__meter.lit : null);
1019
+ if (nb && !nb.unavailable) nextEl.__meter = { height: nb.height, lit: m.lit };
1020
+ const fresh = tipFreshness({
1021
+ ageSec: s?.tip?.ageSec ?? null, avgGapSec: s?.avgBlockGapSec ?? null,
1022
+ ibd: s?.ibd === true || s?.sync?.state === 'ibd', online: s?.online !== false, paused: !!state?.paused,
1023
+ });
1024
+ nextEl.innerHTML = nextHud(nb, { meter: m.html, fresh, mempool: s?.mempool }, h.fmt);
1025
+ }
1026
+ const tipEl = document.getElementById('spTip');
1027
+ if (tipEl) {
1028
+ const tipH = s?.tip?.height ?? null;
1029
+ const recent = a?.recent ?? [];
1030
+ const row = recent.find((r) => r.height === tipH) ?? null;
1031
+ const prev = recent.find((r) => r.height === tipH - 1) ?? null;
1032
+ tipEl.innerHTML = tipHud(tipH, row, prev, { avgGapSec: s?.avgBlockGapSec ?? null }, h.fmt);
1033
+ }
1034
+ const lg = document.getElementById('spLegend');
1035
+ if (lg && !lg.__drawn) { lg.innerHTML = feeLegend(); lg.__drawn = true; }
1036
+ const note = document.getElementById('spNote');
1037
+ if (note) {
1038
+ const mp = state?.mempoolDist ?? s?.mempool ?? {};
1039
+ const count = mp.count ?? s?.mempool?.count ?? null;
1040
+ note.textContent = (mp.cells ?? []).length
1041
+ ? `One block's worth (1,000,000 vB) of the ${count != null ? fmtNum(count, h) : '?'} transactions waiting, laid out richest first; hover a block for its transaction. Blocks lift off, travel and land when the pool changes; the pool is polled every 30 s and a change waits for the running animation to land.`
1042
+ : 'The mempool detail has not arrived yet: it is polled every 30 s while this page is open.';
1043
+ }
1044
+ applyMiningStyles(document);
1045
+ }
1046
+
1047
+ export function renderMining(s, state, h) {
1048
+ const a = s?.attribution;
1049
+ // Ask on every paint of the page; the helper de-duplicates by age and the server by
1050
+ // in-flight call, so this cannot turn into a polling storm with several tabs open.
1051
+ h.nextBlock?.();
1052
+ blockFlow(document.getElementById('mnFlow'), flowArgs(s, state), h.fmt);
1053
+ packagesView(document.getElementById('mnPackages'), a?.nextBlock?.packages, h.fmt);
1054
+ const nb = a?.nextBlock ?? null;
1055
+ h.mempoolDetail?.();
1056
+ const mp = state?.mempoolDist ?? s?.mempool ?? {};
1057
+ drawPoolViewer(h.canvas('gnMempoolTreemap'), s, state);
1058
+ const mnote = document.getElementById('gnMempoolNote');
1059
+ if (mnote) {
1060
+ const cells = mp.cells ?? [];
1061
+ const tail = cells.find((c) => c.aggregate) ?? null;
1062
+ const total = mp.totalVsize ?? 0;
1063
+ const drawn = cells.length - (tail ? 1 : 0);
1064
+ if (cells.length) {
1065
+ const parts = [`${fmtNum(drawn, h)} drawn`];
1066
+ if (tail) parts.push(`+ 1 aggregate of ${fmtNum(tail.aggregate, h)} smaller ones`);
1067
+ if (mp.cellCount != null) parts.push(`= ${fmtNum(mp.cellCount, h)} transactions as at the poll`);
1068
+ if (mp.fetchedAt != null) parts.push(`${Math.round((Date.now() - mp.fetchedAt) / 1000)}s ago`);
1069
+ const fits = total <= 1_000_000
1070
+ ? '<b>everything waiting fits in a single block</b>'
1071
+ : 'the line sits where one block runs out and everything right of it waits';
1072
+ mnote.innerHTML = `${parts.join(', ')}; ${fmtNum(total, h)} vB waiting. One block is 1,000,000 vB, so ${fits}.`
1073
+ + ' Ordered by feerate, because that is the order a miner takes them; colour is what each transaction pays.'
1074
+ + (mp.stale ? ' <span class="warn">Last poll failed; this picture may be old.</span>' : '');
1075
+ } else if (mp.stale) {
1076
+ mnote.innerHTML = '<span class="warn">Detail stale</span> — the last poll of the full pool failed. Anything drawn above is the previous reading.';
1077
+ } else if (mp.count != null && mp.count > 0) {
1078
+ mnote.innerHTML = `<span class="warn">Cells not loaded yet</span> — ${fmtNum(mp.count, h)} transactions are waiting; the full pool is polled on the 20 s tier and this page has not received it.`;
1079
+ } else if (mp.count === 0) {
1080
+ mnote.textContent = 'The mempool is empty: nothing is waiting, so there is nothing to draw.';
1081
+ } else {
1082
+ mnote.textContent = 'Nothing to draw yet — the full pool is polled on the 20 s tier, not every second.';
1083
+ }
1084
+ }
1085
+ feeLandscape(h.canvas('mnFeeLandscape'), a?.nextBlock ?? null, h.fmt);
1086
+ poolTable(document.getElementById('mnPools'), a, h.fmt);
1087
+ const el = document.getElementById('mnCoverage');
1088
+ if (el) el.innerHTML = coveragePanel(a, h);
1089
+ applyMiningStyles(document);
1090
+ }
1091
+
1092
+ function coveragePanel(a, h) {
1093
+ const esc = h.fmt.esc;
1094
+ const row = (k, v) => `<dt>${esc(k)}</dt><dd>${v}</dd>`;
1095
+ if (!a) return `<dl class="kv">${row('attribution', '<span class="warn">not in this snapshot yet</span>')}</dl>`;
1096
+ const nb = a.nextBlock;
1097
+ return `<dl class="kv">
1098
+ ${row('blocks attributed', a.windowBlocks ?? 0)}
1099
+ ${row('window', a.windowHeights ? `#${a.windowHeights.from} – #${a.windowHeights.to}` : '–')}
1100
+ ${row('curated labels matched', `${(a.recent ?? []).filter((r) => r.poolLabel).length} of ${(a.recent ?? []).length}`)}
1101
+ ${row('skipped during IBD', a.skippedIbd ? `${a.skippedIbd} heights` : 'none')}
1102
+ ${row('label source', a.labelSource
1103
+ ? `<span class="mono">${esc((a.labelSource.sha256 ?? '').slice(0, 10))}</span> fetched ${esc((a.labelSource.fetchedAt ?? '').slice(0, 10))}`
1104
+ : '<span class="warn">none — run node scripts/pool-map.js</span>')}
1105
+ ${row('block in progress', nb && !nb.unavailable
1106
+ ? `#${nb.height ?? '?'} · ${(nb.weightPct ?? 0).toFixed(1)}% full · ${nb.txCount ?? '?'} txs${nb.ageMs != null ? '' : ''}`
1107
+ : `<span class="warn">${esc(nb?.unavailable ?? 'not requested yet — this page asks while it is visible')}</span>`)}
1108
+ ${nb?.ms != null ? row('template cost', `${nb.ms} ms to assemble here, from the mempool — no call to the node`) : ''}
1109
+ ${a.lastError ? row('last error', `<span class="bad">${esc(a.lastError)}</span>`) : ''}
1110
+ </dl>`;
1111
+ }
1112
+
1113
+ /** The pool table. Kept here because it shares the colour scale with the flow cards. */
1114
+ export function poolTable(el, a, fmt) {
1115
+ if (!el) return;
1116
+ const rows = a?.byPool ?? [];
1117
+ if (!rows.length) {
1118
+ el.innerHTML = `<div class="note">No blocks attributed yet. Attribution is two small reads per block on the node's own RPC lane, skipped entirely during initial download${a?.skippedIbd ? ` — ${a.skippedIbd} heights skipped so far` : ''}.</div>`;
1119
+ return;
1120
+ }
1121
+ const w = a.windowHeights;
1122
+ el.innerHTML = `<table class="t"><thead><tr><th>pool</th><th class="r">blocks</th><th class="r">share</th><th class="r">median sat/vB</th><th class="r">avg weight</th><th>coinbase tags seen</th></tr></thead><tbody>`
1123
+ + rows.map((p) => `<tr>
1124
+ <td><span class="bdot" data-pool-key="${fmt.esc(p.poolKey ?? p.poolLabel ?? '')}"></span>
1125
+ ${p.labelled || p.label ? fmt.esc(p.label ?? p.name) : `<span class="muted">${fmt.esc(p.poolKey)}</span> <span class="warn">unlabelled</span>`}</td>
1126
+ <td class="r">${p.blocks}</td>
1127
+ <td class="r">${p.sharePct != null ? `${p.sharePct}%` : '–'}</td>
1128
+ <td class="r">${p.medianFeeRate ?? '–'}</td>
1129
+ <td class="r">${p.avgWeight != null ? fmt.num(p.avgWeight) : '–'}</td>
1130
+ <td class="mono muted" title="${fmt.esc((p.tags ?? []).join(' | '))}">${fmt.esc((p.tags ?? [])[0] ?? '–')}</td>
1131
+ </tr>`).join('')
1132
+ + `</tbody></table>
1133
+ <div class="note tiny faint">${rows.reduce((n, p) => n + p.blocks, 0)} blocks between #${w?.from ?? '?'} and #${w?.to ?? '?'}; shares are of that window only. ${a.labelSource
1134
+ ? `Labels: ${fmt.esc(a.labelSource.source)} @ <span class="mono">${fmt.esc((a.labelSource.sha256 ?? '').slice(0, 10))}</span>, fetched ${fmt.esc((a.labelSource.fetchedAt ?? '').slice(0, 10) ?? '?')}. An unlabelled row is a coinbase the curated map does not know.`
1135
+ : 'No label map loaded — run <span class="mono">node scripts/pool-map.js</span>; until then only the raw coinbase text is shown.'}</div>`;
1136
+ }
1137
+
1138
+ export { poolColor };