blockyard 0.0.1 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,886 @@
1
+ // The block, drawn as the space it is: a treemap of the transactions the node selected,
2
+ // each rectangle sized by its vbytes and coloured by the feerate it paid, richest first.
3
+ //
4
+ // Why this shape: a list of 1,500 transactions tells you nothing about the one thing that
5
+ // matters -- that a block is a fixed budget and every transaction spends some of it. A
6
+ // treemap makes the budget the canvas. The big rectangles are the fat transactions; the
7
+ // coloured strip at the right edge is where the block ran out of room, which is the same
8
+ // line as "what feerate still got in".
9
+ //
10
+ // The order is the miner's rule, not a styling choice: highest feerate first, so the cut
11
+ // line means something. Rectangles animate between refreshes because the point of watching
12
+ // it is to see the ordering shift as the landscape moves -- with prefers-reduced-motion the
13
+ // same layout is drawn instantly and nothing is lost but the motion.
14
+ //
15
+ // The animation runs as a loop on the canvas, not as one draw per snapshot, for the same
16
+ // reason a mempool.space-style view works: the picture only reads as "alive" if it is
17
+ // actually in motion between snapshots. A new snapshot RETARGETS the loop (it never
18
+ // restarts it); the loop also keeps drawing so that time-linked effects — the marching
19
+ // cut line, the ring on cells that actually changed, the frontier creeping as the block
20
+ // assembles — have a frame to happen in. Everything that could be mistaken for data is
21
+ // anchored: areas are the layout's, colours are the palette's, pulses mark real churn,
22
+ // and the loop parks itself once the picture has settled. Nothing twinkles on its own
23
+ // authority.
24
+
25
+ import { paint, COL } from './charts.js';
26
+
27
+ function mix(a, b, t) {
28
+ return a.map((v, k) => v + (b[k] - v) * t);
29
+ }
30
+
31
+ // The feerate ramp, tuned against the colours the live node's cells actually get
32
+ // (see test/floor-colour.test.js — the rates below are today's, from /api/mempool and
33
+ // /api/nextblock, not a guess). Two design facts from that data decide the shape:
34
+ //
35
+ // 1. The POOL's mass and the BLOCK's mass live in different halves of the range.
36
+ // Pool: p50 4.1, p90 10.0, max 100. Block: max 8.4, median 0.4. A ramp that saves
37
+ // its brightest third for 30-100 sat/vB leaves the entire BLOCK grey — so the
38
+ // green/lime band starts where the block's top rates actually are (8-14), not where
39
+ // a whale once paid.
40
+ // 2. The pool's floor aggregate (the majority by area) sits at 0.2-0.6. It must not be
41
+ // a void (luma ~90 read as empty space); it is a visible, saturated steel-blue
42
+ // "nobody paid" category, one colour across the band.
43
+ //
44
+ // Order is carried by warmth (cool -> warm) and saturation, not by luminance alone:
45
+ // luminance peaks at amber then falls toward crimson, so an assertion of "brighter per
46
+ // sat/vB" is false at the hot end, and was removed rather than the palette bent to it.
47
+ const RAMP = [
48
+ { at: 0, rgb: [92, 132, 228] }, // the relay floor: saturated steel-blue — a visible "unpaid" category
49
+ { at: 1.6, rgb: [56, 158, 255] }, // above the floor at last
50
+ { at: 3.4, rgb: [0, 194, 255] }, // electric cyan: the mempool median lives here
51
+ { at: 7, rgb: [21, 224, 132] }, // neon green: comfortably above it
52
+ { at: 14, rgb: [154, 230, 46] }, // lime: a real race (and the block's top end starts here)
53
+ { at: 28, rgb: [255, 209, 46] }, // amber: paying properly
54
+ { at: 52, rgb: [255, 124, 26] }, // orange: near the top of the day
55
+ { at: 96, rgb: [255, 68, 77] }, // red: racing the next block
56
+ { at: 1e9, rgb: [255, 43, 111] }, // hot pink: paying handsomely (today's max: 100)
57
+ ];
58
+
59
+ const hexOf = (rgb) => `#${rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`;
60
+
61
+ /**
62
+ * The palette, in one place, so the legend strip and the hover readout cannot drift from
63
+ * what the cells are actually painted with.
64
+ */
65
+ export function rateColor(rate) {
66
+ const r = Number.isFinite(rate) && rate > 0 ? rate : 0;
67
+ let i = 0;
68
+ while (i < RAMP.length - 2 && r > RAMP[i + 1].at) i++;
69
+ const a = RAMP[i];
70
+ const b = RAMP[i + 1];
71
+ const span = b.at - a.at;
72
+ const t = span > 0 ? Math.max(0, Math.min(1, (r - a.at) / span)) : 0;
73
+ return hexOf(mix(a.rgb, b.rgb, t));
74
+ }
75
+
76
+ /**
77
+ * Squarified treemap. Pure: cells in, rectangles out, so the geometry can be tested
78
+ * without a browser -- which matters because the one thing a treemap must never do is
79
+ * lie about area. If a 2x-wide rectangle isn't 2x the vbytes, the picture is decoration.
80
+ */
81
+ export function layoutTreemap(cells, w, h) {
82
+ const items = (cells ?? [])
83
+ .filter((c) => Number.isFinite(c?.vbytes) && c.vbytes > 0)
84
+ .map((c) => ({ ...c }));
85
+ const total = items.reduce((n, c) => n + c.vbytes, 0);
86
+ if (!items.length || !(w > 0) || !(h > 0) || total <= 0) return [];
87
+
88
+ // Scale every cell into the box once, in pixel area. From here on the only law is
89
+ // "rect area == cell area", because a treemap that breaks that is decoration.
90
+ const scale = (w * h) / total;
91
+ for (const c of items) c.area = c.vbytes * scale;
92
+
93
+ // Squarify's row-greedy needs the AREAS in descending order — that is the
94
+ // classic algorithm's one real requirement, and the reason a hand-written
95
+ // version of it can look correct while painting garbage. The input here is
96
+ // the MINER's order (templateCells/poolCells sort by feerate, richest first,
97
+ // and the note under the chart promises that order). Fed to the row loop
98
+ // as-is, each row receives a size-ASCENDING run, the worst-ratio test flushes
99
+ // after nearly every cell, and the map degenerates into slivers. Measured
100
+ // 2026-09-10 against the live 433x433 map with a real template's 401 cells:
101
+ // 45 rects with aspect ratio > 10 (worst 49:1) and 30 sub-2px rects on the
102
+ // block view; 138 > 10:1 (worst 273:1) and 348 sub-2px on the mempool view.
103
+ // Sorting a WORKING LIST only is deliberate: `items` keeps the miner order,
104
+ // the rects below carry their cells' data wherever they land, and the paint
105
+ // layer still draws aggregates first and real transactions on top. Fixed
106
+ // with the live data: 0 rects over 10:1 on both maps (worst 2.6 / 4.6), and
107
+ // the area law is untouched — it never depended on the order.
108
+ const rowsrc = [...items].sort((a, b) => b.area - a.area);
109
+
110
+ const rects = [];
111
+ let x = 0;
112
+ let y = 0;
113
+ let rw = w;
114
+ let rh = h;
115
+ let row = [];
116
+
117
+ const worst = (list, side) => {
118
+ const s = list.reduce((n, c) => n + c.area, 0);
119
+ if (s <= 0 || side <= 0) return Infinity;
120
+ const mx = Math.max(...list.map((c) => c.area));
121
+ const mn = Math.min(...list.map((c) => c.area));
122
+ return Math.max((side * side * mx) / (s * s), (s * s) / (side * side * mn));
123
+ };
124
+
125
+ const flush = () => {
126
+ if (!row.length || rw <= 0 || rh <= 0) { row = []; return; }
127
+ const s = row.reduce((n, c) => n + c.area, 0);
128
+ const side = Math.min(rw, rh); // the row runs along the short edge
129
+ const thickness = s / side; // ...and this is how deep it is
130
+ let offset = 0;
131
+ for (const c of row) {
132
+ const len = c.area / s * side;
133
+ if (rw >= rh) rects.push({ ...c, x, y: y + offset, w: thickness, h: len });
134
+ else rects.push({ ...c, x: x + offset, y, w: len, h: thickness });
135
+ offset += len;
136
+ }
137
+ if (rw >= rh) { x += thickness; rw = Math.max(0, rw - thickness); }
138
+ else { y += thickness; rh = Math.max(0, rh - thickness); }
139
+ row = [];
140
+ };
141
+
142
+ for (const c of rowsrc) {
143
+ if (row.length) {
144
+ const side = Math.min(rw, rh);
145
+ if (worst([...row, c], side) > worst(row, side)) flush();
146
+ }
147
+ row.push(c);
148
+ }
149
+ flush();
150
+
151
+ // Greedy layouts can land the last cells a hair outside on degenerate input. Clamp --
152
+ // overflow is the same class of error as a rectangle whose area is not its share.
153
+ return rects.map((r) => {
154
+ const cw = Math.max(0.001, Math.min(r.w, w - r.x));
155
+ const ch = Math.max(0.001, Math.min(r.h, h - r.y));
156
+ return { ...r, x: Math.max(0, r.x), y: Math.max(0, r.y), w: cw, h: ch };
157
+ });
158
+ }
159
+
160
+ /**
161
+ * The mempool picture: the whole waiting set, and where a single block's worth of space
162
+ * would cut through it. On a pool smaller than one block the line sits past the right
163
+ * edge -- and saying so is the honest result, because "everything currently waiting fits
164
+ * in one block" is information, not a missing chart.
165
+ */
166
+ export function mempoolTreemap(canvas, dist, opts = {}) {
167
+ const cells = dist?.cells ?? [];
168
+ const capacity = (opts.blockVbytes ?? 1_000_000);
169
+ const total = Number.isFinite(dist?.totalVsize) ? dist.totalVsize : cells.reduce((n, c) => n + (c.vbytes || 0), 0);
170
+ return drawMap(canvas, cells, {
171
+ total,
172
+ // Where one block's worth of space falls inside the pool. Beyond 100% means the whole
173
+ // pool fits, and the note says that instead of drawing a line off-canvas.
174
+ cutFrac: capacity > 0 && total > 0 ? Math.min(1, capacity / total) : 0,
175
+ labelLine: capacity > 0 && total > capacity ? 'one block of space ends here' : null,
176
+ });
177
+ }
178
+
179
+ /**
180
+ * How much of the canvas the selection is allowed to claim, given that the canvas is a
181
+ * whole block. A block that is 16% full must look 16% full -- rescaling the chosen
182
+ * transactions to fill the box is how a half-empty block gets photographed as a busy one.
183
+ */
184
+ export function blockFillFraction(totalVbytes, weightLimit = 4_000_000) {
185
+ const capacity = (Number.isFinite(weightLimit) && weightLimit > 0 ? weightLimit : 4_000_000) / 4;
186
+ const total = Number.isFinite(totalVbytes) ? totalVbytes : 0;
187
+ if (capacity <= 0 || total <= 0) return 0.02;
188
+ // A floor of 2% so a nearly-empty block still draws its cells instead of a hairline.
189
+ return Math.max(0.02, Math.min(1, total / capacity));
190
+ }
191
+
192
+ /** Where the block ran out of room, in the same vbytes the cells are measured in. */
193
+ export function cutVbytes({ weightLimit = 4_000_000, remainingWeight = null }) {
194
+ const cap = weightLimit / 4;
195
+ return remainingWeight != null ? cap - remainingWeight / 4 : cap;
196
+ }
197
+
198
+ // An aggregate cell has no txid. Keying it by its feerate made every pool's tail cell
199
+ // share ONE key (they all sit at the pool's cut feerate), so two tails fought over the
200
+ // same animation slot and the frontier drew the wrong shape. Key by rank + mean rate:
201
+ // unique per snapshot, and stable enough that a tail that merely grew tweens.
202
+ const keyOf = (r, i) => (r.txid ? `t:${r.txid}` : `a:${i}:${Math.round(r.rate)}`);
203
+
204
+ const EASE = 0.18; // frontier easing: ~150 ms to close the gap between cuts
205
+ const SETTLE_MS = 900; // one reshuffle reads as ~600-900 ms of movement
206
+ const LEAVE_MS = 450; // a departed cell fades out over this
207
+ const PULSE_MS = 1400; // how long a cell that just changed stays ringed
208
+ const PARK_MS = 400; // settled picture repaints at this cadence (marching line only)
209
+
210
+ // A tiny canvas tile, patterned once and reused: the hatched void is what "the block is
211
+ // not full" LOOKS like, and it costs one pattern fill instead of a per-cell loop.
212
+ const paintState = new WeakMap(); // one ctx -> its cached patterns, whatever a proxy says
213
+
214
+ function cachedPattern(ctx, key, makeTile) {
215
+ let bag = paintState.get(ctx);
216
+ if (!bag) { bag = {}; paintState.set(ctx, bag); }
217
+ if (bag[key]) return bag[key];
218
+ // makeTile itself can fail on a host with no `document` (a bare unit-test global);
219
+ // that is the same "no pattern host" case as a missing createPattern, so it is caught
220
+ // in the same breath rather than escaping into the draw path.
221
+ const mk = (c) => {
222
+ if (!c || typeof c.createPattern !== 'function') return null;
223
+ const tile = makeTile();
224
+ return tile ? c.createPattern(tile, 'repeat') : null;
225
+ };
226
+ let pat = null;
227
+ try { pat = mk(ctx); } catch { pat = null; }
228
+ if (!pat) {
229
+ try {
230
+ const off = new OffscreenCanvas(8, 8);
231
+ pat = mk(off.getContext('2d'));
232
+ } catch { /* no pattern host here — no texture is acceptable, a throw is not */ }
233
+ }
234
+ if (pat) bag[key] = pat;
235
+ return pat;
236
+ }
237
+
238
+ function hatchPattern(ctx) { return cachedPattern(ctx, 'hatch', tileOf); }
239
+
240
+ // Map-context pattern cache for the crowd: ctx -> (tile canvas -> pattern). Patterns
241
+ // belong to the context that will paint them, and the crowd tiles are shared objects, so
242
+ // the cache key is the tile itself. A null result is cached too — a host whose
243
+ // createPattern is null should be asked exactly once per tile, not once per frame.
244
+ const patternByCtx = new WeakMap();
245
+ function crowdPatternFor(ctx, tile) {
246
+ if (!tile) return null;
247
+ let bag = patternByCtx.get(ctx);
248
+ if (!bag) { bag = new Map(); patternByCtx.set(ctx, bag); }
249
+ if (bag.has(tile)) return bag.get(tile);
250
+ let pat = null;
251
+ try { pat = typeof ctx.createPattern === 'function' ? ctx.createPattern(tile, 'repeat') : null; } catch { pat = null; }
252
+ bag.set(tile, pat);
253
+ return pat;
254
+ }
255
+
256
+ // The crowd's slow path: when a host cannot make a usable pattern (the measured-null
257
+ // headless case), the tiles are drawn straight onto the map, one fillRect per aggregated
258
+ // transaction. Correct everywhere; the pattern path is what makes it cheap.
259
+ function crowdTiles(ctx, it, x, y, rw, rh, n) {
260
+ const side = Math.sqrt((rw * rh) / n);
261
+ if (side >= 1.4) {
262
+ const cols = Math.max(1, Math.floor(rw / side));
263
+ const rows = Math.max(1, Math.floor(rh / side));
264
+ ctx.fillStyle = 'rgba(233, 240, 250, 0.16)';
265
+ const gap = Math.max(0.5, side * 0.22);
266
+ const dot = Math.max(1, side - gap);
267
+ for (let rI = 0; rI < rows; rI++) {
268
+ const py = y + rI * side + gap / 2;
269
+ for (let cI = 0; cI < cols; cI++) ctx.fillRect(x + cI * side + gap / 2, py, dot, dot);
270
+ }
271
+ } else {
272
+ const tp = tilePattern(ctx);
273
+ if (tp) { ctx.fillStyle = tp; ctx.fillRect(x, y, rw, rh); }
274
+ }
275
+ }
276
+ function tileOf() {
277
+ const tile = document.createElement('canvas');
278
+ tile.width = 8; tile.height = 8;
279
+ const tc = tile.getContext('2d');
280
+ tc.strokeStyle = 'rgba(160, 176, 200, 0.11)';
281
+ tc.lineWidth = 1.2;
282
+ tc.beginPath();
283
+ tc.moveTo(-2, 6); tc.lineTo(6, -2);
284
+ tc.moveTo(2, 10); tc.lineTo(10, 2);
285
+ tc.stroke();
286
+ return tile;
287
+ }
288
+
289
+ // The aggregate cell's micro-grid: a 7px grid of faint dark lines over its colour, so a
290
+ // rect holding thousands of floor-rate transactions LOOKS like a crowd instead of a
291
+ // slab. Same pattern-host fallbacks as the hatch (a stub that cannot createPattern just
292
+ // loses the texture).
293
+ function tilePattern(ctx) { return cachedPattern(ctx, 'tile', gridTile); }
294
+
295
+ // The crowd texture at 60 fps. The first version built a bitmap the SIZE OF THE AGGREGATE
296
+ // (898x898 backing on this box, 1796x1798 on a retina client — 3.2 megapixels) and
297
+ // drawImage'd it every chase frame. That is the bug that made "the block space view
298
+ // disappear on update": a source that large, blitted dozens of times per second, crosses
299
+ // Chromium's GPU raster budget and the canvas surface gets dropped from the compositor —
300
+ // the map vanishes on screen (the readback probe saw its signature: transparent black at
301
+ // the aggregate's centre, while the force-rasterising screenshot path still showed it
302
+ // painted). A texture is never worth a megapixel source.
303
+ //
304
+ // The crowd is a pure grid, so it needs no bitmap at all — it needs ONE TILE and a
305
+ // repeat pattern. Tile side is derived from the measured density (one aggregated
306
+ // transaction = one cell of the grid), capped to 1..24 px; the tile canvas itself is
307
+ // fixed at 24x24, 576 pixels, whatever the pool or the display. A host that returns a
308
+ // null pattern falls back to drawing the tiles directly (the slow path, tested), never
309
+ // to a large bitmap.
310
+ const crowdCache = new Map(); // "count|colour" -> {tile, pattern} (tile is always <= 24x24)
311
+ const CROWD_TILE = 24; // the tile canvas; its cells carry the density, see below
312
+ function crowdLayer(n, w, h, base) {
313
+ const key = `${n}|${base}`;
314
+ const hit = crowdCache.get(key);
315
+ if (hit) return hit;
316
+ let made = null;
317
+ try {
318
+ // The grid's cell size: one aggregated transaction per cell, at THIS rect's density
319
+ // — but computed from the rect, then baked into a tile whose dimensions are a whole
320
+ // number of cells, so the pattern repeats seamlessly at any rect size.
321
+ const side = Math.sqrt((w * h) / Math.max(1, n));
322
+ if (side >= 1.4) {
323
+ const cell = Math.max(1, Math.min(CROWD_TILE, side));
324
+ const reps = Math.max(1, Math.round(CROWD_TILE / cell));
325
+ const tile = document.createElement('canvas');
326
+ tile.width = cell * reps; tile.height = cell * reps; // always <= 24*? but capped below
327
+ if (tile.width > CROWD_TILE) tile.width = CROWD_TILE;
328
+ if (tile.height > CROWD_TILE) tile.height = CROWD_TILE;
329
+ const tc = tile.getContext('2d');
330
+ if (tc) {
331
+ tc.fillStyle = 'rgba(233, 240, 250, 0.16)';
332
+ const gap = Math.max(0.5, cell * 0.22);
333
+ const dot = Math.max(1, cell - gap);
334
+ const repsX = Math.floor(tile.width / cell);
335
+ const repsY = Math.floor(tile.height / cell);
336
+ for (let r = 0; r < repsY; r++) {
337
+ for (let q = 0; q < repsX; q++) tc.fillRect(q * cell + gap / 2, r * cell + gap / 2, dot, dot);
338
+ }
339
+ // The pattern itself is made on the MAP's context at paint time (crowdTiles/
340
+ // the fill below), because contexts are host-specific. If this host's
341
+ // createPattern is the measured-null kind, paint falls to crowdTiles.
342
+ made = { tile, pattern: null, cell };
343
+ }
344
+ } else {
345
+ // Sub-pixel crowd: the micro-grid carries it where individual tiles would alias
346
+ // into noise. Made the same small-tile way — a 7x7 tile, patterned on the map's
347
+ // own context — and never a large bitmap. If createPattern is null (the measured
348
+ // headless case), crowdTiles falls through and paints the grid directly.
349
+ const micro = gridTile();
350
+ made = { tile: micro, pattern: null, cell: 0 };
351
+ }
352
+ } catch { made = null; }
353
+ if (made) crowdCache.set(key, made);
354
+ return made;
355
+ }
356
+
357
+ // A pattern made on the crowd layer's own context (never on the map's — the map's
358
+ // context is the one measured null in headless).
359
+ function cachedLayerPattern(lc, makeTile) {
360
+ try {
361
+ const t = makeTile();
362
+ return t && typeof lc.createPattern === 'function' ? lc.createPattern(t, 'repeat') : null;
363
+ } catch { return null; }
364
+ }
365
+ function gridTile() {
366
+ const tile = document.createElement('canvas');
367
+ tile.width = 7; tile.height = 7;
368
+ const tc = tile.getContext('2d');
369
+ tc.strokeStyle = 'rgba(4, 8, 14, 0.55)';
370
+ tc.lineWidth = 1;
371
+ tc.strokeRect(0.5, 0.5, 6, 6);
372
+ return tile;
373
+ }
374
+
375
+ function easeOutCubic(t) { return 1 - (1 - t) * (1 - t) * (1 - t); }
376
+ const nowMs = () => (Number.isFinite(globalThis.performance?.now?.()) ? globalThis.performance.now() : Date.now());
377
+
378
+ // Gradients are cosmetic here (a frontier glow, the legend ramp), and every host that
379
+ // cannot build one must degrade to "that band/strip is not painted", never to a throw
380
+ // that takes the whole frame with it. A recording stub that returns a bare object from
381
+ // createLinearGradient once turned a green unit suite into a page that painted nothing —
382
+ // rule 14's shape again: a feature the real browser always has cannot be assumed.
383
+ //
384
+ // The pattern twins (hatchPattern, tilePattern) follow the same doctrine one level
385
+ // deeper, and the cache line is load-bearing: `ctx.__hatch = pat` LOOKS harmless, but a
386
+ // Proxy-backed recording context forwards the set to its target and then reports
387
+ // `ctx.__hatch` as undefined on every later frame — so the cache silently misses,
388
+ // createPattern runs per frame, and any test that counts pattern calls sees zero.
389
+ // defineProperty with enumerable:false draws no `set` trap, and the get trap's own
390
+ // target lookup finds the value. Cache on the raw object, degrade to no texture, never
391
+ // throw.
392
+ function gradient(ctx, build, stops, fallback) {
393
+ try {
394
+ const g = build();
395
+ if (!g || typeof g.addColorStop !== 'function') return fallback;
396
+ for (const [o, c] of stops) g.addColorStop(o, c);
397
+ return g;
398
+ } catch { return fallback; }
399
+ }
400
+
401
+ // One complete frame of the map, painted straight onto the live canvas. History of
402
+ // this function is the history of the disappearance: the offscreen-composite version
403
+ // it replaced was measured (headless swiftshader — the same fallback chain a VM or
404
+ // blocklisted-GPU browser hits) to ACCEPT the blit into the live surface and never
405
+ // land it, while the identical frame drawn directly lands in full. One draw path has
406
+ // no host that can lie about which pixels exist.
407
+ function paintComposited(ctx, c, w, h, dpr, geom, view) {
408
+ drawFrame(ctx, geom, view);
409
+ }
410
+
411
+ /**
412
+ * One frame of the map. `view` is the live animation state the loop owns; everything
413
+ * time-linked reads `view.now` so a frame is a pure function of (layout, view, clock).
414
+ */
415
+ function drawFrame(ctx, geom, view) {
416
+ const { w, h, unused = 0, label = null, labelLine = null } = geom;
417
+ const now = view.now;
418
+ const cut = view.cutNow;
419
+
420
+ // --- the cells ------------------------------------------------------------
421
+ // Geometry interpolates with eased wall-clock time; a settled size is ALWAYS the
422
+ // layout's, never a tween artifact — the law the old grow-from-zero tween broke
423
+ // (401 rects on paper, zero painted pixels found in the real browser).
424
+ //
425
+ // Frontier handling WITHOUT ctx.clip(): swiftshader (headless, and real GPUs on a
426
+ // blocklist/VM) silently DROPS fills drawn through a clip — no error, ops recorded,
427
+ // nothing lands — which is the mechanism behind every "map blank but line and legend
428
+ // painted" reading this project has ever taken. The frontier band to the right of the
429
+ // cut is repainted over any cell that overshoots it (below, the unfilled band paints
430
+ // after the cells), so cells need no clip to stay honest: worst case a chase frame
431
+ // overshoots the line for one frame and the band repaint covers it. Zero clip
432
+ // calls = one rasteriser that cannot silently eat the map.
433
+
434
+ // Aggregates paint FIRST, so the individual transactions always land on top of them
435
+ // (painting order must never let a tail hide a real transaction).
436
+ const ordered = view.items.some((it) => it.aggregate)
437
+ ? [...view.items.filter((it) => it.aggregate), ...view.items.filter((it) => !it.aggregate)]
438
+ : view.items;
439
+ for (const it of ordered) {
440
+ const span = Math.max(1, it.t1 - it.t0);
441
+ const a = Math.max(0, Math.min(1, (now - it.t0) / span));
442
+ const e = easeOutCubic(a);
443
+ const x = it.sx + (it.tx - it.sx) * e;
444
+ const y = it.sy + (it.ty - it.sy) * e;
445
+ // A cell that just arrived fades in AT its real size (the old rule, kept); a cell
446
+ // that merely moved keeps its size and moves. Departing cells shrink away to make
447
+ // the vacated gap visible.
448
+ const sizeE = it.sw > 0 && it.sh > 0 ? 1 : e;
449
+ // Arrival fade is done through the COLOUR's alpha, never ctx.globalAlpha: the
450
+ // software rasteriser ignores globalAlpha for texture fills (measured headless:
451
+ // globalAlpha 0.06 painted the crowd at full opacity). A cell that just arrived
452
+ // fades in at its real size via a softened fill; everything else is opaque.
453
+ const arrived = it.sw > 0 && it.sh > 0;
454
+ const rw = Math.max(1, it.sw + (it.tw - it.sw) * (it.departing ? e : sizeE));
455
+ const rh = Math.max(1, it.sh + (it.th - it.sh) * (it.departing ? e : sizeE));
456
+ const cellAlpha = arrived ? 1 : Math.max(0.06, e);
457
+ if (it.aggregate) {
458
+ // The tail cell — hundreds or thousands of floor-rate transactions in one rect
459
+ // (measured 2026-09-10: 76% of a real block's area, and 88% of the mempool's).
460
+ // It keeps its EXACT area (the area law is this chart's only obligation) but it
461
+ // must not read as one flat slab. The texture is not decoration — it is the
462
+ // crowd itself: one micro-tile per aggregated transaction, laid out inside the
463
+ // rect at the exact scale (tile side = sqrt(rect area / count)), so the density
464
+ // you see IS the count, and the count label says it in words. The base colour
465
+ // stays the aggregate's own feerate.
466
+ const base = rateColor(it.rate);
467
+ ctx.fillStyle = base;
468
+ ctx.fillRect(x, y, rw, rh);
469
+ const n = Number(it.aggregate) || 0;
470
+ if (n > 0 && rw > 16 && rh > 16) {
471
+ // The crowd, painted as a REPEAT PATTERN of the small tile — never as a blit of
472
+ // a large bitmap (that is what dropped the canvas from the GPU and blanked the
473
+ // view; see crowdLayer). A pattern fill is one small source regardless of rect
474
+ // size. During a tween the pattern cell stays screen-sized while the rect
475
+ // settles, which reads correctly: the crowd is not moving, its border is.
476
+ const crowd = crowdLayer(n, it.tw, it.th, base);
477
+ // Patterns are host-context objects, so the pattern lives on the MAP's context,
478
+ // cached there by tile identity — a WeakMap of ctx -> (tile -> pattern). If the
479
+ // host's createPattern is the measured-null kind, the entry stays null and every
480
+ // paint takes the direct-tiles path; nothing ever blits a big source.
481
+ const pat = crowd ? crowdPatternFor(ctx, crowd.tile) : null;
482
+ if (pat) {
483
+ ctx.fillStyle = pat;
484
+ ctx.fillRect(x, y, rw, rh);
485
+ } else {
486
+ // No pattern host: draw the tiles directly. crowdTiles also covers the
487
+ // sub-pixel case with the 7x7 micro-grid.
488
+ crowdTiles(ctx, it, x, y, rw, rh, n);
489
+ }
490
+ }
491
+ if (rw >= 4 && rh >= 4) {
492
+ ctx.fillStyle = 'rgba(248, 252, 255, 0.22)';
493
+ ctx.fillRect(x, y, rw, 1);
494
+ ctx.fillRect(x, y, 1, rh);
495
+ }
496
+ } else {
497
+ // Arrival fade through the fill colour itself (see cellAlpha): the software
498
+ // rasteriser is unreliable with ctx.globalAlpha, but rgba() is universal.
499
+ const base = rateColor(it.rate);
500
+ ctx.fillStyle = cellAlpha < 1
501
+ ? `rgba(${parseInt(base.slice(1, 3), 16)},${parseInt(base.slice(3, 5), 16)},${parseInt(base.slice(5, 7), 16)},${cellAlpha.toFixed(3)})`
502
+ : base;
503
+ ctx.fillRect(x, y, rw, rh);
504
+ // One light bevel per cell: this is what makes each transaction's SHAPE readable —
505
+ // without it a 400-cell map is a colour field, with it every rectangle has edges.
506
+ if (rw >= 4 && rh >= 4) {
507
+ ctx.fillStyle = 'rgba(248, 252, 255, 0.30)';
508
+ ctx.fillRect(x, y, rw, 1);
509
+ ctx.fillRect(x, y, 1, rh);
510
+ }
511
+ }
512
+ // The churn ring: a cell that changed at the last snapshot (arrived, or its feerate
513
+ // moved) wears a fading white frame. This is the honest "something happened HERE"
514
+ // channel — it fires on real deltas only, never on decoration. The ring draws at
515
+ // any cell size (a 2px cell can still fire, and its frame is the only part of it
516
+ // that can read), so the frame is what the eye catches, not the fill.
517
+ if (it.pulse > 0) {
518
+ const age = (now - it.pulse) / PULSE_MS;
519
+ if (age >= 1) it.pulse = 0;
520
+ else {
521
+ ctx.strokeStyle = `rgba(255, 255, 255, ${(0.95 * (1 - age)).toFixed(3)})`;
522
+ ctx.lineWidth = 1 + 2 * (1 - age);
523
+ ctx.strokeRect(x + 0.5, y + 0.5, Math.max(1, rw - 1), Math.max(1, rh - 1));
524
+ }
525
+ }
526
+ if (it.aggregate && rw > 24 && rh > 12) {
527
+ // The count fits inside most aggregate rects; when the rect is smaller than the
528
+ // count needs, fall back to the compact rate/vbytes pair — the hover carries the
529
+ // full identity either way.
530
+ // Whether the count fits is a measurement, not an assumption: dom-stub's context
531
+ // answers measureText with `() => {}`, so the result can be undefined on a host
532
+ // that has no text engine. No measurement, no claim — fall back to the compact
533
+ // label rather than dereference a width nobody gave us.
534
+ const m = ctx.measureText(`${it.aggregate} more transactions`);
535
+ const wide = Number.isFinite(m?.width) ? m.width : Infinity;
536
+ if (rw > wide + 16) {
537
+ ctx.fillStyle = 'rgba(210, 222, 240, 0.9)';
538
+ ctx.font = 'bold 11px ui-monospace, monospace';
539
+ ctx.textAlign = 'left';
540
+ ctx.textBaseline = 'middle';
541
+ ctx.fillText(`${it.aggregate.toLocaleString('en-US')} more transactions`, x + 8, y + 12);
542
+ ctx.font = '9px ui-monospace, monospace';
543
+ ctx.fillStyle = 'rgba(170, 185, 205, 0.85)';
544
+ ctx.fillText(`mean ${it.rate} sat/vB · ${Math.round(it.vbytes / 1000)}k vB`, x + 8, y + 26);
545
+ } else {
546
+ ctx.fillStyle = '#04060ac9';
547
+ ctx.font = '9px ui-monospace, monospace';
548
+ ctx.textAlign = 'left';
549
+ ctx.textBaseline = 'middle';
550
+ ctx.fillText(`${it.aggregate} tx`, x + 3, y + Math.min(rh / 2, 8));
551
+ }
552
+ } else if (rw > 26 && rh > 12) {
553
+ ctx.fillStyle = '#04060ac9';
554
+ ctx.font = '9px ui-monospace, monospace';
555
+ ctx.textAlign = 'left';
556
+ ctx.textBaseline = 'middle';
557
+ ctx.fillText(`${it.rate}`, x + 3, y + Math.min(rh / 2, 8));
558
+ if (rh > 26) ctx.fillText(`${Math.round(it.vbytes / 100) / 10}k`, x + 3, y + 19);
559
+ }
560
+ }
561
+
562
+ // --- the unfilled part of the block -----------------------------------------
563
+ if (unused > 0.005 && cut < w - 0.5) {
564
+ // The part of the block the mempool could not fill. Blank, not scaled away: a block
565
+ // that is 16% full should look 16% full. The hatch reads as capacity-with-nothing-in-
566
+ // it; the glow at the frontier reads as "assembly is happening right here".
567
+ const band = Math.max(0, w - cut);
568
+ ctx.fillStyle = '#0d1015';
569
+ ctx.fillRect(cut, 0, band, h);
570
+ const hp = hatchPattern(ctx);
571
+ if (hp) { ctx.fillStyle = hp; ctx.fillRect(cut, 0, band, h); }
572
+ const gl = gradient(ctx, () => ctx.createLinearGradient(cut, 0, cut + Math.min(56, band), 0),
573
+ [[0, 'rgba(0, 200, 255, 0.16)'], [1, 'rgba(0, 200, 255, 0)']], 'rgba(0, 200, 255, 0.07)');
574
+ ctx.fillStyle = gl;
575
+ ctx.fillRect(cut, 0, Math.min(56, band), h);
576
+ ctx.fillStyle = COL.textDim;
577
+ ctx.font = '10px ui-monospace, monospace';
578
+ ctx.textAlign = 'left';
579
+ ctx.fillText(label || `${Math.round(unused * 100)}% unfilled`, cut + 8, h / 2);
580
+ ctx.fillText('nothing above the cut was left to put here', cut + 8, h / 2 + 14);
581
+ }
582
+
583
+ // --- the cut line --------------------------------------------------------------
584
+ if (cut > 0 && geom.total > 0) {
585
+ // Where the selection ends. Right of this line is space, not money. It MARCHES:
586
+ // a static dash could be mistaken for a static block, and the one thing this panel
587
+ // is for is watching the assembly move. The march is tied to wall-clock time, so it
588
+ // has a real cadence and no animation of its own pretending to be data.
589
+ const dashOff = -(Math.floor(now / 240) * 3) % 24;
590
+ ctx.strokeStyle = '#ffffffe6';
591
+ ctx.setLineDash([3, 3]);
592
+ ctx.lineDashOffset = dashOff;
593
+ ctx.lineWidth = 1.5;
594
+ ctx.shadowColor = 'rgba(255, 255, 255, 0.55)';
595
+ ctx.shadowBlur = 6;
596
+ ctx.beginPath();
597
+ ctx.moveTo(cut, 0);
598
+ ctx.lineTo(cut, h);
599
+ ctx.stroke();
600
+ ctx.shadowBlur = 0;
601
+ ctx.setLineDash([]);
602
+ ctx.lineDashOffset = 0;
603
+ ctx.fillStyle = '#ffffffd9';
604
+ ctx.font = '9px ui-monospace, monospace';
605
+ // Label what the line actually is. It sits where the SELECTION ends; the canvas edge
606
+ // is the capacity. Calling this 'block capacity' is a claim the geometry does not
607
+ // support, and on a pool smaller than a block it is simply false.
608
+ ctx.fillText(labelLine || (unused <= 0.005 ? 'everything fits in one block' : 'selection ends'), Math.max(2, Math.min(w - 132, cut - 82)), 7);
609
+ }
610
+
611
+ // --- the live legend strip --------------------------------------------------------
612
+ // The exact ramp the cells are painted with, so "brighter" is never a guess.
613
+ if (w >= 220 && h >= 110) {
614
+ const lw = Math.min(132, w - 24);
615
+ const lx = 8;
616
+ const ly = h - 15;
617
+ const rampStops = [];
618
+ // The ramp's own alpha rides on the stop colours, not on ctx.globalAlpha (the
619
+ // software rasteriser is inconsistent with texture alpha; the rgba() is universal).
620
+ for (let g = 0; g <= 16; g++) {
621
+ const hex = rateColor(Math.pow(g / 16, 1.7) * 100);
622
+ rampStops.push([g / 16, `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},0.92)`]);
623
+ }
624
+ const grad = gradient(ctx, () => ctx.createLinearGradient(lx, 0, lx + lw, 0), rampStops, 'rgba(0,186,255,0.92)');
625
+ ctx.fillStyle = grad;
626
+ ctx.fillRect(lx, ly, lw, 7);
627
+ ctx.fillStyle = 'rgba(190, 203, 220, 0.9)';
628
+ ctx.font = '8px ui-monospace, monospace';
629
+ ctx.textAlign = 'left';
630
+ ctx.fillText('0', lx, ly - 4);
631
+ ctx.fillText('100+ sat/vB', lx + lw - 50, ly - 4);
632
+ }
633
+
634
+ // --- the hover crosshair -----------------------------------------------------------
635
+ if (view.hover) {
636
+ const r = view.hover;
637
+ ctx.strokeStyle = '#ffffffcc';
638
+ ctx.lineWidth = 1;
639
+ ctx.setLineDash([4, 3]);
640
+ ctx.strokeRect(r.x - 1.5, r.y - 1.5, r.w + 3, r.h + 3);
641
+ ctx.setLineDash([]);
642
+ }
643
+ }
644
+
645
+ /**
646
+ * The visualiser itself. `visual` is {cells, totalVbytes, tailCount} from the node's own
647
+ * template; `economy` supplies where the block actually ran out.
648
+ */
649
+ export function blockTreemap(canvas, visual, economy, opts = {}) {
650
+ const cells = visual?.cells ?? [];
651
+ const total = visual?.totalVbytes || cells.reduce((n, c) => n + (c.vbytes || 0), 0);
652
+ const capacity = (opts.weightLimit ?? 4_000_000) / 4;
653
+ const used = Math.min(1, total / capacity);
654
+ return drawMap(canvas, cells, {
655
+ total,
656
+ // Cells occupy the left `used` fraction; the band to its right is the block space
657
+ // nothing was found to fill it with.
658
+ cutFrac: used,
659
+ unused: Math.max(0, 1 - used),
660
+ label: `${Math.round((1 - used) * 100)}% of the block unfilled`,
661
+ placeholder: visual?.unavailable
662
+ ? `no template: ${visual.unavailable}`
663
+ : 'no block template yet — this page asks the node for one on load and every 20 s while it is open',
664
+ });
665
+ }
666
+
667
+ /** Shared layout + chase + hover for both maps. */
668
+ function drawMap(canvas, cells, map) {
669
+ const reduce = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
670
+ paint(canvas, {
671
+ when: cells.length > 0,
672
+ placeholder: map.placeholder ?? 'nothing to draw yet',
673
+ draw: (c) => {
674
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
675
+ const w = c.clientWidth || 600;
676
+ const h = c.clientHeight || 220;
677
+ // RESIZE ONLY ON CHANGE. Assigning canvas.width — even to the value it already
678
+ // has — reallocates the backing store in some Chromium/SwiftShader combinations,
679
+ // and a surface reallocated mid-chase is the "view disappears after the first
680
+ // refresh" the operator reported: the first paint works, the next update
681
+ // reallocates, and the page shows the hole. Size is idempotent when guarded;
682
+ // unguarded, it is a teardown every frame. (charts.js prep() has guarded this the
683
+ // same way all along; the map was the one surface that didn't.)
684
+ const bw = Math.round(w * dpr);
685
+ const bh = Math.round(h * dpr);
686
+ if (c.width !== bw || c.height !== bh) {
687
+ c.width = bw;
688
+ c.height = bh;
689
+ }
690
+ const ctx = c.getContext('2d');
691
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
692
+ ctx.clearRect(0, 0, w, h);
693
+
694
+ const now = nowMs();
695
+ const total = map.total > 0 ? map.total : cells.reduce((n, x) => n + (x.vbytes || 0), 0);
696
+ const frac = Math.max(0.02, Math.min(1, map.cutFrac ?? 1));
697
+ // Cells are laid out across the WHOLE canvas; the cut then decides how much of
698
+ // them is allowed to show. The frontier easing between snapshot cuts is what makes
699
+ // the block visibly CREEP toward full — with a per-region relayout (the old way) a
700
+ // 65% block filled 100% of its region and the fill never moved.
701
+ const targetRects = layoutTreemap(cells, w, h);
702
+ const targetCut = map.unused != null ? w * Math.max(0.02, 1 - map.unused) : w * frac;
703
+ const geom = { w, h, total, unused: map.unused ?? 0, label: map.label, labelLine: map.labelLine };
704
+ const byKey = new Map(targetRects.map((r, i) => [keyOf(r, i), r]));
705
+
706
+ if (reduce) {
707
+ paintComposited(ctx, c, w, h, dpr, geom, {
708
+ now, cutNow: targetCut, hover: null,
709
+ items: targetRects.map((r) => ({
710
+ rate: r.rate, vbytes: r.vbytes, aggregate: r.aggregate,
711
+ sx: r.x, sy: r.y, sw: r.w, sh: r.h,
712
+ tx: r.x, ty: r.y, tw: r.w, th: r.h, t0: now, t1: now, pulse: 0,
713
+ })),
714
+ });
715
+ c.__rects = byKey;
716
+ c.__hasData = true;
717
+ return;
718
+ }
719
+
720
+ // The chase. Animated state lives ON THE CANVAS between snapshots; a new snapshot
721
+ // retargets it — each cell keeps where it is and starts easing toward where the
722
+ // block now says it belongs — it never restarts the loop from zero. Three laws,
723
+ // all learned the hard way on this canvas: never clear to a zero-size state, never
724
+ // restart the motion per snapshot (it left the chart mostly blank more often than
725
+ // whole), and the loop always ends on its own budget even if the host clock never
726
+ // advances (a throttled tab, or a test harness whose rAF runs inline).
727
+ const anim = c.__anim instanceof Map ? c.__anim : new Map();
728
+ const prevByKey = c.__prevByKey instanceof Map ? c.__prevByKey : new Map();
729
+ const prevSeen = c.__seen === true;
730
+
731
+ const items = [];
732
+ const seen = new Set();
733
+ let i = 0;
734
+ for (const [k, tr] of byKey) {
735
+ i++;
736
+ seen.add(k);
737
+ const was = anim.get(k);
738
+ const prev = prevByKey.get(k);
739
+ // Churn = arrived since the last frame, or its feerate moved. The ring fires on
740
+ // these two real events only. On a page's FIRST snapshot everything is new; the
741
+ // map is alive then too, but ringing 400 borders at once reads as a glitch, so
742
+ // the first paint pulses nothing.
743
+ const churned = prevSeen && (!prev || (prev.rate !== tr.rate));
744
+ items.push({
745
+ key: k, rate: tr.rate, vbytes: tr.vbytes, aggregate: tr.aggregate,
746
+ sx: was ? was.x : tr.x, sy: was ? was.y : tr.y,
747
+ sw: prev ? (was ? was.w : tr.w) : 0, sh: prev ? (was ? was.h : tr.h) : 0,
748
+ tx: tr.x, ty: tr.y, tw: tr.w, th: tr.h,
749
+ t0: now, t1: now + SETTLE_MS,
750
+ pulse: churned ? now : 0,
751
+ });
752
+ }
753
+ // Cells the block dropped leave from where they were and shrink away — the gap a
754
+ // departed transaction vacates is half of what a reshuffle is.
755
+ for (const [k, it] of anim) {
756
+ if (seen.has(k)) continue;
757
+ items.push({
758
+ key: k, rate: it.rate, vbytes: it.vbytes, aggregate: it.aggregate,
759
+ sx: it.x ?? it.tx, sy: it.y ?? it.ty, sw: it.w ?? it.tw, sh: it.h ?? it.th,
760
+ tx: it.tx, ty: it.ty, tw: 0, th: 0,
761
+ t0: now, t1: now + LEAVE_MS, pulse: 0, departing: true,
762
+ });
763
+ }
764
+
765
+ const view = {
766
+ now,
767
+ cutNow: prevSeen && Number.isFinite(c.__cutNow) ? c.__cutNow : targetCut,
768
+ targetCut,
769
+ hover: c.__view?.hover ?? null,
770
+ items,
771
+ geom,
772
+ };
773
+ c.__view = view;
774
+ const first = !prevSeen;
775
+
776
+ const step = () => {
777
+ if (c.__destroyed) { c.__raf = 0; return; }
778
+ const clock = nowMs();
779
+ view.now = clock;
780
+ // A stalled clock (hidden tab, an inline stub) must not spin the loop forever:
781
+ // a frame budget ends the run no matter what the clock does.
782
+ view.frames = (view.frames ?? 0) + 1;
783
+ const budgetOut = view.frames > 200;
784
+ if (budgetOut) { c.__parked = true; c.__raf = 0; }
785
+ // The frontier eases toward its target so the block visibly FILLS between
786
+ // snapshots (the node's template gains transactions with every refresh).
787
+ view.cutNow += (view.targetCut - view.cutNow) * EASE;
788
+ if (Math.abs(view.targetCut - view.cutNow) < 0.3) view.cutNow = view.targetCut;
789
+
790
+ // Keep the animation state honest BEFORE the frame is painted, so the frame on
791
+ // screen always matches the state the next snapshot will chase from — and so the
792
+ // final frame of a run (budget or settle) is the settled layout, never a half-
793
+ // chased one.
794
+ let moving = false;
795
+ for (const it of items) {
796
+ const done = budgetOut || clock >= it.t1;
797
+ if (!done) moving = true;
798
+ const e = done ? 1 : easeOutCubic(Math.max(0, Math.min(1, (clock - it.t0) / Math.max(1, it.t1 - it.t0))));
799
+ it.x = it.sx + (it.tx - it.sx) * e;
800
+ it.y = it.sy + (it.ty - it.sy) * e;
801
+ if (it.departing) { if (!done) anim.set(it.key, it); else anim.delete(it.key); }
802
+ else anim.set(it.key, it);
803
+ }
804
+ c.__anim = anim;
805
+ c.__cutNow = view.cutNow;
806
+
807
+ paintComposited(ctx, c, w, h, dpr, geom, view);
808
+ if (budgetOut) return;
809
+
810
+ // Termination. When the picture has settled, the ONLY thing left in motion is
811
+ // the marching cut line — a decoration, and no frame is justified at 60 fps for
812
+ // it. The loop parks itself: the settled map stays on screen, the next snapshot
813
+ // unparks it, and a hidden tab that never receives a snapshot never burns frames.
814
+ // A stalled clock never spins either: the frame budget above ends the run no
815
+ // matter what the clock does.
816
+ if (!moving && view.cutNow === view.targetCut) {
817
+ c.__parked = true;
818
+ c.__raf = 0;
819
+ return;
820
+ }
821
+ c.__raf = requestAnimationFrame(step);
822
+ };
823
+
824
+ // First frame drawn synchronously — never deferred to a callback another canvas's
825
+ // render could cancel (see the header note on shared handles). Then, once the
826
+ // settled map is on screen, kick off the chase so the snapshot that produced THIS
827
+ // frame is the one that visibly arrives.
828
+ const settledView = {
829
+ now: now + SETTLE_MS + 1,
830
+ cutNow: targetCut,
831
+ hover: view.hover,
832
+ items: items.map((it) => ({ ...it, pulse: 0, sw: it.tw, sh: it.th, sx: it.tx, sy: it.ty, t1: now })),
833
+ };
834
+ paintComposited(ctx, c, w, h, dpr, geom, settledView);
835
+ for (const it of items) if (!it.departing) anim.set(it.key, it);
836
+ c.__anim = anim;
837
+ c.__prevByKey = byKey;
838
+ c.__seen = true;
839
+ c.__cutNow = first ? targetCut : c.__cutNow;
840
+
841
+ if (first) {
842
+ // The page's first paint: nothing to chase yet, but the loop still has to exist
843
+ // so the cut line marches. Run it; it parks itself once the clock settles.
844
+ c.__raf = requestAnimationFrame(step);
845
+ } else {
846
+ // Unpark: a snapshot arrived while the loop was parked (or in flight); retarget
847
+ // and resume. A loop that is mid-flight continues with its new `items` naturally.
848
+ if (!c.__raf || c.__parked) { c.__parked = false; view.frames = 0; c.__raf = requestAnimationFrame(step); }
849
+ }
850
+
851
+ c.__rects = byKey;
852
+ c.__geom = geom;
853
+ c.__hasData = true;
854
+ bindTreemapHover(canvas);
855
+ },
856
+ });
857
+ }
858
+
859
+ function bindTreemapHover(canvas) {
860
+ if (canvas.__gogglesBound) return;
861
+ canvas.__gogglesBound = true;
862
+ const show = (ev) => {
863
+ const map = canvas.__rects;
864
+ if (!map) return;
865
+ const r = canvas.getBoundingClientRect();
866
+ const mx = ev.clientX - r.left;
867
+ const my = ev.clientY - r.top;
868
+ let hit = null;
869
+ for (const rect of map.values()) {
870
+ if (mx >= rect.x && mx <= rect.x + rect.w && my >= rect.y && my <= rect.y + rect.h) { hit = rect; break; }
871
+ }
872
+ if (canvas.__view) canvas.__view.hover = hit ?? null;
873
+ const el = canvas.parentElement?.querySelector('.goggles-tip');
874
+ if (!hit) { el?.classList.add('hidden'); return; }
875
+ if (!el) return;
876
+ el.classList.remove('hidden');
877
+ el.textContent = hit.aggregate
878
+ ? `${hit.aggregate} more transactions · ${Math.round(hit.vbytes / 1000)}k vB · weighted mean ${hit.rate} sat/vB (aggregated so the block stays whole)`
879
+ : `${(hit.txid ?? '').slice(0, 16)}… · ${hit.vbytes} vB · ${hit.rate} sat/vB`;
880
+ };
881
+ canvas.addEventListener('mousemove', show);
882
+ canvas.addEventListener('mouseleave', () => {
883
+ if (canvas.__view) canvas.__view.hover = null;
884
+ canvas.parentElement?.querySelector('.goggles-tip')?.classList.add('hidden');
885
+ });
886
+ }