blockyard 0.0.9 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +251 -1
  2. package/README.md +42 -23
  3. package/bin/blockyard.js +2 -1
  4. package/docs/API.md +16 -14
  5. package/docs/ARCHITECTURE.md +92 -5
  6. package/docs/CONFIGURATION.md +33 -26
  7. package/docs/GETTING-STARTED.md +5 -2
  8. package/docs/INSTALL.md +90 -33
  9. package/docs/MEASUREMENTS.md +147 -0
  10. package/docs/SECURITY.md +32 -15
  11. package/docs/TROUBLESHOOTING.md +35 -1
  12. package/docs/USER-GUIDE.md +266 -26
  13. package/package.json +1 -1
  14. package/public/404.html +1 -1
  15. package/public/css/app.css +306 -82
  16. package/public/donate-qr.png +0 -0
  17. package/public/index.html +295 -103
  18. package/public/js/agents.js +228 -51
  19. package/public/js/app.js +82 -8
  20. package/public/js/blockscene3d.js +179 -27
  21. package/public/js/charts.js +21 -21
  22. package/public/js/depthchart.js +31 -27
  23. package/public/js/details3d.js +1456 -71
  24. package/public/js/doom.js +31 -0
  25. package/public/js/dosaudio.js +48 -0
  26. package/public/js/dosgame.js +389 -0
  27. package/public/js/dosio.js +186 -0
  28. package/public/js/dospc.js +1353 -0
  29. package/public/js/dosworker.js +196 -0
  30. package/public/js/login.js +5 -0
  31. package/public/js/markets.js +46 -8
  32. package/public/js/mining.js +310 -32
  33. package/public/js/panels.js +14 -10
  34. package/public/js/pricechart.js +14 -13
  35. package/public/js/quake.js +20 -0
  36. package/public/js/settings.js +103 -21
  37. package/public/js/soundcard.js +459 -0
  38. package/public/js/theme.js +235 -0
  39. package/public/js/wolf3d.js +22 -0
  40. package/public/js/x86.js +1978 -0
  41. package/scripts/donate-qr.py +12 -9
  42. package/scripts/dos-bench.js +56 -0
  43. package/scripts/setup.js +34 -12
  44. package/scripts/shots.mjs +6 -0
  45. package/scripts/smoke.sh +1 -1
  46. package/scripts/tls.js +31 -0
  47. package/server/chain/index/build.js +21 -4
  48. package/server/collect/monitor.js +30 -1
  49. package/server/collect/network.js +295 -0
  50. package/server/config.js +46 -22
  51. package/server/http/api.js +49 -5
  52. package/server/http/games.js +77 -0
  53. package/server/http/server.js +8 -0
  54. package/server/main.js +53 -8
  55. package/server/tls/selfsigned.js +160 -0
  56. package/systemd/blockyard.service +7 -5
  57. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  58. package/docs/STATE-2026-09-09.md +0 -200
@@ -16,7 +16,8 @@
16
16
  // fees.chunk/chunkweight -- so the block being built is assembled from the mempool the
17
17
  // monitor already reads, and costs the node no call at all (server/collect/gbt.js).
18
18
 
19
- import { paint, COL } from './charts.js';
19
+ import { paint, COL, lineChart } from './charts.js';
20
+ import { INK } from './theme.js'; // the pie's labels and slice seams follow the theme
20
21
  import { blockTreemap, mempoolTreemap, rateColor as rateBucketColor } from './goggles.js';
21
22
  import { loadSettings, spaceOptions } from './settings.js';
22
23
  // 2026-09-10: the block and the pool now draw as lit solids on a square-packed
