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
@@ -0,0 +1,196 @@
1
+ // THE DOS WORKER: the PC runs here, off the page's thread, for the DOOM and Quake Diversions
2
+ // (operator, 2026-09-15: "Get DOOM working as a diversion inside blockyard with zero dependancies",
3
+ // then "get Quake working as a diversion").
4
+ //
5
+ // A worker because the emulated 486 wants every millisecond it can get: on the page's own thread it
6
+ // would share a 16 ms frame with the layout, the SSE feed and every other tab of the app, and the
7
+ // game would stutter whenever the monitor redrew a chart. Here it runs in ~10 ms slices and yields
8
+ // between them so key presses arrive; the page draws what it is sent.
9
+ //
10
+ // Messages in: boot {game, rate, controls}, run {on}, key {codes}, mouse {dx, dy, buttons}, audio {port}
11
+ // Messages out: status {text}, frame {pixels, palette?}, text {cells, cursor}, stats {mips},
12
+ // saved {name}, exit {code, cells}, error {message}
13
+ // (and controls {scheme}: rebind the running game's keys)
14
+ import { createPC } from './dospc.js';
15
+ import { createSoundCard } from './soundcard.js';
16
+ import { withControls, rebindKeys, quakeAutoexec, GAMES } from './dosio.js';
17
+
18
+ const SLICE_MS = 10;
19
+ const CHUNK = 50000; // instructions between looks for a finished picture
20
+
21
+ let pc = null, card = null, audioPort = null;
22
+ let running = false, scheduled = false;
23
+ let clock = 0, lastWall = 0; // machine time: wall time while running, frozen while not
24
+ let lastFrameSeq = -1, lastPalSeq = -1, lastText = null;
25
+ let pixels = new Uint8Array(64000); // bounced back by the page after each frame, to reuse
26
+ let lastWrites = -1, seenWrites = -1;
27
+ const keyEntries = {}; // where DOOM's key settings live, once found
28
+ const yieldChannel = new MessageChannel();
29
+ yieldChannel.port1.onmessage = () => { scheduled = false; loop(); };
30
+
31
+ const post = (m, transfer) => self.postMessage(m, transfer ?? []);
32
+ const now = () => (running ? clock + (performance.now() - lastWall) : clock);
33
+
34
+ // ------------------------------------------------------------------ saved files (IndexedDB)
35
+ // Savegames and the config the game writes on quit, kept in this browser. A browser that refuses
36
+ // IndexedDB still plays; its saves last as long as the tab.
37
+ function db() {
38
+ return new Promise((resolve) => {
39
+ try {
40
+ const req = indexedDB.open(game.db, 1);
41
+ req.onupgradeneeded = () => req.result.createObjectStore('files');
42
+ req.onsuccess = () => resolve(req.result);
43
+ req.onerror = () => resolve(null);
44
+ } catch { resolve(null); }
45
+ });
46
+ }
47
+ async function loadSaved() {
48
+ const d = await db();
49
+ if (!d) return {};
50
+ return new Promise((resolve) => {
51
+ const out = {};
52
+ try {
53
+ const tx = d.transaction('files', 'readonly');
54
+ const cur = tx.objectStore('files').openCursor();
55
+ cur.onsuccess = () => {
56
+ const c = cur.result;
57
+ if (!c) { resolve(out); return; }
58
+ if (c.value instanceof Uint8Array) out[c.key] = c.value;
59
+ c.continue();
60
+ };
61
+ cur.onerror = () => resolve(out);
62
+ } catch { resolve(out); }
63
+ });
64
+ }
65
+ async function store(name, bytes) {
66
+ const d = await db();
67
+ if (!d) return;
68
+ try {
69
+ const tx = d.transaction('files', 'readwrite');
70
+ if (bytes) tx.objectStore('files').put(bytes, name); else tx.objectStore('files').delete(name);
71
+ } catch { /* quota, private mode: the save lives for this session */ }
72
+ }
73
+
74
+ // ------------------------------------------------------------------ boot
75
+ let game = null;
76
+ async function fetchFile(name, required) {
77
+ const r = await fetch(`/games/${game.key}/${name}`, { credentials: 'same-origin' });
78
+ if (!r.ok) {
79
+ if (required) throw new Error(`${name} is not installed: put the shareware files in ${game.dir}/ on the server (HTTP ${r.status})`);
80
+ return null;
81
+ }
82
+ return new Uint8Array(await r.arrayBuffer());
83
+ }
84
+
85
+ async function boot({ game: key = 'doom', rate, controls }) {
86
+ game = { key, ...GAMES[key] };
87
+ if (!GAMES[key]) throw new Error(`no game called ${key}`);
88
+ post({ type: 'status', text: `loading ${game.label}…` });
89
+ const names = [game.exe, ...game.required, ...game.optional];
90
+ const [saved, ...got] = await Promise.all([loadSaved(), ...names.map((n, i) => fetchFile(n, i <= game.required.length))]);
91
+ const fetched = Object.fromEntries(names.map((n, i) => [n, got[i]]));
92
+ const files = {};
93
+ for (const n of [...game.required, ...game.optional]) if (fetched[n]) files[n] = fetched[n];
94
+ // the game's saves and the config it wrote on its last quit, over the shipped ones
95
+ const firstRun = !saved[game.config];
96
+ for (const [name, bytes] of Object.entries(saved)) files[name] = bytes;
97
+ if (key === 'doom' && files[game.config]) files[game.config] = withControls(files[game.config], controls);
98
+ if (key === 'quake') files['ID1/AUTOEXEC.CFG'] = quakeAutoexec({ firstRun });
99
+ pc = createPC({
100
+ files,
101
+ args: game.args,
102
+ programName: game.exe,
103
+ now,
104
+ sound: rate ? (mem) => (card = createSoundCard({ mem, rate })) : null,
105
+ onWrite: (name, bytes) => { store(name, bytes); post({ type: 'saved', name, deleted: !bytes }); },
106
+ onExit: (code) => { running = false; post({ type: 'exit', code, cells: pc.mem.slice(0xb8000, 0xb8000 + 4000) }); },
107
+ log: (m) => post({ type: 'log', text: m }),
108
+ });
109
+ pc.boot(fetched[game.exe]);
110
+ post({ type: 'status', text: 'booted' });
111
+ }
112
+
113
+ // ------------------------------------------------------------------ the loop
114
+ function loop() {
115
+ if (!running || !pc || pc.exited) return;
116
+ const start = performance.now();
117
+ let n = 0;
118
+ try {
119
+ // in chunks of about half a millisecond, looking for a finished picture after each: at 50 frames
120
+ // a second two of Quake's frame copies could otherwise land in one 10 ms slice and one never be shown
121
+ do { n += pc.run(CHUNK); present(); } while (performance.now() - start < SLICE_MS && !pc.exited);
122
+ } catch (e) {
123
+ running = false;
124
+ post({ type: 'error', message: e.message });
125
+ return;
126
+ }
127
+ const spent = performance.now() - start;
128
+ mips = mips * 0.95 + (n / Math.max(0.001, spent)) * 0.05 * 1000 / 1e6;
129
+ if (card && audioPort && card.buffered > 0) {
130
+ const chunk = card.drain();
131
+ audioPort.postMessage(chunk, [chunk.buffer]);
132
+ }
133
+ if (performance.now() - lastStats > 1000) { lastStats = performance.now(); post({ type: 'stats', mips }); }
134
+ if (!scheduled) { scheduled = true; yieldChannel.port2.postMessage(0); }
135
+ }
136
+ let mips = 0, lastStats = 0, textTick = 0;
137
+
138
+ function present() {
139
+ if (pc.vga.mode === 0x13) {
140
+ lastText = null;
141
+ // a new picture: a page flipped (DOOM), the palette changed, or the linear window was written
142
+ // (Quake copies each finished frame into A0000h and never flips). A copy can straddle two chunks,
143
+ // so writes are only shown once a chunk has passed without any: never half a frame
144
+ const writing = pc.vga.writes !== seenWrites;
145
+ seenWrites = pc.vga.writes;
146
+ if (writing && pc.vga.frames === lastFrameSeq) return;
147
+ if (pc.vga.frames === lastFrameSeq && pc.vga.palSeq === lastPalSeq && pc.vga.writes === lastWrites) return;
148
+ if (!pixels) return; // the page still has the last frame
149
+ lastFrameSeq = pc.vga.frames; lastWrites = pc.vga.writes;
150
+ const m = { type: 'frame', pixels: pc.renderIndexed(pixels) };
151
+ if (pc.vga.palSeq !== lastPalSeq) { lastPalSeq = pc.vga.palSeq; m.palette = pc.vga.pal.slice(); }
152
+ const buf = pixels.buffer;
153
+ pixels = null;
154
+ post(m, [buf]);
155
+ return;
156
+ }
157
+ lastFrameSeq = -1; lastPalSeq = -1;
158
+ if (++textTick % 20 !== 0) return; // text mode changes slowly: look every ten milliseconds
159
+ const cells = pc.mem.subarray(0xb8000, 0xb8000 + 4000);
160
+ if (lastText && lastText.every((v, i) => v === cells[i])) return;
161
+ lastText = cells.slice();
162
+ post({ type: 'text', cells: lastText.slice(), cursor: [pc.text.x, pc.text.y] });
163
+ }
164
+
165
+ function setRunning(on) {
166
+ if (on === running) return;
167
+ if (on) { lastWall = performance.now(); running = true; loop(); }
168
+ else { clock = now(); running = false; }
169
+ }
170
+
171
+ self.onmessage = async (e) => {
172
+ const m = e.data;
173
+ try {
174
+ switch (m.type) {
175
+ case 'boot': await boot(m); setRunning(true); break;
176
+ case 'run': setRunning(m.on); break;
177
+ case 'key': if (pc) for (const c of m.codes) pc.key(c); break;
178
+ case 'mouse':
179
+ if (pc) { pc.mouse.dx += m.dx; pc.mouse.dy += m.dy; pc.mouse.buttons = m.buttons; }
180
+ break;
181
+ case 'controls':
182
+ if (pc) {
183
+ // the running game's keys now, and the config it would read if it started again
184
+ rebindKeys(pc.mem, m.scheme, keyEntries);
185
+ const cfg = pc.dir.get('DEFAULT.CFG');
186
+ if (cfg) pc.dir.set('DEFAULT.CFG', withControls(cfg, m.scheme));
187
+ }
188
+ break;
189
+ case 'audio': audioPort = m.port; break;
190
+ case 'pixels': pixels = new Uint8Array(m.buffer); break;
191
+ }
192
+ } catch (err) {
193
+ running = false;
194
+ post({ type: 'error', message: err.message });
195
+ }
196
+ };
@@ -1,6 +1,11 @@
1
1
  // Login page. Separate file because the page's CSP is script-src 'self' -- an
