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.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -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
|
+
}
|