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