2
2
  // inline <script> would simply not run, and "the button does nothing" is a
3
3
  // miserable bug to chase over HTTP.
4
+ // the theme the browser last saw (settings.js keeps a copy under blockyard.settings), so a light
5
+ // theme's sign-in page is light too; a first visit gets the shipped look
6
+ import { followTheme } from './theme.js';
7
+ followTheme();
8
+
4
9
  const err = document.getElementById('err');
5
10
  const form = document.getElementById('f');
6
11
  const btn = document.getElementById('go');
@@ -20,6 +20,7 @@ import { board3d } from './details3d.js';
20
20
  import { loadSettings, setSetting, marketsOptions } from './settings.js';
21
21
  import { drawPriceChart, readout, EX_COLORS } from './pricechart.js';
22
22
  import { niceTicks } from './charts.js';
23
+ import { INK } from './theme.js';
23
24
  import { renderDepth } from './depthchart.js';
24
25
 
25
26
  export const REFRESH_MS = 15_000;
@@ -119,13 +120,23 @@ export function fitZ(aspect, gridW, cam = CAMERA_3D) {
119
120
  return Math.max(12, Math.min(160, Math.floor(z * 0.97)));
120
121
  }
121
122
 
122
- export function chart3d(ser, { hours = MAX_3D_HOURS, zMax = C3.zMax } = {}) {
123
+ // `fit`: the price range the board was last drawn to ({ lo, hi }), from the caller's memory. A
124
+ // STEADY FIT (operator, 2026-09-15: "when the black hole sequence finishes, it causes a strange
125
+ // redraw of the entire market screen that snaps it into a new sized view"). A refresh arriving
126
+ // during an effect is parked until the effect ends, and every refresh re-fitted the price range
127
+ // from the new candles -- so the whole chart re-scaled, unanimated, at the moment the effect let
128
+ // go. The last fit is kept while the data still sits inside it and fills at least two-thirds of
129
+ // it; only data that leaves the range, or shrinks well inside it, re-fits.
130
+ export function chart3d(ser, { hours = MAX_3D_HOURS, zMax = C3.zMax, fit = null } = {}) {
123
131
  const cs = (ser?.candles ?? []).slice(-hours).filter((k) => Number.isFinite(k.c) && Number.isFinite(k.o));
124
132
  if (!cs.length) return { tiles: [], gridW: C3.slot, gridH: C3.depth, axes: null, lo: null, hi: null };
125
133
  let lo = Infinity, hi = -Infinity, vmax = 1e-9;
126
134
  for (const k of cs) { lo = Math.min(lo, k.l ?? k.c); hi = Math.max(hi, k.h ?? k.c); vmax = Math.max(vmax, k.v ?? 0); }
127
135
  const pad = (hi - lo) * 0.04 || 1;
136
+ const rawLo = lo, rawHi = hi;
128
137
  lo -= pad; hi += pad;
138
+ if (fit && Number.isFinite(fit.lo) && Number.isFinite(fit.hi) && fit.hi > fit.lo
139
+ && rawLo >= fit.lo && rawHi <= fit.hi && (rawHi - rawLo) >= (fit.hi - fit.lo) * 0.66) { lo = fit.lo; hi = fit.hi; }
129
140
  const Z = (p) => C3.zBase + ((p - lo) / (hi - lo)) * zMax;
130
141
  const id = ser.base?.id ?? 'x';
131
142
  const name = `${ser.base?.name ?? ''} ${ser.base?.pair ?? ''}`.trim();
@@ -176,18 +187,18 @@ export function tableHtml(d, fmt, now = Date.now()) {
176
187
  const spreadBps = e.spread != null && e.last ? (e.spread / e.last) * 1e4 : null;
177
188
  return `<tr>
178
189
  <td class="w"><b>${fmt.esc(e.name)}</b></td>
179
- <td class="faint">${fmt.esc(e.pair)}</td>
190
+ <td class="faint opt">${fmt.esc(e.pair)}</td>
180
191
  <td class="r">${money(e.last)}</td>
181
192
  <td class="r">${money(e.bid)}</td>
182
193
  <td class="r">${money(e.ask)}</td>
183
194
  <td class="r">${e.spread == null ? '–' : `${money(e.spread)} <span class="faint">${spreadBps < 0.1 ? '<0.1' : spreadBps.toFixed(1)} bp</span>`}</td>
184
195
  <td class="r ${ch > 0 ? 'xpos' : ch < 0 ? 'xneg' : ''}">${ch == null ? '–' : `${ch > 0 ? '+' : ''}${(ch * 100).toFixed(2)}%`}</td>
185
- <td class="r">${money(e.low24, 0)} – ${money(e.high24, 0)}</td>
186
- <td class="r">${e.vol24 == null ? '–' : `${fmt.num(Math.round(e.vol24))} <span class="faint">BTC</span>`}</td>
196
+ <td class="r opt">${money(e.low24, 0)} – ${money(e.high24, 0)}</td>
197
+ <td class="r opt">${e.vol24 == null ? '–' : `${fmt.num(Math.round(e.vol24))} <span class="faint">BTC</span>`}</td>
187
198
  <td class="r ${e.stale ? 'stale' : 'faint'}">${e.error ? `<span class="warn" title="${fmt.esc(e.error)}">${fmt.esc(e.error.slice(0, 40))}</span>` : e.at ? fmt.ago(e.at, now) : '–'}</td>
188
199
  </tr>`;
189
200
  }).join('');
190
- return `<div class="scroll"><table class="t mktbl"><thead><tr><th>exchange</th><th>pair</th><th class="r">last</th><th class="r">bid</th><th class="r">ask</th><th class="r">spread</th><th class="r">24 h</th><th class="r">24 h low – high</th><th class="r">24 h volume</th><th class="r">updated</th></tr></thead><tbody>${rows}</tbody></table></div>`;
201
+ return `<div class="scroll"><table class="t mktbl"><thead><tr><th>exchange</th><th class="opt">pair</th><th class="r">last</th><th class="r">bid</th><th class="r">ask</th><th class="r">spread</th><th class="r">24 h</th><th class="r opt">24 h low – high</th><th class="r opt">24 h volume</th><th class="r">updated</th></tr></thead><tbody>${rows}</tbody></table></div>`;
191
202
  }
