blockyard 0.0.9 → 0.1.1

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 (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -0,0 +1,494 @@
1
+ // THE LIVING SKY (operator, 2026-09-16: "suggest a new sky background type we can add to
2
+ // blockyard. I would like for it to be something realistic with blue skies, clouds, the sun,
3
+ // change of day, night with the moon coming out, just something fucking beautiful"; then "do it").
4
+ //
5
+ // A second sky type beside the star field: a real day, drawn from the machine's clock, over every
6
+ // board that has a sky. The sun climbs and sets, the dome goes through night, dawn, day and dusk
7
+ // by the sun's altitude, clouds drift lit from the sun's side, the moon rises at its real phase
8
+ // and the star field (the one details3d already draws) comes out as the sun goes down.
9
+ //
10
+ // It obeys the canvas rules the rest of the renderer lives by (test/viewer-canvas-rules.test.js):
11
+ // no gradients, no globalAlpha, no composite modes, no shadowBlur. The dome is ONE huge soft disc
12
+ // centred far below the horizon, so its rings become gently curved horizontal bands -- which is
13
+ // how a sky dome looks -- and it is cached on an offscreen canvas that is repainted only when the
14
+ // colours move. Clouds are clusters of soft discs, the way the supernova's gas is built. The sun
15
+ // and the moon are discs with halos. Rain is lines. Nothing here needs a dependency.
16
+ //
17
+ // `drawLivingSky` takes the two helpers it needs from details3d as arguments (softStops, and a
18
+ // drawStars bound to the canvas) rather than importing them, so the two files do not import each
19
+ // other. Everything else here is pure, and the tests hold the astronomy to known dates.
20
+
21
+ // ------------------------------------------------------------------- the clock
22
+ /**
23
+ * The hour the sky is drawn at, 0..24, by the clock setting: 'real' (this machine's local time),
24
+ * 'cycle' (a whole day every 24 minutes), 'fixed' (an hour to admire). The date is today's for
25
+ * the moon's phase whatever the clock says.
26
+ */
27
+ export function skyTime(opts = {}, nowMs = Date.now()) {
28
+ const d = new Date(nowMs);
29
+ const mode = opts.skyClock ?? 'real';
30
+ let hour;
31
+ if (mode === 'cycle') hour = ((nowMs / 60000) % 24 + 24) % 24; // a minute an hour
32
+ else if (mode === 'fixed') hour = Math.max(0, Math.min(24, Number(opts.skyHour) || 0)) % 24;
33
+ else hour = d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
34
+ const start = new Date(d.getFullYear(), 0, 0);
35
+ const dayOfYear = Math.floor((d - start) / 86400000);
36
+ // under the cycle a day is 24 minutes, and the moon keeps its calendar in THOSE days: a lunar
37
+ // month goes by in about twelve hours of the clock on the wall, so the phases can be watched
38
+ const cycleDays = mode === 'cycle' ? Math.floor(nowMs / (24 * 60000)) : 0;
39
+ return { hour, dayOfYear, date: d, mode, cycleDays };
40
+ }
41
+
42
+ // ------------------------------------------------------------------- the sun
43
+ const RAD = Math.PI / 180;
44
+ /**
45
+ * Where the sun is: altitude in degrees and its progress across the sky, 0 at rising and 1 at
46
+ * setting. With no latitude, a symmetric day from six to six that reaches 62° at noon. With one,
47
+ * the standard formula: declination from the day of the year, the hour angle from the clock
48
+ * (the machine's local hour is taken as solar time), altitude from both.
49
+ */
50
+ export function sunPosition(hour, dayOfYear = 172, lat = null) {
51
+ if (lat == null || !Number.isFinite(lat)) {
52
+ const alt = 62 * Math.sin(((hour - 6) / 12) * Math.PI);
53
+ return { alt, frac: (hour - 6) / 12, rise: 6, set: 18 };
54
+ }
55
+ const phi = Math.max(-89, Math.min(89, lat)) * RAD;
56
+ const decl = 23.44 * RAD * Math.sin(((284 + dayOfYear) / 365) * Math.PI * 2);
57
+ const H = (hour - 12) * 15 * RAD;
58
+ const alt = Math.asin(Math.sin(phi) * Math.sin(decl) + Math.cos(phi) * Math.cos(decl) * Math.cos(H)) / RAD;
59
+ const c = -Math.tan(phi) * Math.tan(decl);
60
+ const half = c <= -1 ? 12 : c >= 1 ? 0 : Math.acos(c) / RAD / 15; // hours from noon to the horizon
61
+ const rise = 12 - half, set = 12 + half;
62
+ const frac = half > 0 ? (hour - rise) / (2 * half) : (hour - 12) / 12 + 0.5;
63
+ return { alt, frac, rise, set };
64
+ }
65
+
66
+ /**
67
+ * The sun's place on a canvas: left to right over the day, up by altitude; below the horizon at
68
+ * night. `horizon` is where the horizon IS, as a share of the panel's height from the top -- 1 is
69
+ * the bottom edge, which is right for a board with nothing in front of the sky; a board whose land
70
+ * fills the lower third passes about 0.6, so the sun sets behind the hills and not under them
71
+ * (operator, 2026-09-16: "so sun and moon any more?" -- at dusk the disc had dropped behind the
72
+ * terrain long before the sky called it set).
73
+ */
74
+ export function sunScreen(sun, pw, ph, maxAlt = 62, horizon = 1) {
75
+ const H = ph * Math.max(0.2, Math.min(1, horizon));
76
+ const x = pw * (0.06 + 0.88 * Math.max(-0.25, Math.min(1.25, sun.frac)));
77
+ const k = Math.max(-0.6, Math.min(1, sun.alt / maxAlt));
78
+ const y = H - (0.10 + 0.80 * k) * H;
79
+ return { x, y };
80
+ }
81
+
82
+ // ------------------------------------------------------------------- the moon
83
+ const SYNODIC = 29.530588853; // days
84
+ const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14); // a known new moon
85
+ /** The moon's phase for a date: 0 new, 0.5 full, 1 new again; the lit fraction; waxing or not. */
86
+ export function moonPhase(date = new Date()) {
87
+ const days = (date.getTime() - NEW_MOON) / 86400000;
88
+ const p = ((days / SYNODIC) % 1 + 1) % 1;
89
+ return { p, lit: (1 - Math.cos(p * Math.PI * 2)) / 2, waxing: p < 0.5 };
90
+ }
91
+ /** The moon rides the sun's track a share of a day behind: opposite the sun when full, beside it when new. */
92
+ export function moonPosition(hour, phase, dayOfYear = 172, lat = null) {
93
+ const h = ((hour - 24 * phase.p) % 24 + 24) % 24;
94
+ return sunPosition(h, dayOfYear, lat);
95
+ }
96
+
97
+ // ------------------------------------------------------------------- the dome's colours
98
+ // keyframes by the sun's altitude: [alt, zenith, horizon, glow, glow strength]
99
+ export const KEYFRAMES = Object.freeze([
100
+ [-90, [3, 5, 16], [8, 11, 28], [20, 24, 60], 0],
101
+ [-18, [3, 5, 16], [8, 11, 28], [20, 24, 60], 0],
102
+ [-12, [6, 9, 26], [16, 20, 48], [50, 40, 80], 0.15],
103
+ [-6, [12, 20, 54], [56, 46, 84], [150, 80, 90], 0.45],
104
+ [-3, [26, 46, 108], [150, 90, 90], [240, 130, 70], 0.8],
105
+ [0, [52, 92, 168], [232, 140, 84], [255, 160, 70], 1],
106
+ [4, [82, 132, 206], [246, 186, 128], [255, 200, 120], 0.7],
107
+ [12, [66, 134, 224], [196, 220, 242], [255, 235, 200], 0.25],
108
+ [30, [44, 112, 222], [200, 224, 244], [255, 250, 235], 0.08],
109
+ [90, [30, 92, 210], [206, 228, 246], [255, 255, 245], 0],
110
+ ]);
111
+ const lerp = (a, b, k) => a + (b - a) * k;
112
+ const mix3 = (a, b, k) => [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
113
+ const rgb = (c, a = 1) => `rgba(${Math.round(c[0])},${Math.round(c[1])},${Math.round(c[2])},${a})`;
114
+
115
+ /** The dome's colours at a sun altitude: zenith, horizon, the glow at the sun's side, and how strong. */
116
+ export function skyColours(alt) {
117
+ const a = Math.max(-90, Math.min(90, alt));
118
+ let i = 0;
119
+ while (i < KEYFRAMES.length - 2 && KEYFRAMES[i + 1][0] <= a) i++;
120
+ const [a0, z0, h0, g0, s0] = KEYFRAMES[i], [a1, z1, h1, g1, s1] = KEYFRAMES[i + 1];
121
+ const k = a1 > a0 ? Math.max(0, Math.min(1, (a - a0) / (a1 - a0))) : 0;
122
+ return { zenith: mix3(z0, z1, k), horizon: mix3(h0, h1, k), glow: mix3(g0, g1, k), glowStrength: lerp(s0, s1, k), dayness: Math.max(0, Math.min(1, (a + 6) / 18)) };
123
+ }
124
+ /** How much of the star field shows: all of it below -12°, none above -1°. */
125
+ export const starVisibility = (alt) => Math.max(0, Math.min(1, (-1 - alt) / 11));
126
+
127
+ // ------------------------------------------------------------------- clouds
128
+ const hash = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
129
+ /**
130
+ * A seeded field of cumulus: each a cluster of puffs at a height band, with its own drift. The
131
+ * same seed always gives the same sky, so a screenshot and a test agree.
132
+ */
133
+ export function cloudField(seed = 3, count = 8) {
134
+ const out = [];
135
+ for (let i = 0; i < count; i++) {
136
+ const h = (k) => hash(seed * 97 + i * 13 + k);
137
+ const puffs = [];
138
+ const n = 4 + Math.floor(h(1) * 5);
139
+ for (let j = 0; j < n; j++) puffs.push({ dx: (j / (n - 1) - 0.5) * 1.6 + (h(10 + j) - 0.5) * 0.3, dy: -(0.35 * Math.sin((j / (n - 1)) * Math.PI)) - h(20 + j) * 0.15, r: 0.32 + h(30 + j) * 0.3 });
140
+ out.push({ u: h(2), v: 0.10 + h(3) * 0.42, s: 0.55 + h(4) * 0.9, speed: 0.004 + h(5) * 0.006, puffs });
141
+ }
142
+ return out;
143
+ }
144
+ /** Long thin cirrus streaks, high up. */
145
+ export function cirrusField(seed = 5, count = 4) {
146
+ const out = [];
147
+ for (let i = 0; i < count; i++) {
148
+ const h = (k) => hash(seed * 131 + i * 17 + k);
149
+ out.push({ u: h(1), v: 0.05 + h(2) * 0.2, len: 0.18 + h(3) * 0.25, tilt: (h(4) - 0.5) * 0.12, speed: 0.0015 + h(5) * 0.002, thick: 0.006 + h(6) * 0.008 });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ export const WEATHER = Object.freeze({
155
+ clear: { cover: 0.12, clouds: 3, grey: 0, rain: false, storm: false },
156
+ scattered: { cover: 0.4, clouds: 8, grey: 0.08, rain: false, storm: false },
157
+ overcast: { cover: 0.85, clouds: 16, grey: 0.35, rain: false, storm: false },
158
+ storm: { cover: 1, clouds: 18, grey: 0.6, rain: true, storm: true },
159
+ });
160
+
161
+ // ------------------------------------------------------------------- drawing
162
+ const CACHE = new WeakMap(); // canvas -> { dome, key, w, h }
163
+
164
+ function domeLayer(ctx, pw, ph, key, paint) {
165
+ const cv = ctx.canvas;
166
+ if (!cv || typeof ctx.drawImage !== 'function' || typeof document === 'undefined' || !document.createElement) return false;
167
+ let c = CACHE.get(cv);
168
+ if (!c || c.w !== pw || c.h !== ph) {
169
+ const canvas = document.createElement('canvas'); canvas.width = pw; canvas.height = ph;
170
+ const c2 = canvas.getContext?.('2d');
171
+ if (!c2) return false;
172
+ c = { canvas, ctx: c2, key: null, w: pw, h: ph };
173
+ CACHE.set(cv, c);
174
+ }
175
+ if (c.key !== key) { c.ctx.setTransform(1, 0, 0, 1, 0, 0); c.ctx.clearRect(0, 0, pw, ph); paint(c.ctx); c.key = key; }
176
+ ctx.drawImage(c.canvas, 0, 0);
177
+ return true;
178
+ }
179
+
180
+ function paintDome(ctx, pw, ph, cols) {
181
+ // OPAQUE DISCS FROM THE OUTSIDE IN. The dome is one circle centred well below the bottom edge,
182
+ // so its rings curve gently like a sky's; each disc is painted whole in the colour of its own
183
+ // height, largest (the zenith) first, so what is left showing between two radii is the colour
184
+ // of the smaller -- the horizon's at the bottom, the zenith's at the top. Not softStops: that
185
+ // helper composes translucent discs for a bright centre fading OUTWARD, and a sky is the other
186
+ // way round (the first cut came out one flat zenith blue for exactly that reason).
187
+ const cy = ph * 2.4, R = cy;
188
+ const tH = (cy - ph) / R; // the horizon's radius, as a share of R
189
+ // the zenith colour under everything: the corners above the dome's crown are the top of the
190
+ // sky, and a first cut that left them unpainted showed the board's black as a dark arc
191
+ ctx.fillStyle = rgb(cols.zenith);
192
+ ctx.fillRect(0, 0, pw, ph);
193
+ const N = 140;
194
+ for (let i = 0; i <= N; i++) {
195
+ const t = 1 - (i / N) * (1 - tH) - (i === N ? 0.002 : 0);
196
+ const k = Math.pow(Math.max(0, (t - tH) / (1 - tH)), 0.75); // 1 at the zenith, 0 at the horizon; the haze holds the low sky
197
+ ctx.fillStyle = rgb(mix3(cols.horizon, cols.zenith, k));
198
+ ctx.beginPath(); ctx.arc(pw / 2, cy, Math.max(1, R * t), 0, Math.PI * 2); ctx.fill();
199
+ }
200
+ // the ground line: the horizon colour fills what the smallest disc leaves at the very bottom
201
+ ctx.fillStyle = rgb(cols.horizon);
202
+ ctx.fillRect(0, ph - 2, pw, 2);
203
+ }
204
+
205
+ function paintTwilight(ctx, pw, ph, sunX, cols, softStops) {
206
+ if (cols.glowStrength <= 0.02) return;
207
+ const R = pw * 0.62;
208
+ softStops(ctx, sunX, ph + ph * 0.12, R, [
209
+ [0, rgb(cols.glow, 0.62 * cols.glowStrength)], [0.35, rgb(cols.glow, 0.32 * cols.glowStrength)], [1, rgb(cols.glow, 0)],
210
+ ], 90);
211
+ }
212
+
213
+ // The sun in two passes: its GLOW is painted before the clouds, so they are lit from behind, and
214
+ // its DISC after them, so it is never lost behind one (operator, 2026-09-16: at noon it was a small
215
+ // pale spot under a cloud). And it is half again as big as the first cut: a sun a fiftieth of the
216
+ // panel high is a star, not a sun.
217
+ function paintSun(ctx, pw, ph, s, sun, cols, softStops, pass = 'both') {
218
+ if (sun.alt < -8) return;
219
+ const k = Math.max(0, Math.min(1, sun.alt / 30));
220
+ // THE SUN IS NOT WHITE (operator, 2026-09-16: "The Sun is white?!"). A white disc is what a
221
+ // camera makes of it; the eye, and every painting of a sky, sees gold. Deep orange on the
222
+ // horizon, warm gold overhead -- never past a warm cream, even at noon.
223
+ const c = mix3([255, 120, 60], [255, 212, 116], k);
224
+ const r = ph * 0.032 * (1 + 0.7 * (1 - k));
225
+ const glow = 0.3 + 0.35 * (1 - k);
226
+ const vis = sun.alt < 0 ? Math.max(0, 1 + sun.alt / 8) : 1;
227
+ if (pass !== 'disc') softStops(ctx, s.x, s.y, ph * 0.5, [[0, rgb(c, glow * 0.7 * vis)], [0.08, rgb(c, glow * 0.45 * vis)], [0.3, rgb(c, glow * 0.12 * vis)], [1, rgb(c, 0)]], 90);
228
+ if (pass === 'glow') return;
229
+ // THE HALO AND THE EDGE (operator, 2026-09-16, with a screenshot: "still seeing some banding
230
+ // here. Can we add a glow effect around the sun?"). The disc's soft edge was twelve rings, which
231
+ // on a big panel is a step of several pixels each -- the rings in the picture. Ring counts are
232
+ // left to softStops now, which lays one per pixel or so. And a halo in front of the clouds: a
233
+ // wide, faint corona and a tighter bright one, so the sun sits in its own light rather than
234
+ // being a coin on the sky.
235
+ softStops(ctx, s.x, s.y, r * 4.2, [[0, rgb(c, 0.5 * vis)], [0.22, rgb(c, 0.28 * vis)], [0.55, rgb(c, 0.09 * vis)], [1, rgb(c, 0)]]);
236
+ softStops(ctx, s.x, s.y, r * 2.1, [[0, rgb([255, 236, 190], 0.6 * vis)], [0.5, rgb(c, 0.3 * vis)], [1, rgb(c, 0)]]);
237
+ // the disc, with a soft edge
238
+ softStops(ctx, s.x, s.y, r * 1.35, [[0, rgb(c, vis)], [0.7, rgb(c, vis)], [1, rgb(c, 0)]]);
239
+ softStops(ctx, s.x, s.y, r * 0.9, [[0, rgb([255, 246, 214], 0.92 * k * vis)], [1, rgb([255, 246, 214], 0)]]);
240
+ }
241
+
242
+ function paintMoon(ctx, pw, ph, m, moon, phase, night, cols, softStops) {
243
+ if (moon.alt < -3 || phase.lit < 0.03) return;
244
+ const r = ph * 0.046;
245
+ // faint by day (a daytime moon is there, but it is not a lamp), and a thin crescent by day is invisible
246
+ const show = Math.max(0, Math.min(1, (moon.alt + 3) / 6)) * (0.12 + 0.88 * night);
247
+ if (show <= 0.02 || (night < 0.3 && phase.lit < 0.25)) return;
248
+ // THE HALO (operator, 2026-09-16: "Can we give the moon a glow effect as well?"): the same two
249
+ // layers the sun has, in moonlight silver, and stronger the fuller the moon -- a full moon lights
250
+ // the sky round it; a crescent barely does
251
+ if (night > 0.3) {
252
+ const g = night * show * (0.35 + 0.65 * Math.max(0, Math.min(1, phase.lit)));
253
+ const silver = [222, 230, 248];
254
+ softStops(ctx, m.x, m.y, r * 7, [[0, rgb(silver, 0.42 * g)], [0.2, rgb(silver, 0.22 * g)], [0.5, rgb(silver, 0.08 * g)], [1, rgb(silver, 0)]]);
255
+ softStops(ctx, m.x, m.y, r * 2.6, [[0, rgb([236, 240, 255], 0.6 * g)], [0.5, rgb(silver, 0.28 * g)], [1, rgb(silver, 0)]]);
256
+ }
257
+ // ONLY THE LIT PART IS PAINTED. The first cut drew a full disc and then a disc of sky colour over
258
+ // the dark side; once the moon moved in front of the clouds and the stars, that sky-coloured
259
+ // disc was a hole punched in them. The lit shape is a polygon: the outer half-circle on the lit
260
+ // side, and the terminator -- a half-ellipse whose width is the phase -- back along the other.
261
+ const L = Math.max(0.03, Math.min(1, phase.lit));
262
+ const side = phase.waxing ? 1 : -1; // waxing: lit on the right
263
+ const a = r * (2 * L - 1); // the terminator's half-width: negative bulges into the lit side
264
+ const N = 28;
265
+ ctx.fillStyle = rgb([236, 238, 244], show);
266
+ ctx.beginPath();
267
+ for (let i = 0; i <= N; i++) { // the limb, top to bottom on the lit side
268
+ const th = -Math.PI / 2 + (i / N) * Math.PI;
269
+ const px = m.x + side * Math.cos(th) * r, py = m.y + Math.sin(th) * r;
270
+ if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
271
+ }
272
+ for (let i = N; i >= 0; i--) { // the terminator, bottom to top
273
+ const th = -Math.PI / 2 + (i / N) * Math.PI;
274
+ ctx.lineTo(m.x + side * Math.cos(th) * a, m.y + Math.sin(th) * r);
275
+ }
276
+ ctx.closePath();
277
+ ctx.fill();
278
+ // the seas, faint, only when enough of the face is lit for them to sit inside it
279
+ if (L > 0.6) {
280
+ ctx.fillStyle = rgb([196, 200, 212], show * 0.6);
281
+ for (const [dx, dy, rr] of [[0.3 * side, -0.2, 0.28], [0.45 * side, 0.2, 0.16], [0.2 * side, 0.45, 0.14]]) { ctx.beginPath(); ctx.arc(m.x + dx * r, m.y + dy * r, rr * r, 0, Math.PI * 2); ctx.fill(); }
282
+ }
283
+ }
284
+
285
+ function paintClouds(ctx, pw, ph, now, opts, sun, s, cols, weather, softStops) {
286
+ const cover = Number.isFinite(opts.skyCover) ? Math.max(0, Math.min(1, opts.skyCover)) : weather.cover;
287
+ if (cover <= 0.01) return;
288
+ const count = Math.max(1, Math.round(2 + cover * 16));
289
+ const clouds = cloudField(3, count);
290
+ const grey = weather.grey + Math.max(0, cover - 0.6) * 0.5;
291
+ const day = cols.dayness;
292
+ const sunk = Math.max(0, Math.min(1, sun.alt / 25));
293
+ // lit from the sun's side by day, warm at the horizon, silver by moonlight at night
294
+ const litBase = mix3(mix3([70, 80, 110], [250, 252, 255], day), [255, 205, 150], (1 - sunk) * day * 0.7);
295
+ const lit = mix3(litBase, [120, 125, 140], grey);
296
+ const shade = mix3(lit, mix3([20, 26, 48], [110, 120, 140], day), 0.45 + grey * 0.3);
297
+ const t = now / 1000;
298
+ const wind = (opts.skyWind ?? 1);
299
+ for (let i = 0; i < clouds.length; i++) {
300
+ const c = clouds[i];
301
+ const u = ((c.u + t * c.speed * wind * 0.5) % 1.3 + 1.3) % 1.3 - 0.15;
302
+ const cx = u * pw, cy = c.v * ph;
303
+ const S = ph * 0.11 * c.s;
304
+ const a = 0.55 + 0.4 * cover;
305
+ const toSun = s.x > cx ? 1 : -1;
306
+ for (const p of c.puffs) {
307
+ const px = cx + p.dx * S, py = cy + p.dy * S, r = p.r * S;
308
+ softStops(ctx, px, py + r * 0.35, r * 1.15, [[0, rgb(shade, a * 0.9)], [0.6, rgb(shade, a * 0.6)], [1, rgb(shade, 0)]], 24);
309
+ softStops(ctx, px, py, r, [[0, rgb(lit, a)], [0.55, rgb(lit, a * 0.75)], [1, rgb(lit, 0)]], 24);
310
+ // the bright rim on the sun's side
311
+ if (day > 0.2) softStops(ctx, px + toSun * r * 0.25, py - r * 0.25, r * 0.55, [[0, rgb([255, 255, 255], a * 0.35 * day)], [1, rgb([255, 255, 255], 0)]], 16);
312
+ }
313
+ }
314
+ // cirrus, high and slow, by day -- AS GAS, NOT AS A CHAIN OF DISCS (operator, 2026-09-16: "The
315
+ // banding on that faint cloud strip looks awful. Can we replace with plasma gasses instead?").
316
+ // The first cut laid hard-edged flat discs fourteen pixels apart along each wisp, and where
317
+ // they overlapped the outlines stacked into a row of rings. Each wisp is now a scatter of
318
+ // soft-edged puffs -- softStops, the same way the cumulus is built -- jittered off the line,
319
+ // of varying size, each drawn twice with a small offset along the wisp so it reads stretched
320
+ // in the direction it drifts. No puff has an edge, so nothing bands.
321
+ if (day > 0.3 && !weather.storm) {
322
+ const wisps = cirrusField(5, 3 + Math.round(cover * 3));
323
+ const white = [255, 255, 255];
324
+ for (const w of wisps) {
325
+ const u = ((w.u + t * w.speed * wind) % 1.4 + 1.4) % 1.4 - 0.2;
326
+ const x0 = u * pw, y0 = w.v * ph;
327
+ const n = Math.max(6, Math.round(w.len * pw / 34));
328
+ const dx = w.len * pw / n, dy = w.tilt * ph / n;
329
+ for (let k = 0; k <= n; k++) {
330
+ const f = k / n;
331
+ const j = (q) => hash(w.u * 977 + k * 31 + q);
332
+ const x = x0 + f * w.len * pw + (j(1) - 0.5) * dx * 0.9;
333
+ const y = y0 + f * w.tilt * ph + Math.sin(f * Math.PI * 2 + w.u * 9) * ph * 0.006 + (j(2) - 0.5) * w.thick * ph * 2.5;
334
+ const r = w.thick * ph * (0.45 + Math.sin(f * Math.PI)) * (2.6 + 1.6 * j(3));
335
+ const a = 0.028 * day * (0.6 + 0.8 * j(4));
336
+ const stops = [[0, rgb(white, a)], [0.4, rgb(white, a * 0.55)], [1, rgb(white, 0)]];
337
+ softStops(ctx, x, y, r, stops, 12);
338
+ softStops(ctx, x + dx * 0.45, y + dy * 0.45, r * 0.85, stops, 12);
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ function paintRays(ctx, pw, ph, s, sun, cols) {
345
+ if (sun.alt < 1 || sun.alt > 28) return;
346
+ const a = 0.045 * (1 - sun.alt / 28) * cols.dayness;
347
+ if (a <= 0.004) return;
348
+ ctx.fillStyle = rgb(cols.glow, a);
349
+ for (let i = 0; i < 7; i++) {
350
+ const ang = -Math.PI / 2 + (i - 3) * 0.22 + Math.sin(i * 1.7) * 0.05;
351
+ const w = 0.05 + (i % 3) * 0.02;
352
+ const L = ph * 1.6;
353
+ ctx.beginPath();
354
+ ctx.moveTo(s.x, s.y);
355
+ ctx.lineTo(s.x + Math.cos(ang - w) * L, s.y - Math.sin(ang - w) * L);
356
+ ctx.lineTo(s.x + Math.cos(ang + w) * L, s.y - Math.sin(ang + w) * L);
357
+ ctx.closePath(); ctx.fill();
358
+ }
359
+ }
360
+
361
+ function paintRain(ctx, pw, ph, now, dpr) {
362
+ const n = Math.round((pw * ph) / 3500);
363
+ const fall = (now / 1000) * ph * 1.4;
364
+ ctx.strokeStyle = 'rgba(200,212,232,0.28)';
365
+ ctx.lineWidth = Math.max(1, dpr * 0.8);
366
+ ctx.beginPath();
367
+ for (let i = 0; i < n; i++) {
368
+ const x = ((hash(i * 7 + 1) * pw + (now / 1000) * 30 * (0.5 + hash(i * 3 + 9))) % (pw + 40)) - 20;
369
+ const y = ((hash(i * 11 + 2) * ph + fall * (0.7 + hash(i * 5 + 4) * 0.6)) % (ph + 30)) - 15;
370
+ ctx.moveTo(x, y); ctx.lineTo(x - 3, y + 14 * dpr);
371
+ }
372
+ ctx.stroke();
373
+ }
374
+
375
+ /** A bolt: midpoint displacement from a cloud base to the ground, seeded, with a branch. */
376
+ export function boltPoints(seed, x0, y0, x1, y1, depth = 5) {
377
+ let pts = [{ x: x0, y: y0 }, { x: x1, y: y1 }];
378
+ let k = 0;
379
+ for (let d = 0; d < depth; d++) {
380
+ const next = [pts[0]];
381
+ for (let i = 1; i < pts.length; i++) {
382
+ const a = pts[i - 1], b = pts[i];
383
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
384
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
385
+ const off = (hash(seed * 31 + k++) - 0.5) * len * 0.5;
386
+ next.push({ x: mx + off, y: my + (hash(seed * 37 + k++) - 0.5) * len * 0.12 }, b);
387
+ }
388
+ pts = next;
389
+ }
390
+ return pts;
391
+ }
392
+
393
+ function paintLightning(ctx, pw, ph, now, dpr) {
394
+ const slot = Math.floor(now / 5000);
395
+ if (hash(slot * 3 + 0.5) > 0.35) return; // most slots are quiet
396
+ const at = slot * 5000 + hash(slot * 5 + 0.25) * 4000;
397
+ const age = now - at;
398
+ if (age < 0 || age > 220) return;
399
+ const k = 1 - age / 220;
400
+ ctx.fillStyle = `rgba(220,228,255,${(0.28 * k).toFixed(3)})`;
401
+ ctx.fillRect(0, 0, pw, ph);
402
+ const x0 = pw * (0.15 + hash(slot * 7 + 0.75) * 0.7);
403
+ const pts = boltPoints(slot, x0, ph * 0.2, x0 + (hash(slot * 9 + 0.1) - 0.5) * pw * 0.2, ph * 0.98);
404
+ for (const [w, a] of [[9, 0.18], [4, 0.45], [1.6, 0.95]]) {
405
+ ctx.strokeStyle = `rgba(236,240,255,${(a * k).toFixed(3)})`;
406
+ ctx.lineWidth = w * dpr;
407
+ ctx.beginPath();
408
+ pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
409
+ ctx.stroke();
410
+ }
411
+ }
412
+
413
+ function paintRainbow(ctx, pw, ph, s, sun, dpr) {
414
+ if (sun.alt < 4 || sun.alt > 38) return;
415
+ const k = 1 - Math.abs(sun.alt - 15) / 25;
416
+ const a = 0.16 * Math.max(0, k);
417
+ if (a <= 0.01) return;
418
+ const cx = pw - s.x, cy = ph + ph * 0.55;
419
+ const R = ph * 1.05;
420
+ const bands = [[255, 70, 60], [255, 150, 50], [250, 230, 70], [90, 210, 90], [70, 150, 255], [90, 80, 220], [150, 70, 200]];
421
+ ctx.lineWidth = Math.max(2, ph * 0.011);
422
+ bands.forEach((c, i) => {
423
+ ctx.strokeStyle = rgb(c, a);
424
+ ctx.beginPath(); ctx.arc(cx, cy, R - i * ph * 0.011, Math.PI, Math.PI * 2); ctx.stroke();
425
+ });
426
+ }
427
+
428
+ function paintShootingStar(ctx, pw, ph, now, night, dpr) {
429
+ if (night < 0.5) return;
430
+ const slot = Math.floor(now / 14000);
431
+ if (hash(slot * 13 + 0.35) > 0.5) return;
432
+ const at = slot * 14000 + hash(slot * 17 + 0.65) * 12000;
433
+ const age = now - at;
434
+ if (age < 0 || age > 800) return;
435
+ const f = age / 800;
436
+ const x0 = pw * (0.1 + hash(slot * 19 + 0.2) * 0.8), y0 = ph * (0.05 + hash(slot * 23 + 0.4) * 0.35);
437
+ const dx = pw * 0.18 * (hash(slot * 29 + 0.8) > 0.5 ? 1 : -1), dy = ph * 0.12;
438
+ const hx = x0 + dx * f, hy = y0 + dy * f;
439
+ const tail = Math.min(1, f * 3) * 0.7;
440
+ const a = (1 - f) * night;
441
+ ctx.strokeStyle = `rgba(255,255,255,${(a * 0.8).toFixed(3)})`;
442
+ ctx.lineWidth = 1.2 * dpr;
443
+ ctx.beginPath(); ctx.moveTo(hx - dx * tail, hy - dy * tail); ctx.lineTo(hx, hy); ctx.stroke();
444
+ ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
445
+ ctx.beginPath(); ctx.arc(hx, hy, 1.4 * dpr, 0, Math.PI * 2); ctx.fill();
446
+ }
447
+
448
+ /**
449
+ * The whole sky at `now` (an animation clock, ms) onto `ctx`, `pw` x `ph` device pixels.
450
+ * `helpers.softStops(ctx, x, y, r, stops, N)` and `helpers.drawStars(opts)` come from details3d.
451
+ * Returns what it drew, for a test: the sun's altitude, whether the sun, the moon and the stars
452
+ * showed, the weather.
453
+ */
454
+ export function drawLivingSky(ctx, pw, ph, dpr, now, opts, helpers) {
455
+ const { softStops, drawStars } = helpers;
456
+ const t = skyTime(opts, opts.skyNowMs ?? Date.now());
457
+ const lat = Number.isFinite(opts.skyLat) ? opts.skyLat : null;
458
+ const sun = sunPosition(t.hour, t.dayOfYear, lat);
459
+ const cols = skyColours(sun.alt);
460
+ const night = starVisibility(sun.alt);
461
+ const weather = WEATHER[opts.skyWeather] ?? WEATHER.scattered;
462
+ const horizon = Number.isFinite(opts.skyHorizon) ? opts.skyHorizon : 1;
463
+ const s = sunScreen(sun, pw, ph, lat == null ? 62 : 90, horizon);
464
+ // the dome, cached until the sun has moved half a degree
465
+ const key = `${pw}x${ph}|${Math.round(sun.alt * 2) / 2}`;
466
+ const painted = domeLayer(ctx, pw, ph, key, (c2) => paintDome(c2, pw, ph, cols));
467
+ if (!painted) paintDome(ctx, pw, ph, cols);
468
+ // the stars, fading in as the sun goes down (the star field details3d already keeps)
469
+ if (night > 0.01 && drawStars) drawStars({ ...opts, stars: true, starBrightness: (Number.isFinite(opts.starBrightness) ? opts.starBrightness : 1) * night });
470
+ paintTwilight(ctx, pw, ph, s.x, cols, softStops);
471
+ if (opts.skyShooting !== false) paintShootingStar(ctx, pw, ph, now, night, dpr);
472
+ // the real calendar's phase (operator: "Is it ever a full moon? Does it go through phases?" --
473
+ // yes, on the real dates: new, crescent, quarter, gibbous, full and back over 29.5 days); under
474
+ // the cycle clock the calendar runs a day per 24-minute day, so the month passes in twelve hours
475
+ const phase = moonPhase(t.mode === 'cycle' ? new Date(t.date.getTime() + t.cycleDays * 86400000) : t.date);
476
+ // THE MOON EVERY NIGHT (opts.skyMoon 'night', the shipped choice): on the real track a young
477
+ // crescent sets an hour or two after the sun and a night has no moon in it at all, which is
478
+ // astronomy and not what anyone looking at a night sky for pleasure expects. 'night' puts it on
479
+ // the sun's own track twelve hours behind -- highest at midnight -- and never thinner than a
480
+ // fat crescent; 'real' is the real thing, for the astronomer.
481
+ const nightly = opts.skyMoon !== 'real';
482
+ const moon = nightly ? sunPosition(((t.hour + 12) % 24 + 24) % 24, t.dayOfYear, lat) : moonPosition(t.hour, phase, t.dayOfYear, lat);
483
+ const shown = nightly ? { ...phase, lit: Math.max(phase.lit, 0.4) } : phase;
484
+ const m = sunScreen(moon, pw, ph, lat == null ? 62 : 90, horizon);
485
+ paintSun(ctx, pw, ph, s, sun, cols, softStops, 'glow');
486
+ if (opts.skyRays !== false) paintRays(ctx, pw, ph, s, sun, cols);
487
+ paintClouds(ctx, pw, ph, now, opts, sun, s, cols, weather, softStops);
488
+ // the two discs over the clouds: a sun or a moon lost behind one is a sky with neither
489
+ paintMoon(ctx, pw, ph, m, moon, shown, night, cols, softStops);
490
+ paintSun(ctx, pw, ph, s, sun, cols, softStops, 'disc');
491
+ if (opts.skyRainbow && opts.skyWeather === 'scattered') paintRainbow(ctx, pw, ph, s, sun, dpr);
492
+ if (weather.rain) { paintRain(ctx, pw, ph, now, dpr); if (weather.storm) paintLightning(ctx, pw, ph, now, dpr); }
493
+ return { hour: t.hour, alt: sun.alt, sun: sun.alt >= -8, moon: moon.alt >= -3 && shown.lit >= 0.03, stars: night, weather: opts.skyWeather ?? 'scattered', phase: phase.p, horizon };
494
+ }
@@ -1,6 +1,12 @@
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
+ import { safeNext } from './safenext.js';
8
+ followTheme();
9
+
4
10
  const err = document.getElementById('err');
5
11
  const form = document.getElementById('f');
6
12
  const btn = document.getElementById('go');
@@ -71,8 +77,7 @@ form.addEventListener('submit', async (ev) => {
71
77
  // X-CSRF-Token header -- reading it is the thing a cross-origin page cannot
72
78
  // do, which is what makes the check mean anything.
73
79
  void /(?:^|;\s*)blockyard_csrf=/.test(document.cookie);
74
- const back = new URLSearchParams(location.search).get('next');
75
- window.location.replace(back && back.startsWith('/') ? back : '/');
80
+ window.location.replace(safeNext(new URLSearchParams(location.search).get('next'), location.origin));
76
81
  } catch (e) {
77
82
  show(`cannot reach the server: ${e.message}`);
78
83
  btn.disabled = false;
@@ -81,3 +86,4 @@ form.addEventListener('submit', async (ev) => {
81
86
  });
82
87
 
83
88
  document.getElementById('u').focus();
89
+
@@ -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();