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.
- package/CHANGELOG.md +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- 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/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- 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
|
+
}
|
package/public/js/login.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
|
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();
|