blockyard 0.0.1 → 0.1.0

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