blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,635 @@
1
+ // Canvas chart kit. No dependencies, no CDN: the panel has to render identically
2
+ // with no network reachable.
3
+ //
4
+ // THE ONE RULE THAT MATTERS MOST HERE: a chart never erases itself to show a
5
+ // status message. Data already on screen is the most valuable thing this page
6
+ // has; a transient gap in sampling must not destroy it. Absence is reported with
7
+ // an unobtrusive, idempotent "stale" pill drawn ON TOP of the existing pixels,
8
+ // and a placeholder is only ever painted onto a canvas that has never held data
9
+ // for the node currently selected.
10
+ //
11
+ // The flip side of "never blank": stale data must be visibly stale. Keeping an
12
+ // old chart and calling it current is the same sin as inventing a number, so the
13
+ // pill says how old it is.
14
+ const M = { top: 8, right: 10, bottom: 18, left: 46 };
15
+ // THE COLOURS COME FROM THE THEME (theme.js, 2026-09-16): COL is the theme module's live object,
16
+ // refilled when the operator picks a theme, and the chrome colours below read INK the same way --
17
+ // a chart painted from literals of the dark look was unreadable on a light card.
18
+ import { COL, INK } from './theme.js';
19
+ const palette = () => [COL.accent, COL.info, COL.ok, COL.purple, COL.cyan, COL.pink, COL.warn, COL.bad];
20
+
21
+ function prep(canvas) {
22
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
23
+ const w = canvas.clientWidth || canvas.parentElement?.clientWidth || 320;
24
+ const h = canvas.clientHeight || 130;
25
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
26
+ canvas.width = Math.round(w * dpr);
27
+ canvas.height = Math.round(h * dpr);
28
+ }
29
+ const ctx = canvas.getContext('2d');
30
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
31
+ ctx.clearRect(0, 0, w, h);
32
+ ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
33
+ ctx.textBaseline = 'middle';
34
+ return { ctx, w, h, dpr };
35
+ }
36
+
37
+ // ------------------------------------------------------------------ content
38
+
39
+ /** True once this canvas has been drawn with real data for the current node. */
40
+ export function hasData(canvas) { return !!canvas.__hasData; }
41
+
42
+ /**
43
+ * Wipe the canvas and its memory of having held data.
44
+ * Only two things may call this: the first data arriving for a node that has
45
+ * never been drawn, and a node switch -- where the old pixels belong to a
46
+ * different daemon and showing them would be a lie.
47
+ */
48
+ export function resetCanvas(canvas) {
49
+ canvas.__hasData = false;
50
+ canvas.__chart = null;
51
+ canvas.__since = null;
52
+ try { prep(canvas); } catch { /* not laid out yet */ }
53
+ }
54
+
55
+ /**
56
+ * The absence report. If the canvas holds data, the data stays: a "stale" pill
57
+ * is drawn over it instead. A placeholder only ever lands on an empty canvas.
58
+ *
59
+ * The pill is drawn with OPAQUE colours because this runs every animation frame;
60
+ * a translucent badge would composite on itself and darken the chart into
61
+ * blackness within a minute. Idempotent by construction.
62
+ */
63
+ export function empty(canvas, msg = 'no data from the node yet') {
64
+ if (canvas.__hasData) { markStale(canvas, msg); return; }
65
+ const { ctx, w, h } = prep(canvas);
66
+ ctx.fillStyle = INK.textDim;
67
+ ctx.textAlign = 'center';
68
+ ctx.fillText(msg, w / 2, h / 2);
69
+ ctx.textAlign = 'left';
70
+ canvas.__chart = null;
71
+ }
72
+
73
+ /** A small, opaque, repeatable caption over existing pixels. Never clears. */
74
+ export function markStale(canvas, msg = 'no fresh samples') {
75
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
76
+ const ctx = canvas.getContext('2d');
77
+ if (!ctx) return;
78
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
79
+ const w = canvas.clientWidth || 320;
80
+ const label = String(msg ?? 'no fresh samples');
81
+ ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
82
+ const tw = ctx.measureText(label).width;
83
+ const pw = tw + 20;
84
+ const x = Math.max(4, w - pw - 6);
85
+ ctx.fillStyle = INK.panel;
86
+ ctx.strokeStyle = INK.panelLine;
87
+ ctx.lineWidth = 1;
88
+ ctx.beginPath();
89
+ ctx.roundRect(x, 4, pw, 16, 5);
90
+ ctx.fill();
91
+ ctx.stroke();
92
+ ctx.fillStyle = COL.warn;
93
+ ctx.beginPath();
94
+ ctx.arc(x + 9, 12, 3, 0, 7);
95
+ ctx.fill();
96
+ ctx.fillStyle = INK.label;
97
+ ctx.textAlign = 'left';
98
+ ctx.fillText(label, x + 15, 12);
99
+ }
100
+
101
+ /** Elapsed since the series backing a chart last changed, as a short string. */
102
+ export function ageLabel(ms) {
103
+ if (!Number.isFinite(ms)) return null;
104
+ const s = ms / 1000;
105
+ if (s < 60) return `${Math.round(s)}s` ;
106
+ if (s < 3600) return `${Math.round(s / 60)}m`;
107
+ return `${(s / 3600).toFixed(1)}h`;
108
+ }
109
+
110
+ // At most `count` intervals, on a 1/2/5/10 step. `count` used to be a hint the step then
111
+ // rounded DOWN from, so 0..160M came out as nine ticks at 20M -- on a 64 px chart the labels
112
+ // stacked into a smear (2026-09-11, the Chain & Sync page made compact). Now the step is the
113
+ // smallest nice one that keeps to the count.
114
+ export function niceTicks(lo, hi, count = 4) {
115
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi) return [lo];
116
+ const span = hi - lo;
117
+ const raw = span / Math.max(1, count);
118
+ const mag = 10 ** Math.floor(Math.log10(raw));
119
+ const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => span / s <= count + 1e-9) ?? 10 * mag;
120
+ const out = [];
121
+ for (let v = Math.ceil(lo / step) * step; v <= hi + step * 1e-9; v += step) out.push(v);
122
+ return out;
123
+ }
124
+
125
+ function logTicks(lo, hi) {
126
+ const out = [];
127
+ for (let e = Math.floor(Math.log10(lo)); e <= Math.ceil(Math.log10(hi)); e++) {
128
+ for (const m of [1, 2, 5]) {
129
+ const v = m * 10 ** e;
130
+ if (v >= lo && v <= hi) out.push(v);
131
+ }
132
+ }
133
+ return out.length ? out : [lo, hi];
134
+ }
135
+
136
+ // ------------------------------------------------------------------ line
137
+
138
+ /**
139
+ * Multi-series line/area. series: [{label, color, points:[{t,v}], axis:'right', type:'line'|'area'}]
140
+ * opts: {fmtY, fmtX, fmtTip, min, max, rightMax, zeroBase, marker, staleMs}
141
+ */
142
+ export function lineChart(canvas, series, opts = {}) {
143
+ const pts = (series ?? []).filter((s) => s.points && s.points.length);
144
+ if (!pts.length) return empty(canvas, opts.emptyMsg);
145
+ const { ctx, w, h } = prep(canvas);
146
+ const L = { ...M, left: opts.left ?? M.left, bottom: opts.bottom ?? M.bottom };
147
+ const plotW = Math.max(10, w - L.left - L.right);
148
+ const plotH = Math.max(10, h - L.top - L.bottom);
149
+
150
+ const all = pts.flatMap((s) => s.points.filter((p) => Number.isFinite(p.v)));
151
+ if (!all.length) return empty(canvas, opts.emptyMsg);
152
+ const t0 = Math.min(...all.map((p) => p.t));
153
+ const t1 = Math.max(...all.map((p) => p.t));
154
+ const tSpan = Math.max(1, t1 - t0);
155
+
156
+ const leftVals = pts.filter((s) => s.axis !== 'right').flatMap((s) => s.points.map((p) => p.v)).filter(Number.isFinite);
157
+ const rightVals = pts.filter((s) => s.axis === 'right').flatMap((s) => s.points.map((p) => p.v)).filter(Number.isFinite);
158
+ let lo = opts.min != null ? opts.min : Math.min(0, ...leftVals);
159
+ let hi = opts.max != null ? opts.max : Math.max(...leftVals);
160
+ if (hi === lo) hi = lo + 1;
161
+ if (opts.zeroBase !== false && lo > 0 && Math.min(...leftVals) >= 0) lo = 0;
162
+ let rLo = opts.rightMin ?? Math.min(...rightVals, 0);
163
+ let rHi = opts.rightMax ?? Math.max(...rightVals, 1);
164
+ if (rHi === rLo) rHi = rLo + 1;
165
+
166
+ const X = (t) => L.left + ((t - t0) / tSpan) * plotW;
167
+ const Y = (v) => L.top + plotH - ((v - lo) / (hi - lo)) * plotH;
168
+ const Y2 = (v) => L.top + plotH - ((v - rLo) / (rHi - rLo)) * plotH;
169
+
170
+ ctx.strokeStyle = COL.grid;
171
+ ctx.fillStyle = COL.text;
172
+ ctx.lineWidth = 1;
173
+ // as many ticks as the height can label (one per ~22 px, 2..4), and no label repeated:
174
+ // bytes rounded to whole MB printed "2 MB, 2 MB, 1 MB, 1 MB" down the Block size axis
175
+ const yticks = opts.logY ? logTicks(Math.max(lo, 1e-9), hi) : niceTicks(lo, hi, Math.max(2, Math.min(4, Math.floor(plotH / 22))));
176
+ ctx.textAlign = 'right';
177
+ let lastLabel = null;
178
+ for (const tv of yticks) {
179
+ const y = Math.round((opts.logY ? logY(tv, lo, hi, L, plotH) : Y(tv))) + 0.5;
180
+ if (y < L.top - 2 || y > L.top + plotH + 2) continue;
181
+ ctx.beginPath(); ctx.moveTo(L.left, y); ctx.lineTo(w - L.right, y); ctx.stroke();
182
+ const label = (opts.fmtY ?? short)(tv);
183
+ if (label !== lastLabel) ctx.fillText(label, L.left - 5, y);
184
+ lastLabel = label;
185
+ }
186
+ if (rightVals.length) {
187
+ ctx.fillStyle = pts.find((s) => s.axis === 'right')?.color ?? COL.info;
188
+ ctx.textAlign = 'left';
189
+ for (const tv of niceTicks(rLo, rHi, 3)) {
190
+ const y = Math.round(Y2(tv)) + 0.5;
191
+ ctx.fillText((opts.fmtRight ?? short)(tv), w - L.right + 3, y);
192
+ }
193
+ ctx.fillStyle = COL.text;
194
+ }
195
+
196
+ ctx.textAlign = 'center';
197
+ ctx.fillStyle = COL.text;
198
+ const xt = 4;
199
+ for (let i = 0; i <= xt; i++) {
200
+ const t = t0 + (tSpan * i) / xt;
201
+ const x = Math.min(w - L.right - 12, Math.max(L.left + 10, X(t)));
202
+ ctx.fillText((opts.fmtX ?? clock)(t), x, h - 7);
203
+ }
204
+
205
+ for (let si = 0; si < pts.length; si++) {
206
+ const s = pts[si];
207
+ const pal = palette();
208
+ const color = s.color ?? pal[si % pal.length];
209
+ const yOf = s.axis === 'right' ? Y2 : (opts.logY ? (v) => logY(v, lo, hi, L, plotH) : Y);
210
+ const sorted = s.points.slice().sort((a, b) => a.t - b.t);
211
+
212
+ if (s.type === 'area' || s.area) {
213
+ const g = ctx.createLinearGradient(0, L.top, 0, L.top + plotH);
214
+ g.addColorStop(0, hexA(color, 0.34));
215
+ g.addColorStop(1, hexA(color, 0.02));
216
+ ctx.fillStyle = g;
217
+ ctx.beginPath();
218
+ ctx.moveTo(X(sorted[0].t), L.top + plotH);
219
+ for (const p of sorted) ctx.lineTo(X(p.t), clampN(yOf(p.v), L.top - 40, L.top + plotH + 40));
220
+ ctx.lineTo(X(sorted[sorted.length - 1].t), L.top + plotH);
221
+ ctx.closePath();
222
+ ctx.fill();
223
+ }
224
+ ctx.strokeStyle = color;
225
+ ctx.lineWidth = s.width ?? 1.6;
226
+ ctx.lineJoin = 'round';
227
+ ctx.beginPath();
228
+ let started = false;
229
+ for (const p of sorted) {
230
+ const x = X(p.t);
231
+ const y = clampN(yOf(p.v), L.top - 60, L.top + plotH + 60);
232
+ if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
233
+ }
234
+ ctx.stroke();
235
+ const last = sorted[sorted.length - 1];
236
+ ctx.fillStyle = color;
237
+ ctx.beginPath();
238
+ ctx.arc(X(last.t), clampN(yOf(last.v), L.top, L.top + plotH), 2.2, 0, 7);
239
+ ctx.fill();
240
+ }
241
+
242
+ if (opts.marker != null && Number.isFinite(opts.marker.v)) {
243
+ const y = clampN(Y(opts.marker.v), L.top, L.top + plotH);
244
+ ctx.strokeStyle = opts.marker.color ?? COL.warn;
245
+ ctx.setLineDash([3, 3]);
246
+ ctx.beginPath(); ctx.moveTo(L.left, y); ctx.lineTo(w - L.right, y); ctx.stroke();
247
+ ctx.setLineDash([]);
248
+ if (opts.marker.label) {
249
+ ctx.fillStyle = opts.marker.color ?? COL.warn;
250
+ ctx.textAlign = 'left';
251
+ ctx.fillText(opts.marker.label, L.left + 4, y - 7);
252
+ }
253
+ }
254
+
255
+ if (pts.length > 1 && opts.legend !== false) drawLegend(ctx, pts, L, w);
256
+
257
+ canvas.__hasData = true;
258
+ canvas.__since = t1;
259
+ canvas.__chart = { kind: 'line', t0, t1, X, Y, Y2, series: pts, L, plotW, plotH, opts, w, h };
260
+ canvas.__chart.redrawWith = () => lineChart(canvas, series, opts);
261
+ attachLineTip(canvas, opts);
262
+ // Freshness pill: if the newest sample is old, say so rather than let the
263
+ // chart read as live.
264
+ if (opts.staleMs != null && Date.now() - t1 > opts.staleMs) {
265
+ markStale(canvas, `stale ${ageLabel(Date.now() - t1)}`);
266
+ }
267
+ }
268
+
269
+ function logY(v, lo, hi, L, plotH) {
270
+ const l = Math.log10(Math.max(1e-9, lo || 1e-9));
271
+ const hh = Math.log10(Math.max(1e-9, hi));
272
+ const y = Math.log10(Math.max(1e-9, v));
273
+ return L.top + plotH - ((y - l) / (hh - l)) * plotH;
274
+ }
275
+
276
+ function drawLegend(ctx, series, L, w) {
277
+ ctx.font = '10px system-ui, sans-serif';
278
+ let x = L.left + 2;
279
+ const y = L.top + 7;
280
+ for (const s of series.slice(0, 5)) {
281
+ const label = s.label ?? '';
282
+ const wid = ctx.measureText(label).width;
283
+ ctx.fillStyle = s.color ?? COL.accent;
284
+ ctx.fillRect(x, y - 4, 7, 2);
285
+ ctx.fillStyle = COL.text;
286
+ ctx.textAlign = 'left';
287
+ ctx.fillText(label, x + 10, y - 3);
288
+ x += wid + 20;
289
+ if (x > w - 40) break;
290
+ }
291
+ ctx.font = '10px ui-monospace, monospace';
292
+ }
293
+
294
+ function attachLineTip(canvas, opts) {
295
+ if (canvas.__tipBound) return;
296
+ canvas.__tipBound = true;
297
+ canvas.addEventListener('mousemove', (ev) => {
298
+ const c = canvas.__chart;
299
+ if (!c || c.kind !== 'line') return;
300
+ const r = canvas.getBoundingClientRect();
301
+ const mx = ev.clientX - r.left;
302
+ if (mx < c.L.left || mx > c.w - c.L.right) { redraw(canvas); return; }
303
+ const t = c.t0 + ((mx - c.L.left) / c.plotW) * (c.t1 - c.t0);
304
+ const rows = [];
305
+ let nearX = mx;
306
+ for (const s of c.series) {
307
+ let best = null;
308
+ let bd = Infinity;
309
+ for (const p of s.points) {
310
+ const d = Math.abs(p.t - t);
311
+ if (d < bd) { bd = d; best = p; }
312
+ }
313
+ if (best) {
314
+ rows.push({ label: s.label ?? '', v: best.v, color: s.color ?? COL.accent, axis: s.axis });
315
+ nearX = c.X(best.t);
316
+ }
317
+ }
318
+ if (!rows.length) return hideTip(canvas);
319
+ redraw(canvas);
320
+ const { ctx } = ctxOf(canvas);
321
+ ctx.strokeStyle = INK.faintLine;
322
+ ctx.beginPath(); ctx.moveTo(nearX, c.L.top); ctx.lineTo(nearX, c.L.top + c.plotH); ctx.stroke();
323
+ const fmt = opts.fmtTip ?? ((v, s) => short(v));
324
+ const boxW = 118;
325
+ const boxH = 14 + rows.length * 12;
326
+ const bx = Math.min(c.w - boxW - 4, nearX + 8);
327
+ const by = c.L.top + 4;
328
+ ctx.fillStyle = INK.tip;
329
+ ctx.strokeStyle = INK.tipLine;
330
+ ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 5); ctx.fill(); ctx.stroke();
331
+ ctx.textAlign = 'left';
332
+ ctx.fillStyle = COL.text;
333
+ ctx.fillText(clock(t), bx + 6, by + 8);
334
+ rows.forEach((row, i) => {
335
+ const y = by + 20 + i * 12;
336
+ ctx.fillStyle = row.color;
337
+ ctx.fillRect(bx + 6, y - 3, 6, 2);
338
+ ctx.fillStyle = INK.tipText;
339
+ const label = (row.label || '').slice(0, 12);
340
+ ctx.fillText(label, bx + 16, y - 2);
341
+ ctx.textAlign = 'right';
342
+ ctx.fillStyle = INK.bright;
343
+ ctx.fillText(fmt(row.v, row), bx + boxW - 6, y - 2);
344
+ ctx.textAlign = 'left';
345
+ });
346
+ });
347
+ canvas.addEventListener('mouseleave', () => hideTip(canvas));
348
+ }
349
+
350
+ function redraw(canvas) {
351
+ const c = canvas.__chart;
352
+ if (!c) return;
353
+ if (c.redrawWith) c.redrawWith();
354
+ }
355
+ function ctxOf(canvas) {
356
+ // Deliberately does NOT clear. The caller has just repainted the chart through
357
+ // redraw(), and this context exists only to draw the crosshair and the popup ON TOP
358
+ // of it. The clearRect this used to carry -- on the reasonable-looking theory that a
359
+ // helper handing out a context should start from a clean slate -- erased the very
360
+ // chart it had been asked to annotate: hovering replaced 120 draw ops of chart with
361
+ // the 12 a popup needs, so the chart vanished and only the tooltip was left standing.
362
+ // "Never erase the screen to show a status" applies to a tooltip exactly as much as
363
+ // to a placeholder: the overlay may sit on the data, never instead of it.
364
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
365
+ const ctx = canvas.getContext('2d');
366
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
367
+ ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
368
+ ctx.textBaseline = 'middle';
369
+ return { ctx };
370
+ }
371
+ /** Drop the crosshair and the popup by repainting the chart that sits under them. */
372
+ function hideTip(canvas) { redraw(canvas); }
373
+
374
+ // ------------------------------------------------------------------- bar
375
+
376
+ /** values aligned to edges (histogram). opts: {edges, logX, color, fmtX, fmtY, highlight} */
377
+ export function histogram(canvas, values, opts = {}) {
378
+ const vals = (values ?? []).map(Number);
379
+ if (!vals.length || vals.every((v) => !v)) return empty(canvas, opts.emptyMsg ?? 'no samples');
380
+ const { ctx, w, h } = prep(canvas);
381
+ const L = { ...M, left: opts.left ?? M.left };
382
+ const plotW = w - L.left - L.right;
383
+ const plotH = h - L.top - L.bottom;
384
+ const hi = Math.max(...vals);
385
+ const lo = 0;
386
+ const n = vals.length;
387
+ const bw = plotW / n;
388
+ const edges = opts.edges ?? null;
389
+
390
+ ctx.strokeStyle = COL.grid; ctx.fillStyle = COL.text; ctx.lineWidth = 1;
391
+ ctx.textAlign = 'right';
392
+ for (const tv of niceTicks(lo, hi, 3)) {
393
+ const y = Math.round(L.top + plotH - (tv / hi) * plotH) + 0.5;
394
+ ctx.beginPath(); ctx.moveTo(L.left, y); ctx.lineTo(w - L.right, y); ctx.stroke();
395
+ ctx.fillText(short(tv), L.left - 5, y);
396
+ }
397
+
398
+ for (let i = 0; i < n; i++) {
399
+ if (!vals[i]) continue;
400
+ const bh = (vals[i] / hi) * plotH;
401
+ const x = L.left + i * bw;
402
+ const isHi = opts.highlight != null && edges && edges[i] != null && edges[i] <= opts.highlight && (edges[i + 1] ?? Infinity) > opts.highlight;
403
+ const g = ctx.createLinearGradient(0, L.top + plotH - bh, 0, L.top + plotH);
404
+ const col = isHi ? COL.ok : (opts.color ?? COL.accent);
405
+ g.addColorStop(0, col);
406
+ g.addColorStop(1, hexA(col, 0.42));
407
+ ctx.fillStyle = g;
408
+ ctx.fillRect(x + Math.min(1.5, bw * 0.12), L.top + plotH - bh, Math.max(1, bw - Math.min(3, bw * 0.24)), bh);
409
+ }
410
+
411
+ if (edges && edges.length) {
412
+ ctx.fillStyle = COL.text;
413
+ ctx.textAlign = 'center';
414
+ const step = Math.max(1, Math.floor(n / 6));
415
+ for (let i = 0; i < n; i += step) {
416
+ const x = L.left + i * bw + bw / 2;
417
+ if (x > w - L.right - 8) break;
418
+ ctx.fillText((opts.fmtX ?? short)(edges[i]), x, h - 7);
419
+ }
420
+ }
421
+ // The axis label sits in the plot's top-right corner, not on the tick row:
422
+ // at the tick row's end it printed over the last tick ("sat/873 (log)").
423
+ if (opts.axisLabel) {
424
+ ctx.fillStyle = COL.textDim;
425
+ ctx.textAlign = 'right';
426
+ ctx.textBaseline = 'top';
427
+ ctx.fillText(opts.axisLabel, w - L.right, 3);
428
+ ctx.textBaseline = 'alphabetic';
429
+ }
430
+ canvas.__hasData = true;
431
+ canvas.__since = Date.now();
432
+ canvas.__chart = { kind: 'hist' };
433
+ }
434
+
435
+ // --------------------------------------------------------------- scatter
436
+
437
+ /** points: [[x, y, size]] ; logY for feerate */
438
+ export function scatter(canvas, points, opts = {}) {
439
+ const pts = (points ?? []).filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
440
+ if (!pts.length) return empty(canvas, opts.emptyMsg ?? 'no transactions to plot');
441
+ const { ctx, w, h } = prep(canvas);
442
+ const L = { ...M, left: opts.left ?? M.left };
443
+ const plotW = w - L.left - L.right;
444
+ const plotH = h - L.top - L.bottom;
445
+ const xs = pts.map((p) => p[0]);
446
+ const ys = pts.map((p) => p[1]);
447
+ const x0 = opts.xMin ?? Math.min(...xs);
448
+ const x1 = opts.xMax ?? Math.max(...xs);
449
+ let y0 = opts.yMin ?? Math.max(0.1, Math.min(...ys));
450
+ let y1 = opts.yMax ?? Math.max(...ys) * 1.15;
451
+ if (opts.logY) { y0 = Math.max(0.05, y0); y1 = Math.max(y0 * 10, y1); }
452
+ const X = (v) => L.left + ((v - x0) / Math.max(1e-9, x1 - x0)) * plotW;
453
+ const Y = (v) => opts.logY ? logY(v, y0, y1, L, plotH) : L.top + plotH - ((v - y0) / (y1 - y0)) * plotH;
454
+
455
+ ctx.strokeStyle = COL.grid; ctx.fillStyle = COL.text; ctx.lineWidth = 1;
456
+ ctx.textAlign = 'right';
457
+ const yticks = opts.logY ? logTicks(y0, y1) : niceTicks(y0, y1, Math.max(2, Math.min(4, Math.floor(plotH / 22))));
458
+ let lastLabel = null;
459
+ for (const tv of yticks) {
460
+ const y = Math.round(Y(tv)) + 0.5;
461
+ if (y < L.top || y > L.top + plotH) continue;
462
+ ctx.beginPath(); ctx.moveTo(L.left, y); ctx.lineTo(w - L.right, y); ctx.stroke();
463
+ const label = (opts.fmtY ?? short)(tv);
464
+ if (label !== lastLabel) ctx.fillText(label, L.left - 5, y);
465
+ lastLabel = label;
466
+ }
467
+ ctx.textAlign = 'center';
468
+ for (const tv of niceTicks(x0, x1, 4)) {
469
+ const x = X(tv);
470
+ ctx.fillText((opts.fmtX ?? dur)(tv), x, h - 7);
471
+ }
472
+
473
+ const maxS = Math.max(...pts.map((p) => p[2] ?? 1), 1);
474
+ for (const p of pts) {
475
+ const x = X(p[0]);
476
+ const y = Y(p[1]);
477
+ if (x < L.left || x > w - L.right || y < L.top || y > L.top + plotH) continue;
478
+ const r = opts.logY ? 1.2 + 2.4 * Math.sqrt((p[2] ?? 1) / maxS) : 1.8;
479
+ ctx.fillStyle = opts.color ?? hexA(COL.accent, 0.5);
480
+ ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill();
481
+ }
482
+ if (opts.yLabel) {
483
+ ctx.save();
484
+ ctx.fillStyle = COL.textDim;
485
+ ctx.textAlign = 'left';
486
+ ctx.translate(10, L.top + 4);
487
+ ctx.rotate(-Math.PI / 2);
488
+ ctx.textAlign = 'right';
489
+ ctx.fillText(opts.yLabel, 0, 0);
490
+ ctx.restore();
491
+ }
492
+ canvas.__hasData = true;
493
+ canvas.__since = Date.now();
494
+ canvas.__chart = { kind: 'scatter' };
495
+ }
496
+
497
+ // ------------------------------------------------------------ gauge/usage
498
+
499
+ /** horizontal usage meter with a danger zone; used for mempool usage vs max */
500
+ export function meter(canvas, { value, max, label, danger = 0.9, fmt }) {
501
+ if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) return empty(canvas, 'no usage figure');
502
+ const { ctx, w, h } = prep(canvas);
503
+ // Text is placed in PIXELS, not as fractions of the radius: in a short
504
+ // canvas the three radius-relative lines landed on top of each other
505
+ // ("of maxmempool", the percentage and the byte figures, in one pile).
506
+ // The percentage sits inside the arc; the byte figures sit below it.
507
+ const r = Math.max(18, Math.min(h - 26, w / 2 - 12, 64));
508
+ const cx = w / 2;
509
+ const cy = h - 20;
510
+ const pct = Math.min(1.15, value / max);
511
+ const a0 = Math.PI;
512
+ const a1 = Math.PI * 2;
513
+ ctx.lineWidth = Math.max(7, r * 0.34);
514
+ ctx.lineCap = 'butt';
515
+ ctx.strokeStyle = INK.panel;
516
+ ctx.beginPath(); ctx.arc(cx, cy, r, a0, a1); ctx.stroke();
517
+ // the region past which the node starts feerate-evicting
518
+ ctx.strokeStyle = hexA(COL.bad, 0.5);
519
+ ctx.beginPath(); ctx.arc(cx, cy, r, a0 + Math.PI * danger, a1); ctx.stroke();
520
+ const col = pct >= danger ? COL.bad : pct > 0.75 ? COL.warn : COL.ok;
521
+ ctx.strokeStyle = col;
522
+ ctx.beginPath(); ctx.arc(cx, cy, r, a0, a0 + Math.PI * Math.min(1, pct)); ctx.stroke();
523
+ ctx.fillStyle = INK.bright;
524
+ ctx.textAlign = 'center';
525
+ ctx.font = '600 17px ui-monospace, monospace';
526
+ ctx.fillText(`${((value / max) * 100).toFixed(1)}%`, cx, cy - 4);
527
+ ctx.font = '10px ui-monospace, monospace';
528
+ ctx.fillStyle = COL.text;
529
+ ctx.fillText((fmt ? fmt(value) : short(value)) + ' of ' + (fmt ? fmt(max) : short(max)) + (label ? ' ' + label : ''), cx, h - 4);
530
+ ctx.textAlign = 'left';
531
+ canvas.__hasData = true;
532
+ canvas.__since = Date.now();
533
+ canvas.__chart = { kind: 'meter' };
534
+ }
535
+
536
+ export function sparkline(canvas, points, opts = {}) {
537
+ const pts = (points ?? []).filter((p) => Number.isFinite(p.v ?? p));
538
+ if (!pts.length) return empty(canvas, opts.emptyMsg ?? '');
539
+ const norm = pts.map((p) => (p.v != null ? { t: p.t, v: p.v } : { t: 0, v: p }));
540
+ lineChart(canvas, [{ label: opts.label ?? '', color: opts.color ?? COL.info, points: norm, area: true }], {
541
+ ...opts, left: 0, bottom: 2, legend: false, fmtY: () => '', fmtX: () => '',
542
+ });
543
+ }
544
+
545
+ /** stacked bars for the peer-connect mix / orphan states */
546
+ export function stackedBars(canvas, rows, opts = {}) {
547
+ const data = (rows ?? []).filter((r) => r && Number.isFinite(r.value));
548
+ if (!data.length) return empty(canvas, opts.emptyMsg);
549
+ const { ctx, w, h } = prep(canvas);
550
+ const L = { top: 6, right: 8, bottom: 16, left: opts.left ?? 116 };
551
+ const plotW = w - L.left - L.right;
552
+ const rowH = Math.min(20, (h - L.top - L.bottom) / data.length);
553
+ const max = Math.max(...data.map((r) => r.value), 1);
554
+ data.forEach((r, i) => {
555
+ const y = L.top + i * rowH;
556
+ ctx.fillStyle = COL.text;
557
+ ctx.textAlign = 'right';
558
+ ctx.fillText(String(r.label ?? '').slice(0, 20), L.left - 6, y + rowH / 2);
559
+ ctx.fillStyle = INK.panel;
560
+ ctx.fillRect(L.left, y + 2, plotW, rowH - 5);
561
+ const bw = (r.value / max) * plotW;
562
+ ctx.fillStyle = r.color ?? COL.accent;
563
+ ctx.fillRect(L.left, y + 2, Math.max(1, bw), rowH - 5);
564
+ ctx.fillStyle = INK.bright;
565
+ ctx.textAlign = 'left';
566
+ ctx.fillText(String(r.display ?? short(r.value)), L.left + Math.min(bw + 5, plotW - 40), y + rowH / 2);
567
+ });
568
+ canvas.__hasData = true;
569
+ canvas.__since = Date.now();
570
+ canvas.__chart = { kind: 'bars' };
571
+ }
572
+
573
+ /**
574
+ * The only way panels should draw.
575
+ *
576
+ * `paint(canvas, { when, draw, placeholder, staleMs })`:
577
+ * - has data -> draw (clearing is fine, it is replacing like with like)
578
+ * - no data, but this canvas already shows data for THIS node -> leave it
579
+ * alone and mark it stale
580
+ * - no data, never had any -> placeholder
581
+ *
582
+ * Background sampling therefore never reaches the foreground: a gap in the
583
+ * stream costs you a small amber pill, not your chart.
584
+ */
585
+ export function paint(canvas, { when, draw, placeholder = 'no data yet', staleMs = 90_000, staleMsg }) {
586
+ if (!canvas) return false;
587
+ if (when) {
588
+ draw(canvas);
589
+ return true;
590
+ }
591
+ if (hasData(canvas)) {
592
+ const since = canvas.__since;
593
+ const age = Number.isFinite(since) ? Date.now() - since : null;
594
+ markStale(canvas, staleMsg ?? (age != null ? `no fresh data · ${ageLabel(age)}` : 'no fresh data'));
595
+ return false;
596
+ }
597
+ empty(canvas, placeholder);
598
+ return false;
599
+ }
600
+
601
+ // ------------------------------------------------------------ formatters
602
+
603
+ export function short(v) {
604
+ if (v == null || !Number.isFinite(v)) return '–';
605
+ const a = Math.abs(v);
606
+ if (a >= 1e12) return (v / 1e12).toFixed(1) + 'T';
607
+ if (a >= 1e9) return (v / 1e9).toFixed(1) + 'G';
608
+ if (a >= 1e6) return (v / 1e6).toFixed(1) + 'M';
609
+ if (a >= 1e3) return (v / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'k';
610
+ if (a >= 100) return v.toFixed(0);
611
+ if (a >= 1) return +v.toFixed(1) + '';
612
+ return +v.toFixed(4) + '';
613
+ }
614
+ export function clock(t) {
615
+ const d = new Date(t);
616
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
617
+ }
618
+ export function dur(sec) {
619
+ if (!Number.isFinite(sec)) return '–';
620
+ if (sec < 90) return `${Math.round(sec)}s`;
621
+ if (sec < 5400) return `${Math.round(sec / 60)}m`;
622
+ if (sec < 172800) return `${(sec / 3600).toFixed(1)}h`;
623
+ return `${(sec / 86400).toFixed(1)}d`;
624
+ }
625
+ function hexA(hex, a) {
626
+ const m = hex.replace('#', '');
627
+ const n = m.length === 3 ? m.split('').map((c) => c + c).join('') : m;
628
+ const r = parseInt(n.slice(0, 2), 16);
629
+ const g = parseInt(n.slice(2, 4), 16);
630
+ const b = parseInt(n.slice(4, 6), 16);
631
+ return `rgba(${r},${g},${b},${a})`;
632
+ }
633
+ function clampN(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
634
+
635
+ export { COL, palette };