@@ -1051,44 +1052,321 @@ export function renderMining(s, state, h) {
1051
1052
  h.nextBlock?.();
1052
1053
  blockFlow(document.getElementById('mnFlow'), flowArgs(s, state), h.fmt);
1053
1054
  packagesView(document.getElementById('mnPackages'), a?.nextBlock?.packages, h.fmt);
1054
- const nb = a?.nextBlock ?? null;
1055
- h.mempoolDetail?.();
1056
- const mp = state?.mempoolDist ?? s?.mempool ?? {};
1057
- drawPoolViewer(h.canvas('gnMempoolTreemap'), s, state);
1058
- const mnote = document.getElementById('gnMempoolNote');
1059
- if (mnote) {
1060
- const cells = mp.cells ?? [];
1061
- const tail = cells.find((c) => c.aggregate) ?? null;
1062
- const total = mp.totalVsize ?? 0;
1063
- const drawn = cells.length - (tail ? 1 : 0);
1064
- if (cells.length) {
1065
- const parts = [`${fmtNum(drawn, h)} drawn`];
1066
- if (tail) parts.push(`+ 1 aggregate of ${fmtNum(tail.aggregate, h)} smaller ones`);
1067
- if (mp.cellCount != null) parts.push(`= ${fmtNum(mp.cellCount, h)} transactions as at the poll`);
1068
- if (mp.fetchedAt != null) parts.push(`${Math.round((Date.now() - mp.fetchedAt) / 1000)}s ago`);
1069
- const fits = total <= 1_000_000
1070
- ? '<b>everything waiting fits in a single block</b>'
1071
- : 'the line sits where one block runs out and everything right of it waits';
1072
- mnote.innerHTML = `${parts.join(', ')}; ${fmtNum(total, h)} vB waiting. One block is 1,000,000 vB, so ${fits}.`
1073
- + ' Ordered by feerate, because that is the order a miner takes them; colour is what each transaction pays.'
1074
- + (mp.stale ? ' <span class="warn">Last poll failed; this picture may be old.</span>' : '');
1075
- } else if (mp.stale) {
1076
- mnote.innerHTML = '<span class="warn">Detail stale</span> — the last poll of the full pool failed. Anything drawn above is the previous reading.';
1077
- } else if (mp.count != null && mp.count > 0) {
1078
- mnote.innerHTML = `<span class="warn">Cells not loaded yet</span> — ${fmtNum(mp.count, h)} transactions are waiting; the full pool is polled on the 20 s tier and this page has not received it.`;
1079
- } else if (mp.count === 0) {
1080
- mnote.textContent = 'The mempool is empty: nothing is waiting, so there is nothing to draw.';
1081
- } else {
1082
- mnote.textContent = 'Nothing to draw yet — the full pool is polled on the 20 s tier, not every second.';
1083
- }
1084
- }
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)
1085
1057
  feeLandscape(h.canvas('mnFeeLandscape'), a?.nextBlock ?? null, h.fmt);
1086
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);
1087
1063
  const el = document.getElementById('mnCoverage');
1088
1064
  if (el) el.innerHTML = coveragePanel(a, h);
1089
1065
  applyMiningStyles(document);
1090
1066
  }
1091
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 &amp; 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
+
1092
1370
  function coveragePanel(a, h) {
1093
1371
  const esc = h.fmt.esc;
1094
1372
  const row = (k, v) => `<dt>${esc(k)}</dt><dd>${v}</dd>`;
@@ -30,7 +30,7 @@ export function renderChain(s, state, h) {
30
30
  placeholder: 'no tip samples yet',
31
31
  });
32
32
  h.setText('chTipNote', s.tip?.headers != null && s.tip?.height != null
33
- ? `Applied ${fmt.num(s.tip.height)} of ${fmt.num(s.tip.headers)} announced headers. Headers-first means this reaches 100% only when the last block lands.`
33
+ ? `<span title="Headers-first: this reaches 100% only when the last block lands">${fmt.num(s.tip.height)} of ${fmt.num(s.tip.headers)} announced headers applied</span>`
34
34
  : 'no header count reported yet');
35
35
 
36
36
  const gap = (b.gap ?? []).filter((p) => Number.isFinite(p.v));