192
203
 
193
204
  // (legend3dHtml lived here: a 250px column of prose beside the board explaining that a green body
@@ -236,7 +247,9 @@ function drawBoard(id = 'mkBoard') {
236
247
  if (!canvas || !ser) return null;
237
248
  const hours = Math.min(MAX_3D_HOURS, ser.candles.length);
238
249
  const aspect = (canvas.clientHeight || 400) / Math.max(1, canvas.clientWidth || 1000);
239
- const c3 = chart3d(ser, { zMax: fitZ(aspect, hours * C3.slot) });
250
+ const fitKey = `${ser.base?.id ?? 'x'}|${M.range}|${hours}`;
251
+ const c3 = chart3d(ser, { zMax: fitZ(aspect, hours * C3.slot), fit: M.fitKey === fitKey ? M.fit3d : null });
252
+ M.fitKey = fitKey; M.fit3d = c3.lo != null ? { lo: c3.lo, hi: c3.hi } : null; // the fit the board was drawn to, kept for the next refresh
240
253
  const cam = { ...CAMERA_3D, oblique: { ...CAMERA_3D.oblique, headroom: C3.zBase + c3.zMax + 2 } };
241
254
  if (c3.tiles.length) board3d(canvas, c3.tiles, { ...cam, gridW: c3.gridW, gridH: c3.gridH, axes: c3.axes, ...marketsOptions(loadSettings()) });
242
255
  return { c3, ser };
@@ -295,6 +308,7 @@ function fetchMarkets(h, now) {
295
308
  // 24 h high, low and volume and the spread across books; every exchange's last, change and
296
309
  // spread; how fresh it is. Pure: the /api/markets reply in, markup out.
297
310
  export function priceInfoHtml(d, fmt, now = Date.now()) {
311
+ if (d?.enabled === false) return `<div class="kp-wait">${fmt.esc(d.note ?? 'market data is off on this monitor')}</div>`;
298
312
  if (!d?.exchanges) return '<div class="kp-wait">asking the exchanges…</div>';
299
313
  const usd = d.exchanges.filter((e) => e.quote === 'USD' && e.last != null && !e.stale);
300
314
  const med = d.summary?.median ?? null;
@@ -323,7 +337,19 @@ export function renderPriceInfo(id, h) {
323
337
  // The 3D board alone, on any canvas -- the Kiosk tab's markets panel (kiosk.js)
324
338
  export function renderMarketsBoard(id, h) {
325
339
  fetchMarkets(h, Date.now());
326
- if (M.data?.enabled === false) return null;
340
+ if (M.data?.enabled === false) {
341
+ // the Kiosk's board says so on its own canvas, since there is nothing else on that panel
342
+ const cv = document.getElementById(id), ctx = cv?.getContext?.('2d');
343
+ if (ctx) {
344
+ const w = cv.clientWidth || 600, hh = cv.clientHeight || 300;
345
+ if (cv.width !== w || cv.height !== hh) { cv.width = w; cv.height = hh; }
346
+ ctx.clearRect(0, 0, w, hh);
347
+ ctx.fillStyle = INK.text; ctx.font = '13px system-ui, sans-serif'; ctx.textAlign = 'center';
348
+ ctx.fillText(M.data.polling === false ? 'market polling is off' : 'market data is off on this server', w / 2, hh / 2 - 10);
349
+ if (M.data.polling === false) ctx.fillText('Display settings → Markets & Price → Enable market polling', w / 2, hh / 2 + 12);
350
+ }
351
+ return { label: M.data.polling === false ? 'market polling is off · Display settings → Markets & Price' : 'market data is off on this server' };
352
+ }
327
353
  const b = drawBoard(id);
328
354
  if (!b) return null;
329
355
  const last = b.ser.candles.at(-1)?.c;
@@ -336,7 +362,19 @@ export function renderMarkets(s, state, h) {
336
362
  const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
337
363
  const d = M.data;
338
364
  if (!d) { put('mkTable', `<div class="note">${M.error ? h.fmt.esc(M.error) : 'asking the exchanges…'}</div>`); return; }
339
- if (d.enabled === false) { put('mkTable', `<div class="caveat">${h.fmt.esc(d.note ?? 'market data is off')}</div>`); return; }
365
+ // OFF: the note at the TOP, where the figures go, with a button to the switch -- and the board,
366
+ // the chart and the depth panel folded away (the CSS class), because six hundred pixels of empty
367
+ // board above a note nobody scrolls to is no way to say "polling is off" (2026-09-15)
368
+ const card = document.querySelector('section.page[data-page="markets"] .mkcard');
369
+ if (d.enabled === false) {
370
+ card?.classList.add('mk-off');
371
+ put('mkBar', '');
372
+ put('mkTable', '');
373
+ put('mkSummary', `<div class="caveat mkoff">${h.fmt.esc(d.note ?? 'market data is off')}${d.polling === false ? ' <button type="button" class="btn small" id="mkOpenSettings">Open Display settings</button>' : ''}</div>`);
374
+ document.getElementById('mkOpenSettings')?.addEventListener('click', () => document.getElementById('btnSettings')?.click(), { once: true });
375
+ return;
376
+ }
377
+ card?.classList.remove('mk-off');
340
378
  put('mkSummary', summaryHtml(d, h.fmt));
341
379
  put('mkBar', toolbarHtml(d));
342
380
  bindChart();