blockyard 0.0.1 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,405 @@
1
+ // THE EXPLORER (operator, 2026-09-11: "we need to completely rip off the mempool space block and
2
+ // transaction explorers. I want us to be a complete superior replacement for mempool space"; then,
3
+ // with our page beside theirs: "Surely we can make our display look at least as good as mempool.
4
+ // Do better. Make it beautiful").
5
+ //
6
+ // Our own pages over this node's RPC (server/http/explorer.js); no mempool.space code (AGPL --
7
+ // decided 2026-09-11), their layout ideas in our own light: a big title with the id beside it and
8
+ // a status pill; two panels of roomy rows; solid feature badges; dollar figures in green (a spot
9
+ // price the server attaches, when it has one within 1.5 s); a flow whose bands curve into one
10
+ // narrow glowing trunk; inputs and outputs as clean columns with arrow buttons to where the coins
11
+ // came from and went. Every page is a link, because the route lives in the URL:
12
+ // #explorer search + the latest blocks
13
+ // #explorer/block/<height|hash>[/<page>] header, statistics, the transactions a page at a time
14
+ // #explorer/tx/<txid> fee, rate, features, flow, inputs, outputs, spent-by
15
+ // #explorer/address/<address>[/<page>] balance, received, the transactions newest first
16
+ // The page renderers are pure (data in, HTML out) so the tests can hold them; renderExplorer
17
+ // fetches once per route and repaints only when what it would draw changed, so the once-a-second
18
+ // frame never resets a half-typed search. No inline styles anywhere (the CSP forbids them).
19
+
20
+ const X = { key: null, data: null, error: null, loading: false, shown: null, bound: false };
21
+
22
+ export function parseRoute(r) {
23
+ const [kind, id, page] = String(r ?? '').split('/').filter((x) => x !== '').map((x) => decodeURIComponent(x));
24
+ if ((kind === 'block' || kind === 'address') && id) return { kind, id, page: Math.max(0, Math.floor(Number(page) || 0)) };
25
+ if (kind === 'tx' && id) return { kind, id };
26
+ return { kind: 'home' };
27
+ }
28
+
29
+ const enc = encodeURIComponent;
30
+ const mid = (s, a = 14, b = 8) => (String(s).length > a + b + 1 ? `${String(s).slice(0, a)}…${String(s).slice(-b)}` : String(s));
31
+ const blockLink = (id, label) => `<a href="#explorer/block/${enc(id)}">${label}</a>`;
32
+ const txLink = (fmt, txid, label = null) => `<a class="xmono" href="#explorer/tx/${enc(txid)}">${label ?? fmt.esc(fmt.hash(txid, 8))}</a>`;
33
+ const addrLink = (fmt, a, short = false) => `<a class="xmono xaddr" href="#explorer/address/${enc(a)}" title="${fmt.esc(a)}">${fmt.esc(short ? mid(a) : a)}</a>`;
34
+ const sats = (fmt, s) => (s == null ? '–' : `${fmt.num(s)} <small>sats</small>`);
35
+ const btcv = (fmt, s) => (s == null ? '–' : `${fmt.btc(s)} <small>BTC</small>`);
36
+ const usdOf = (s, usd) => (s == null || !usd ? null : (s / 1e8) * usd);
37
+ const dollars = (v) => (v >= 100 ? Math.round(v).toLocaleString('en-US') : v.toFixed(2));
38
+ const usdFmt = (v) => (v == null ? '' : `<b class="xusd">$${dollars(v)}</b>`);
39
+ const rate = (v) => (v == null ? '–' : `${v >= 10 ? Math.round(v) : v.toFixed(2)} <small>sat/vB</small>`);
40
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
41
+ const FEAT = { segwit: 'SegWit', taproot: 'Taproot', rbf: 'RBF', consolidation: 'Consolidation', op_return: 'OP_RETURN' };
42
+ const feats = (list) => (list ?? []).map((f) => `<span class="xf xf-${f}">${FEAT[f] ?? f}</span>`).join('');
43
+ const when = (fmt, t) => (t ? `${new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 16)} <small>UTC</small><span class="xago">${fmt.ago(t * 1000)}</span>` : '–');
44
+ const panel = (rows) => `<div class="xpanel">${rows.filter(Boolean).map(([k, v]) => `<div class="xrow"><span class="xk">${k}</span><span class="xv">${v}</span></div>`).join('')}</div>`;
45
+ const cards = (left, right) => `<div class="xcards">${panel(left)}${panel(right)}</div>`;
46
+ const ICON_COPY = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.6"/><path d="M11 3.5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h.5"/></svg>';
47
+ const ARROW = '<svg class="xico" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="9.5"/><path d="M5.5 10h8M10 6l4 4-4 4"/></svg>';
48
+ const copyBtn = (fmt, v) => `<button type="button" class="xcopy" data-copy="${fmt.esc(v)}" title="copy" aria-label="copy">${ICON_COPY}</button>`;
49
+ const hero = (title, sub, pill = '') => `<div class="xhero"><h1>${title}</h1>${sub}<span class="xgrow"></span>${pill}</div>`;
50
+ const section = (title, extra = '') => `<div class="xsect"><h2>${title}</h2>${extra}</div>`;
51
+ const confPill = (fmt, n) => (n > 0 ? `<span class="xpill ok">${plural(fmt.num(n), 'confirmation').replace(/^(\S+) confirmations?$/, (m) => m)}</span>` : n < 0 ? '<span class="xpill warn">Stale — off the best chain</span>' : '<span class="xpill warn">Unconfirmed</span>');
52
+ const pager = (base, page, pages) => (pages > 1 ? `<div class="xpager">
53
+ ${page > 0 ? `<a href="#explorer/${base}/${page - 1}">‹ newer</a>` : '<span class="xdim">‹ newer</span>'}
54
+ <span class="xdim">page ${page + 1} of ${pages}</span>
55
+ ${page + 1 < pages ? `<a href="#explorer/${base}/${page + 1}">older ›</a>` : '<span class="xdim">older ›</span>'}</div>` : '');
56
+
57
+ export function blockHtml(d, fmt) {
58
+ const b = d.block, st = d.stats ?? {}, usd = d.usd ?? null;
59
+ const pct = Array.isArray(st.feerate_percentiles) ? st.feerate_percentiles : null;
60
+ const prev = b.height > 0 ? `<a class="xnav" href="#explorer/block/${b.height - 1}" title="previous block">‹</a>` : '';
61
+ const next = d.tip != null && b.height < d.tip ? `<a class="xnav" href="#explorer/block/${b.height + 1}" title="next block">›</a>` : '';
62
+ const reward = st.subsidy != null && st.totalfee != null ? st.subsidy + st.totalfee : null;
63
+ const left = [
64
+ ['Hash', `<span class="xid sm">${fmt.esc(mid(b.hash, 18, 18))}</span>${copyBtn(fmt, b.hash)}`],
65
+ ['Timestamp', when(fmt, b.time)],
66
+ ['Size', `${fmt.bytes(b.size)} <span class="xdim">· ${b.weight != null ? `${(b.weight / 1e6).toFixed(2)} MWU` : '–'}</span>`],
67
+ ['Transactions', fmt.num(b.nTx)],
68
+ ['Miner', fmt.esc(d.pool?.label ?? d.pool?.tag ?? '–')],
69
+ ['Merkle root', `<span class="xmono xdim">${fmt.esc(mid(b.merkleroot ?? '–', 12, 12))}</span>`],
70
+ ];
71
+ const right = [
72
+ ['Median fee rate', pct ? rate(pct[2]) : '–'],
73
+ ['Fee span', st.minfeerate != null ? `${st.minfeerate} – ${st.maxfeerate} <small>sat/vB</small>` : '–'],
74
+ ['Total fees', `${btcv(fmt, st.totalfee)}${usdFmt(usdOf(st.totalfee, usd))}`],
75
+ ['Subsidy + fees', `${btcv(fmt, reward)}${usdFmt(usdOf(reward, usd))}`],
76
+ ['Difficulty', `${b.difficulty != null ? fmt.short(b.difficulty) : '–'} <span class="xdim">· nonce ${b.nonce ?? '–'}</span>`],
77
+ ['Version · bits', `<span class="xmono">${fmt.esc(b.versionHex ?? String(b.version ?? '–'))}</span> <span class="xdim">· ${fmt.esc(b.bits ?? '–')}</span>`],
78
+ ];
79
+ const txRows = d.txs.map((t) => (t.missing
80
+ ? `<tr><td>${txLink(fmt, t.txid)}</td><td colspan="6" class="xdim">not decoded</td></tr>`
81
+ : `<tr><td>${txLink(fmt, t.txid)}</td>
82
+ <td class="r">${t.coinbase ? '<span class="xf xf-cb">coinbase</span>' : rate(t.feerate)}</td>
83
+ <td class="r">${t.fee == null ? '–' : sats(fmt, t.fee)}</td>
84
+ <td class="r">${fmt.num(t.vsize)} <small>vB</small></td>
85
+ <td class="r">${btcv(fmt, t.outSat)}</td>
86
+ <td class="r">${t.inCount} → ${t.outCount}</td>
87
+ <td>${feats(t.features)}</td></tr>`)).join('');
88
+ return `${hero(`Block ${prev}<span class="xh">${fmt.num(b.height)}</span>${next}`, '', confPill(fmt, b.confirmations))}
89
+ ${cards(left, right)}
90
+ ${section('Transactions', `<span class="xdim">${fmt.num(b.nTx)}</span>`)}
91
+ <div class="xtablecard"><table class="xtable"><thead><tr><th>transaction</th><th class="r">fee rate</th><th class="r">fee</th><th class="r">size</th><th class="r">value out</th><th class="r">in → out</th><th>features</th></tr></thead><tbody>${txRows}</tbody></table></div>
92
+ ${pager(`block/${b.height}`, d.page, d.pages)}`;
93
+ }
94
+
95
+ export function txHtml(d, fmt) {
96
+ const t = d.tx, usd = d.usd ?? null;
97
+ const inBlock = t.confirmations > 0
98
+ ? (t.height != null ? blockLink(t.height, fmt.num(t.height)) : blockLink(t.blockhash, fmt.esc(fmt.hash(t.blockhash, 8))))
99
+ : null;
100
+ const left = [
101
+ ['Status', inBlock ? `Confirmed in block ${inBlock}` : 'In the mempool'],
102
+ ['Timestamp', when(fmt, t.time)],
103
+ ['Features', feats(t.features) || '<span class="xdim">none</span>'],
104
+ ['Size', `${fmt.num(t.vsize)} <small>vB</small> <span class="xdim">· ${fmt.num(t.size)} B · ${fmt.num(t.weight)} WU</span>`],
105
+ ];
106
+ const right = t.coinbase ? [
107
+ ['Block reward', `${btcv(fmt, t.outSat)}${usdFmt(usdOf(t.outSat, usd))}`],
108
+ ['Fee', '<span class="xdim">none — a coinbase pays the miner</span>'],
109
+ ['Inputs → outputs', `${fmt.num(t.vin.length)} → ${fmt.num(t.vout.length)}`],
110
+ ['Version · locktime', `${t.version ?? '–'} · ${t.locktime ?? 0}`],
111
+ ] : [
112
+ ['Fee', `${sats(fmt, t.fee)}${usdFmt(usdOf(t.fee, usd))}`],
113
+ ['Fee rate', rate(t.feerate)],
114
+ ['Total in → out', `${btcv(fmt, t.inSat)} <span class="xdim">→</span> ${btcv(fmt, t.outSat)}`],
115
+ ['Version · locktime', `${t.version ?? '–'} · ${t.locktime ?? 0}`],
116
+ ];
117
+ const ins = t.vin.map((v) => (v.coinbase
118
+ ? `<div class="xitem"><span class="xico-wrap cb" title="new coins">${ARROW}</span><span class="xlabel"><span class="xf xf-cb">coinbase</span> new coins</span><span class="xamt">${btcv(fmt, t.outSat)}</span></div>`
119
+ : `<div class="xitem"><a class="xico-wrap in" href="#explorer/tx/${enc(v.txid)}" title="spends ${fmt.esc(v.txid)}:${v.vout}">${ARROW}</a>`
120
+ + `<span class="xlabel">${v.address ? addrLink(fmt, v.address, true) : `<span class="xdim">${fmt.esc(v.type ?? 'unknown script')}</span>`}</span>`
121
+ + `<span class="xamt">${btcv(fmt, v.value)}</span></div>`)).join('');
122
+ const shown = Math.min(t.vout.length, d.outputsShown ?? t.vout.length);
123
+ const outs = t.vout.slice(0, shown).map((o) => '<div class="xitem">'
124
+ + `<span class="xlabel">${o.address ? addrLink(fmt, o.address, true) : `<span class="xdim">${o.type === 'nulldata' ? 'OP_RETURN · unspendable' : fmt.esc(o.type ?? '–')}</span>`}</span>`
125
+ + `<span class="xamt">${btcv(fmt, o.value)}</span>`
126
+ + (o.spentBy ? `<a class="xico-wrap out xspent" href="#explorer/tx/${enc(o.spentBy.txid)}" title="spent by ${fmt.esc(o.spentBy.txid)}">${ARROW}</a>`
127
+ : o.type === 'nulldata' ? '<span class="xico-wrap none" title="unspendable"></span>'
128
+ : '<span class="xico-wrap unspent" title="unspent"><i></i></span>')
129
+ + '</div>').join('');
130
+ const totUsd = usdOf(t.outSat, usd);
131
+ return `${hero('Transaction', `<span class="xid">${fmt.esc(t.txid)}</span>${copyBtn(fmt, t.txid)}`, confPill(fmt, t.confirmations))}
132
+ ${cards(left, right)}
133
+ ${section('Flow', `<span class="xdim">${plural(t.vin.length, 'input')} → ${plural(t.vout.length, 'output')}</span>`)}
134
+ <div class="xflowcard">${flowSvg(t, fmt)}</div>
135
+ ${section('Inputs &amp; Outputs')}
136
+ <div class="xiocard">
137
+ <div class="xio2">
138
+ <div class="xiocol"><div class="xiohead">Inputs <span>${fmt.num(t.vin.length)}</span></div>${ins}</div>
139
+ <div class="xiocol"><div class="xiohead">Outputs <span>${fmt.num(t.vout.length)}</span></div>${outs}${shown < t.vout.length ? `<div class="xdim xmore">the first ${fmt.num(shown)} of ${fmt.num(t.vout.length)}</div>` : ''}</div>
140
+ </div>
141
+ <div class="xtotals"><span class="xtot">${t.coinbase ? 'a coinbase: new coins' : `fee ${sats(fmt, t.fee)}`}</span><span class="xtotpill">${btcv(fmt, t.outSat)}${totUsd != null ? `<span class="xtotusd">$${dollars(totUsd)}</span>` : ''}</span></div>
142
+ </div>`;
143
+ }
144
+
145
+ export function addressHtml(d, fmt) {
146
+ const bal = d.balance ?? {}, usd = d.usd ?? null;
147
+ const sent = bal.received != null && bal.balance != null ? bal.received - bal.balance : null;
148
+ // WHAT THIS NODE CANNOT ANSWER, SAID ONCE AND PLAINLY. Core has no address index -- measured on
149
+ // both configured nodes, getaddressbalance and getaddresstxids return "Method not found" -- so
150
+ // balance, history and totals have no source here. Printing the raw refusal where a balance
151
+ // belongs, or a count of 0 where nothing was counted, both read as "this address is unused".
152
+ const noIndex = d.indexed === false;
153
+ const unknown = '<span class="xdim">not indexed</span>';
154
+ // THE LOCAL INDEX (server/chain/index/) answers history and balance but keeps no UTXO list, and it
155
+ // covers the chain up to the block it was built at -- both said on the page, never implied away
156
+ const local = d.source === 'local-index';
157
+ const left = [
158
+ ['Type', fmt.esc(d.type ?? '–')],
159
+ ['Transactions', noIndex ? unknown : fmt.num(d.txCount)],
160
+ ['Unspent outputs', bal.utxos != null ? fmt.num(bal.utxos) : local ? `<span class="xdim">${fmt.esc(d.utxoNote ?? 'not tracked')}</span>` : unknown],
161
+ ];
162
+ const right = [
163
+ ['Balance', bal.balance != null ? `${btcv(fmt, bal.balance)}${usdFmt(usdOf(bal.balance, usd))}` : unknown],
164
+ ['Total received', bal.received != null ? btcv(fmt, bal.received) : unknown],
165
+ ['Total sent', sent != null ? btcv(fmt, sent) : unknown],
166
+ ];
167
+ const txRows = d.txs.map((t) => (t.missing
168
+ ? `<tr><td>${txLink(fmt, t.txid)}</td><td colspan="3" class="xdim">not decoded</td></tr>`
169
+ : `<tr><td>${txLink(fmt, t.txid)}</td>
170
+ <td>${t.height != null ? blockLink(t.height, fmt.num(t.height)) : '<span class="xdim">mempool</span>'}</td>
171
+ <td class="r ${t.delta > 0 ? 'xpos' : t.delta < 0 ? 'xneg' : ''}">${t.delta > 0 ? '+' : ''}${fmt.btc(t.delta)} <small>BTC</small></td>
172
+ <td class="r">${t.coinbase ? '<span class="xf xf-cb">coinbase</span>' : rate(t.feerate)}</td></tr>`)).join('');
173
+ return `${hero('Address', `<span class="xid">${fmt.esc(d.address)}</span>${copyBtn(fmt, d.address)}`)}
174
+ ${cards(left, right)}
175
+ ${section('Transactions', '<span class="xdim">newest first</span>')}
176
+ ${local && d.index.stale ? `<div class="caveat bad tiny"><b>The address index has stopped following the chain.</b> ${fmt.esc(d.index.stale)}</div>` : ''}
177
+ ${local && d.index.postTip ? `<div class="caveat tiny"><b>${fmt.num(d.index.postTip)} transaction${d.index.postTip === 1 ? '' : 's'} in blocks the node no longer has</b> are left out of the figures: the chain reorganised and the index is catching up.</div>` : ''}
178
+ ${local ? `<div class="note tiny">From the local address index, complete through block ${blockLink(d.index.tip, fmt.num(d.index.tip))}${d.index.behind ? ` — <b>${fmt.num(d.index.behind)} newer block${d.index.behind === 1 ? '' : 's'} not yet included</b>${d.index.following ? ', catching up' : ''}` : ''}. Unconfirmed transactions are not included. Received and sent add up each transaction's net effect on this address.</div>` : ''}
179
+ ${noIndex && d.indexBuilding ? `<div class="note tiny"><b>The address index is being built</b>: ${fmt.esc(d.indexBuilding.phase ?? 'starting')}${d.indexBuilding.total ? ` ${fmt.num(d.indexBuilding.done)} of ${fmt.num(d.indexBuilding.total)} (${Math.round((100 * d.indexBuilding.done) / d.indexBuilding.total)}%)` : ''}${d.indexBuilding.rows ? `, ${fmt.num(d.indexBuilding.rows)} rows so far` : ''}${d.indexBuilding.eta ? `, about ${fmt.esc(d.indexBuilding.eta)} left` : ''}. The Overview shows its progress, and a notification says when it is done; this page fills in then.</div>` : ''}
180
+ ${noIndex && !d.indexBuilding ? `<div class="note tiny">This node keeps <b>no address index</b>, so an address's balance and history have no source here — the address above is confirmed valid and nothing more is claimed. Bitcoin Core has never had <span class="mono">getaddressbalance</span> or <span class="mono">getaddresstxids</span>; they belong to insight-style forks. Transaction and block lookups are unaffected: those use the node's <span class="mono">txindex</span>, which is synced.</div>` : ''}
181
+ <div class="xtablecard"><table class="xtable"><thead><tr><th>transaction</th><th>block</th><th class="r">change</th><th class="r">fee rate</th></tr></thead><tbody>${txRows || `<tr><td colspan="4" class="xdim">${noIndex ? 'no address index on this node — history cannot be listed' : 'no transactions for this address'}</td></tr>`}</tbody></table></div>
182
+ ${noIndex ? '' : pager(`address/${enc(d.address)}`, d.page, d.pages)}
183
+ ${Array.isArray(d.utxos) ? `${section('Unspent outputs', `<span class="xdim">${fmt.num(d.utxos.length)} · as the node's UTXO set has them, less what the mempool already spends</span>`)}
184
+ <div class="xtablecard"><table class="xtable"><thead><tr><th>output</th><th>block</th><th class="r">value</th></tr></thead><tbody>${d.utxos.length
185
+ ? d.utxos.map((u) => `<tr><td>${txLink(fmt, u.txid)}<span class="xdim">:${u.n}</span></td><td>${u.height != null ? blockLink(u.height, fmt.num(u.height)) : '<span class="xdim">–</span>'}</td><td class="r">${fmt.btc(u.value)} <small>BTC</small></td></tr>`).join('')
186
+ : '<tr><td colspan="3" class="xdim">nothing unspent</td></tr>'}</tbody></table></div>` : ''}`;
187
+ }
188
+
189
+ // the fee band a block's median rate falls in, for its cube's colour (no inline styles: classes)
190
+ export const feeBand = (r) => (r == null ? 0 : r < 2 ? 1 : r < 4 ? 2 : r < 8 ? 3 : r < 16 ? 4 : r < 32 ? 5 : r < 64 ? 6 : 7);
191
+
192
+ export function homeHtml(s, fmt) {
193
+ const recent = s?.blocks?.recent ?? [];
194
+ const pools = new Map((s?.attribution?.recent ?? []).map((r) => [r.height, r.poolLabel ?? r.tagText ?? null]));
195
+ const med = (b) => (Array.isArray(b.p) ? b.p[2] : b.avgFeerate ?? null);
196
+ const cubes = recent.slice(0, 8).map((b) => `<a class="xblk f${feeBand(med(b))}" href="#explorer/block/${b.height}">
197
+ <span class="xblk-h">${fmt.num(b.height)}</span>
198
+ <span class="xblk-face"><b>~${med(b) ?? '–'} sat/vB</b>${Array.isArray(b.p) ? `<small>${b.p[0]} – ${b.p[4]} sat/vB</small>` : '<small></small>'}<em>${b.size != null ? fmt.bytes(b.size, 1) : '–'}</em><small>${fmt.num(b.txs)} transactions</small><small>${b.time ? fmt.ago(b.time * 1000) : ''}</small></span>
199
+ <span class="xblk-pool">${fmt.esc(pools.get(b.height) ?? '')}</span></a>`).join('');
200
+ const rows = recent.slice(0, 15).map((b) => `<tr><td>${blockLink(b.height, fmt.num(b.height))}</td>
201
+ <td>${b.time ? fmt.ago(b.time * 1000) : '–'}</td><td>${fmt.esc(pools.get(b.height) ?? '–')}</td>
202
+ <td class="r">${fmt.num(b.txs)}</td><td class="r">${b.weight != null ? `${(b.weight / 1e6).toFixed(2)} <small>MWU</small>` : '–'}</td>
203
+ <td class="r">${b.totalfee != null ? btcv(fmt, b.totalfee) : '–'}</td></tr>`).join('');
204
+ return `${hero('Latest blocks', '')}
205
+ <div class="xblocks">${cubes}</div>
206
+ <div class="xtablecard"><table class="xtable"><thead><tr><th>height</th><th>mined</th><th>pool</th><th class="r">transactions</th><th class="r">weight</th><th class="r">fees</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="xdim">no blocks seen yet</td></tr>'}</tbody></table></div>`;
207
+ }
208
+
209
+ export function errorHtml(d, fmt) {
210
+ return `<div class="caveat bad"><b>${fmt.esc(d?.error?.message ?? 'the node refused')}</b>${d?.hint ? `<div class="tiny">${fmt.esc(d.hint)}</div>` : ''}</div>`;
211
+ }
212
+
213
+ // Copy an id. The monitor is served over plain http on the LAN, where navigator.clipboard does
214
+ // not exist (it needs a secure context), so the old select-and-copy path is kept as the fallback.
215
+ function copyText(text) {
216
+ if (globalThis.navigator?.clipboard && globalThis.isSecureContext) return navigator.clipboard.writeText(text);
217
+ return new Promise((resolve, reject) => {
218
+ const ta = document.createElement('textarea');
219
+ ta.value = text;
220
+ ta.setAttribute('readonly', '');
221
+ ta.className = 'xclip';
222
+ document.body.appendChild(ta);
223
+ ta.select();
224
+ const ok = document.execCommand?.('copy');
225
+ ta.remove();
226
+ if (ok) resolve(); else reject(new Error('copy refused'));
227
+ });
228
+ }
229
+
230
+ function bindSearch(h) {
231
+ if (X.bound) return;
232
+ const form = document.getElementById('xSearch');
233
+ const input = document.getElementById('xQuery');
234
+ const body = document.getElementById('xBody');
235
+ if (!form || !input) return;
236
+ X.bound = true;
237
+ form.addEventListener('submit', async (e) => {
238
+ e.preventDefault();
239
+ const q = input.value.trim();
240
+ if (!q) return;
241
+ const node = h.state.node ? `&node=${enc(h.state.node)}` : '';
242
+ try {
243
+ const d = await h.api(`/api/x/search?q=${enc(q)}${node}`);
244
+ if (d.ok) { location.hash = `#explorer/${d.type}/${enc(d.id)}`; input.value = ''; }
245
+ else h.toast?.(d.error?.message ?? 'nothing found', 'bad');
246
+ } catch (err) { h.toast?.(err.message, 'bad'); }
247
+ });
248
+ body?.addEventListener('click', (e) => {
249
+ const btn = e.target.closest?.('[data-copy]');
250
+ if (!btn) return;
251
+ copyText(btn.dataset.copy)
252
+ .then(() => { btn.classList.add('done'); setTimeout(() => btn.classList.remove('done'), 1200); h.toast?.('copied', 'ok'); })
253
+ .catch(() => h.toast?.('the browser refused to copy', 'bad'));
254
+ });
255
+ }
256
+
257
+ async function load(route, key, h) {
258
+ X.loading = true; X.key = key; X.data = null; X.error = null;
259
+ const node = h.state.node ? `&node=${enc(h.state.node)}` : '';
260
+ const url = route.kind === 'block' ? `/api/x/block?id=${enc(route.id)}&page=${route.page}${node}`
261
+ : route.kind === 'tx' ? `/api/x/tx?txid=${enc(route.id)}${node}`
262
+ : `/api/x/address?addr=${enc(route.id)}&page=${route.page}${node}`;
263
+ try {
264
+ const d = await h.api(url);
265
+ if (X.key !== key) return; // a newer route took over while this one was out
266
+ if (d.ok) X.data = d; else X.error = d;
267
+ } catch (err) {
268
+ if (X.key === key) X.error = { error: { message: err.message } };
269
+ } finally {
270
+ if (X.key === key) X.loading = false;
271
+ h.render();
272
+ }
273
+ }
274
+
275
+ export function renderExplorer(s, state, h) {
276
+ const body = document.getElementById('xBody');
277
+ if (!body) return;
278
+ bindSearch(h);
279
+ const route = parseRoute(state.xroute);
280
+ const key = `${route.kind}|${route.id ?? ''}|${route.page ?? 0}|${state.node ?? ''}`;
281
+ if (route.kind !== 'home' && X.key !== key && !(X.loading && X.key === key)) load(route, key, h);
282
+ const fmt = h.fmt;
283
+ const html = route.kind === 'home' ? homeHtml(s, fmt)
284
+ : X.key === key && X.data ? (route.kind === 'block' ? blockHtml(X.data, fmt) : route.kind === 'tx' ? txHtml(X.data, fmt) : addressHtml(X.data, fmt))
285
+ : X.key === key && X.error ? errorHtml(X.error, fmt)
286
+ : '<div class="note">asking the node…</div>';
287
+ if (html !== X.shown) { body.innerHTML = html; X.shown = html; }
288
+ }
289
+
290
+ // THE FLOW. Where the value came from and where it went, drawn the way the eye wants it: every
291
+ // input a band as thick as its share of the value, running in straight from the left edge (its
292
+ // tail notched like an arrow's fletching), curving into ONE narrow trunk where they merge, and
293
+ // fanning out again to the outputs, each ending in an arrow tip. One gradient runs across the
294
+ // whole picture -- violet at the edges, electric blue in the trunk -- so the bands read as one
295
+ // current. The fee leaves the trunk as a thin gold stream that rises toward the top right and
296
+ // fades out; a coinbase input glows gold; an OP_RETURN output is a grey sliver. SVG rather than
297
+ // canvas so every band is a link (an input to the transaction that made it, an output to its
298
+ // address) with its own tooltip. Past FLOW_MAX bands a side folds the rest into one "N more" band.
299
+ // Pure: summary in, markup out.
300
+ export const FLOW_MAX = 24;
301
+ const r1 = (v) => Math.round(v * 10) / 10;
302
+
303
+ function flowDefs(W) {
304
+ const g = (id, stops, x1 = 0, x2 = W) => `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${x1}" y1="0" x2="${x2}" y2="0">${stops.map(([o, c, a = 1]) => `<stop offset="${o}" stop-color="${c}" stop-opacity="${a}"/>`).join('')}</linearGradient>`;
305
+ return `<defs>${[
306
+ g('xg-flow', [[0, '#3b1a78', 0.25], [0.06, '#8f3cf2'], [0.3, '#6a4cf6'], [0.5, '#1d8fff'], [0.7, '#6a4cf6'], [0.94, '#9a45f5'], [1, '#b56cff']]),
307
+ g('xg-cb', [[0, '#6b4a00', 0.3], [0.06, '#f5b400'], [0.3, '#e0913a'], [0.5, '#1d8fff']]),
308
+ g('xg-nul', [[0.5, '#1d8fff'], [0.75, '#6c7686'], [1, '#8a93a1']]),
309
+ g('xg-more', [[0, '#39414d', 0.3], [0.08, '#6c7686'], [0.5, '#1d8fff'], [0.92, '#6c7686'], [1, '#8a93a1']]),
310
+ g('xg-fee', [[0, '#1d8fff'], [0.45, '#ffb13d'], [1, '#ffb13d', 0]], W / 2, W * 0.97),
311
+ ].join('')}</defs>`;
312
+ }
313
+
314
+ export function flowSvg(t, fmt, W = 1000) {
315
+ const fold = (items) => (items.length <= FLOW_MAX ? items : [...items.slice(0, FLOW_MAX - 1), {
316
+ kind: 'more', value: items.slice(FLOW_MAX - 1).reduce((a, x) => a + (x.value ?? 0), 0),
317
+ title: `${fmt.num(items.length - FLOW_MAX + 1)} more`,
318
+ }]);
319
+ const left = fold(t.vin.map((v) => (v.coinbase
320
+ ? { kind: 'cb', value: t.outSat, title: 'coinbase: the subsidy and the block\'s fees' }
321
+ : { kind: 'in', value: v.value, href: `#explorer/tx/${enc(v.txid)}`, title: `${v.address ?? v.type ?? 'input'} · ${fmt.btc(v.value)} BTC` })));
322
+ const right = fold(t.vout.map((o) => ({
323
+ kind: o.type === 'nulldata' ? 'nul' : 'out', value: o.value,
324
+ href: o.address ? `#explorer/address/${enc(o.address)}` : null,
325
+ title: `${o.address ?? (o.type === 'nulldata' ? 'OP_RETURN' : o.type ?? 'output')} · ${fmt.btc(o.value)} BTC`,
326
+ })));
327
+ const fee = !t.coinbase && t.fee > 0 ? { kind: 'fee', value: t.fee, title: `fee · ${fmt.num(t.fee)} sat` } : null;
328
+
329
+ const T = 150, MIN = 2, GAP = 14, PAD = 16;
330
+ const known = [...left, ...right].every((x) => Number.isFinite(x.value));
331
+ const total = known ? Math.max(1, left.reduce((a, x) => a + x.value, 0), right.reduce((a, x) => a + x.value, 0) + (fee?.value ?? 0)) : 1;
332
+ const thick = (x, n) => Math.max(MIN, known ? (x.value / total) * T : T / n);
333
+ const sum = (xs) => xs.reduce((a, x) => a + x.h, 0);
334
+ const L0 = left.map((x) => ({ ...x, h: thick(x, left.length) }));
335
+ const R0 = right.map((x) => ({ ...x, h: thick(x, right.length) }));
336
+ const F0 = fee ? { ...fee, h: Math.max(1.5, known ? (fee.value / total) * T : 2) } : null;
337
+
338
+ // ONE RIVER, TWO BANKS THE SAME HEIGHT (operator, 2026-09-13: "how do we fix that broken seam in
339
+ // the middle so it smoothly transitions").
340
+ //
341
+ // The two halves of the trunk meet at x = W/2, and each used to be centred on its OWN total --
342
+ // the left on sum(L), the right on sum(R) + the fee. Those totals are equal in VALUE (in = out +
343
+ // fee, which is what `total` above is built from) but not in PIXELS, because MIN floors every
344
+ // band to 2px. Measured on the reported transaction -- one fat input, three dust outputs of
345
+ // 0.006px each floored to 2px -- the right bank gained 5.98px of padding the left never got, the
346
+ // two centre lines fell 3.7px apart, and the trunk stepped at the join on both edges.
347
+ //
348
+ // The clamp has to stay: a 0.006px band is invisible, and a dust output is still an output. So
349
+ // the surplus is spread instead of stepped. Each side is scaled to the same trunk height, which
350
+ // restores the invariant the picture is meant to show -- both banks of one river are the same
351
+ // height -- and makes the seam flush by construction rather than by luck. The distortion is the
352
+ // clamp's, not this: rescaling only stops one side carrying it alone.
353
+ const sumL0 = sum(L0), sumR0 = sum(R0) + (F0?.h ?? 0);
354
+ const trunk = Math.max(sumL0, sumR0);
355
+ const kL = sumL0 > 0 ? trunk / sumL0 : 1, kR = sumR0 > 0 ? trunk / sumR0 : 1;
356
+ const L = L0.map((x) => ({ ...x, h: x.h * kL }));
357
+ const Rt = R0.map((x) => ({ ...x, h: x.h * kR }));
358
+ const F = F0 ? { ...F0, h: F0.h * kR } : null;
359
+ const span = (xs) => sum(xs) + GAP * Math.max(0, xs.length - 1);
360
+ const feeRoom = F ? F.h + GAP * 2 : 0;
361
+ const H = Math.ceil(Math.max(span(L), span(Rt) + feeRoom, T + 40) + PAD * 2);
362
+
363
+ const xm = W / 2, xb = W * 0.44, xa = W * 0.17, xb2 = W * 0.56, xa2 = W * 0.83;
364
+ const cxL = (xa + xb) / 2, cxR = (xb2 + xa2) / 2;
365
+ const band = (x, d, fill) => {
366
+ const path = `<path class="xb-${x.kind}" d="${d}" fill="${fill}"><title>${fmt.esc(x.title)}</title></path>`;
367
+ return x.href ? `<a href="${x.href}">${path}</a>` : path;
368
+ };
369
+ const fillOf = (k) => (k === 'cb' ? 'url(#xg-cb)' : k === 'nul' ? 'url(#xg-nul)' : k === 'more' ? 'url(#xg-more)' : 'url(#xg-flow)');
370
+
371
+ // inputs: straight in from the edge, curving into the left half of the trunk
372
+ // both banks start at the SAME trunk top, so the halves meet flush at x = W/2
373
+ let y = (H - span(L)) / 2, c = (H - trunk) / 2;
374
+ const ins = L.map((x) => {
375
+ const n = Math.min(14, x.h / 2 + 3);
376
+ const d = `M0 ${r1(y)}L${r1(xa)} ${r1(y)}C${r1(cxL)} ${r1(y)} ${r1(cxL)} ${r1(c)} ${r1(xb)} ${r1(c)}L${r1(xm)} ${r1(c)}`
377
+ + `L${r1(xm)} ${r1(c + x.h)}L${r1(xb)} ${r1(c + x.h)}C${r1(cxL)} ${r1(c + x.h)} ${r1(cxL)} ${r1(y + x.h)} ${r1(xa)} ${r1(y + x.h)}`
378
+ + `L0 ${r1(y + x.h)}L${r1(n)} ${r1(y + x.h / 2)}Z`;
379
+ y += x.h + GAP; c += x.h;
380
+ return band(x, d, fillOf(x.kind));
381
+ }).join('');
382
+
383
+ // the right half of the trunk: the fee on top, then the outputs
384
+ let cr = (H - trunk) / 2; // the same top edge as the inputs: no step at the seam
385
+ let feePath = '';
386
+ if (F) {
387
+ const top = PAD, xe = W * 0.97;
388
+ const d = `M${r1(xm)} ${r1(cr)}L${r1(xb2)} ${r1(cr)}C${r1(W * 0.7)} ${r1(cr)} ${r1(W * 0.72)} ${r1(top)} ${r1(xe)} ${r1(top)}`
389
+ + `L${r1(xe)} ${r1(top + F.h)}C${r1(W * 0.72)} ${r1(top + F.h)} ${r1(W * 0.7)} ${r1(cr + F.h)} ${r1(xb2)} ${r1(cr + F.h)}L${r1(xm)} ${r1(cr + F.h)}Z`;
390
+ feePath = band(F, d, 'url(#xg-fee)');
391
+ cr += F.h;
392
+ }
393
+ let yr = PAD + feeRoom + ((H - PAD * 2 - feeRoom) - span(Rt)) / 2;
394
+ const outs = Rt.map((x) => {
395
+ const tip = Math.min(22, x.h / 2 + 4);
396
+ const d = `M${r1(xm)} ${r1(cr)}L${r1(xb2)} ${r1(cr)}C${r1(cxR)} ${r1(cr)} ${r1(cxR)} ${r1(yr)} ${r1(xa2)} ${r1(yr)}`
397
+ + `L${r1(W - tip)} ${r1(yr)}L${r1(W)} ${r1(yr + x.h / 2)}L${r1(W - tip)} ${r1(yr + x.h)}L${r1(xa2)} ${r1(yr + x.h)}`
398
+ + `C${r1(cxR)} ${r1(yr + x.h)} ${r1(cxR)} ${r1(cr + x.h)} ${r1(xb2)} ${r1(cr + x.h)}L${r1(xm)} ${r1(cr + x.h)}Z`;
399
+ yr += x.h + GAP; cr += x.h;
400
+ return band(x, d, fillOf(x.kind));
401
+ }).join('');
402
+
403
+ return `<svg class="xflow" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" height="${H}" role="img" aria-label="${fmt.esc(`${t.vin.length} inputs to ${t.vout.length} outputs`)}">`
404
+ + `${flowDefs(W)}${ins}${feePath}${outs}</svg>`;
405
+ }
@@ -0,0 +1,149 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // feepalette.js -- feerate bands and their colours, for the block-space
4
+ // viewer, the block meter and the feerate legend.
5
+ //
6
+ // THE BANDS are a geometric series: from FLOOR_RATE to TOP_RATE in equal
7
+ // ratios, each threshold rounded to two significant figures so the legend
8
+ // reads in plain numbers. Everything below FLOOR_RATE is band 0. A geometric
9
+ // series is what a feerate scale wants: the difference between 1 and 2 sat/vB
10
+ // matters as much as the difference between 500 and 1000, so the bands sit
11
+ // close together near the floor and far apart near the ceiling.
12
+ //
13
+ // 128 BANDS FROM 0.1 sat/vB (operator, 2026-09-11: "we really need more color
14
+ // separation. at least 64 different colors and shades", then "we should try
15
+ // 128 colors"). The first palette began at 1 sat/vB with 36 bands, and this
16
+ // chain's blocks run from 0.1 to a few tens of sat/vB -- so most of a block
17
+ // fell into band 0 and the board was one green. Starting at 0.1 puts
18
+ // 0.1..30 sat/vB across some 73 bands, each step about 8% up the scale.
19
+ // Thresholds are two significant figures, three where two would repeat the
20
+ // band below (an 8% step is finer than two figures near the start of a decade).
21
+ //
22
+ // THE COLOURS are a ramp through a handful of anchors, interpolated in HSL
23
+ // with the hue unwrapped so it only ever travels one way: sky blue for the
24
+ // cheapest, through teal, green, yellow-green, yellow, amber, orange, red and
25
+ // crimson, to purple for the most expensive -- 280 degrees of hue. Band i takes
26
+ // the colour at i / (bands - 1) along the ramp, and its lightness steps
27
+ // through a three-band pattern (LIGHT_STEP), so two neighbouring bands differ
28
+ // in tone as well as hue and read as separate colours side by side.
29
+ //
30
+ // feeShade() then gives each tile its own tone of its band's colour: darker
31
+ // at the bottom of the band, brighter toward its top, plus a small fixed
32
+ // variation per transaction id so a field of equal feerates reads as a field
33
+ // of separate stones rather than one flat slab. Only the brightness moves;
34
+ // the hue is the band's.
35
+
36
+ const FLOOR_RATE = 0.1; // sat/vB: the start of band 1 (below it is band 0)
37
+ const TOP_RATE = 2000; // sat/vB: the start of the last band
38
+ const STEPS = 126; // ratios between FLOOR_RATE and TOP_RATE: 128 bands in all
39
+
40
+ // n significant figures: figures(1.2505, 2) -> 1.3, figures(14.6, 2) -> 15, figures(1.049, 3) -> 1.05
41
+ function figures(v, n) {
42
+ const mag = 10 ** (Math.floor(Math.log10(v)) - (n - 1));
43
+ return Math.round(v / mag) * mag;
44
+ }
45
+ const tidy = (v) => Number(v.toPrecision(12)); // drop float dust (1.3000000000000003)
46
+
47
+ export const FEE_BANDS = Object.freeze((() => {
48
+ const bands = [0];
49
+ for (let i = 0; i <= STEPS; i++) {
50
+ const v = FLOOR_RATE * (TOP_RATE / FLOOR_RATE) ** (i / STEPS);
51
+ const two = tidy(figures(v, 2));
52
+ bands.push(two > bands.at(-1) ? two : tidy(figures(v, 3)));
53
+ }
54
+ return bands;
55
+ })());
56
+
57
+ // [position along the ramp, hue (deg), saturation, lightness], with the rate
58
+ // that lands there. Hues are unwrapped (they fall from blue past 0 into
59
+ // negative = crimson/purple), so linear interpolation moves the hue one way.
60
+ const ANCHORS = [
61
+ [0.00, 205, 0.62, 0.42], // sky blue under 0.1 sat/vB
62
+ [0.12, 172, 0.60, 0.40], // teal ~0.3
63
+ [0.24, 138, 0.58, 0.40], // green ~1
64
+ [0.35, 96, 0.58, 0.45], // yellow-green ~3
65
+ [0.47, 58, 0.72, 0.50], // yellow ~10
66
+ [0.56, 40, 0.76, 0.51], // amber ~25
67
+ [0.65, 24, 0.78, 0.51], // orange ~60
68
+ [0.78, 2, 0.72, 0.50], // red ~250
69
+ [0.89, -28, 0.70, 0.46], // crimson ~700
70
+ [1.00, -75, 0.60, 0.46], // purple 2000 and up
71
+ ];
72
+ // lightness added band by band, in a repeating three-step pattern
73
+ const LIGHT_STEP = [0, 0.075, -0.06];
74
+
75
+ function hslToRgb(h, s, l) {
76
+ const hh = ((h % 360) + 360) % 360;
77
+ const c = (1 - Math.abs(2 * l - 1)) * s;
78
+ const x = c * (1 - Math.abs(((hh / 60) % 2) - 1));
79
+ const m = l - c / 2;
80
+ const [r, g, b] = hh < 60 ? [c, x, 0] : hh < 120 ? [x, c, 0] : hh < 180 ? [0, c, x]
81
+ : hh < 240 ? [0, x, c] : hh < 300 ? [x, 0, c] : [c, 0, x];
82
+ return [r + m, g + m, b + m].map((v) => Math.round(v * 255));
83
+ }
84
+
85
+ function rampAt(t, dl = 0) {
86
+ let i = 0;
87
+ while (i < ANCHORS.length - 2 && t > ANCHORS[i + 1][0]) i++;
88
+ const [t0, h0, s0, l0] = ANCHORS[i];
89
+ const [t1, h1, s1, l1] = ANCHORS[i + 1];
90
+ const f = Math.min(1, Math.max(0, (t - t0) / (t1 - t0)));
91
+ return hslToRgb(h0 + (h1 - h0) * f, s0 + (s1 - s0) * f, l0 + (l1 - l0) * f + dl);
92
+ }
93
+
94
+ const hex = (rgb) => '#' + rgb.map((v) => Math.min(255, Math.max(0, Math.round(v))).toString(16).padStart(2, '0')).join('');
95
+
96
+ const BAND_RGB = FEE_BANDS.map((_, i) => rampAt(i / (FEE_BANDS.length - 1), LIGHT_STEP[i % LIGHT_STEP.length]));
97
+ const BAND_HEX = BAND_RGB.map(hex);
98
+
99
+ export function feeBandIndex(rate) {
100
+ const r = Number(rate);
101
+ if (!Number.isFinite(r) || r <= 0) return 0;
102
+ // binary search: the highest band whose threshold is <= r
103
+ let lo = 0, hi = FEE_BANDS.length - 1;
104
+ while (lo < hi) {
105
+ const mid = (lo + hi + 1) >> 1;
106
+ if (FEE_BANDS[mid] <= r) lo = mid; else hi = mid - 1;
107
+ }
108
+ return lo;
109
+ }
110
+
111
+ export function feeColor(rate) {
112
+ return BAND_HEX[feeBandIndex(rate)];
113
+ }
114
+
115
+ // Where a rate sits inside its band, 0 at the threshold and 1 at the next.
116
+ // The last band has no upper threshold; it is given one as wide as itself.
117
+ function inBand(rate, i) {
118
+ const r = Number(rate);
119
+ if (!Number.isFinite(r) || r <= 0) return 0;
120
+ const lo = FEE_BANDS[i];
121
+ const hi = i + 1 < FEE_BANDS.length ? FEE_BANDS[i + 1] : lo * 2;
122
+ return Math.min(1, Math.max(0, (r - lo) / (hi - lo)));
123
+ }
124
+
125
+ // FNV-1a, 32 bits: a stable number per key, the same in every browser
126
+ function hashKey(key) {
127
+ let h = 0x811c9dc5;
128
+ for (let i = 0; i < key.length; i++) {
129
+ h ^= key.charCodeAt(i);
130
+ h = Math.imul(h, 0x01000193);
131
+ }
132
+ return h >>> 0;
133
+ }
134
+
135
+ const SHADE_LOW = 0.84; // brightness at the bottom of a band
136
+ const SHADE_SPAN = 0.26; // added across the band (so 1.10 at its top)
137
+ const KEY_SPREAD = 0.06; // +/- per-key variation
138
+
139
+ export function feeShade(rate, key = '') {
140
+ const i = feeBandIndex(rate);
141
+ const rgb = BAND_RGB[i];
142
+ let k = SHADE_LOW + SHADE_SPAN * inBand(rate, i);
143
+ const id = key == null ? '' : String(key);
144
+ if (id) k += KEY_SPREAD * ((hashKey(id) % 1001) / 500 - 1);
145
+ // never past full brightness in any channel: clipping one channel would
146
+ // change the hue, and the hue is the band's
147
+ k = Math.min(k, 255 / Math.max(1, ...rgb));
148
+ return hex(rgb.map((v) => v * k));
149
+ }