@@ -55,10 +55,11 @@ export function renderChain(s, state, h) {
55
55
  // reads as "broken panel" unless the panel says otherwise: this figure sat empty
56
56
  // for a day because the request asked for statistics the endpoint has never had.
57
57
  const sizeRow = (s.blocks?.recent ?? [])[0] ?? null;
58
+ // one line (2026-09-15, the packing pass): the basis sentence is the note's title, the figures its text
58
59
  h.setText('chSizeNote', sizeRow?.sizeBasis
59
- ? `<span class="mono">total_size</span> ${sizeRow.sizeBasis}.`
60
+ ? `<span class="mono" title="${fmt.esc(sizeRow.sizeBasis)}">total_size</span>`
60
61
  + (sizeRow.medianTxSize != null && sizeRow.swtotalSize != null && sizeRow.size != null
61
- ? ` This block: median tx ${fmt.bytes(sizeRow.medianTxSize, 0)}, witness ${fmt.bytes(sizeRow.swtotalSize, 0)} of ${fmt.bytes(sizeRow.size, 0)}.`
62
+ ? ` · this block: median tx ${fmt.bytes(sizeRow.medianTxSize, 0)}, witness ${fmt.bytes(sizeRow.swtotalSize, 0)} of ${fmt.bytes(sizeRow.size, 0)}`
62
63
  : '')
63
64
  : (sizeRow?.sizeMissing ?? 'no block stats collected yet — the monitor has not seen a new height since it started'));
64
65
  drawFromSeries(h, 'chFeeChart', b.fee, COL.ok, (v) => fmt.short(v), { fmtTip: (v) => `${fmt.sats(v)} sat` });
@@ -71,10 +72,13 @@ export function renderChain(s, state, h) {
71
72
  placeholder: 'getchaintxstats has not answered yet',
72
73
  });
73
74
  const cs = s.chaintxstats;
74
- h.setText('chTxStats', cs ? `<dt>window</dt><dd>${fmt.num(cs.window_block_count ?? 0)} blocks</dd>
75
- <dt>txs in window</dt><dd>${fmt.num(cs.window_tx_count ?? 0)}</dd>
76
- <dt>all-time txs</dt><dd>${fmt.num(cs.txcount ?? 0)}</dd>
77
- <dt>rate</dt><dd>${cs.txrate != null ? cs.txrate.toFixed(2) + ' tx/s' : '–'}</dd>` : '<dt>–</dt><dd>not yet fetched</dd>');
75
+ // ONE WRAPPING LINE (2026-09-15, the packing pass): as a four-column grid these overflowed a
76
+ // 330px card and as two columns they made the card the row's tallest
77
+ const item = (k, v) => `<span><span class="k">${k}</span><b>${v}</b></span>`;
78
+ h.setText('chTxStats', cs
79
+ ? item('window', `${fmt.num(cs.window_block_count ?? 0)} blocks`) + item('txs in window', fmt.num(cs.window_tx_count ?? 0))
80
+ + item('all-time txs', fmt.num(cs.txcount ?? 0)) + item('rate', cs.txrate != null ? `${cs.txrate.toFixed(2)} tx/s` : '–')
81
+ : '<span class="faint">not yet fetched</span>');
78
82
 
