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.
- package/CHANGELOG.md +251 -1
- package/README.md +42 -23
- package/bin/blockyard.js +2 -1
- package/docs/API.md +16 -14
- package/docs/ARCHITECTURE.md +92 -5
- package/docs/CONFIGURATION.md +33 -26
- package/docs/GETTING-STARTED.md +5 -2
- package/docs/INSTALL.md +90 -33
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/SECURITY.md +32 -15
- package/docs/TROUBLESHOOTING.md +35 -1
- package/docs/USER-GUIDE.md +266 -26
- package/package.json +1 -1
- package/public/404.html +1 -1
- package/public/css/app.css +306 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +295 -103
- package/public/js/agents.js +228 -51
- package/public/js/app.js +82 -8
- package/public/js/blockscene3d.js +179 -27
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1456 -71
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/login.js +5 -0
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +310 -32
- package/public/js/panels.js +14 -10
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +103 -21
- package/public/js/soundcard.js +459 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/setup.js +34 -12
- package/scripts/shots.mjs +6 -0
- package/scripts/smoke.sh +1 -1
- package/scripts/tls.js +31 -0
- package/server/chain/index/build.js +21 -4
- package/server/collect/monitor.js +30 -1
- package/server/collect/network.js +295 -0
- package/server/config.js +46 -22
- package/server/http/api.js +49 -5
- package/server/http/games.js +77 -0
- package/server/http/server.js +8 -0
- package/server/main.js +53 -8
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +7 -5
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- 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
|
+
};
|
package/public/js/login.js
CHANGED
|
@@ -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');
|
package/public/js/markets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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();
|