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.
- package/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -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 +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -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 +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -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 +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -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 +20 -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 +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -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 +193 -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 +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -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/util/fmt.js +29 -0
- 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 & 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
|
+
}
|