79
83
  h.setText('chState', kv([
80
84
  ['chain', s.chain ?? '–'],
@@ -89,7 +93,7 @@ export function renderChain(s, state, h) {
89
93
  ['pruned', s.pruned == null ? '–' : String(s.pruned)],
90
94
  ['chain work', s.chainwork ? fmt.hash(s.chainwork.replace(/^0+/, '') || '0', 6) : '–'],
91
95
  ['IBD', s.ibd == null ? '–' : String(s.ibd)],
92
- ['monitor uptime', s.uptimeSec != null ? fmt.uptime(s.uptimeSec * 1000) : '–'],
96
+ ['uptime', s.uptimeSec != null ? fmt.uptime(s.uptimeSec * 1000) : '–'],
93
97
  ]));
94
98
 
95
99
  const u = s.utxo ?? {};
@@ -348,7 +352,7 @@ export function peerTableHtml(rows, fmt, { now = Date.now(), tip = null } = {})
348
352
  const faint = (s) => ` <span class="faint">${fmt.esc(s)}</span>`;
349
353
  const body = sorted.map((p) => `<tr>
350
354
  <td>${p.inbound ? '<span class="badge muted">in</span>' : '<span class="badge">out</span>'}</td>
351
- <td>${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
355
+ <td class="addr" title="${fmt.esc(p.addr ?? '')}">${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
352
356
  <td class="w">${fmt.esc(ua(p.subver))}${p.version ? faint(String(p.version)) : ''}${svc(p).map((t) => ` <span class="badge muted">${t}</span>`).join('')}</td>
353
357
  <td class="r">${Number.isFinite(p.conntime) ? fmt.uptime((now / 1000 - p.conntime) * 1000) : '–'}</td>
354
358
  <td class="r">${since(p.lastrecv)}</td>
@@ -472,7 +476,7 @@ export function renderNode(s, state, h) {
472
476
  h.setText('ndRpc', kv([
473
477
  ['endpoint', rpc.url ?? '–'],
474
478
  ['cookie source', rpc.cookieSource ? fmt.hash(rpc.cookieSource, 14) : '–'],
475
- ['in flight', '1 (by design)'],
479
+ ['in flight', '1'], // one request at a time is the design (the note under the chart says so); '(by design)' was cut off in the two-column list
476
480
  ['calls (60 s window)', String(rpc.ratePerSec ?? 0) + '/s'],
477
481
  ['total calls', fmt.num(rpc.calls)],
478
482
  ['batches', fmt.num(rpc.batches)],
@@ -4,6 +4,7 @@
4
4
  // crosshair that says exactly what an hour did. This is that chart. The 3D board under it is a
5
5
  // view of the same hours, not a substitute for it.
6
6
  import { niceTicks } from './charts.js';
7
+ import { INK } from './theme.js'; // the axis, grid and tooltip colours of the chosen theme
7
8
 
8
9
  export const EX_COLORS = { coinbase: '#4c8dff', kraken: '#a78bfa', bitstamp: '#2ecc8f', bitfinex: '#4dd0e1', okx: '#f0b429' };
9
10
  const UP = '#26c281', DOWN = '#ef5350';
@@ -79,7 +80,7 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
79
80
  if (!canvas?.getContext) return null;
80
81
  const { ctx, w, h } = prep(canvas);
81
82
  if (!candles.length) {
82
- ctx.fillStyle = '#6a7484'; ctx.textAlign = 'center';
83
+ ctx.fillStyle = INK.text; ctx.textAlign = 'center';
83
84
  ctx.fillText('no candles yet', w / 2, h / 2);
84
85
  return null;
85
86
  }
@@ -92,22 +93,22 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
92
93
  for (const v of niceTicks(L.lo, L.hi, Math.max(3, Math.floor(L.priceH / 46)))) {
93
94
  const y = Math.round(L.Y(v)) + 0.5;
94
95
  if (y < PAD.top || y > PAD.top + L.priceH) continue;
95
- ctx.strokeStyle = '#1a2029';
96
+ ctx.strokeStyle = INK.grid;
96
97
  ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(right, y); ctx.stroke();
97
- ctx.fillStyle = '#7d8898';
98
+ ctx.fillStyle = INK.axisText;
98
99
  ctx.fillText(money(v, 0), right + 7, y);
99
100
  }
100
101
  // time grid and axis
101
102
  ctx.textAlign = 'center';
102
103
  for (const tk of timeTicks(L.t0, L.t1, L.plotW)) {
103
104
  const x = Math.round(L.XT(tk.t)) + 0.5;
104
- ctx.strokeStyle = tk.label.includes(' ') ? '#27303b' : '#161b22';
105
+ ctx.strokeStyle = tk.label.includes(' ') ? INK.axis : INK.grid;
105
106
  ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, L.VY0); ctx.stroke();
106
- ctx.fillStyle = tk.label.includes(' ') ? '#b5c0cc' : '#7d8898';
107
+ ctx.fillStyle = tk.label.includes(' ') ? INK.label : INK.axisText;
107
108
  ctx.fillText(tk.label, x, h - PAD.bottom / 2);
108
109
  }
109
110
  // the pane edges
110
- ctx.strokeStyle = '#2a323d';
111
+ ctx.strokeStyle = INK.axis;
111
112
  ctx.beginPath(); ctx.moveTo(right + 0.5, PAD.top); ctx.lineTo(right + 0.5, L.VY0); ctx.stroke();
112
113
  ctx.beginPath(); ctx.moveTo(PAD.left, L.VY0 + 0.5); ctx.lineTo(right, L.VY0 + 0.5); ctx.stroke();
113
114
 
@@ -119,7 +120,7 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
119
120
  ctx.fillStyle = k.c >= k.o ? UP_V : DOWN_V;
120
121
  ctx.fillRect(L.X(i) - bodyW / 2, L.VY0 - vh, bodyW, vh);
121
122
  });
122
- ctx.fillStyle = '#56606e'; ctx.textAlign = 'left';
123
+ ctx.fillStyle = INK.textDim; ctx.textAlign = 'left';
123
124
  ctx.fillText('volume', PAD.left + 4, L.VY0 - L.volH + 6);
124
125
 
125
126
  // the other exchanges, as thin close lines
@@ -152,7 +153,7 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
152
153
  ctx.beginPath(); ctx.moveTo(PAD.left, ly); ctx.lineTo(right, ly); ctx.stroke();
153
154
  ctx.setLineDash([]);
154
155
  ctx.fillStyle = lcol; ctx.fillRect(right + 1, ly - 9, PAD.right - 2, 18);
155
- ctx.fillStyle = '#ffffff'; ctx.textAlign = 'left';
156
+ ctx.fillStyle = INK.bright; ctx.textAlign = 'left';
156
157
  ctx.fillText(money(lastK.c), right + 6, ly);
157
158
 
158
159
  // the crosshair
@@ -161,26 +162,26 @@ export function drawPriceChart(canvas, { candles = [], overlays = [], name = '',
161
162
  const i = L.index(hover.x);
162
163
  shown = candles[i];
163
164
  const x = Math.round(L.X(i)) + 0.5;
164
- ctx.strokeStyle = 'rgba(200,215,230,0.45)'; ctx.setLineDash([3, 3]);
165
+ ctx.strokeStyle = INK.dash; ctx.setLineDash([3, 3]);
165
166
  ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, L.VY0); ctx.stroke();
166
167
  if (hover.y >= PAD.top && hover.y <= PAD.top + L.priceH) {
167
168
  const y = Math.round(hover.y) + 0.5;
168
169
  ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(right, y); ctx.stroke();
169
170
  ctx.setLineDash([]);
170
- ctx.fillStyle = '#3a4452'; ctx.fillRect(right + 1, y - 9, PAD.right - 2, 18);
171
- ctx.fillStyle = '#ffffff'; ctx.fillText(money(L.V(hover.y)), right + 6, y);
171
+ ctx.fillStyle = INK.cursorFill; ctx.fillRect(right + 1, y - 9, PAD.right - 2, 18);
172
+ ctx.fillStyle = INK.cursorText; ctx.fillText(money(L.V(hover.y)), right + 6, y);
172
173
  }
173
174
  ctx.setLineDash([]);
174
175
  }
175
176
  // readout (top-left) and overlay legend (top-right)
176
177
  ctx.textAlign = 'left';
177
- ctx.fillStyle = '#dfe6ee';
178
+ ctx.fillStyle = INK.bright;
178
179
  ctx.fillText(readout(shown, name), PAD.left + 2, 12);
179
180
  ctx.textAlign = 'right';
180
181
  let lx = right;
181
182
  for (const o of [...overlays].reverse()) {
182
183
  const wT = ctx.measureText(o.name).width;
183
- ctx.fillStyle = '#9aa5b3'; ctx.fillText(o.name, lx, 12);
184
+ ctx.fillStyle = INK.label; ctx.fillText(o.name, lx, 12);
184
185
  ctx.fillStyle = o.color; ctx.fillRect(lx - wT - 16, 11, 11, 2);
185
186
  lx -= wT + 26;
186
187
  }
@@ -0,0 +1,20 @@
1
+ // QUAKE, the tab (operator, 2026-09-15: "yes, get Quake working as a diversion").
2
+ //
3
+ // The shareware QUAKE.EXE v1.06 and its PAK0.PAK from games/quake_dos/, unmodified, on the emulated
4
+ // PC (dosgame.js has the tab, dosworker.js the machine). Quake is a DJGPP program where DOOM was a
5
+ // DOS/4GW one, so the same machine plays the go32 stub and CWSDPMI for it (dospc.js bootCoff), and it
6
+ // asks far more of the FPU. Its keys are its own: W A S D and mouse look on the first run
7
+ // (dosio.js quakeAutoexec), and after that whatever the player binds in Quake's own menu.
8
+ import { createDosGame } from './dosgame.js';
9
+
10
+ export const renderQuake = createDosGame({
11
+ game: 'quake',
12
+ page: 'quake',
13
+ prefix: 'quake',
14
+ title: 'Quake',
15
+ idleText: 'The shareware episode, Dimension of the Doomed: the real QUAKE.EXE v1.06 running on a PC this monitor emulates. Click the screen to use the mouse.',
16
+ loadingText: 'loading the shareware episode (18 MB)…',
17
+ saveName: /\.SAV$/i,
18
+ keysText: () => 'W S walk · A D strafe · mouse to look · left click fire · Space or right click jump · Shift run · 1–8 weapons · − = smaller or bigger view (smaller runs faster) · Esc menu · ~ console · F6 quicksave · F9 quickload',
19
+ switches: [],
20
+ });