blockyard 0.0.1 → 0.0.9
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 +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,2957 @@
|
|
|
1
|
+
// details3d.js -- the renderer. Owns the canvas, the loop, the camera and the
|
|
2
|
+
// per-canvas state; the geometry and choreography are blockscene3d.js.
|
|
3
|
+
//
|
|
4
|
+
// THE RULES THIS FILE EXISTS UNDER, all three learned the hard way here:
|
|
5
|
+
//
|
|
6
|
+
// 1. NO ctx.clip(), NO ctx.globalAlpha, no composite modes. Software
|
|
7
|
+
// rasterisers (headless swiftshader, a VM, a blocklisted GPU) silently
|
|
8
|
+
// DROP fills drawn through a clip and honour globalAlpha inconsistently.
|
|
9
|
+
// That blanked the entire map once. Every fill is a closed path with a
|
|
10
|
+
// solid rgba() colour; depth is paint ORDER alone.
|
|
11
|
+
// 2. Per-canvas state, never module-level. goggles.js once kept ONE rAF
|
|
12
|
+
// handle for two maps, so whichever painted second cancelled the first
|
|
13
|
+
// one's loop.
|
|
14
|
+
// 3. Zero dependencies, no CDN.
|
|
15
|
+
|
|
16
|
+
import { packBlock, packExact, vbytesPerUnit, vsizeForSide } from './blockpack.js';
|
|
17
|
+
import { planTransition, frameAt, fitToBox, project, fxFront, TRANSITION, SLAB_H, TILE_H, surfaceNormal, cellTops } from './blockscene3d.js';
|
|
18
|
+
// THE AGENTS (agents.js): the effects that are something happening rather than a pattern.
|
|
19
|
+
// This module keeps three seams and nothing else -- build here in startFx, frame in fxNow,
|
|
20
|
+
// draw in paintFrame -- so fifty agents do not become fifty `if`s in the renderer.
|
|
21
|
+
import { AGENTS, isAgent, rng } from './agents.js';
|
|
22
|
+
|
|
23
|
+
const STATE = new WeakMap();
|
|
24
|
+
|
|
25
|
+
// The tail cell stands for thousands of small transactions the backend does
|
|
26
|
+
// not send individually. As ONE tile it is a disaster: measured in the
|
|
27
|
+
// browser 2026-09-10, an aggregate of 19,689 transactions carried ~87% of the
|
|
28
|
+
// pool's vbytes and packed to a square 52 units on a side in a 56-wide grid,
|
|
29
|
+
// swallowing everything else. Split into equal pieces instead -- honest,
|
|
30
|
+
// because it really is many transactions and equal pieces claim nothing about
|
|
31
|
+
// individual sizes we do not have.
|
|
32
|
+
const AGG_PIECES = 96;
|
|
33
|
+
|
|
34
|
+
// ONE BLOCK'S WORTH, richest first -- which is how mempool.space always has a
|
|
35
|
+
// full grid and we did not. Their view is a mempool BLOCK: exactly
|
|
36
|
+
// blockVbytes of transactions, so the packing fills resolution x resolution
|
|
37
|
+
// by construction and the last row is never ragged. Ours drew the entire
|
|
38
|
+
// queue, so the grid was however many rows the pool happened to need and the
|
|
39
|
+
// top of it was always partly empty.
|
|
40
|
+
//
|
|
41
|
+
// Cells arrive richest first (the miner's order), so this is a prefix: take
|
|
42
|
+
// transactions until the block is full, and split the cell that straddles the
|
|
43
|
+
// boundary rather than dropping it -- that cell is what makes the fill exact.
|
|
44
|
+
function takeOneBlock(cells, blockVbytes) {
|
|
45
|
+
const out = [];
|
|
46
|
+
let used = 0;
|
|
47
|
+
for (const c of cells || []) {
|
|
48
|
+
const vb = Math.max(1, Number(c.vbytes ?? c.vsize) || 0);
|
|
49
|
+
const room = blockVbytes - used;
|
|
50
|
+
if (room <= 0) break;
|
|
51
|
+
if (vb <= room) { out.push(c); used += vb; continue; }
|
|
52
|
+
// the straddling cell: keep the part that fits, scaled down
|
|
53
|
+
const count = Number(c.aggregate) || 0;
|
|
54
|
+
out.push({ ...c, vbytes: room, aggregate: count ? Math.max(2, Math.round(count * (room / vb))) : c.aggregate });
|
|
55
|
+
used = blockVbytes;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// THE TAIL, CUT TO WHOLE SQUARES (2026-09-11). A square's side is rounded to
|
|
62
|
+
// whole grid units, and 96 equal pieces rounded UP carried up to ~25% more
|
|
63
|
+
// area than their vbytes: the block overflowed the grid. Shrinking the scale
|
|
64
|
+
// to make it fit did nothing until every equal piece dropped a unit at once
|
|
65
|
+
// (4 -> 3 is 44% of their area), which left a quarter of the board as bare
|
|
66
|
+
// floor across the top -- measured on the live Overview. So the tail is cut
|
|
67
|
+
// into however many pieces make each one an EXACT k-unit square, with k no
|
|
68
|
+
// larger than the 96-piece side: the rounding then adds nothing and the block
|
|
69
|
+
// packs flush at full scale. The count is free -- the pieces stand for
|
|
70
|
+
// thousands of transactions we were never sent -- and equal pieces still
|
|
71
|
+
// claim nothing about individual sizes.
|
|
72
|
+
function aggregatePieces(vsize, count, vpu) {
|
|
73
|
+
const base = Math.max(1, Math.min(AGG_PIECES, count));
|
|
74
|
+
if (!(vpu > 0)) return base;
|
|
75
|
+
// k: the whole side of a 1/base share of the tail, allowed 10% of vbytes'
|
|
76
|
+
// slack before it rounds down a unit
|
|
77
|
+
const k = Math.max(1, Math.floor(Math.sqrt((1.1 * (vsize / base)) / vpu)));
|
|
78
|
+
const exact = vsizeForSide(k, vpu); // a vsize blockpack's sideFor turns into exactly k
|
|
79
|
+
return Math.max(1, Math.min(count, 600, Math.round(vsize / exact)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The tail's pieces take the feerate of the stratum they fall in, richest first (the server's
|
|
83
|
+
// strata, 2026-09-11: one mean feerate painted 96% of the Simple board one colour). The number and
|
|
84
|
+
// size of the pieces are exactly as before, so a layout is unchanged; only the colours are new.
|
|
85
|
+
function strataRates(strata, fallback) {
|
|
86
|
+
if (!Array.isArray(strata) || strata.length < 2) return () => fallback;
|
|
87
|
+
const total = strata.reduce((a, s) => a + (Number(s.vbytes) || 0), 0);
|
|
88
|
+
if (!(total > 0)) return () => fallback;
|
|
89
|
+
return (frac) => {
|
|
90
|
+
const want = frac * total;
|
|
91
|
+
let acc = 0;
|
|
92
|
+
for (const s of strata) {
|
|
93
|
+
acc += Number(s.vbytes) || 0;
|
|
94
|
+
if (want < acc) return Math.max(0, Number(s.rate) || 0);
|
|
95
|
+
}
|
|
96
|
+
return Math.max(0, Number(strata.at(-1).rate) || 0);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function toTxs(cells, vpu = 0) {
|
|
101
|
+
const out = [];
|
|
102
|
+
let i = 0;
|
|
103
|
+
for (const c of cells || []) {
|
|
104
|
+
const vsize = Math.max(1, Number(c.vbytes ?? c.vsize) || 0);
|
|
105
|
+
const rate = Math.max(0, Number(c.rate) || 0);
|
|
106
|
+
const count = Number(c.aggregate) || 0;
|
|
107
|
+
if (count > 1) {
|
|
108
|
+
const n = aggregatePieces(vsize, count, vpu);
|
|
109
|
+
const each = Math.max(1, vsize / n);
|
|
110
|
+
// stable ids, or a refresh treats the whole field as departing and
|
|
111
|
+
// arriving and it flashes
|
|
112
|
+
const rateAt = strataRates(c.strata, rate);
|
|
113
|
+
for (let k = 0; k < n; k++) out.push({ txid: `aggregate-${k}`, vsize: each, fee: rateAt((k + 0.5) / n) * each });
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
out.push({ txid: c.txid || `cell-${i}`, vsize, fee: rate * vsize });
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sizeCanvas(canvas, maxDpr = Infinity) {
|
|
124
|
+
// maxDpr: a canvas that may draw at fewer device pixels than the screen has -- Tetrust's sky,
|
|
125
|
+
// where the star count follows the pixel count and a panel-sized galaxy at 2x was a slow game
|
|
126
|
+
const dpr = Math.min(maxDpr, (globalThis.window && window.devicePixelRatio) || 1);
|
|
127
|
+
const w = canvas.clientWidth || canvas.width || 0;
|
|
128
|
+
const h = canvas.clientHeight || canvas.height || 0;
|
|
129
|
+
const pw = Math.max(1, Math.round(w * dpr));
|
|
130
|
+
const ph = Math.max(1, Math.round(h * dpr));
|
|
131
|
+
if (canvas.width !== pw) canvas.width = pw;
|
|
132
|
+
if (canvas.height !== ph) canvas.height = ph;
|
|
133
|
+
return { w, h, dpr, pw, ph };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// THE BOARD AT REST: every 7-13 s while nothing is moving, one idle effect
|
|
137
|
+
// plays (see fxAt in blockscene3d.js), chosen at random and never one played
|
|
138
|
+
// within the no-repeat window (chooseIdleFx). The loop wakes for the effect and parks again, so a still
|
|
139
|
+
// board costs nothing between them. Only with a real DOM (the unit harness and
|
|
140
|
+
// the DOM stub never get one), never under prefers-reduced-motion, never while
|
|
141
|
+
// a transition runs, and it retries later while the board is not on screen.
|
|
142
|
+
// THE THIRTY -- twenty-six at the time (operator, 2026-09-12: "Think of many more other video-game inspired effects ...
|
|
143
|
+
// at least 25 total different effects, all toggleable"). Nine were here; seventeen more live in
|
|
144
|
+
// fxAt (blockscene3d) as pure per-tile functions. Each is one entry here -- how long it runs --
|
|
145
|
+
// and one toggle in settings.js (the `effects` and `marketEffects` groups), and the lists are checked against each other
|
|
146
|
+
// by a test, so an effect cannot ship without a switch or a switch without an effect.
|
|
147
|
+
const FX_MS = {
|
|
148
|
+
ripple: 5200, outline: 4400, tide: 5200, cascade: 5600, twinkle: 3800, scan: 4200,
|
|
149
|
+
lightcycle: 6500, ball: 5600, pulse: 9000, // pulse 7000 -> 9000 (2026-09-14: "make it a bit slower")
|
|
150
|
+
bulge: 16000, // a sphere rolling through the price line; half speed (was 8000)
|
|
151
|
+
shockwave: 4200, nova: 5200, firework: 5600, flare: 3600, wave: 6000, quake: 3200,
|
|
152
|
+
rain: 6400, sparkle: 4600, checker: 4400, radar: 6000, vortex: 6400, powerup: 5000, combo: 4800, aurora: 7200, plasma: 6400,
|
|
153
|
+
// THE AGENTS (agents.js): effects that are a thing MOVING rather than a pattern over the board.
|
|
154
|
+
// Longer than the fields, because something that travels needs time to be watched -- a field
|
|
155
|
+
// reads at a glance, an agent has to arrive, do something, and leave.
|
|
156
|
+
// Most of these were removed on the operator's call after seeing them on the board. The seven
|
|
157
|
+
// that stayed are the ones worth the second animation loop: two riders (lightcycle, ball), a
|
|
158
|
+
// splitter, a thief, an interception, a collapse and a gateway. The count is deliberately not
|
|
159
|
+
// written as a number here -- it went stale twice as effects were culled.
|
|
160
|
+
// tractor 6800 -> 8000 (2026-09-14): the drop is timed by real gravity now and needs the room
|
|
161
|
+
centipede: 7400, tractor: 8000, missile: 7400,
|
|
162
|
+
// stormball replaced portal (2026-09-14); slow on purpose -- it drifts across the whole view
|
|
163
|
+
boulderdash: 6400, stormball: 11000,};
|
|
164
|
+
export const FX_KINDS = Object.keys(FX_MS);
|
|
165
|
+
// The longest a refresh will ever wait for an effect to finish, plus a second of slack. Taken from
|
|
166
|
+
// the table rather than written as a number, so culling or adding an effect cannot leave the cap
|
|
167
|
+
// shorter than the effect it is meant to outlast. See the deferral in render3d.
|
|
168
|
+
const FX_DEFER_MAX = Math.max(...Object.values(FX_MS)) + 1000;
|
|
169
|
+
// THE PULSE RIDES THE PRICE LINE (operator, 2026-09-12: "the energy pulse effect needs to run
|
|
170
|
+
// across the yellow line, not through space on an invisible grid ... travel the yellow line from
|
|
171
|
+
// one end to the other leaving a electric blue tint on the yellow line that starts fading back to
|
|
172
|
+
// normal yellow after 3 seconds"), then, watching it: "the blue line just changes color back to
|
|
173
|
+
// yellow, it doesn't fade out along a path from left to right. Shorten the blue tail ... it should
|
|
174
|
+
// fade out blue and fade back into yellow".
|
|
175
|
+
//
|
|
176
|
+
// So the tint is a TAIL, not a timer. The first cut held every segment blue for three seconds
|
|
177
|
+
// after the head passed -- and the head crosses the line in under three, so the whole line went
|
|
178
|
+
// blue together and snapped back together. Now the blue is strongest at the head and fades to
|
|
179
|
+
// yellow over the quarter of the line behind it, and the head takes most of the effect to cross
|
|
180
|
+
// so the tail can be watched sliding along. The head runs on past the far end until its tail has
|
|
181
|
+
// left too, which is why PULSE_TRAVEL is less than 1 -- and 1/PULSE_TRAVEL must exceed
|
|
182
|
+
// 1 + PULSE_TAIL or the tail is cut off at the edge.
|
|
183
|
+
//
|
|
184
|
+
// Then: "Make the tail at least 2 seconds before it fades out and back towards yellow". 0.66 of
|
|
185
|
+
// 7000 ms is a 4.6 s crossing; a tail 0.45 of the line long therefore lasts 2.1 s at any point.
|
|
186
|
+
const PULSE_TRAVEL = 0.66;
|
|
187
|
+
const PULSE_TAIL = 0.45;
|
|
188
|
+
// a cheap deterministic 0..1 from an integer, for the pulse's particle motes
|
|
189
|
+
const hash01 = (n) => { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); };
|
|
190
|
+
// A board with a price line is not a grid to sweep across. The straight-front effects (outline,
|
|
191
|
+
// scan, tide), the light cycles and the lightning ball all travel the FLOOR, which on the candle
|
|
192
|
+
// board is empty space -- which is what "through space on an invisible grid" describes. Where a
|
|
193
|
+
// line exists, the effects that run are the ones with something to run along.
|
|
194
|
+
// ...and ball lightning crosses the price board too (operator, 2026-09-14: "Add rare ball lightning
|
|
195
|
+
// travelling the 3D Markets from one side to the other and electrifying any elements it comes
|
|
196
|
+
// near. Same rarity as the other effects for the markets")
|
|
197
|
+
// LIGHT CYCLES AND THE LIGHTNING BALL RIDE THE CANDLE GRID TOO (operator, 2026-09-14: "Lightcycles
|
|
198
|
+
// don't work anymore on the market view" -- the line-only list had cut them, while the Markets
|
|
199
|
+
// switch still promised them)
|
|
200
|
+
// EVERY EFFECT PLAYS ON THE PRICE BOARD except the two that ALTER the board (operator, 2026-09-14:
|
|
201
|
+
// "a lot of effects that don't work on the market display"): the fields are light patterns over
|
|
202
|
+
// tiles and light candles as well as cubes; boulder dash collapses tiles and the tractor beam
|
|
203
|
+
// lifts one, and a candle is a price, not a thing to move. The line effects stay the board's own.
|
|
204
|
+
// THE MARKETS LIST IS THE TWELVE THAT TRANSLATE (operator, 2026-09-14: "I think the selection I
|
|
205
|
+
// have made for the market effects is what we should ship with, and remove all the unchecked
|
|
206
|
+
// options from selection. Many of the effects don't translate over to the market chart"). The
|
|
207
|
+
// board is eight units deep and as wide as the hours: what works on it runs along the hours
|
|
208
|
+
// (the fronts, the wave), spreads from the candle row (ripple, the bursts), lights candles
|
|
209
|
+
// where they stand (cascade, twinkle, the flare), or is the price line's own (pulse, bulge, ball
|
|
210
|
+
// lightning). The rest -- riders and walkers, the field patterns, anything that falls down the
|
|
211
|
+
// depth or turns in place, anything that moves a tile -- stays on the block board.
|
|
212
|
+
const ON_CANDLES = new Set(['ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'pulse', 'bulge', 'firework', 'flare', 'wave', 'stormball']);
|
|
213
|
+
// effects that are drawn on the price line and nowhere else: never offered to a board of blocks
|
|
214
|
+
const LINE_ONLY = new Set(['pulse', 'bulge']);
|
|
215
|
+
// EACH BOARD ITS OWN LIST (operator, 2026-09-14: "I want the markets tab to have a separate effects
|
|
216
|
+
// list ... the Block Space effects specific to that panel, and settings specific to market panel"):
|
|
217
|
+
// settings.js keeps one group of switches per list (`effects` for the block board, `marketEffects`
|
|
218
|
+
// for the price board), and test/effects.test.js holds each group to its list here.
|
|
219
|
+
export const SPACE_FX = FX_KINDS.filter((k) => !LINE_ONLY.has(k));
|
|
220
|
+
export const MARKET_FX = FX_KINDS.filter((k) => ON_CANDLES.has(k));
|
|
221
|
+
const DEREZ_MS = 800; // how long a crashed light cycle takes to shatter and fade
|
|
222
|
+
|
|
223
|
+
/** A board with a price line: the Markets board (and Kiosk's), never the block board. */
|
|
224
|
+
export const onPriceBoard = (st) => (st?.axes?.line?.length ?? 0) > 1;
|
|
225
|
+
/** Which way a front (outline, scan, tide, wave) travels: any of seven ways on the block board; along the hours, either way, on the price board. */
|
|
226
|
+
export function fxDirection(kind, st, rnd = Math.random) {
|
|
227
|
+
if (onPriceBoard(st)) return rnd() < 0.5 ? [1, 0] : [-1, 0];
|
|
228
|
+
const dirs = kind === 'scan' ? [[0, 1], [0, -1], [1, 0], [-1, 0]]
|
|
229
|
+
: [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7071, 0.7071], [-0.7071, -0.7071], [0.7071, -0.7071]];
|
|
230
|
+
return dirs[(rnd() * dirs.length) | 0];
|
|
231
|
+
}
|
|
232
|
+
/** Where a ring (ripple, shockwave, nova) starts: anywhere on the block board; on the candles' own row on the price board, so it spreads along them. */
|
|
233
|
+
export function fxOrigin(st, rnd = Math.random) {
|
|
234
|
+
const x = rnd() * st.gridW;
|
|
235
|
+
if (onPriceBoard(st)) return { x, y: Number.isFinite(st.axes.y) ? st.axes.y : st.gridH / 2 };
|
|
236
|
+
return { x, y: rnd() * st.gridH };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function startFx(st, kind, now) {
|
|
240
|
+
const d = fxDirection(kind, st);
|
|
241
|
+
const origin = fxOrigin(st);
|
|
242
|
+
let rank = null;
|
|
243
|
+
if (kind === 'cascade') {
|
|
244
|
+
const byRate = [...(st.restTiles || [])].sort((a, b) => (b.rate ?? 0) - (a.rate ?? 0));
|
|
245
|
+
rank = new Map(byRate.map((t, i) => [t.txid, byRate.length > 1 ? i / (byRate.length - 1) : 0]));
|
|
246
|
+
}
|
|
247
|
+
const seed = Math.floor(Math.random() * 1e6);
|
|
248
|
+
// LIGHT CYCLES and DATA PACKETS (see cyclePath): the routes and the height of every
|
|
249
|
+
// stretch are fixed when the effect starts -- the board is still -- so a frame only moves
|
|
250
|
+
// the heads along them. Two cycles in TRON's blue and orange from opposite edges.
|
|
251
|
+
// AGENTS BUILD THEIR OWN WORLD, once, here (agents.js). The board is STILL while an effect
|
|
252
|
+
// runs, so a route, a formation or a sprite's flight is decided now and a frame only moves
|
|
253
|
+
// along it -- which is what makes these cheap enough to have fifty of.
|
|
254
|
+
let agent = null;
|
|
255
|
+
const spec = AGENTS[kind];
|
|
256
|
+
if (spec?.build) {
|
|
257
|
+
const W = st.gridW, H = st.gridH;
|
|
258
|
+
const tiles = st.restTiles || [];
|
|
259
|
+
agent = spec.build({ st, seed, W, H, tiles, tops: cellTops(tiles, W, H), rnd: rng(seed) });
|
|
260
|
+
}
|
|
261
|
+
st.fx = { kind, t0: now, ms: FX_MS[kind] ?? 4500, x: origin.x, y: origin.y, dx: d[0], dy: d[1], rank, seed, agent,
|
|
262
|
+
// `paths`/`crashes` stay on the record because drawCycles reads view.fx.cycles, which the
|
|
263
|
+
// agent's frame() produces from them; nothing outside agents.js builds them any more.
|
|
264
|
+
paths: agent?.paths ?? null, crashes: agent?.crashes ?? null };
|
|
265
|
+
st.lastFx = kind;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function fxNow(st, t) {
|
|
269
|
+
const f = st.fx;
|
|
270
|
+
if (!f) return null;
|
|
271
|
+
const u = (t - f.t0) / f.ms;
|
|
272
|
+
if (!(u >= 0 && u < 1)) return null;
|
|
273
|
+
// `ms` rides along: a consumer that needs wall-clock age (the price-line pulse holds its tint
|
|
274
|
+
// for three SECONDS, not for a share of the effect) has to know how long the effect is. It was
|
|
275
|
+
// missing, so the pulse computed NaN colours and the canvas kept the yellow it already had.
|
|
276
|
+
// x and y -- where the effect is centred -- ride along for EVERY kind: the radial arrivals
|
|
277
|
+
// (shockwave, nova, radar, vortex) need an origin, and they were once ripple's alone.
|
|
278
|
+
const out = { kind: f.kind, u, ms: f.ms, gridW: st.gridW, gridH: st.gridH, dx: f.dx, dy: f.dy, rank: f.rank, seed: f.seed,
|
|
279
|
+
x: f.x, y: f.y, amp: Math.min(1, u * 6) * Math.pow(1 - u, 0.8) };
|
|
280
|
+
if (f.kind === 'ripple') {
|
|
281
|
+
const reach = Math.hypot(Math.max(f.x, st.gridW - f.x), Math.max(f.y, st.gridH - f.y));
|
|
282
|
+
Object.assign(out, { x: f.x, y: f.y, r: reach * (1 - Math.pow(1 - u, 2)), w: 2.2 + 2.4 * u });
|
|
283
|
+
}
|
|
284
|
+
// AN AGENT'S FRAME comes from its own spec (agents.js). Everything it publishes lands on the
|
|
285
|
+
// frame object: `heads` (how it lights the cubes -- fxAt's heads branch), plus whatever its
|
|
286
|
+
// draw reads, such as `cycles` for the light walls or `ball` for the plasma ball.
|
|
287
|
+
if (f.agent && isAgent(f.kind)) {
|
|
288
|
+
const spec = AGENTS[f.kind];
|
|
289
|
+
if (spec?.frame) Object.assign(out, spec.frame(f.agent, u, { ms: f.ms, derezMs: DEREZ_MS, seed: f.seed }) ?? {});
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// WHICH EFFECT PLAYS NEXT. Pure apart from `st` (recentFx) and `rnd`, so the tests
|
|
295
|
+
// drive the real rule instead of a copy of it.
|
|
296
|
+
//
|
|
297
|
+
// NO EFFECT WAITS ITS TURN. There was a rare set here -- the pulse, the bulge and ball lightning
|
|
298
|
+
// on the price board each held to a clock of 2.5-6 minutes between plays (operator, 2026-09-14:
|
|
299
|
+
// "much too often", then "should be rare") -- and it is gone (operator, later that day: "Make the
|
|
300
|
+
// pipe 'special rare' effects no longer special, and bake them into the regular round of choosing
|
|
301
|
+
// effects for the market"). The price board has its own list now, and the operator trims it;
|
|
302
|
+
// rarity is the no-repeat window and the length of the list, the same as on the block board.
|
|
303
|
+
//
|
|
304
|
+
// NO EFFECT REPEATS WITHIN THE LAST `noRepeat` PLAYED (operator, 2026-09-14: "add a config field that
|
|
305
|
+
// defaults to 12 ... never pick one that has been played in the last 12 sequences"). This replaces
|
|
306
|
+
// "not the same one twice running", which was the same rule with a window of one. `st.recentFx` keeps
|
|
307
|
+
// the kinds played, newest last. Where fewer kinds are switched on than the window, no kind can be
|
|
308
|
+
// twelve plays clear, so the pick is among those that have waited longest: the rule degrades to
|
|
309
|
+
// taking turns, never to a repeat while something else is waiting.
|
|
310
|
+
export function chooseIdleFx(kinds, st, now, rnd = Math.random, noRepeat = 12) {
|
|
311
|
+
void now; // the clock was the rare set's; kept in the signature so the callers and tests read the same
|
|
312
|
+
if (!kinds.length) return null;
|
|
313
|
+
const recent = st.recentFx ?? (st.recentFx = []);
|
|
314
|
+
const window = Math.max(0, Math.min(Math.floor(noRepeat), kinds.length - 1));
|
|
315
|
+
const blocked = new Set(recent.slice(recent.length - window));
|
|
316
|
+
let pool = kinds.filter((k) => !blocked.has(k));
|
|
317
|
+
if (!pool.length) {
|
|
318
|
+
// every kind is inside the window: the ones played least recently
|
|
319
|
+
const lastSeen = (k) => recent.lastIndexOf(k);
|
|
320
|
+
const oldest = Math.min(...kinds.map(lastSeen));
|
|
321
|
+
pool = kinds.filter((k) => lastSeen(k) === oldest);
|
|
322
|
+
}
|
|
323
|
+
// NO FAVOURITES (operator, 2026-09-13: "the tron lightcycles effect happens way too often").
|
|
324
|
+
// There used to be a rule here: the first effect after the board came to rest was a light-cycle
|
|
325
|
+
// race HALF THE TIME. That was written when there were nine effects and it read as a flourish.
|
|
326
|
+
// Measured with fifty-six: 33.2% of every first-after-landing pick was the light cycles,
|
|
327
|
+
// against 1.8% for an even split -- an eighteenfold bias. And the block-space board re-lays on
|
|
328
|
+
// every pool refresh, so `soon` fires constantly, which is why it felt relentless.
|
|
329
|
+
//
|
|
330
|
+
// A hardcoded favourite also contradicts the scheduling the operator actually chose (flat, so
|
|
331
|
+
// any one effect is a genuine surprise), so it is gone rather than merely reduced.
|
|
332
|
+
const from = pool.length ? pool : kinds;
|
|
333
|
+
const kind = from[(rnd() * from.length) | 0];
|
|
334
|
+
recent.push(kind);
|
|
335
|
+
if (recent.length > 64) recent.splice(0, recent.length - 64);
|
|
336
|
+
return kind;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// SOONER (operator, 2026-09-11: "trigger any effects sooner when the board comes to rest,
|
|
340
|
+
// rather than later"): the first effect after a transition lands -- or on a board already
|
|
341
|
+
// still when first drawn -- comes within opts.idleFirst (about a second), and replaces any
|
|
342
|
+
// longer timer left over from before; the ones after it keep opts.idleEvery.
|
|
343
|
+
function scheduleFx(canvas, st, opts, soon = false) {
|
|
344
|
+
if (soon && st.fxTimer) { clearTimeout(st.fxTimer); st.fxTimer = null; }
|
|
345
|
+
if (!opts.idleFx || st.fxTimer || !globalThis.document || typeof setTimeout !== 'function' || reducedMotion()) return;
|
|
346
|
+
const [a, b] = soon ? (opts.idleFirst ?? [800, 1600]) : opts.idleEvery;
|
|
347
|
+
st.fxTimer = setTimeout(() => {
|
|
348
|
+
st.fxTimer = null;
|
|
349
|
+
if (canvas.isConnected === false) return;
|
|
350
|
+
const now = (globalThis.performance && performance.now()) || 0;
|
|
351
|
+
const busy = (st.plan && now < st.plan.settleAt) || st.dirty || !!st.pending;
|
|
352
|
+
if (busy || !canvas.clientWidth) { scheduleFx(canvas, st, opts, soon); return; }
|
|
353
|
+
// a board with a price line gets the effects that follow it; every other board gets the grid
|
|
354
|
+
const onALine = (opts.axes?.line?.length ?? 0) > 1;
|
|
355
|
+
// every effect is switchable (settings.js `effects`): opts.fxKinds is the operator's list, and
|
|
356
|
+
// an empty one means the board rests in peace -- idleFx off is not the only way to say so
|
|
357
|
+
const allowed = Array.isArray(opts.fxKinds) ? new Set(opts.fxKinds) : null;
|
|
358
|
+
const kinds = (onALine ? MARKET_FX : SPACE_FX).filter((k) => !allowed || allowed.has(k));
|
|
359
|
+
if (!kinds.length) return;
|
|
360
|
+
const kind = chooseIdleFx(kinds, st, now, Math.random, opts.fxNoRepeat ?? 12);
|
|
361
|
+
if (!kind) { scheduleFx(canvas, st, opts); return; } // only the pulse is on, and it is still waiting
|
|
362
|
+
startFx(st, kind, now);
|
|
363
|
+
st.wake?.();
|
|
364
|
+
}, a + Math.random() * (b - a));
|
|
365
|
+
st.fxTimer?.unref?.();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Play one idle effect now -- for the demo page and the tests, which cannot
|
|
369
|
+
// wait on a timer. Returns false for a canvas the renderer has not seen.
|
|
370
|
+
export function triggerIdle(canvas, kind = 'ripple') {
|
|
371
|
+
const st = STATE.get(canvas);
|
|
372
|
+
if (!st || !FX_MS[kind]) return false;
|
|
373
|
+
startFx(st, kind, (globalThis.performance && performance.now()) || 0);
|
|
374
|
+
st.wake?.();
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function reducedMotion() {
|
|
379
|
+
try { return !!(globalThis.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches); }
|
|
380
|
+
catch { return false; }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// The block template, drawn on the ground as a white grid. This is the
|
|
384
|
+
// reference the whole picture hangs off: one block is `n` x `n` cells at this
|
|
385
|
+
// resolution, so a transaction sitting inside the square is one that fits in
|
|
386
|
+
// the next block and anything beyond it is queued behind. It is drawn FIRST,
|
|
387
|
+
// at z = 0, so every tile stands on it -- and because the tiles are packed on
|
|
388
|
+
// exactly this grid, they line up with it cell for cell.
|
|
389
|
+
// A GROUND WORTH CASTING A SHADOW ON (operator, 2026-09-11: "Can we have a more
|
|
390
|
+
// interesting ground texture. Something that looks higher res and shows the
|
|
391
|
+
// shadows well?"). A lit deck of tiled plates, every piece a solid rgba polygon
|
|
392
|
+
// or stroke laid through the projection, so all of it lies on the sphere:
|
|
393
|
+
// * a slate-teal cell grain -- each cell a fixed shade of its own, lit by the
|
|
394
|
+
// sphere's slope toward the light (upper left, above), so the curve reads
|
|
395
|
+
// and the base is light enough for a shadow to darken (the old floor was
|
|
396
|
+
// near black under a black checker, and a shadow had nothing to take away);
|
|
397
|
+
// * dark hairline seams between cells: the "higher res";
|
|
398
|
+
// * 4x4 plates, each a touch different in tone, bevelled -- lit along the
|
|
399
|
+
// edges facing the light, shaded along the others -- with corner rivets;
|
|
400
|
+
// * on some plates a circuit trace running to a pad.
|
|
401
|
+
// Its geometry cannot change while the view does not, so it is built once into
|
|
402
|
+
// Path2D objects and a frame is a couple of dozen fills; without Path2D (node
|
|
403
|
+
// tests) the same polygons are drawn directly. Batched by colour, the ground is
|
|
404
|
+
// painted without a far-to-near order -- safe because at the shipped curvature
|
|
405
|
+
// (radius several views wide) no part of the surface hides another.
|
|
406
|
+
const GROUND = new Map();
|
|
407
|
+
const GROUND_LIGHT = (() => { const v = [-0.5, 0.5, 1], l = Math.hypot(...v); return v.map((c) => c / l); })();
|
|
408
|
+
function hash2(a, b, salt = 0) {
|
|
409
|
+
let h = (Math.imul(a | 0, 73856093) ^ Math.imul(b | 0, 19349663) ^ Math.imul(salt | 0, 83492791)) >>> 0;
|
|
410
|
+
h ^= h >>> 13; h = Math.imul(h, 0x5bd1e995) >>> 0; h ^= h >>> 15;
|
|
411
|
+
return (h >>> 0) / 4294967296;
|
|
412
|
+
}
|
|
413
|
+
function rgbOf(c) { const m = String(c).match(/rgba?\(([^)]+)\)/); return m ? m[1].split(',').slice(0, 3).map(Number) : [40, 82, 70]; }
|
|
414
|
+
|
|
415
|
+
// a cell's brightness: the sphere's light on it, its plate's tone, its own grain
|
|
416
|
+
export function groundShade(cx, cy, view, opts) {
|
|
417
|
+
const nrm = surfaceNormal(cx + 0.5, cy + 0.5, view);
|
|
418
|
+
const L = GROUND_LIGHT;
|
|
419
|
+
const lit = 1 + 1.6 * (nrm.x * L[0] + nrm.y * L[1] + nrm.z * L[2] - L[2]);
|
|
420
|
+
const plate = opts.gridStep || 4;
|
|
421
|
+
return lit * (1 + 0.09 * (hash2(Math.floor(cx / plate), Math.floor(cy / plate), 1) - 0.5)) * (1 + 0.12 * (hash2(cx, cy, 2) - 0.5));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// THE SPHERE REACHES EVERY EDGE (operator, 2026-09-11: "The surface of the sphere is not being
|
|
425
|
+
// drawn high enough. You can see black when there should be at least another grid row"). The
|
|
426
|
+
// panel's rect is in FLAT grid units, but the ground is drawn on the cap: near the middle it rises
|
|
427
|
+
// toward the viewer (up and right on screen), and past the cap it lies `sink` below the flat plane
|
|
428
|
+
// (down and left) -- so a fixed three-unit margin left the far edge short of the top of the panel.
|
|
429
|
+
// Each edge is pushed outward until every point along it projects outside the panel, then one
|
|
430
|
+
// more unit for good measure. Cached per view: it depends on nothing that moves.
|
|
431
|
+
const EXTENT = new Map();
|
|
432
|
+
export function groundExtent(view, vr) {
|
|
433
|
+
const u = view.unit ?? 12, dS = view.oblique?.dy ?? 1;
|
|
434
|
+
const key = [vr.x0, vr.x1, vr.y0, vr.y1, view.dome, view.gridW, view.gridH, u, view.flipY, view.oblique?.ox, view.oblique?.oy, dS].join('|');
|
|
435
|
+
const hit = EXTENT.get(key);
|
|
436
|
+
if (hit) return hit;
|
|
437
|
+
const P = (gx, gy) => project(gx, gy, 0, view);
|
|
438
|
+
// the panel in the board's drawing units (rows flipped: the far edge is the top, y grows downward)
|
|
439
|
+
const L = vr.x0 * u, R = vr.x1 * u, T = -vr.y1 * u * dS, B = -vr.y0 * u * dS;
|
|
440
|
+
let X0 = Math.floor(vr.x0) - 1, X1 = Math.ceil(vr.x1) + 1, Y0 = Math.floor(vr.y0) - 1, Y1 = Math.ceil(vr.y1) + 1;
|
|
441
|
+
const any = (a, b, f) => { for (let t = a; t < b; t += 0.5) if (f(t)) return true; return f(b); };
|
|
442
|
+
for (let i = 0; i < 120; i++) {
|
|
443
|
+
let grew = false;
|
|
444
|
+
if (any(X0, X1, (x) => P(x, Y1).y > T)) { Y1++; grew = true; }
|
|
445
|
+
if (any(X0, X1, (x) => P(x, Y0).y < B)) { Y0--; grew = true; }
|
|
446
|
+
if (any(Y0, Y1, (y) => P(X1, y).x < R)) { X1++; grew = true; }
|
|
447
|
+
if (any(Y0, Y1, (y) => P(X0, y).x > L)) { X0--; grew = true; }
|
|
448
|
+
if (!grew) break;
|
|
449
|
+
}
|
|
450
|
+
const ext = { X0: X0 - 1, X1: X1 + 1, Y0: Y0 - 1, Y1: Y1 + 1 };
|
|
451
|
+
if (EXTENT.size > 8) EXTENT.delete(EXTENT.keys().next().value);
|
|
452
|
+
EXTENT.set(key, ext);
|
|
453
|
+
return ext;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function groundLayers(view, opts, X0, X1, Y0, Y1) {
|
|
457
|
+
const key = [X0, X1, Y0, Y1, view.dome, view.gridW, view.gridH, view.unit, view.flipY, view.oblique?.ox, view.oblique?.oy, view.oblique?.dy, opts.floor, opts.gridStep].join('|');
|
|
458
|
+
const hit = GROUND.get(key);
|
|
459
|
+
if (hit) return hit;
|
|
460
|
+
const P = (gx, gy) => project(gx, gy, 0, view);
|
|
461
|
+
const W = X1 - X0 + 1;
|
|
462
|
+
const lat = new Array(W * (Y1 - Y0 + 1));
|
|
463
|
+
const L = (gx, gy) => lat[(gy - Y0) * W + (gx - X0)] ??= P(gx, gy); // integer lattice points, shared
|
|
464
|
+
const layers = [];
|
|
465
|
+
const layer = (kind, style) => { const l = { kind, ...style, polys: [], lines: [] }; layers.push(l); return l; };
|
|
466
|
+
|
|
467
|
+
// the cells, grouped by quantised shade
|
|
468
|
+
const base = rgbOf(opts.floor);
|
|
469
|
+
const shades = new Map();
|
|
470
|
+
for (let cy = Y0; cy < Y1; cy++) {
|
|
471
|
+
for (let cx = X0; cx < X1; cx++) {
|
|
472
|
+
const q = Math.round(groundShade(cx, cy, view, opts) * 40);
|
|
473
|
+
let l = shades.get(q);
|
|
474
|
+
if (!l) { l = layer('cell', { fill: `rgba(${base.map((c) => Math.max(0, Math.min(255, Math.round((c * q) / 40)))).join(',')},1)` }); shades.set(q, l); }
|
|
475
|
+
l.polys.push([L(cx, cy), L(cx + 1, cy), L(cx + 1, cy + 1), L(cx, cy + 1)]);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// hairline seams between cells (the plate edges are the phosphor lines)
|
|
479
|
+
const plate = opts.gridStep || 4;
|
|
480
|
+
const seams = layer('seam', { stroke: 'rgba(0,0,0,0.32)', lw: 0.7 });
|
|
481
|
+
for (let i = X0; i <= X1; i++) if (((i % plate) + plate) % plate) { const pts = []; for (let j = Y0; j <= Y1; j++) pts.push(L(i, j)); seams.lines.push(pts); }
|
|
482
|
+
for (let j = Y0; j <= Y1; j++) if (((j % plate) + plate) % plate) { const pts = []; for (let i = X0; i <= X1; i++) pts.push(L(i, j)); seams.lines.push(pts); }
|
|
483
|
+
// the plates: bevels, rivets, traces
|
|
484
|
+
const lit = layer('bevel-lit', { fill: 'rgba(190,255,225,0.10)' });
|
|
485
|
+
const dark = layer('bevel-dark', { fill: 'rgba(0,0,0,0.24)' });
|
|
486
|
+
const rivet = layer('rivet', { fill: 'rgba(0,0,0,0.40)' });
|
|
487
|
+
const glint = layer('rivet-glint', { fill: 'rgba(210,255,235,0.32)' });
|
|
488
|
+
const trace = layer('trace', { fill: 'rgba(110,255,210,0.14)' });
|
|
489
|
+
const pad = layer('pad', { fill: 'rgba(130,255,215,0.24)' });
|
|
490
|
+
const core = layer('pad-core', { fill: 'rgba(0,0,0,0.36)' });
|
|
491
|
+
const sq = (cx, cy, r) => [P(cx - r, cy - r), P(cx + r, cy - r), P(cx + r, cy + r), P(cx - r, cy + r)];
|
|
492
|
+
const bw = 0.16, e = plate;
|
|
493
|
+
for (let py = Math.ceil(Y0 / plate) * plate; py + plate <= Y1; py += plate) {
|
|
494
|
+
for (let px = Math.ceil(X0 / plate) * plate; px + plate <= X1; px += plate) {
|
|
495
|
+
// rows are flipped: the far edge (py + e) is the top of the screen, toward the light
|
|
496
|
+
lit.polys.push([P(px, py), P(px + bw, py + bw), P(px + bw, py + e - bw), P(px, py + e)]); // left
|
|
497
|
+
lit.polys.push([P(px, py + e), P(px + bw, py + e - bw), P(px + e - bw, py + e - bw), P(px + e, py + e)]); // far
|
|
498
|
+
dark.polys.push([P(px + e, py + e), P(px + e - bw, py + e - bw), P(px + e - bw, py + bw), P(px + e, py)]); // right
|
|
499
|
+
dark.polys.push([P(px + e, py), P(px + e - bw, py + bw), P(px + bw, py + bw), P(px, py)]); // near
|
|
500
|
+
for (const [rx, ry] of [[0.55, 0.55], [e - 0.55, 0.55], [e - 0.55, e - 0.55], [0.55, e - 0.55]]) {
|
|
501
|
+
rivet.polys.push(sq(px + rx, py + ry, 0.13));
|
|
502
|
+
glint.polys.push(sq(px + rx - 0.04, py + ry + 0.04, 0.055));
|
|
503
|
+
}
|
|
504
|
+
if (hash2(px, py, 3) < 0.4) {
|
|
505
|
+
const r = 1 + Math.floor(hash2(px, py, 4) * (e - 1)), c = 1 + Math.floor(hash2(px, py, 5) * (e - 1));
|
|
506
|
+
let r2 = 1 + Math.floor(hash2(px, py, 6) * (e - 1));
|
|
507
|
+
if (r2 === r) r2 = r > 1 ? r - 1 : r + 1;
|
|
508
|
+
const w = 0.05;
|
|
509
|
+
trace.polys.push([P(px, py + r - w), P(px + c + w, py + r - w), P(px + c + w, py + r + w), P(px, py + r + w)]);
|
|
510
|
+
trace.polys.push([P(px + c - w, Math.min(r, r2) + py - w), P(px + c + w, Math.min(r, r2) + py - w), P(px + c + w, Math.max(r, r2) + py + w), P(px + c - w, Math.max(r, r2) + py + w)]);
|
|
511
|
+
pad.polys.push(sq(px + c, py + r2, 0.2));
|
|
512
|
+
core.polys.push(sq(px + c, py + r2, 0.08));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (GROUND.size > 8) GROUND.delete(GROUND.keys().next().value);
|
|
517
|
+
GROUND.set(key, layers);
|
|
518
|
+
return layers;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// THE NEON GRID ON THE BOARD (operator, 2026-09-11: "i want to see the green
|
|
522
|
+
// neon grid explicitly drawn within the block bounds. Right now its just
|
|
523
|
+
// drawing the outer border"). The phosphor lines ran over the whole sphere at
|
|
524
|
+
// the ground's own faint strength, so on the lighter deck only the bright outer
|
|
525
|
+
// edge still read. Inside the board the grid is neon now: a thin green line
|
|
526
|
+
// between every pair of cells, a bright line in a wide glow on every plate
|
|
527
|
+
// boundary. Outside it the ground keeps its quieter texture, so the board reads
|
|
528
|
+
// as a lit template set into the deck. On the floor, so cubes cover it: it
|
|
529
|
+
// shows in every gap and round the edges. Built once per view, like the ground.
|
|
530
|
+
export function boardGridLayers(view, opts, n, rows) {
|
|
531
|
+
const key = ['board', n, rows, view.dome, view.gridW, view.gridH, view.unit, view.flipY, view.oblique?.ox, view.oblique?.oy, view.oblique?.dy, opts.gridStep, opts.neonCell, opts.neonHalo, opts.neonGlow, opts.neonLine].join('|');
|
|
532
|
+
const hit = GROUND.get(key);
|
|
533
|
+
if (hit) return hit;
|
|
534
|
+
const P = (gx, gy) => project(gx, gy, 0, view);
|
|
535
|
+
const step = opts.gridStep || 4;
|
|
536
|
+
const col = (i) => { const pts = []; for (let j = 0; j <= rows; j++) pts.push(P(i, j)); return pts; };
|
|
537
|
+
const row = (j) => { const pts = []; for (let i = 0; i <= n; i++) pts.push(P(i, j)); return pts; };
|
|
538
|
+
const cell = { kind: 'neon-cell', stroke: opts.neonCell, lw: 0.8, polys: [], lines: [] };
|
|
539
|
+
const line = { kind: 'neon', stroke: opts.neonLine, lw: 1.3, polys: [], lines: [] };
|
|
540
|
+
for (let i = 1; i < n; i++) (i % step ? cell : line).lines.push(col(i));
|
|
541
|
+
for (let j = 1; j < rows; j++) (j % step ? cell : line).lines.push(row(j));
|
|
542
|
+
const halo = { kind: 'neon-halo', stroke: opts.neonHalo, lw: 11, polys: [], lines: line.lines };
|
|
543
|
+
const glow = { kind: 'neon-glow', stroke: opts.neonGlow, lw: 5, polys: [], lines: line.lines };
|
|
544
|
+
const layers = [cell, halo, glow, line];
|
|
545
|
+
if (GROUND.size > 8) GROUND.delete(GROUND.keys().next().value);
|
|
546
|
+
GROUND.set(key, layers);
|
|
547
|
+
return layers;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// LIGHT CYCLES AND DATA PACKETS, drawn over the cubes (see startFx / cyclePath). Each trail
|
|
551
|
+
// is stroked unit stretch by unit stretch -- a wide glow, a hot core, white near the head --
|
|
552
|
+
// brightest at the head and gone a trail-length behind it. A stretch rides at its own height
|
|
553
|
+
// and a change of height at a corner is a vertical wall, so every turn is a right angle, over
|
|
554
|
+
// the blocks as well as across them. The head is a small glowing data block. Plain rgba
|
|
555
|
+
// strokes and fills only: no composite modes, no shadowBlur.
|
|
556
|
+
export function drawCycles(ctx, view, lw) {
|
|
557
|
+
const fx = view.fx;
|
|
558
|
+
if (!fx?.cycles) return;
|
|
559
|
+
const P = (x, y, z) => project(x, y, z, view);
|
|
560
|
+
const line = (a, b, color, alpha, width) => {
|
|
561
|
+
ctx.strokeStyle = `rgba(${color.join(',')},${Math.max(0, Math.min(1, alpha)).toFixed(3)})`;
|
|
562
|
+
ctx.lineWidth = lw * width;
|
|
563
|
+
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
564
|
+
};
|
|
565
|
+
const fill = (q, color, alpha) => {
|
|
566
|
+
ctx.fillStyle = `rgba(${color.join(',')},${Math.max(0, Math.min(1, alpha)).toFixed(3)})`;
|
|
567
|
+
ctx.beginPath(); ctx.moveTo(q[0].x, q[0].y); for (let i = 1; i < q.length; i++) ctx.lineTo(q[i].x, q[i].y); ctx.closePath(); ctx.fill();
|
|
568
|
+
};
|
|
569
|
+
// DE-RES: the crashed cycle's wall breaks into shards that burst up and out under gravity and
|
|
570
|
+
// fade, with a white flash and an expanding ring where it hit. Deterministic per crash.
|
|
571
|
+
const sqAt = (pt, z, r) => [P(pt.x - r, pt.y - r, z), P(pt.x + r, pt.y - r, z), P(pt.x + r, pt.y + r, z), P(pt.x - r, pt.y + r, z)];
|
|
572
|
+
for (const c of fx.cycles) {
|
|
573
|
+
if (c.derez == null || c.derez >= 1 || !c.crash) continue;
|
|
574
|
+
const t = c.derez, fade = 1 - t;
|
|
575
|
+
let seed = ((c.crash.d * 7919 + c.crash.at.x * 131 + c.crash.at.y * 17) >>> 0) || 1;
|
|
576
|
+
const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
|
|
577
|
+
const at = c.crash.at;
|
|
578
|
+
const hz = (c.hs[Math.max(0, Math.min(c.hs.length - 1, c.crash.d - 1))] ?? 0) + 1;
|
|
579
|
+
const ring = [];
|
|
580
|
+
for (let k = 0; k <= 28; k++) { const a = (k / 28) * Math.PI * 2; ring.push(P(at.x + Math.cos(a) * (0.5 + 4 * t), at.y + Math.sin(a) * (0.5 + 4 * t), hz)); }
|
|
581
|
+
const loop = (w, rgb, alpha) => {
|
|
582
|
+
ctx.strokeStyle = `rgba(${rgb.join(',')},${Math.max(0, alpha).toFixed(3)})`;
|
|
583
|
+
ctx.lineWidth = lw * w;
|
|
584
|
+
ctx.beginPath(); ring.forEach((p, k) => (k ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.stroke();
|
|
585
|
+
};
|
|
586
|
+
loop(9, c.color, 0.5 * fade);
|
|
587
|
+
loop(2.4, [255, 255, 255], 0.9 * fade);
|
|
588
|
+
if (t < 0.35) fill(sqAt(at, hz + 0.5, 0.3 + 1.6 * (1 - t / 0.35)), [255, 255, 255], 0.9 * (1 - t / 0.35));
|
|
589
|
+
const shardFrom = Math.max(0, c.crash.d - 120); // the whole wall, bounded so a long route cannot flood the frame
|
|
590
|
+
for (let k = shardFrom; k < c.crash.d && k < c.pts.length - 1; k++) {
|
|
591
|
+
const a = c.pts[k], b = c.pts[k + 1], h = c.hs[k] ?? 0;
|
|
592
|
+
for (let q = 0; q < 3; q++) {
|
|
593
|
+
const f0 = (q + rnd()) / 3;
|
|
594
|
+
const vx = (rnd() - 0.5) * 6, vy = (rnd() - 0.5) * 6, vz = 2 + rnd() * 6, r = 0.18 + rnd() * 0.22;
|
|
595
|
+
const pt = { x: a.x + (b.x - a.x) * f0 + vx * t, y: a.y + (b.y - a.y) * f0 + vy * t };
|
|
596
|
+
const z = h + 1.5 + vz * t - 4 * t * t;
|
|
597
|
+
fill(sqAt(pt, z, r), c.color, 0.95 * fade);
|
|
598
|
+
fill(sqAt(pt, z, r * 0.45), [255, 255, 255], 0.8 * fade);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
ctx.lineWidth = lw;
|
|
603
|
+
for (const c of fx.cycles) {
|
|
604
|
+
const end = c.pts.length - 1;
|
|
605
|
+
if (!(c.alpha > 0.01) || !(c.d > c.from) || end < 1) continue;
|
|
606
|
+
// in world units; the camera draws height at about a third (oblique.oy), so a 0.95-unit
|
|
607
|
+
// wall came out ~5 px tall and read as a scratch -- 3 units stands ~16 px off the blocks
|
|
608
|
+
const wallH = c.small ? 1.4 : 3;
|
|
609
|
+
const at = (dist) => {
|
|
610
|
+
const k = Math.max(0, Math.min(end - 1, Math.floor(dist)));
|
|
611
|
+
const f = Math.min(1, dist - k), a = c.pts[k], b = c.pts[k + 1];
|
|
612
|
+
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, k };
|
|
613
|
+
};
|
|
614
|
+
const segs = [];
|
|
615
|
+
for (let k = Math.floor(c.from); k < Math.ceil(c.d) && k < end; k++) {
|
|
616
|
+
const s0 = Math.max(k, c.from), s1 = Math.min(k + 1, c.d);
|
|
617
|
+
if (s1 <= s0) continue;
|
|
618
|
+
const a = at(s0), b = at(s1), h = c.hs[k];
|
|
619
|
+
// a wall that holds: full strength along its length, with the stretch nearest the head
|
|
620
|
+
// brighter still, rather than a 12-unit fade that erased the route behind the rider
|
|
621
|
+
const back = c.d - (s0 + s1) / 2;
|
|
622
|
+
const near = Math.pow(Math.max(0, 1 - back / 6), 1.5); // the hot end, just behind the head
|
|
623
|
+
const bright = Math.min(1, 0.62 + 0.38 * near) * c.alpha;
|
|
624
|
+
if (k > 0 && s0 === k && c.hs[k - 1] !== h) segs.push({ step: true, a: P(a.x, a.y, c.hs[k - 1]), b: P(a.x, a.y, h), top: P(a.x, a.y, Math.max(h, c.hs[k - 1]) + wallH), br: bright });
|
|
625
|
+
segs.push({ a: P(a.x, a.y, h), b: P(b.x, b.y, h), at: P(a.x, a.y, h + wallH), bt: P(b.x, b.y, h + wallH), br: bright });
|
|
626
|
+
}
|
|
627
|
+
// THE LIGHT WALL: a translucent ribbon standing up off the route, bright along its top
|
|
628
|
+
// and glowing along its foot -- the first cut was a bare line and read as a scratch
|
|
629
|
+
// across the blocks, not a light cycle's wall
|
|
630
|
+
for (const g of segs) if (!g.step) fill([g.a, g.b, g.bt, g.at], c.color, 0.42 * g.br);
|
|
631
|
+
for (const g of segs) line(g.a, g.b, c.color, 0.26 * g.br, c.small ? 8 : 14);
|
|
632
|
+
for (const g of segs) if (g.step) line(g.a, g.top, c.color, 0.9 * g.br, c.small ? 2.2 : 3.6);
|
|
633
|
+
for (const g of segs) if (!g.step) line(g.at, g.bt, c.color, 0.95 * g.br, c.small ? 2.2 : 3.6);
|
|
634
|
+
for (const g of segs) if (!g.step) line(g.a, g.b, c.color, 0.8 * g.br, c.small ? 1.4 : 2.2);
|
|
635
|
+
for (const g of segs) if (!g.step && g.br > 0.5) line(g.at, g.bt, [255, 255, 255], 1.6 * (g.br - 0.5), 1.4);
|
|
636
|
+
// NO CHARGE ON A LIGHT CYCLE (operator, 2026-09-12: "the lightcycles shouldn't have the
|
|
637
|
+
// electricity effect"). It was given the Markets pulse's blue -- puffs, crackle, motes -- along
|
|
638
|
+
// with the lightning ball the same day; on a rider whose whole point is a clean light wall it
|
|
639
|
+
// read as static. The ball keeps it (drawBall): it IS electricity.
|
|
640
|
+
if (c.d < end && c.alpha > 0.3) {
|
|
641
|
+
const hd = at(c.d), hz = (c.hs[hd.k] ?? 0) + wallH * 0.5, r = c.small ? 0.75 : 1.25;
|
|
642
|
+
const sq = (q, z) => [P(hd.x - q, hd.y - q, z), P(hd.x + q, hd.y - q, z), P(hd.x + q, hd.y + q, z), P(hd.x - q, hd.y + q, z)];
|
|
643
|
+
fill(sq(1.1 * r, hz + 0.3), c.color, 0.22 * c.alpha);
|
|
644
|
+
fill(sq(0.42 * r, hz + 0.45), c.color, 0.95 * c.alpha);
|
|
645
|
+
fill(sq(0.2 * r, hz + 0.5), [255, 255, 255], 0.9 * c.alpha);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
ctx.lineWidth = lw;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// THE CHARGE TRAIL (operator, 2026-09-12: "We need the block space energy effect also emit a
|
|
652
|
+
// blue line and dust trail just like the markets view"). What the Markets pulse leaves on the
|
|
653
|
+
// price line, for the board's own riders: the light cycles' wall tops and the lightning ball's
|
|
654
|
+
// trace. `segs` are screen-space stretches just behind a head, each with its `tint` (1 at the
|
|
655
|
+
// head, 0 at the trail's end) and `age` (0..1 the other way). Drawn as the pulse is drawn --
|
|
656
|
+
// an emitter of soft blue puffs behind, a fat electric-blue tube over the stretch, a flickering
|
|
657
|
+
// white-blue core with crackle forks re-rolled every frame, and a spray of hashed motes -- so
|
|
658
|
+
// the two effects are visibly the same energy.
|
|
659
|
+
// `pale`: the lightning ball's charge (operator, 2026-09-14: "The energy ball blue plasma is way too
|
|
660
|
+
// dark. Need to make it way subtler like the ball lightning stuff"). The cloud below is 22 puffs of
|
|
661
|
+
// deep blue (48,110,255) per segment over sixteen segments, which stacks into a dark blue mass with
|
|
662
|
+
// the ball lost in it; the ball asks for the same cloud in the stormball's pale tones and at a
|
|
663
|
+
// fraction of the alpha, so it reads as a haze the ball lights rather than a shadow it drags.
|
|
664
|
+
function chargeTrail(ctx, segs, lw, now, seedBase = 0, pale = false) {
|
|
665
|
+
if (!segs.length) return;
|
|
666
|
+
const body = pale ? '150,200,255' : '48,110,255', core = pale ? '215,238,255' : '120,180,255', dim = pale ? 0.45 : 1;
|
|
667
|
+
for (const g of segs) {
|
|
668
|
+
if (g.tint < 0.04) continue;
|
|
669
|
+
const i = g.i + seedBase;
|
|
670
|
+
const mx = (g.a.x + g.b.x) / 2, my = (g.a.y + g.b.y) / 2;
|
|
671
|
+
// A CLOUD WITH SOMETHING IN IT (operator, 2026-09-13: "the nebula emissions are not
|
|
672
|
+
// substantial enough"). Ten puffs at 0.09 alpha over a 7-21 line-width radius is a haze you
|
|
673
|
+
// have to look for. Now: 22 puffs, half again as wide, at more than double the alpha, and in
|
|
674
|
+
// TWO tones -- a deep blue body with a lighter core drawn over its inner half -- so the cloud
|
|
675
|
+
// has depth rather than being one flat wash. Alpha stays low per puff because the substance
|
|
676
|
+
// comes from LAYERING; a single fat translucent disc reads as a bubble.
|
|
677
|
+
for (let k = 0; k < 22; k++) {
|
|
678
|
+
const a1 = hash01(i * 47 + k * 11 + 3) * Math.PI * 2;
|
|
679
|
+
const spread = lw * (5 + 46 * g.age) * (0.35 + 0.65 * hash01(i * 13 + k * 5 + 29));
|
|
680
|
+
const rad = lw * (9 + 22 * hash01(i * 7 + k * 17 + 61)) * (1 + 1.35 * g.age);
|
|
681
|
+
const al = 0.2 * dim * g.tint * (1 - 0.6 * g.age) * (0.5 + 0.5 * hash01(i * 3 + k * 23 + 97));
|
|
682
|
+
const px = mx + Math.cos(a1) * spread, py = my + Math.sin(a1) * spread;
|
|
683
|
+
ctx.fillStyle = `rgba(${body},${al.toFixed(3)})`;
|
|
684
|
+
ctx.beginPath(); ctx.arc(px, py, rad, 0, Math.PI * 2); ctx.fill();
|
|
685
|
+
if (k % 2 === 0) {
|
|
686
|
+
ctx.fillStyle = `rgba(${core},${(al * 0.7).toFixed(3)})`;
|
|
687
|
+
ctx.beginPath(); ctx.arc(px, py, rad * 0.5, 0, Math.PI * 2); ctx.fill();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const line = (g, w, col) => { ctx.strokeStyle = col; ctx.lineWidth = lw * w; ctx.beginPath(); ctx.moveTo(g.a.x, g.a.y); ctx.lineTo(g.b.x, g.b.y); ctx.stroke(); };
|
|
692
|
+
for (const g of segs) {
|
|
693
|
+
if (g.tint < 0.04) continue;
|
|
694
|
+
line(g, 14 * (1 + 0.8 * g.tint), `rgba(${pale ? '140,200,255' : '30,130,255'},${(0.16 * dim * g.tint).toFixed(3)})`);
|
|
695
|
+
line(g, 5 * (1 + 0.8 * g.tint), `rgba(${pale ? '170,222,255' : '110,200,255'},${(0.7 * g.tint).toFixed(3)})`);
|
|
696
|
+
}
|
|
697
|
+
for (const g of segs) {
|
|
698
|
+
if (g.tint < 0.08) continue;
|
|
699
|
+
const i = g.i + seedBase;
|
|
700
|
+
const flick = 0.55 + 0.45 * Math.abs(Math.sin(now * 0.023 + i * 1.7) * Math.sin(now * 0.041 + i * 0.9));
|
|
701
|
+
line(g, 2.2, `rgba(210,240,255,${(0.9 * g.tint * flick).toFixed(3)})`);
|
|
702
|
+
// LIGHTNING THAT LOOKS LIKE LIGHTNING (operator, 2026-09-13: "the lightning still looks
|
|
703
|
+
// terrible"). What was here scattered 1-3 forks at a uniformly random ANGLE off the wire and
|
|
704
|
+
// let each leg wander +-0.8 rad, which is a random walk, not a discharge: legs doubled back,
|
|
705
|
+
// crossed the wire, and the whole thing read as scribble at a constant width.
|
|
706
|
+
//
|
|
707
|
+
// A real arc has direction and it tapers. So: every fork leaves the wire roughly PERPENDICULAR
|
|
708
|
+
// (+-0.55 rad off the normal, sign picked per fork), holds that heading with only a small
|
|
709
|
+
// per-leg deviation, and is drawn in three passes -- a wide dim halo, the arc, and a hot thin
|
|
710
|
+
// core -- each shorter and brighter than the last, so it comes to a point instead of ending in
|
|
711
|
+
// a stub. Still re-rolled every frame: electrical precisely because it never draws twice.
|
|
712
|
+
const forks = 1 + ((Math.random() * 4 * g.tint) | 0);
|
|
713
|
+
const nx = -(g.b.y - g.a.y), ny = g.b.x - g.a.x;
|
|
714
|
+
const nlen = Math.hypot(nx, ny) || 1;
|
|
715
|
+
for (let k = 0; k < forks; k++) {
|
|
716
|
+
const f0 = Math.random();
|
|
717
|
+
const ox = g.a.x + (g.b.x - g.a.x) * f0, oy = g.a.y + (g.b.y - g.a.y) * f0;
|
|
718
|
+
const side = Math.random() < 0.5 ? 1 : -1;
|
|
719
|
+
const base = Math.atan2((ny / nlen) * side, (nx / nlen) * side) + (Math.random() - 0.5) * 1.1;
|
|
720
|
+
const reach = lw * (10 + Math.random() * 9) * (0.45 + 0.55 * g.tint);
|
|
721
|
+
// SHORT AND JAGGED. Five legs, each shorter than the last, with a hard alternating zig of
|
|
722
|
+
// +-0.9 rad about the heading -- a discharge kinks, it does not curve. The first cut held a
|
|
723
|
+
// heading over four long smooth legs and drew whiskers halfway across the board.
|
|
724
|
+
const legs = 5;
|
|
725
|
+
const pts = [{ x: ox, y: oy }];
|
|
726
|
+
let x = ox, y = oy, ang = base;
|
|
727
|
+
for (let m = 0; m < legs; m++) {
|
|
728
|
+
ang = base + (m % 2 ? -1 : 1) * (0.35 + Math.random() * 0.55) + (Math.random() - 0.5) * 0.3;
|
|
729
|
+
const step = (reach / legs) * (1 - 0.15 * m);
|
|
730
|
+
x += Math.cos(ang) * step; y += Math.sin(ang) * step;
|
|
731
|
+
pts.push({ x, y });
|
|
732
|
+
}
|
|
733
|
+
const arc = (upto, w, col) => {
|
|
734
|
+
ctx.strokeStyle = col; ctx.lineWidth = lw * w;
|
|
735
|
+
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
|
|
736
|
+
for (let m = 1; m <= upto; m++) ctx.lineTo(pts[m].x, pts[m].y);
|
|
737
|
+
ctx.stroke();
|
|
738
|
+
};
|
|
739
|
+
arc(legs, 2.2, `rgba(90,170,255,${(0.3 * g.tint).toFixed(3)})`);
|
|
740
|
+
arc(legs, 0.9, `rgba(185,230,255,${(0.92 * g.tint).toFixed(3)})`);
|
|
741
|
+
arc(Math.max(1, legs - 1), 0.5, `rgba(245,252,255,${(0.95 * g.tint).toFixed(3)})`);
|
|
742
|
+
}
|
|
743
|
+
// SPARKS, NOT BLOBS (operator, 2026-09-13: "the particles are too fat"). They were discs of
|
|
744
|
+
// 1.4-4.2 line-widths, which at this line width read as a spray of dots rather than a spray of
|
|
745
|
+
// sparks. Halved in radius, doubled in number, and each one now flies FURTHER as it ages, so
|
|
746
|
+
// the trail is a fine mist that thins out instead of a clump of fat circles.
|
|
747
|
+
for (let k = 0; k < 16; k++) {
|
|
748
|
+
const f0 = hash01(i * 31 + k * 7);
|
|
749
|
+
const bx = g.a.x + (g.b.x - g.a.x) * f0, by = g.a.y + (g.b.y - g.a.y) * f0;
|
|
750
|
+
const ang = hash01(i * 17 + k * 13 + 101) * Math.PI * 2;
|
|
751
|
+
const dist = lw * (3 + 52 * g.age) * (0.6 + 0.4 * hash01(i + k * 3 + 7));
|
|
752
|
+
const r = lw * (0.55 + 1.15 * hash01(i * 5 + k + 41)) * (1 - 0.45 * g.age);
|
|
753
|
+
ctx.fillStyle = `rgba(200,236,255,${(0.75 * g.tint * (1 - 0.5 * g.age)).toFixed(3)})`;
|
|
754
|
+
ctx.beginPath(); ctx.arc(bx + Math.cos(ang) * dist, by + Math.sin(ang) * dist, r, 0, Math.PI * 2); ctx.fill();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
ctx.lineWidth = lw;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// THE LIGHTNING BALL, over the cubes: the grid line it has traced burning behind it and cooling
|
|
761
|
+
// over 16 units, a plasma ball of one pale gradient with a white-hot heart, and bolts jumping from it
|
|
762
|
+
// to the grid crossings round it, new every frame. Plain rgba fills and strokes only.
|
|
763
|
+
function drawBall(ctx, view, lw) {
|
|
764
|
+
const b = view.fx?.ball;
|
|
765
|
+
if (!b) return;
|
|
766
|
+
const P = (x, y, z) => project(x, y, z, view);
|
|
767
|
+
const U = view.unit ?? 6;
|
|
768
|
+
const stroke = (pts, w, col) => {
|
|
769
|
+
if (pts.length < 2) return;
|
|
770
|
+
ctx.strokeStyle = col; ctx.lineWidth = lw * w;
|
|
771
|
+
ctx.beginPath();
|
|
772
|
+
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
|
773
|
+
ctx.stroke();
|
|
774
|
+
};
|
|
775
|
+
const at = (s) => {
|
|
776
|
+
const i = Math.max(0, Math.min(b.pts.length - 2, Math.floor(s))), f = s - i, a = b.pts[i], c = b.pts[i + 1];
|
|
777
|
+
return { x: a.x + (c.x - a.x) * f, y: a.y + (c.y - a.y) * f };
|
|
778
|
+
};
|
|
779
|
+
const TR = 16, from = Math.max(0, b.d - TR);
|
|
780
|
+
const charged = [];
|
|
781
|
+
for (let k = Math.floor(from); k < b.d && k < b.pts.length - 1; k++) {
|
|
782
|
+
const s0 = Math.max(k, from), s1 = Math.min(k + 1, b.d);
|
|
783
|
+
if (s1 <= s0) continue;
|
|
784
|
+
const a = at(s0), c = at(s1), h = b.hs[k] ?? 0;
|
|
785
|
+
const heat = Math.pow(Math.max(0, 1 - (b.d - (s0 + s1) / 2) / TR), 1.6);
|
|
786
|
+
const seg = [P(a.x, a.y, h), P(c.x, c.y, h)];
|
|
787
|
+
// the burn behind it is pale too: a wide wash of the ball's own light, not a dark blue bar
|
|
788
|
+
stroke(seg, 12, `rgba(150,200,255,${(0.09 * heat).toFixed(3)})`);
|
|
789
|
+
stroke(seg, 4, `rgba(170,222,255,${(0.5 * heat).toFixed(3)})`);
|
|
790
|
+
stroke(seg, 1.6, `rgba(235,248,255,${(0.95 * heat).toFixed(3)})`);
|
|
791
|
+
charged.push({ a: seg[0], b: seg[1], tint: heat, age: 1 - heat, i: k });
|
|
792
|
+
}
|
|
793
|
+
// the dust and the crackle behind the ball, the same charge the light cycles and the Markets
|
|
794
|
+
// pulse carry (chargeTrail)
|
|
795
|
+
chargeTrail(ctx, charged, lw, view.now ?? 0, 977, true);
|
|
796
|
+
const c = P(b.x, b.y, b.z);
|
|
797
|
+
// ONE GRADIENT, NOT STACKED DISCS (operator, 2026-09-14: "The energy ball blue plasma is way too
|
|
798
|
+
// dark. Need to make it way subtler like the ball lightning stuff"). Five discs of deepening blue
|
|
799
|
+
// read as a dark blue blot with a dot in it; this is the stormball's recipe -- a white-hot heart
|
|
800
|
+
// falling off through pale cyan to nothing, one radial gradient -- with the blue kept pale and
|
|
801
|
+
// thin so the ball is light on the board rather than a shadow over it.
|
|
802
|
+
const flick = 0.85 + 0.15 * Math.random();
|
|
803
|
+
const R = 4.2 * U;
|
|
804
|
+
const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, R) : null;
|
|
805
|
+
if (g && typeof g.addColorStop === 'function') {
|
|
806
|
+
g.addColorStop(0, 'rgba(255,255,255,1)');
|
|
807
|
+
g.addColorStop(0.1, 'rgba(240,250,255,1)');
|
|
808
|
+
g.addColorStop(0.22, 'rgba(200,236,255,0.85)');
|
|
809
|
+
g.addColorStop(0.4, `rgba(170,220,255,${(0.32 * flick).toFixed(3)})`);
|
|
810
|
+
g.addColorStop(0.68, `rgba(160,205,255,${(0.1 * flick).toFixed(3)})`);
|
|
811
|
+
g.addColorStop(1, 'rgba(160,200,255,0)');
|
|
812
|
+
ctx.fillStyle = g;
|
|
813
|
+
} else ctx.fillStyle = 'rgba(220,240,255,0.5)';
|
|
814
|
+
ctx.beginPath(); ctx.arc(c.x, c.y, R, 0, Math.PI * 2); ctx.fill();
|
|
815
|
+
const z0 = b.z - 0.9;
|
|
816
|
+
const bolts = 4 + ((Math.random() * 4) | 0);
|
|
817
|
+
for (let i = 0; i < bolts; i++) {
|
|
818
|
+
const end = P(Math.round(b.x + (Math.random() - 0.5) * 7), Math.round(b.y + (Math.random() - 0.5) * 7), z0);
|
|
819
|
+
const pts = [c];
|
|
820
|
+
for (let s = 1; s < 5; s++) {
|
|
821
|
+
const f = s / 5;
|
|
822
|
+
pts.push({ x: c.x + (end.x - c.x) * f + (Math.random() - 0.5) * U * 1.1, y: c.y + (end.y - c.y) * f + (Math.random() - 0.5) * U * 1.1 });
|
|
823
|
+
}
|
|
824
|
+
pts.push(end);
|
|
825
|
+
stroke(pts, 5, 'rgba(110,180,255,0.18)');
|
|
826
|
+
stroke(pts, 1.8, 'rgba(170,220,255,0.7)');
|
|
827
|
+
stroke(pts, 0.8, 'rgba(255,255,255,0.95)');
|
|
828
|
+
ctx.fillStyle = 'rgba(200,235,255,0.5)';
|
|
829
|
+
ctx.beginPath(); ctx.arc(end.x, end.y, 0.35 * U, 0, Math.PI * 2); ctx.fill();
|
|
830
|
+
}
|
|
831
|
+
ctx.lineWidth = lw;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function drawGround(ctx, layers, lw) {
|
|
835
|
+
const P2 = typeof Path2D === 'function';
|
|
836
|
+
const trace = (t, l) => {
|
|
837
|
+
for (const q of l.polys) { t.moveTo(q[0].x, q[0].y); for (let i = 1; i < q.length; i++) t.lineTo(q[i].x, q[i].y); t.closePath(); }
|
|
838
|
+
for (const q of l.lines) { t.moveTo(q[0].x, q[0].y); for (let i = 1; i < q.length; i++) t.lineTo(q[i].x, q[i].y); }
|
|
839
|
+
};
|
|
840
|
+
for (const l of layers) {
|
|
841
|
+
if (!l.polys.length && !l.lines.length) continue;
|
|
842
|
+
if (l.fill) ctx.fillStyle = l.fill;
|
|
843
|
+
else { ctx.strokeStyle = l.stroke; ctx.lineWidth = lw * (l.lw ?? 1); }
|
|
844
|
+
if (P2) {
|
|
845
|
+
if (!l.path) { l.path = new Path2D(); trace(l.path, l); }
|
|
846
|
+
if (l.fill) ctx.fill(l.path); else ctx.stroke(l.path);
|
|
847
|
+
} else {
|
|
848
|
+
ctx.beginPath();
|
|
849
|
+
trace(ctx, l);
|
|
850
|
+
if (l.fill) ctx.fill(); else ctx.stroke();
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
ctx.lineWidth = lw;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function drawGrid(ctx, view, opts, n, blockRows, rows = n) {
|
|
857
|
+
const P = (gx, gy) => project(gx, gy, 0, view);
|
|
858
|
+
// on a curved board a grid line is a curve, so it is drawn as a polyline
|
|
859
|
+
// (one segment every 1.5 units, so a long line on the sphere follows it)
|
|
860
|
+
const seg = (x0, y0, x1, y1) => {
|
|
861
|
+
const S = view.dome ? Math.max(8, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / 1.5)) : 1;
|
|
862
|
+
ctx.beginPath();
|
|
863
|
+
for (let i = 0; i <= S; i++) {
|
|
864
|
+
const p = P(x0 + ((x1 - x0) * i) / S, y0 + ((y1 - y0) * i) / S);
|
|
865
|
+
if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y);
|
|
866
|
+
}
|
|
867
|
+
ctx.stroke();
|
|
868
|
+
};
|
|
869
|
+
const lw = ctx.lineWidth;
|
|
870
|
+
// SPACE (the Markets board; operator, 2026-09-11: "make the floor transparent black against a
|
|
871
|
+
// twinkling star field. Get rid of the texture entirely for the markets page"). No deck, no
|
|
872
|
+
// plates, no sphere past the board: the board alone, a sheet of translucent black the stars
|
|
873
|
+
// show through (paintFrame draws them first), with its neon grid and its glowing edge.
|
|
874
|
+
if (opts.space && opts.floorLine) {
|
|
875
|
+
// ONE LINE (operator, 2026-09-11: "lets get rid of the green ground grid for the markets view.
|
|
876
|
+
// Just draw the one single line between the candles and the date/time"): no floor, no grid,
|
|
877
|
+
// no frame -- a single neon line along the front edge of the board, under the volume and over
|
|
878
|
+
// the hours.
|
|
879
|
+
return () => {
|
|
880
|
+
ctx.strokeStyle = 'rgba(40,255,140,0.07)'; ctx.lineWidth = lw * 12; seg(0, 0, n, 0);
|
|
881
|
+
ctx.strokeStyle = opts.neonGlow; ctx.lineWidth = lw * 5; seg(0, 0, n, 0);
|
|
882
|
+
ctx.strokeStyle = opts.gridEdgeColor; ctx.lineWidth = lw * 1.6; seg(0, 0, n, 0);
|
|
883
|
+
ctx.lineWidth = lw;
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
if (opts.space) {
|
|
887
|
+
const S = 24, ring = [];
|
|
888
|
+
for (let i = 0; i <= S; i++) ring.push(P((n * i) / S, 0));
|
|
889
|
+
for (let i = 1; i <= S; i++) ring.push(P(n, (rows * i) / S));
|
|
890
|
+
for (let i = S - 1; i >= 0; i--) ring.push(P((n * i) / S, rows));
|
|
891
|
+
for (let i = S - 1; i > 0; i--) ring.push(P(0, (rows * i) / S));
|
|
892
|
+
ctx.fillStyle = opts.spaceFloor ?? 'rgba(0,0,0,0.62)';
|
|
893
|
+
ctx.beginPath();
|
|
894
|
+
ring.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
|
895
|
+
ctx.closePath();
|
|
896
|
+
ctx.fill();
|
|
897
|
+
return () => {
|
|
898
|
+
drawGround(ctx, boardGridLayers(view, opts, n, rows), lw);
|
|
899
|
+
ctx.strokeStyle = opts.neonGlow;
|
|
900
|
+
ctx.lineWidth = lw * 6;
|
|
901
|
+
seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
|
|
902
|
+
ctx.lineWidth = lw;
|
|
903
|
+
ctx.strokeStyle = opts.gridEdgeColor;
|
|
904
|
+
seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
// The floor: a SURFACE, not a void (operator, 2026-09-11: "make the grid
|
|
908
|
+
// surface have some texture/non-black area so we can properly see shadows
|
|
909
|
+
// on the grid"). A dim green was still too near black for a shadow to have
|
|
910
|
+
// anything to darken. Now a lighter base, a faint checker between cells,
|
|
911
|
+
// and 4x4 plates in two tones -- floor panels under the phosphor lines. All
|
|
912
|
+
// solid rgba quads (no pattern fills), each drawn through the projection, so
|
|
913
|
+
// on a curved board the texture curves with it.
|
|
914
|
+
const quad = (x0, y0, x1, y1, fill) => {
|
|
915
|
+
const q = [P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)];
|
|
916
|
+
ctx.fillStyle = fill;
|
|
917
|
+
ctx.beginPath();
|
|
918
|
+
ctx.moveTo(q[0].x, q[0].y);
|
|
919
|
+
for (let i = 1; i < 4; i++) ctx.lineTo(q[i].x, q[i].y);
|
|
920
|
+
ctx.closePath();
|
|
921
|
+
ctx.fill();
|
|
922
|
+
};
|
|
923
|
+
// THE SPHERE CARRIES ON PAST THE BOARD (operator, 2026-09-11: "grid surface
|
|
924
|
+
// fills the panel", "sphere continues rendering off the screen, textured
|
|
925
|
+
// ... viewport only obviously"). Under the oblique camera the same texture
|
|
926
|
+
// covers everything the panel can see (view.viewRect, pushed out by
|
|
927
|
+
// groundExtent until the curve covers it); otherwise it is the board alone.
|
|
928
|
+
const vr = view.oblique && view.viewRect;
|
|
929
|
+
const { X0, X1, Y0, Y1 } = vr ? groundExtent(view, vr) : { X0: 0, X1: n, Y0: 0, Y1: rows };
|
|
930
|
+
drawGround(ctx, groundLayers(view, opts, X0, X1, Y0, Y1), lw);
|
|
931
|
+
// Phosphor glow: the same lines once wide and faint, then thin and bright.
|
|
932
|
+
// Two strokes of plain rgba -- no shadowBlur, which a software rasteriser
|
|
933
|
+
// may skip, and no composite mode.
|
|
934
|
+
const lines = () => {
|
|
935
|
+
for (let i = Math.ceil(X0 / opts.gridStep) * opts.gridStep; i <= X1; i += opts.gridStep) seg(i, Y0, i, Y1);
|
|
936
|
+
for (let i = Math.ceil(Y0 / opts.gridStep) * opts.gridStep; i <= Y1; i += opts.gridStep) seg(X0, i, X1, i);
|
|
937
|
+
};
|
|
938
|
+
ctx.strokeStyle = opts.gridGlow;
|
|
939
|
+
ctx.lineWidth = lw * 5;
|
|
940
|
+
lines();
|
|
941
|
+
ctx.lineWidth = lw;
|
|
942
|
+
ctx.strokeStyle = opts.gridColor;
|
|
943
|
+
lines();
|
|
944
|
+
// THE GLOWING LAYER is returned, not drawn: paintFrame lays it after the
|
|
945
|
+
// shadows and before the cubes, so the neon grid, the board edge, the block
|
|
946
|
+
// line and the idle-effect marks are light on the floor that no shadow dims
|
|
947
|
+
// (operator, 2026-09-11: "the grid should be glowing and not affected by
|
|
948
|
+
// shadows"). Everything above this line is the ground the shadows fall on.
|
|
949
|
+
return () => {
|
|
950
|
+
drawGround(ctx, boardGridLayers(view, opts, n, rows), lw);
|
|
951
|
+
// the edge glows too
|
|
952
|
+
ctx.strokeStyle = opts.neonGlow;
|
|
953
|
+
ctx.lineWidth = lw * 6;
|
|
954
|
+
seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
|
|
955
|
+
ctx.lineWidth = lw;
|
|
956
|
+
// the template's own edge, brighter: this is where one block ends
|
|
957
|
+
// ...traced ON the sphere, edge by edge. It was a straight rectangle between
|
|
958
|
+
// the four corners -- the only points of the edge that lie on the plane -- so
|
|
959
|
+
// it floated flat over the bulging surface (operator, 2026-09-11: "the grid
|
|
960
|
+
// is not snapped to the sphere surface. Everything should snap to the
|
|
961
|
+
// sphere surface on the grid").
|
|
962
|
+
ctx.strokeStyle = opts.gridEdgeColor;
|
|
963
|
+
seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
|
|
964
|
+
// the idle effect's mark on the floor, under the blocks it lights
|
|
965
|
+
const fx = view.fx;
|
|
966
|
+
const polyline = (pts, w, color) => {
|
|
967
|
+
if (pts.length < 2) return;
|
|
968
|
+
ctx.strokeStyle = color;
|
|
969
|
+
ctx.lineWidth = lw * w;
|
|
970
|
+
ctx.beginPath();
|
|
971
|
+
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
|
972
|
+
ctx.stroke();
|
|
973
|
+
};
|
|
974
|
+
if (fx && fx.kind === 'ripple') {
|
|
975
|
+
const pts = [];
|
|
976
|
+
for (let i = 0; i <= 96; i++) {
|
|
977
|
+
const a = (i / 96) * Math.PI * 2;
|
|
978
|
+
pts.push(P(Math.max(0, Math.min(n, fx.x + Math.cos(a) * fx.r)), Math.max(0, Math.min(rows, fx.y + Math.sin(a) * fx.r))));
|
|
979
|
+
}
|
|
980
|
+
polyline(pts, 16, `rgba(80,255,190,${(0.12 * fx.amp).toFixed(3)})`);
|
|
981
|
+
polyline(pts, 3, `rgba(180,255,230,${(0.45 * fx.amp).toFixed(3)})`);
|
|
982
|
+
}
|
|
983
|
+
if (fx && (fx.kind === 'outline' || fx.kind === 'scan' || fx.kind === 'tide')) {
|
|
984
|
+
// the front itself, drawn across the floor where it is
|
|
985
|
+
const p = fxFront(fx);
|
|
986
|
+
const L = Math.hypot(n, rows);
|
|
987
|
+
const c0x = n / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dx;
|
|
988
|
+
const c0y = rows / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dy;
|
|
989
|
+
const pts = [];
|
|
990
|
+
for (let i = -40; i <= 40; i++) {
|
|
991
|
+
const gx = c0x - fx.dy * (L * i) / 40, gy = c0y + fx.dx * (L * i) / 40;
|
|
992
|
+
if (gx >= 0 && gx <= n && gy >= 0 && gy <= rows) pts.push(P(gx, gy));
|
|
993
|
+
}
|
|
994
|
+
const col = fx.kind === 'tide' ? '140,255,180' : (fx.kind === 'scan' ? '120,220,255' : '90,230,255');
|
|
995
|
+
polyline(pts, 18, `rgba(${col},${(0.10 * fx.amp).toFixed(3)})`);
|
|
996
|
+
polyline(pts, 3, `rgba(${col},${(0.55 * fx.amp).toFixed(3)})`);
|
|
997
|
+
}
|
|
998
|
+
if (fx && fx.ball) {
|
|
999
|
+
const b = fx.ball;
|
|
1000
|
+
for (const [r, col] of [[5, 'rgba(80,160,255,0.10)'], [3, 'rgba(120,195,255,0.16)'], [1.5, 'rgba(190,230,255,0.24)']]) {
|
|
1001
|
+
ctx.fillStyle = col;
|
|
1002
|
+
ctx.beginPath();
|
|
1003
|
+
for (let i = 0; i <= 32; i++) { const a = (i / 32) * Math.PI * 2; const p = P(b.x + Math.cos(a) * r, b.y + Math.sin(a) * r); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
|
|
1004
|
+
ctx.closePath();
|
|
1005
|
+
ctx.fill();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
ctx.lineWidth = lw;
|
|
1009
|
+
// one block's worth: everything below this line fits in the next block
|
|
1010
|
+
if (blockRows > 0 && blockRows < n) {
|
|
1011
|
+
ctx.strokeStyle = opts.blockLineColor;
|
|
1012
|
+
seg(0, blockRows, n, blockRows);
|
|
1013
|
+
}
|
|
1014
|
+
ctx.lineWidth = lw;
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// THE GRID IS CENTRED ON A SPHERE THAT FILLS THE PANEL (operator, 2026-09-11:
|
|
1019
|
+
// "I want our grid centered within the view-space", "grid surface fills the
|
|
1020
|
+
// panel", "sphere continues rendering off the screen, textured ... viewport
|
|
1021
|
+
// only obviously"). This supersedes "Bottom left should be absolutely flush
|
|
1022
|
+
// with bottom left of the panel" for the oblique camera: the board is centred
|
|
1023
|
+
// with the same margin on opposite sides -- headroom + dome, room for the
|
|
1024
|
+
// tallest cube and for flight -- and drawGrid carries the textured sphere on
|
|
1025
|
+
// past the board to every edge. Still a function of nothing but the fixed
|
|
1026
|
+
// grid and the panel, so the view cannot slide between frames or refreshes.
|
|
1027
|
+
// `rect` is the panel's extent in grid units (x0..x1 across, y0..y1 up).
|
|
1028
|
+
export function obliqueFit(pw, ph, gridW, gridH, opts) {
|
|
1029
|
+
const ob = opts.oblique;
|
|
1030
|
+
const head = ob.headroom + (opts.dome || 0);
|
|
1031
|
+
const u = opts.unit;
|
|
1032
|
+
// anchor 'bottom' (the market chart, facing the board): the board along the bottom with the
|
|
1033
|
+
// headroom above it only, and room on the right for the price labels the axes draw there
|
|
1034
|
+
const low = ob.anchor === 'bottom';
|
|
1035
|
+
const dS = ob.dy ?? 1; // the board's depth as drawn (a lower camera draws it shorter)
|
|
1036
|
+
const extra = low ? 9 : 0;
|
|
1037
|
+
// the strip under the front edge where the axes write the hours: a share of the panel, not a
|
|
1038
|
+
// number of grid units -- in a short panel 2.4 units was ~14 px and the labels were cut off
|
|
1039
|
+
// (operator, 2026-09-11: "The bottom of the chart is getting cut off by the text")
|
|
1040
|
+
const foot = low ? 0.055 * ph : 0;
|
|
1041
|
+
const k = low
|
|
1042
|
+
? Math.min(pw / ((gridW + extra + 2 * head * ob.ox) * u), (ph - foot) / ((gridH * dS + head * ob.oy + 1.2) * u))
|
|
1043
|
+
: Math.min(pw / ((gridW + 2 * head * ob.ox) * u), ph / ((gridH * dS + 2 * head * ob.oy) * u));
|
|
1044
|
+
const tx = pw / 2 - (k * (gridW + extra) * u) / 2;
|
|
1045
|
+
const ty = low ? ph - foot : ph / 2 + (k * gridH * dS * u) / 2;
|
|
1046
|
+
return { k, tx, ty, rect: { x0: -tx / (k * u), x1: (pw - tx) / (k * u), y0: (ty - ph) / (k * u * dS), y1: ty / (k * u * dS) } };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// AXES ON THE BOARD (the market chart; operator: "we can alter the perspective"). The price
|
|
1050
|
+
// levels are lines across the board at the candles' own depth, drawn after the glowing grid and
|
|
1051
|
+
// before the cubes so the candles stand in front of them; the labels go on last, at the right
|
|
1052
|
+
// end, and the hours along the front edge -- every point through the same projection as the
|
|
1053
|
+
// cubes, so a level and a candle at the same price meet. axes = { y, zTop, z: [{ z, label,
|
|
1054
|
+
// color?, strong? }], x: [{ x, label }] }.
|
|
1055
|
+
function drawAxes(ctx, view, axes, n) {
|
|
1056
|
+
const y = axes.y ?? 0;
|
|
1057
|
+
const lw = ctx.lineWidth;
|
|
1058
|
+
const line = (a, b, w, col) => { ctx.strokeStyle = col; ctx.lineWidth = lw * w; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); };
|
|
1059
|
+
for (const t of axes.x ?? []) line(project(t.x, y, 0, view), project(t.x, y, axes.zTop ?? 0, view), 1, 'rgba(120,255,190,0.12)');
|
|
1060
|
+
for (const t of axes.z ?? []) {
|
|
1061
|
+
const a = project(0, y, t.z, view), b = project(n, y, t.z, view);
|
|
1062
|
+
line(a, b, 6, t.strong ? 'rgba(255,255,255,0.06)' : 'rgba(40,255,140,0.05)');
|
|
1063
|
+
line(a, b, t.strong ? 1.6 : 1, t.strong ? (t.color ?? 'rgba(255,255,255,0.9)') : 'rgba(120,255,190,0.3)');
|
|
1064
|
+
}
|
|
1065
|
+
ctx.lineWidth = lw;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// THE PRICE LINE (operator, 2026-09-11: "we need a bright neon yellow line running in 3D showing
|
|
1069
|
+
// the price chart in the Markets display"). The close, hour by hour, at the candles' own depth and
|
|
1070
|
+
// through the same projection, drawn after them in the grid's light style -- a wide faint halo,
|
|
1071
|
+
// a glow, then a bright core -- so it cuts through the scene the way the neon grid does.
|
|
1072
|
+
// ONE CONTINUOUS PIPE, not a run of segments (operator, 2026-09-13: "the yellow price line is
|
|
1073
|
+
// made of line segments. Make it one continuous curved pipe").
|
|
1074
|
+
//
|
|
1075
|
+
// WHAT WAS WRONG. Every layer was stroked as a polyline through the projected closes and, worse,
|
|
1076
|
+
// during the pulse each SEGMENT was stroked separately so it could carry its own colour. Six
|
|
1077
|
+
// layers x forty segments is 240 strokes whose translucent ends overlap at every join: the line
|
|
1078
|
+
// beaded at each candle and the corners came to points. It read as forty things, because it was.
|
|
1079
|
+
//
|
|
1080
|
+
// WHAT IT IS NOW. The closes are a monotonic, evenly-spaced series in x (markets.js builds
|
|
1081
|
+
// `axes.line` at each candle's centre), so they interpolate cleanly: a centripetal-ish
|
|
1082
|
+
// Catmull-Rom through the points, converted to cubic Beziers, gives one smooth curve that passes
|
|
1083
|
+
// exactly through every close -- the data is not smoothed away, only the path between the readings
|
|
1084
|
+
// is. Each layer is then ONE stroke of that curve with round joins and caps, so a layer overlaps
|
|
1085
|
+
// itself nowhere and the whole thing reads as a tube.
|
|
1086
|
+
//
|
|
1087
|
+
// THE TAIL, which is why this needed a gradient. A single stroke cannot change colour along its
|
|
1088
|
+
// length without one, and the pulse's whole point is blue at the head easing back to yellow. The
|
|
1089
|
+
// canvas rules here forbid clip, globalAlpha, composite modes and shadowBlur -- gradients are not
|
|
1090
|
+
// on that list, and charts.js has used them all along. Because x is monotonic, a linear gradient
|
|
1091
|
+
// across the line's x-extent IS distance along the line, so the tint becomes continuous instead of
|
|
1092
|
+
// forty discrete steps.
|
|
1093
|
+
//
|
|
1094
|
+
// AND IT DEGRADES. A context that cannot build a gradient (every recording stub in test/, and any
|
|
1095
|
+
// software rasteriser that refuses) gets a flat colour instead of a throw -- goggles.js learned
|
|
1096
|
+
// that the hard way: "createLinearGradient once turned a green unit suite into a page that painted
|
|
1097
|
+
// nothing". Same doctrine here.
|
|
1098
|
+
function gradientOr(ctx, build, stops, fallback) {
|
|
1099
|
+
try {
|
|
1100
|
+
const g = build();
|
|
1101
|
+
if (!g || typeof g.addColorStop !== 'function') return fallback;
|
|
1102
|
+
for (const [o, c] of stops) g.addColorStop(o, c);
|
|
1103
|
+
return g;
|
|
1104
|
+
} catch { return fallback; }
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* The closes as one smooth curve, traced into `t` (a Path2D or the context itself).
|
|
1109
|
+
*
|
|
1110
|
+
* Catmull-Rom with a tension of 1/6 converted to cubic Beziers. The curve passes THROUGH every
|
|
1111
|
+
* point -- an approximating spline would quietly redraw the prices -- and the ends duplicate the
|
|
1112
|
+
* terminal points so the first and last stretches curve like the rest.
|
|
1113
|
+
*/
|
|
1114
|
+
function traceCurve(t, pts) {
|
|
1115
|
+
t.moveTo(pts[0].x, pts[0].y);
|
|
1116
|
+
if (pts.length === 2) { t.lineTo(pts[1].x, pts[1].y); return; }
|
|
1117
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
1118
|
+
const p0 = pts[i - 1] ?? pts[i];
|
|
1119
|
+
const p1 = pts[i];
|
|
1120
|
+
const p2 = pts[i + 1];
|
|
1121
|
+
const p3 = pts[i + 2] ?? p2;
|
|
1122
|
+
t.bezierCurveTo(
|
|
1123
|
+
p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
|
|
1124
|
+
p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
|
|
1125
|
+
p2.x, p2.y,
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// The price curve, kept between frames: one entry, because there is one price line on screen.
|
|
1131
|
+
const CURVE = { key: null, path: null };
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Where the pulse's head is, INCLUDING its flight past the end of the line.
|
|
1135
|
+
*
|
|
1136
|
+
* `at` is 0..1 along the line and then beyond. Up to 1 it interpolates the points as before. Past
|
|
1137
|
+
* 1 it continues along the direction of the FINAL SEGMENT -- the last vector -- for `overrun` of
|
|
1138
|
+
* the line's length, fading linearly to nothing. Returns null once it is gone, so the caller draws
|
|
1139
|
+
* nothing rather than drawing something transparent.
|
|
1140
|
+
*/
|
|
1141
|
+
function headPoint(pts, at, overrun) {
|
|
1142
|
+
const n = pts.length - 1;
|
|
1143
|
+
if (at < 0) return null;
|
|
1144
|
+
if (at <= 1) {
|
|
1145
|
+
const d = at * n;
|
|
1146
|
+
const k = Math.min(n - 1, Math.floor(d)), fr = d - k;
|
|
1147
|
+
return { x: pts[k].x + (pts[k + 1].x - pts[k].x) * fr, y: pts[k].y + (pts[k + 1].y - pts[k].y) * fr, fade: 1 };
|
|
1148
|
+
}
|
|
1149
|
+
const past = at - 1;
|
|
1150
|
+
if (past >= overrun) return null; // faded out before the edge
|
|
1151
|
+
const a = pts[n - 1], b = pts[n];
|
|
1152
|
+
const vx = b.x - a.x, vy = b.y - a.y;
|
|
1153
|
+
const len = Math.hypot(vx, vy) || 1;
|
|
1154
|
+
// the whole line's length, so the overrun is a share of the line and not of one candle
|
|
1155
|
+
let total = 0;
|
|
1156
|
+
for (let i = 0; i < n; i++) total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
|
1157
|
+
const travel = past * total;
|
|
1158
|
+
return { x: b.x + (vx / len) * travel, y: b.y + (vy / len) * travel, fade: 1 - past / overrun };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// THE PIPE BULGE (operator, 2026-09-14: "Add a new effect on the markets yellow line. Moves along the
|
|
1162
|
+
// yellow bar from left to right. I want it to look like a sphere is moving through the pipe, and the
|
|
1163
|
+
// pipe bulges outward as the sphere moves through the pipe, and contracts back into shape when it
|
|
1164
|
+
// moves onward. Have it cleanly roll in and roll out with no pops, and limit it to within the limits
|
|
1165
|
+
// of the yellow price line").
|
|
1166
|
+
//
|
|
1167
|
+
// SECOND CUT (the same day: "slow it down to half the current speed. The bulge does not look like
|
|
1168
|
+
// it's passing a large sphere being forced through the tube, deforming a spherical bulge as it moves.
|
|
1169
|
+
// Also, make the spawn in and fade out bulge up much sooner on the line, and last longer before it
|
|
1170
|
+
// shrinks. the bulge and shrink effects should take 1 second each at the extents"). The first cut
|
|
1171
|
+
// was a Gaussian swell -- a soft hump, not a ball -- that eased in and out over 18% of the run at each
|
|
1172
|
+
// end. Now:
|
|
1173
|
+
// * the tube's wall is the SHAPE A BALL MAKES in a hose: round over the ball (a circle of radius
|
|
1174
|
+
// R), then an ARC of stretched skin, tangent to the ball and tangent to the tube (bulgeProfile)
|
|
1175
|
+
// * the ball travels the WHOLE line at constant speed, and its size is whatever fits: at the
|
|
1176
|
+
// line's first point the tube is exactly the tube, the ball grows as fast as its own bulge can
|
|
1177
|
+
// stay on the line, is full size for everything between, and shrinks back to exactly the tube
|
|
1178
|
+
// at the last point (bulgeFit). No clock: the size is a function of where the ball is.
|
|
1179
|
+
// The timing went through 1000 ms ramps, 3000 ("time it so the ball shrinks to normal tube size at
|
|
1180
|
+
// the last movement frame"), 500, 100, and then geometry (operator, 2026-09-14: "the bulge is
|
|
1181
|
+
// appearing/disappearing too far from the edge, I want to time it so that it's exactly normal pipe
|
|
1182
|
+
// sized starts and finishes. Have the bulge last as long as possible before it's no longer
|
|
1183
|
+
// discernable"): full for as long as a ball that enters and leaves at the line's ends can be.
|
|
1184
|
+
// The tapering was straight flanks off the ball at 24 degrees (an oval), then 58 (a sphere with a
|
|
1185
|
+
// hard edge: "Now the sphere is too obvious. Can we arc the tapering?"), now a concave arc: the skin
|
|
1186
|
+
// leaving the ball and easing down onto the tube, the way a stretched hose does.
|
|
1187
|
+
const BULGE_FILLET = 1.6; // the skin arc's radius, in ball radii
|
|
1188
|
+
const BULGE_BALL = 4.2; // the ball's radius, in tube radii (of the core)
|
|
1189
|
+
|
|
1190
|
+
// At effect progress u of an `ms`-long run: travel 0..1 along the line. (The size is bulgeFit's.)
|
|
1191
|
+
export function bulgeAt(u, ms = FX_MS.bulge) {
|
|
1192
|
+
return { t: Math.max(0, Math.min(1, u)) };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// the arc's geometry: its centre sits rf above the tube wall and R + rf from the ball's centre, so
|
|
1196
|
+
// it touches both
|
|
1197
|
+
function fillet(R, r0) {
|
|
1198
|
+
const rf = R * BULGE_FILLET, cy = r0 + rf;
|
|
1199
|
+
const cx = Math.sqrt((R + rf) * (R + rf) - cy * cy);
|
|
1200
|
+
return { rf, cx, cy, xt: (cx * R) / (R + rf) };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// The wall's distance from the axis at d pixels from the ball's centre, for a ball of radius R in a
|
|
1204
|
+
// tube of radius r0. Continuous, with a continuous slope: round over the ball, then the arc, then
|
|
1205
|
+
// the tube.
|
|
1206
|
+
export function bulgeProfile(d, R, r0) {
|
|
1207
|
+
const x = Math.abs(d);
|
|
1208
|
+
if (R <= r0) return r0;
|
|
1209
|
+
const { rf, cx, cy, xt } = fillet(R, r0);
|
|
1210
|
+
if (x <= xt) return Math.sqrt(R * R - x * x);
|
|
1211
|
+
if (x >= cx) return r0;
|
|
1212
|
+
return cy - Math.sqrt(rf * rf - (x - cx) * (x - cx));
|
|
1213
|
+
}
|
|
1214
|
+
// how far from the ball's centre the bulge reaches, in pixels
|
|
1215
|
+
export const bulgeReach = (R, r0) => (R <= r0 ? 0 : fillet(R, r0).cx);
|
|
1216
|
+
// the largest ball, up to Rfull, whose bulge stays within `room` pixels of its centre
|
|
1217
|
+
export function bulgeFit(room, Rfull, r0) {
|
|
1218
|
+
if (room <= 0 || Rfull <= r0) return r0;
|
|
1219
|
+
if (bulgeReach(Rfull, r0) <= room) return Rfull;
|
|
1220
|
+
let lo = r0, hi = Rfull;
|
|
1221
|
+
for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2; if (bulgeReach(mid, r0) <= room) lo = mid; else hi = mid; }
|
|
1222
|
+
return lo;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// A point on the drawn curve, and its unit normal: the same Catmull-Rom Beziers traceCurve strokes, so
|
|
1226
|
+
// the swell is laid on exactly the line the eye sees and never separates from it at a candle.
|
|
1227
|
+
function curveAt(pts, s) {
|
|
1228
|
+
const n = pts.length - 1;
|
|
1229
|
+
const d = Math.max(0, Math.min(n, s * n));
|
|
1230
|
+
const i = Math.min(n - 1, Math.floor(d)), t = d - i;
|
|
1231
|
+
const p0 = pts[i - 1] ?? pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] ?? p2;
|
|
1232
|
+
const c1 = { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 };
|
|
1233
|
+
const c2 = { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 };
|
|
1234
|
+
const mt = 1 - t;
|
|
1235
|
+
const x = mt * mt * mt * p1.x + 3 * mt * mt * t * c1.x + 3 * mt * t * t * c2.x + t * t * t * p2.x;
|
|
1236
|
+
const y = mt * mt * mt * p1.y + 3 * mt * mt * t * c1.y + 3 * mt * t * t * c2.y + t * t * t * p2.y;
|
|
1237
|
+
const dx = 3 * mt * mt * (c1.x - p1.x) + 6 * mt * t * (c2.x - c1.x) + 3 * t * t * (p2.x - c2.x);
|
|
1238
|
+
const dy = 3 * mt * mt * (c1.y - p1.y) + 6 * mt * t * (c2.y - c1.y) + 3 * t * t * (p2.y - c2.y);
|
|
1239
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
1240
|
+
return { x, y, nx: -dy / len, ny: dx / len };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// GRAVITY (operator, 2026-09-14: "Can we have gravity working against it? Faster to drop down on the
|
|
1244
|
+
// pipe and slower to rise up the pipe?"). The ball's speed along the pipe follows the pipe's slope
|
|
1245
|
+
// on screen: a run downhill (screen y increasing) is quicker, a climb slower, the level pipe at the
|
|
1246
|
+
// base speed. The speed is averaged over a stretch of pipe either side, so the ball carries some
|
|
1247
|
+
// momentum through a candle rather than snapping to each new slope; and the whole trip still takes
|
|
1248
|
+
// the effect's time, so a steep line simply spends more of it climbing than falling.
|
|
1249
|
+
// v = 1 + BULGE_GRAVITY * (dy/ds), held within BULGE_SPEED (dy/ds: +1 straight down, -1 straight up)
|
|
1250
|
+
const BULGE_GRAVITY = 1.6;
|
|
1251
|
+
const BULGE_SPEED = [0.35, 2.6];
|
|
1252
|
+
const BULGE_MOMENTUM = 0.03; // averaged over this much of the line each way
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Where a ball is at time t (0..1) along a line sampled as [{ y, len }] (screen y, and distance along
|
|
1256
|
+
* the line), given gravity: returns a distance in the same units as `len`, 0 at t=0, the full length
|
|
1257
|
+
* at t=1, never going backwards.
|
|
1258
|
+
*/
|
|
1259
|
+
export function bulgeTravel(table) {
|
|
1260
|
+
const n = table.length - 1;
|
|
1261
|
+
if (n < 1) return () => 0;
|
|
1262
|
+
const total = table[n].len;
|
|
1263
|
+
const raw = [];
|
|
1264
|
+
for (let k = 0; k < n; k++) {
|
|
1265
|
+
const a = table[k], b = table[k + 1], ds = b.len - a.len;
|
|
1266
|
+
const slope = ds > 0 ? Math.max(-1, Math.min(1, (b.y - a.y) / ds)) : 0;
|
|
1267
|
+
raw.push(Math.max(BULGE_SPEED[0], Math.min(BULGE_SPEED[1], 1 + BULGE_GRAVITY * slope)));
|
|
1268
|
+
}
|
|
1269
|
+
const win = Math.max(0, Math.round(n * BULGE_MOMENTUM));
|
|
1270
|
+
const T = [0];
|
|
1271
|
+
for (let k = 0; k < n; k++) {
|
|
1272
|
+
let sum = 0, cnt = 0;
|
|
1273
|
+
for (let j = Math.max(0, k - win); j <= Math.min(n - 1, k + win); j++) { sum += raw[j]; cnt++; }
|
|
1274
|
+
T.push(T[k] + (table[k + 1].len - table[k].len) / (sum / cnt));
|
|
1275
|
+
}
|
|
1276
|
+
const span = T[n] || 1;
|
|
1277
|
+
return (t) => {
|
|
1278
|
+
const want = Math.max(0, Math.min(1, t)) * span;
|
|
1279
|
+
let lo = 0, hi = n;
|
|
1280
|
+
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (T[mid] < want) lo = mid; else hi = mid; }
|
|
1281
|
+
const f = T[hi] > T[lo] ? (want - T[lo]) / (T[hi] - T[lo]) : 0;
|
|
1282
|
+
return Math.min(total, table[lo].len + (table[hi].len - table[lo].len) * f);
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// The curve by arc length, sampled once per draw: `at(px)` is the point that far along the line,
|
|
1287
|
+
// `travel(t)` how far along a ball under gravity has got at time t (0..1).
|
|
1288
|
+
function curveByLength(pts, samples = 400) {
|
|
1289
|
+
const table = [{ s: 0, len: 0, ...curveAt(pts, 0) }];
|
|
1290
|
+
for (let k = 1; k <= samples; k++) {
|
|
1291
|
+
const p = curveAt(pts, k / samples), q = table[k - 1];
|
|
1292
|
+
table.push({ s: k / samples, len: q.len + Math.hypot(p.x - q.x, p.y - q.y), ...p });
|
|
1293
|
+
}
|
|
1294
|
+
const total = table[samples].len;
|
|
1295
|
+
const at = (px) => {
|
|
1296
|
+
const L = Math.max(0, Math.min(total, px));
|
|
1297
|
+
let lo = 0, hi = samples;
|
|
1298
|
+
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (table[mid].len < L) lo = mid; else hi = mid; }
|
|
1299
|
+
const a = table[lo], b = table[hi], f = b.len > a.len ? (L - a.len) / (b.len - a.len) : 0;
|
|
1300
|
+
return curveAt(pts, a.s + (b.s - a.s) * f);
|
|
1301
|
+
};
|
|
1302
|
+
return { total, at, travel: bulgeTravel(table) };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function drawBulge(ctx, pts, lw0, u, layers, ms = FX_MS.bulge) {
|
|
1306
|
+
const lw = Number.isFinite(lw0) && lw0 > 0 ? lw0 : 1; // a canvas that will not say: one pixel
|
|
1307
|
+
const { t } = bulgeAt(u, ms);
|
|
1308
|
+
const coreR = (lw * 5.5) / 2;
|
|
1309
|
+
const Rfull = coreR * BULGE_BALL;
|
|
1310
|
+
const curve = curveByLength(pts);
|
|
1311
|
+
// the ball's centre travels the whole line, quicker downhill and slower up (bulgeTravel); the ball
|
|
1312
|
+
// is as big as fits between it and the nearer end
|
|
1313
|
+
const centre = curve.travel(t);
|
|
1314
|
+
const R = bulgeFit(Math.min(centre, curve.total - centre), Rfull, coreR);
|
|
1315
|
+
const amp = (R - coreR) / (Rfull - coreR || 1);
|
|
1316
|
+
if (amp < 0.002) return;
|
|
1317
|
+
const span = bulgeReach(R, coreR);
|
|
1318
|
+
const STEPS = 72;
|
|
1319
|
+
const samples = [];
|
|
1320
|
+
for (let k = 0; k <= STEPS; k++) {
|
|
1321
|
+
const d = -span + (2 * span * k) / STEPS;
|
|
1322
|
+
samples.push({ ...curve.at(centre + d), r: bulgeProfile(d, R, coreR) });
|
|
1323
|
+
}
|
|
1324
|
+
// NO SPHERE, ONLY THE TUBE (operator, 2026-09-14: "I don't want to see the sphere. I want to see the
|
|
1325
|
+
// obvious deformation of the tube"). What sells a hose being forced wide is the hose: its skin
|
|
1326
|
+
// stretches into the round profile and thins, its hot core keeps running straight through the
|
|
1327
|
+
// middle, and light catches the stretched outline.
|
|
1328
|
+
// glow layers follow the swollen wall at their own constant offset (the skin keeps its halo)
|
|
1329
|
+
// the outer core the WALL: filled out to the profile, as solid as the rest of the line
|
|
1330
|
+
// inner cores MAGNIFIED with the wall, as through a fish-eye lens: each keeps its share of the
|
|
1331
|
+
// tube's width, so the bright thread swells out through the middle of the bulge
|
|
1332
|
+
// (operator: "the yellow line running through the center of the bulge needs to
|
|
1333
|
+
// fish-eye lense distort out instead of staying uniformly thin")
|
|
1334
|
+
// Only the part beyond the tube already stroked is filled, so translucent layers never double up.
|
|
1335
|
+
const band = (rOf, fill, side) => {
|
|
1336
|
+
ctx.fillStyle = fill;
|
|
1337
|
+
ctx.beginPath();
|
|
1338
|
+
samples.forEach((q, k) => { const r = rOf(q); const x = q.x + q.nx * side * r, y = q.y + q.ny * side * r; if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); });
|
|
1339
|
+
return (r0) => { for (let k = samples.length - 1; k >= 0; k--) { const q = samples[k]; ctx.lineTo(q.x + q.nx * side * r0, q.y + q.ny * side * r0); } ctx.closePath(); ctx.fill(); };
|
|
1340
|
+
};
|
|
1341
|
+
for (const [w, c, a, kind] of layers) {
|
|
1342
|
+
const r0 = (lw * w) / 2;
|
|
1343
|
+
const isWall = kind === 'core' && w === Math.max(...layers.filter((l) => l[3] === 'core').map((l) => l[0]));
|
|
1344
|
+
if (kind === 'core' && !isWall) {
|
|
1345
|
+
const lens = (q) => r0 * (q.r / coreR);
|
|
1346
|
+
for (const side of [1, -1]) band(lens, `rgba(${c[0]},${c[1]},${c[2]},${a})`, side)(r0);
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
const off = r0 - coreR;
|
|
1350
|
+
// OPAQUE (operator: "Get rid of the transparency effect and keep the yellow line opaque, not clear"):
|
|
1351
|
+
// the swollen wall is filled solid, where it had been drawn as thinner see-through skin
|
|
1352
|
+
const alpha = isWall ? 1 : a;
|
|
1353
|
+
for (const side of [1, -1]) band((q) => q.r + off, `rgba(${c[0]},${c[1]},${c[2]},${alpha})`, side)(r0);
|
|
1354
|
+
}
|
|
1355
|
+
// the stretched outline catching the light, brightest where it is stretched furthest, and on the
|
|
1356
|
+
// upper side a highlight running along the swell, on the lower a faint shadow -- the roundness
|
|
1357
|
+
const liftOf = (q) => Math.max(0, (q.r - coreR) / (R - coreR || 1));
|
|
1358
|
+
const upSide = samples[samples.length >> 1].ny < 0 ? 1 : -1; // the side whose normal points up the screen
|
|
1359
|
+
const trace = (side, rOf, colOf, width) => {
|
|
1360
|
+
for (let k = 0; k < samples.length - 1; k++) {
|
|
1361
|
+
const q = samples[k], r = samples[k + 1], lift = liftOf(q);
|
|
1362
|
+
if (lift < 0.02) continue;
|
|
1363
|
+
ctx.strokeStyle = colOf(lift);
|
|
1364
|
+
ctx.lineWidth = width;
|
|
1365
|
+
ctx.beginPath();
|
|
1366
|
+
ctx.moveTo(q.x + q.nx * side * rOf(q), q.y + q.ny * side * rOf(q));
|
|
1367
|
+
ctx.lineTo(r.x + r.nx * side * rOf(r), r.y + r.ny * side * rOf(r));
|
|
1368
|
+
ctx.stroke();
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
for (const side of [1, -1]) trace(side, (q) => q.r, (lift) => `rgba(255,250,215,${(0.95 * lift * amp).toFixed(3)})`, Math.max(0.8, lw * 1.4));
|
|
1372
|
+
trace(upSide, (q) => coreR + (q.r - coreR) * 0.62, (lift) => `rgba(255,255,240,${(0.55 * lift * amp).toFixed(3)})`, Math.max(0.8, lw * 1.8));
|
|
1373
|
+
trace(-upSide, (q) => coreR + (q.r - coreR) * 0.7, (lift) => `rgba(110,70,0,${(0.3 * lift * amp).toFixed(3)})`, Math.max(0.8, lw * 2.2));
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// THE LINE, ELECTRIFIED where ball lightning passes: every segment within reach of the ball takes
|
|
1377
|
+
// a blue charge that fades with distance, plus a few sparks jumping off it -- the line is one of
|
|
1378
|
+
// the "elements it comes near"
|
|
1379
|
+
function electrifyLine(ctx, pts, view, lw) {
|
|
1380
|
+
const s = view.fx.stormball;
|
|
1381
|
+
const U = view.unit ?? 8;
|
|
1382
|
+
const c = project(s.at.x, s.at.y, s.at.z, view);
|
|
1383
|
+
const reach = U * 9;
|
|
1384
|
+
const charged = [];
|
|
1385
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
1386
|
+
const mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2;
|
|
1387
|
+
const d = Math.hypot(mx - c.x, my - c.y);
|
|
1388
|
+
if (d < reach) charged.push([i, 1 - d / reach]);
|
|
1389
|
+
}
|
|
1390
|
+
if (!charged.length) return;
|
|
1391
|
+
const seg = (i, w, col) => { ctx.strokeStyle = col; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i + 1].x, pts[i + 1].y); ctx.stroke(); };
|
|
1392
|
+
for (const [i, k] of charged) {
|
|
1393
|
+
const a = k * k;
|
|
1394
|
+
seg(i, lw * 16, `rgba(40,120,255,${(0.28 * a).toFixed(3)})`);
|
|
1395
|
+
seg(i, lw * 7, `rgba(90,190,255,${(0.7 * a).toFixed(3)})`);
|
|
1396
|
+
seg(i, lw * 2.4, `rgba(220,245,255,${(0.95 * a).toFixed(3)})`);
|
|
1397
|
+
// sparks off the charged wire, new every frame
|
|
1398
|
+
if (a > 0.35 && Math.random() < 0.6) {
|
|
1399
|
+
const p = pts[i], ang = Math.random() * Math.PI * 2, len = U * (0.6 + Math.random() * 1.4);
|
|
1400
|
+
ctx.strokeStyle = `rgba(200,240,255,${(0.9 * a).toFixed(3)})`; ctx.lineWidth = Math.max(lw, U * 0.05);
|
|
1401
|
+
ctx.beginPath(); ctx.moveTo(p.x, p.y);
|
|
1402
|
+
ctx.lineTo(p.x + Math.cos(ang) * len * 0.5 + (Math.random() - 0.5) * U, p.y + Math.sin(ang) * len * 0.5);
|
|
1403
|
+
ctx.lineTo(p.x + Math.cos(ang) * len, p.y + Math.sin(ang) * len);
|
|
1404
|
+
ctx.stroke();
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function priceLine(ctx, view, axes) {
|
|
1410
|
+
const pts = (axes.line ?? []).map((q) => project(q.x, axes.y ?? 0, q.z, view));
|
|
1411
|
+
if (pts.length < 2) return;
|
|
1412
|
+
const lw = ctx.lineWidth;
|
|
1413
|
+
const join = ctx.lineJoin, cap = ctx.lineCap;
|
|
1414
|
+
ctx.lineJoin = 'round';
|
|
1415
|
+
ctx.lineCap = 'round';
|
|
1416
|
+
// Built once and CACHED, not rebuilt every frame. All six layers stroke the same shape, and the
|
|
1417
|
+
// shape only changes when the projected closes do -- which is when the series or the camera
|
|
1418
|
+
// changes, not when a frame ticks. drawGround caches its Path2D the same way (l.path); doing it
|
|
1419
|
+
// per frame here would allocate and re-trace thirty Beziers sixty times a second underneath an
|
|
1420
|
+
// effect that is already the expensive thing on screen.
|
|
1421
|
+
const P2 = typeof Path2D === 'function';
|
|
1422
|
+
let path = null;
|
|
1423
|
+
if (P2) {
|
|
1424
|
+
const key = `${pts.length}|${pts[0].x.toFixed(2)},${pts[0].y.toFixed(2)}|${pts[pts.length - 1].x.toFixed(2)},${pts[pts.length - 1].y.toFixed(2)}|${pts[(pts.length / 2) | 0].y.toFixed(2)}`;
|
|
1425
|
+
if (CURVE.key === key && CURVE.path) path = CURVE.path;
|
|
1426
|
+
else {
|
|
1427
|
+
try { path = new Path2D(); traceCurve(path, pts); CURVE.key = key; CURVE.path = path; }
|
|
1428
|
+
catch { path = null; CURVE.key = null; CURVE.path = null; }
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const stroke = (w, col) => {
|
|
1432
|
+
ctx.strokeStyle = col; ctx.lineWidth = lw * w;
|
|
1433
|
+
if (path) { ctx.stroke(path); return; }
|
|
1434
|
+
ctx.beginPath(); traceCurve(ctx, pts); ctx.stroke();
|
|
1435
|
+
};
|
|
1436
|
+
// A BRIGHT NEON GLOW (operator, 2026-09-11, of the crackling light saber that was here: "that
|
|
1437
|
+
// effect is terrible. Remove it. I was hoping for a bright neon glow"). Steady -- no pulses, no
|
|
1438
|
+
// crackle, no flicker: a wide soft bloom, a hot yellow tube and a white core.
|
|
1439
|
+
// the bloom, the tube and the core -- ALL of which the pulse tints. The first cut turned only
|
|
1440
|
+
// the thin core blue and left the fat yellow bloom round it untouched, so the surge read as a
|
|
1441
|
+
// faint whitening of the line (operator: "much too small on the yellow line, it's not obvious
|
|
1442
|
+
// it's a large energy pulse travelling the line"). The bloom goes a deep saturated blue, the
|
|
1443
|
+
// core an electric one, and every pass SWELLS behind the head.
|
|
1444
|
+
const GLOW = [[30, [255, 225, 40], 0.05, 'glow'], [18, [255, 228, 45], 0.10, 'glow'], [10, [255, 232, 55], 0.22, 'glow']];
|
|
1445
|
+
const CORE = [[5.5, [255, 236, 70], 0.78, 'core'], [3, [255, 246, 150], 1, 'core'], [1.3, [255, 255, 240], 1, 'core']];
|
|
1446
|
+
const fx = view.fx && view.fx.kind === 'pulse' ? view.fx : null;
|
|
1447
|
+
const done = () => { ctx.lineWidth = lw; ctx.lineJoin = join; ctx.lineCap = cap; };
|
|
1448
|
+
if (view.fx?.kind === 'bulge') {
|
|
1449
|
+
for (const [w, c, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${c[0]},${c[1]},${c[2]},${a})`);
|
|
1450
|
+
drawBulge(ctx, pts, lw, view.fx.u, [...GLOW, ...CORE], view.fx.ms);
|
|
1451
|
+
done();
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
if (!fx) {
|
|
1455
|
+
for (const [w, c, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${c[0]},${c[1]},${c[2]},${a})`);
|
|
1456
|
+
if (view.fx?.kind === 'stormball' && view.fx.stormball) electrifyLine(ctx, pts, view, lw);
|
|
1457
|
+
done();
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const n = pts.length - 1;
|
|
1461
|
+
const headAt = fx.u / PULSE_TRAVEL; // 0..1 along the line, then past it
|
|
1462
|
+
// BRIGHT NEON BLUE (operator, 2026-09-13: "make the energy pulse bright neon blue as it travels
|
|
1463
|
+
// along the line"). Neon is saturation and luminance TOGETHER, so both move: the core goes
|
|
1464
|
+
// brighter and further toward cyan, and the bloom drops its red channel to nothing so the
|
|
1465
|
+
// surround reads as pure blue instead of the washed periwinkle it was. The tail's shape is
|
|
1466
|
+
// untouched -- every pass still blends wire-yellow through these to HOT at the overshoot; only
|
|
1467
|
+
// how blue "blue" is has changed.
|
|
1468
|
+
const BLUE = [120, 225, 255]; // the core: electric neon
|
|
1469
|
+
const DEEP = [0, 120, 255]; // the bloom: pure saturated blue
|
|
1470
|
+
const HOT = [255, 255, 215]; // the flash: whiter than the wire
|
|
1471
|
+
// THE NEBULA BEHIND THE SURGE (operator, 2026-09-12: "like a blue nebula behind the energy pulse
|
|
1472
|
+
// that starts expanding and fading out to black"). Drawn FIRST, so it sits behind the wire: soft
|
|
1473
|
+
// nested discs on every charged segment, their radius growing with that stretch's age and their
|
|
1474
|
+
// alpha falling with it -- tight and bright just behind the head, spread wide and gone to black
|
|
1475
|
+
// by the tail's end. Plain fills, layered, as every glow in this renderer is.
|
|
1476
|
+
// A TRUE EMITTER (operator, 2026-09-12: "make the nebula an emitter; still seeing concentric
|
|
1477
|
+
// circles when the nebula's/trails are drawn behind the energy ball"). Emitting PER SEGMENT was
|
|
1478
|
+
// the cause: neighbouring segments are almost the same age, so their puffs shared a distance and
|
|
1479
|
+
// a radius and lined up into arcs -- rings made of many little circles are still rings. The
|
|
1480
|
+
// cloud is emitted over the charged SPAN instead. Each puff picks its own place along the trail,
|
|
1481
|
+
// its own angle, its own distance and its own size from four INDEPENDENT hashes, so no two share
|
|
1482
|
+
// a centre or a radius and there is no common edge for the eye to join up. Hashed, not random,
|
|
1483
|
+
// so a puff keeps its place from frame to frame instead of boiling.
|
|
1484
|
+
// (operator, 2026-09-13: "the nebula emissions are not substantial enough") -- more puffs, wider,
|
|
1485
|
+
// and at more than double the alpha, with a lighter core on every other one so the cloud reads as
|
|
1486
|
+
// having depth. Layering is what makes it substantial; a single fat translucent disc is a bubble.
|
|
1487
|
+
// SMALLER AND FAINTER (operator, 2026-09-14: "tune the nebula tails down a bit. They are too
|
|
1488
|
+
// large"). Puffs spread about half as far from the line (4 + 78 * age line-widths -> 3 + 38), are
|
|
1489
|
+
// a bit over half the radius and grow less with age, and sit at three-quarters of the alpha, so the
|
|
1490
|
+
// cloud hugs the wire instead of billowing over the chart. Fewer of them, since each covers less.
|
|
1491
|
+
const PUFFS = 160;
|
|
1492
|
+
for (let k = 0; k < PUFFS; k++) {
|
|
1493
|
+
const u = hash01(k * 7 + 13); // how far back down the tail it sits
|
|
1494
|
+
const at = headAt - u * PULSE_TAIL;
|
|
1495
|
+
if (at < 0) continue;
|
|
1496
|
+
// PAST THE END IT FADES, IT DOES NOT VANISH (operator: "The nebula effects should also fade out
|
|
1497
|
+
// instead of just disappearing"). This was `at > 1 -> continue`, so the cloud was culled the
|
|
1498
|
+
// instant its stretch ran off the last candle and the whole trail blinked out together.
|
|
1499
|
+
const off = at > 1 ? (at - 1) / 0.34 : 0;
|
|
1500
|
+
if (off >= 1) continue;
|
|
1501
|
+
const edge = 1 - off;
|
|
1502
|
+
const tint = Math.pow(1 - u, 1.4) * edge;
|
|
1503
|
+
const age = u;
|
|
1504
|
+
// positioned along the same flight the head takes, so the cloud follows it off the end
|
|
1505
|
+
// instead of piling up on the last candle
|
|
1506
|
+
const hp = headPoint(pts, at, 0.34);
|
|
1507
|
+
if (!hp) continue;
|
|
1508
|
+
const mx = hp.x, my = hp.y;
|
|
1509
|
+
const a1 = hash01(k * 31 + 101) * Math.PI * 2;
|
|
1510
|
+
const spread = lw * (3 + 38 * age) * (0.2 + 0.8 * hash01(k * 17 + 5));
|
|
1511
|
+
const rad = lw * (5 + 19 * hash01(k * 13 + 67)) * (1 + 0.7 * age);
|
|
1512
|
+
const al = 0.13 * tint * (1 - 0.5 * age) * (0.45 + 0.55 * hash01(k * 5 + 29));
|
|
1513
|
+
const px = mx + Math.cos(a1) * spread, py = my + Math.sin(a1) * spread;
|
|
1514
|
+
ctx.fillStyle = `rgba(48,110,255,${al.toFixed(3)})`;
|
|
1515
|
+
ctx.beginPath();
|
|
1516
|
+
ctx.arc(px, py, rad, 0, Math.PI * 2);
|
|
1517
|
+
ctx.fill();
|
|
1518
|
+
if (k % 2 === 0) {
|
|
1519
|
+
ctx.fillStyle = `rgba(120,180,255,${(al * 0.7).toFixed(3)})`;
|
|
1520
|
+
ctx.beginPath(); ctx.arc(px, py, rad * 0.5, 0, Math.PI * 2); ctx.fill();
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
// THE TUBE, AS ONE STROKE PER LAYER WITH A GRADIENT ALONG IT.
|
|
1524
|
+
//
|
|
1525
|
+
// This is what the operator's "one continuous curved pipe" costs and buys. Each layer used to be
|
|
1526
|
+
// stroked segment by segment so a segment could carry its own colour -- that is how the blue tail
|
|
1527
|
+
// was built, and it is also why the line beaded at every candle. Because the closes are evenly
|
|
1528
|
+
// spaced and monotonic in x, a linear gradient across the line's x-extent IS distance along the
|
|
1529
|
+
// line, so the same tail can be expressed as colour stops on ONE stroke of the curve.
|
|
1530
|
+
//
|
|
1531
|
+
// The stops are placed at the head, a short way behind it, and at the tail's end, with the same
|
|
1532
|
+
// easing the per-segment version used (pow(1 - passed/TAIL, 1.4)) sampled at those places -- plus
|
|
1533
|
+
// the overshoot to a hotter, whiter yellow just past the tail, which was a sine bump per segment
|
|
1534
|
+
// and is a stop here. Fewer than forty steps, and continuous between them instead of stepped.
|
|
1535
|
+
const x0 = pts[0].x, x1 = pts[pts.length - 1].x;
|
|
1536
|
+
// where the head and the tail's end fall as a fraction of the x-extent, clamped into 0..1 so a
|
|
1537
|
+
// head that has run off the far end still anchors its stops legally
|
|
1538
|
+
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
1539
|
+
const headStop = clamp01(headAt);
|
|
1540
|
+
const tailStop = clamp01(headAt - PULSE_TAIL);
|
|
1541
|
+
for (const [w, c, a, kind] of [...GLOW, ...CORE]) {
|
|
1542
|
+
const B = kind === 'glow' ? DEEP : BLUE;
|
|
1543
|
+
// the charged stretch is FAT: a pulse is a thing travelling the line, not a colour on it.
|
|
1544
|
+
// One width for the whole stroke now, so it swells while the head is on the line.
|
|
1545
|
+
const onLine = headAt >= 0 && headAt <= 1 + PULSE_TAIL;
|
|
1546
|
+
ctx.lineWidth = lw * w * (1 + (onLine ? 0.9 : 0));
|
|
1547
|
+
const at = (passed) => {
|
|
1548
|
+
const tint = passed >= 0 && passed < PULSE_TAIL ? Math.pow(1 - passed / PULSE_TAIL, 1.4) : 0;
|
|
1549
|
+
const past = passed - PULSE_TAIL * 0.55;
|
|
1550
|
+
const hot = past > 0 && past < PULSE_TAIL ? Math.sin((past / PULSE_TAIL) * Math.PI) * (1 - tint) : 0;
|
|
1551
|
+
const mix = (j) => c[j] + (B[j] - c[j]) * tint + (HOT[j] - c[j]) * hot * 0.85;
|
|
1552
|
+
return `rgba(${Math.round(Math.min(255, mix(0)))},${Math.round(Math.min(255, mix(1)))},${Math.round(Math.min(255, mix(2)))},${a})`;
|
|
1553
|
+
};
|
|
1554
|
+
const yellow = `rgba(${c[0]},${c[1]},${c[2]},${a})`;
|
|
1555
|
+
const col = gradientOr(
|
|
1556
|
+
ctx,
|
|
1557
|
+
() => ctx.createLinearGradient(x0, pts[0].y, x1, pts[pts.length - 1].y),
|
|
1558
|
+
[
|
|
1559
|
+
[0, yellow], // far behind the tail: plain wire again
|
|
1560
|
+
[Math.max(0, tailStop - 0.001), at(PULSE_TAIL)],
|
|
1561
|
+
[tailStop, at(PULSE_TAIL * 0.999)],
|
|
1562
|
+
[clamp01(tailStop + (headStop - tailStop) * 0.45), at(PULSE_TAIL * 0.55)],
|
|
1563
|
+
[clamp01(tailStop + (headStop - tailStop) * 0.8), at(PULSE_TAIL * 0.2)],
|
|
1564
|
+
[headStop, at(0)],
|
|
1565
|
+
[Math.min(1, headStop + 0.001), yellow], // ahead of the head: untouched wire
|
|
1566
|
+
[1, yellow],
|
|
1567
|
+
].filter(([o], i, arr) => i === 0 || o >= arr[i - 1][0]), // stops must not go backwards
|
|
1568
|
+
// no gradient available: the flat wire, which is what every recording stub records
|
|
1569
|
+
yellow,
|
|
1570
|
+
);
|
|
1571
|
+
ctx.strokeStyle = col;
|
|
1572
|
+
if (path) ctx.stroke(path);
|
|
1573
|
+
else { ctx.beginPath(); traceCurve(ctx, pts); ctx.stroke(); }
|
|
1574
|
+
}
|
|
1575
|
+
// SHIMMER on the charged stretch: a thin white-blue core whose brightness flickers per segment
|
|
1576
|
+
// on the frame clock, scaled by that segment's tint so it dies out exactly as the blue does.
|
|
1577
|
+
//
|
|
1578
|
+
// These were removed on 2026-09-12 ("the particle effects ... looks terrible ... get rid of the
|
|
1579
|
+
// lightning bolts effect") and asked for again the same day ("we need to add the energy and
|
|
1580
|
+
// crackle and particle effects back to the electrical pulse that travels the market screen"), so
|
|
1581
|
+
// they are back as they were. The nebula behind them keeps its span emitter -- that part of the
|
|
1582
|
+
// rework stands, because it is what stopped the trail reading as concentric rings.
|
|
1583
|
+
const now = view.now ?? 0;
|
|
1584
|
+
for (let i = 0; i < n; i++) {
|
|
1585
|
+
const at = n > 1 ? i / (n - 1) : 0;
|
|
1586
|
+
const passed = headAt - at;
|
|
1587
|
+
const tint = passed >= 0 && passed < PULSE_TAIL ? Math.pow(1 - passed / PULSE_TAIL, 1.4) : 0;
|
|
1588
|
+
if (tint < 0.08) continue;
|
|
1589
|
+
const p = pts[i], q = pts[i + 1];
|
|
1590
|
+
const flick = 0.55 + 0.45 * Math.abs(Math.sin(now * 0.023 + i * 1.7) * Math.sin(now * 0.041 + i * 0.9));
|
|
1591
|
+
ctx.strokeStyle = `rgba(210,240,255,${(0.9 * tint * flick).toFixed(3)})`;
|
|
1592
|
+
ctx.lineWidth = lw * 2.4;
|
|
1593
|
+
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
|
|
1594
|
+
|
|
1595
|
+
// CRACKLE: short jagged branches off the wire, re-rolled EVERY frame -- electrical precisely
|
|
1596
|
+
// because it never draws the same twice. Reach is in line-widths; the line is under a pixel
|
|
1597
|
+
// wide, so 5-14 was invisible when this was first tried.
|
|
1598
|
+
// LIGHTNING WITH A DIRECTION (operator, 2026-09-13: "the lightning still looks terrible").
|
|
1599
|
+
// Forks now leave the wire near-perpendicular and hold their heading, tapering over four legs,
|
|
1600
|
+
// drawn halo/arc/core so they come to a point. The old version picked a uniformly random angle
|
|
1601
|
+
// and let each leg wander +-0.8 rad, which doubled back across the wire and read as scribble.
|
|
1602
|
+
const forks = 1 + ((Math.random() * 4 * tint) | 0);
|
|
1603
|
+
const nx = -(q.y - p.y), ny = q.x - p.x;
|
|
1604
|
+
const nlen = Math.hypot(nx, ny) || 1;
|
|
1605
|
+
for (let k = 0; k < forks; k++) {
|
|
1606
|
+
const f0 = Math.random();
|
|
1607
|
+
const ox = p.x + (q.x - p.x) * f0, oy = p.y + (q.y - p.y) * f0;
|
|
1608
|
+
const side = Math.random() < 0.5 ? 1 : -1;
|
|
1609
|
+
let ang = Math.atan2((ny / nlen) * side, (nx / nlen) * side) + (Math.random() - 0.5) * 1.1;
|
|
1610
|
+
const reach = lw * (11 + Math.random() * 10) * (0.45 + 0.55 * tint);
|
|
1611
|
+
// SHORT AND JAGGED (see chargeTrail): five kinked legs close to the wire, not four long
|
|
1612
|
+
// smooth ones. The screenshot of the first cut showed pale whiskers curving off the board.
|
|
1613
|
+
const legs = 5;
|
|
1614
|
+
const base = ang;
|
|
1615
|
+
const pl = [{ x: ox, y: oy }];
|
|
1616
|
+
let x = ox, y = oy;
|
|
1617
|
+
for (let m = 0; m < legs; m++) {
|
|
1618
|
+
ang = base + (m % 2 ? -1 : 1) * (0.35 + Math.random() * 0.55) + (Math.random() - 0.5) * 0.3;
|
|
1619
|
+
const step = (reach / legs) * (1 - 0.15 * m);
|
|
1620
|
+
x += Math.cos(ang) * step; y += Math.sin(ang) * step;
|
|
1621
|
+
pl.push({ x, y });
|
|
1622
|
+
}
|
|
1623
|
+
const arc = (upto, w, col) => {
|
|
1624
|
+
ctx.strokeStyle = col; ctx.lineWidth = lw * w;
|
|
1625
|
+
ctx.beginPath(); ctx.moveTo(pl[0].x, pl[0].y);
|
|
1626
|
+
for (let m = 1; m <= upto; m++) ctx.lineTo(pl[m].x, pl[m].y);
|
|
1627
|
+
ctx.stroke();
|
|
1628
|
+
};
|
|
1629
|
+
arc(legs, 2.4, `rgba(90,170,255,${(0.3 * tint).toFixed(3)})`);
|
|
1630
|
+
arc(legs, 0.95, `rgba(185,230,255,${(0.92 * tint).toFixed(3)})`);
|
|
1631
|
+
arc(Math.max(1, legs - 1), 0.5, `rgba(245,252,255,${(0.95 * tint).toFixed(3)})`);
|
|
1632
|
+
}
|
|
1633
|
+
// PARTICLES: a spray of motes streaming off the charged wire, each on its own heading,
|
|
1634
|
+
// spreading wider and fading as its stretch of the line ages. Placed by a HASH of segment and
|
|
1635
|
+
// mote plus the age -- not Math.random -- so a mote moves coherently frame to frame instead of
|
|
1636
|
+
// jittering in place.
|
|
1637
|
+
const age = passed / PULSE_TAIL;
|
|
1638
|
+
// SPARKS, NOT BLOBS (operator, 2026-09-13: "the particles are too fat"): half the radius,
|
|
1639
|
+
// more of them, thrown further as the stretch ages -- a mist that thins, not a clump of discs.
|
|
1640
|
+
for (let k = 0; k < 18; k++) {
|
|
1641
|
+
const f0 = hash01(i * 31 + k * 7);
|
|
1642
|
+
const bx = p.x + (q.x - p.x) * f0, by = p.y + (q.y - p.y) * f0;
|
|
1643
|
+
const ang = hash01(i * 17 + k * 13 + 101) * Math.PI * 2;
|
|
1644
|
+
const dist = lw * (4 + 62 * age) * (0.6 + 0.4 * hash01(i + k * 3 + 7));
|
|
1645
|
+
const r = lw * (0.6 + 1.3 * hash01(i * 5 + k + 41)) * (1 - 0.45 * age);
|
|
1646
|
+
ctx.fillStyle = `rgba(200,236,255,${(0.75 * tint * (1 - 0.5 * age)).toFixed(3)})`;
|
|
1647
|
+
ctx.beginPath(); ctx.arc(bx + Math.cos(ang) * dist, by + Math.sin(ang) * dist, r, 0, Math.PI * 2); ctx.fill();
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
// THE SMOKE LEAVES WITH IT (operator, 2026-09-13: "The ball just disappears as does the smoke
|
|
1651
|
+
// particles"). The spray above is placed per wire SEGMENT, so it can only exist where the wire
|
|
1652
|
+
// does -- at the last candle it stops, which is half of what the operator saw vanish. This is
|
|
1653
|
+
// the same mist, positioned along the head's flight instead, so it follows the ball off the end
|
|
1654
|
+
// and thins out with it.
|
|
1655
|
+
{
|
|
1656
|
+
const OVERRUN = 0.34;
|
|
1657
|
+
for (let m = 0; m < 26; m++) {
|
|
1658
|
+
const back = hash01(m * 11 + 7) * PULSE_TAIL * 0.5; // how far behind the head it trails
|
|
1659
|
+
const hp = headPoint(pts, headAt - back, OVERRUN);
|
|
1660
|
+
if (!hp || hp.fade >= 1) continue; // only past the end: the wire has its own
|
|
1661
|
+
const ang = hash01(m * 17 + 41) * Math.PI * 2;
|
|
1662
|
+
const spread = lw * (5 + 40 * (back / (PULSE_TAIL * 0.5))) * (0.4 + 0.6 * hash01(m * 5 + 3));
|
|
1663
|
+
const r = lw * (0.6 + 1.3 * hash01(m * 7 + 19));
|
|
1664
|
+
const a = 0.7 * hp.fade * (1 - back / (PULSE_TAIL * 0.6));
|
|
1665
|
+
if (a <= 0.01) continue;
|
|
1666
|
+
ctx.fillStyle = `rgba(200,236,255,${a.toFixed(3)})`;
|
|
1667
|
+
ctx.beginPath();
|
|
1668
|
+
ctx.arc(hp.x + Math.cos(ang) * spread, hp.y + Math.sin(ang) * spread, r, 0, Math.PI * 2);
|
|
1669
|
+
ctx.fill();
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
// THE HEAD FLIES ON (operator, 2026-09-13: "When the ball gets to the end of the line, it should
|
|
1673
|
+
// keep going along it's last vector, and fade out before reaching the edge of the screen").
|
|
1674
|
+
// It used to be `if (headAt < 1)` and nothing else -- the bead simply stopped existing at the
|
|
1675
|
+
// last candle, which is the disappearance the operator saw. Past the end it now carries on along
|
|
1676
|
+
// the direction of the final segment and fades, so it leaves rather than blinks out.
|
|
1677
|
+
//
|
|
1678
|
+
// OFF THE END, NOT OFF THE SCREEN: the flight is capped at a fraction of the line's own length,
|
|
1679
|
+
// so on any board width it goes dark before the edge rather than sailing into the panel border.
|
|
1680
|
+
{
|
|
1681
|
+
const OVERRUN = 0.34; // of the line's length, past the last point
|
|
1682
|
+
const hp = headPoint(pts, headAt, OVERRUN);
|
|
1683
|
+
if (hp) {
|
|
1684
|
+
// a head you cannot miss: a wide blue corona, a bright core, a white point -- all of it
|
|
1685
|
+
// scaled and faded together once it is off the wire
|
|
1686
|
+
const f = hp.fade;
|
|
1687
|
+
// THE BALL ITSELF IS BLUE, not just the tint behind it (operator, 2026-09-13: "The 3d Price
|
|
1688
|
+
// chart still doesn't have a neon blue leading pulse like I asked for. It's white").
|
|
1689
|
+
//
|
|
1690
|
+
// The earlier pass turned the TAIL neon blue and left this stack alone -- and this stack is
|
|
1691
|
+
// the head: four discs, of which the inner two were rgba(235,250,255,0.92) over pure
|
|
1692
|
+
// rgba(255,255,255,1). A white disc at full alpha painted on top of a blue corona is a white
|
|
1693
|
+
// ball with a blue halo, which is exactly what was reported. Every layer is blue-dominant
|
|
1694
|
+
// now, the hot centre included -- it is the brightest, palest blue rather than white, so the
|
|
1695
|
+
// head still reads as the hottest point on the line without going colourless.
|
|
1696
|
+
for (const [r, c0, a0] of [[28, '0,150,255', 0.20], [15, '40,190,255', 0.48], [7, '80,220,255', 0.95], [3, '150,240,255', 1]]) {
|
|
1697
|
+
ctx.fillStyle = `rgba(${c0},${(a0 * f).toFixed(3)})`;
|
|
1698
|
+
ctx.beginPath(); ctx.arc(hp.x, hp.y, lw * r * (0.55 + 0.45 * f), 0, Math.PI * 2); ctx.fill();
|
|
1699
|
+
}
|
|
1700
|
+
// THE HEAD CRACKLES (operator, 2026-09-13: "Did you copy the energy crackle from the grid
|
|
1701
|
+
// effect, and add to the head of the energy ball travelling along the yellow price line?").
|
|
1702
|
+
// It had not been. The crackle above is emitted per wire SEGMENT, gated on that segment's
|
|
1703
|
+
// tint, so it lights the charged stretch BEHIND the head and -- this is the part that shows --
|
|
1704
|
+
// cannot exist at all once the head runs off the last candle, because out there is no wire to
|
|
1705
|
+
// hang it on. The ball spent its entire flight past the end as four bare discs.
|
|
1706
|
+
//
|
|
1707
|
+
// So this is the grid lightning ball's own vocabulary (drawBall, which strikes bolts from the
|
|
1708
|
+
// ball to the crossings round it): bolts thrown OUTWARD from the head, re-rolled every frame,
|
|
1709
|
+
// each drawn in the same three passes the wire crackle uses -- a wide dim halo, the arc, then
|
|
1710
|
+
// a hot core stopping one leg short so it tapers to a point rather than ending in a stub.
|
|
1711
|
+
// Struck from the rim of the bright core, not the centre, so the ball does not swallow them.
|
|
1712
|
+
const R = lw * 7;
|
|
1713
|
+
const bolts = 3 + ((Math.random() * 4 * f) | 0);
|
|
1714
|
+
for (let k = 0; k < bolts; k++) {
|
|
1715
|
+
const out = Math.random() * Math.PI * 2;
|
|
1716
|
+
const reach = lw * (9 + Math.random() * 14) * (0.45 + 0.55 * f);
|
|
1717
|
+
const legs = 5;
|
|
1718
|
+
const bp = [{ x: hp.x + Math.cos(out) * R * 0.5, y: hp.y + Math.sin(out) * R * 0.5 }];
|
|
1719
|
+
let bx = bp[0].x, by = bp[0].y;
|
|
1720
|
+
for (let m = 0; m < legs; m++) {
|
|
1721
|
+
// the same hard alternating zig as the wire's forks: a discharge kinks, it does not curve
|
|
1722
|
+
const a = out + (m % 2 ? -1 : 1) * (0.3 + Math.random() * 0.5) + (Math.random() - 0.5) * 0.3;
|
|
1723
|
+
const step = (reach / legs) * (1 - 0.15 * m);
|
|
1724
|
+
bx += Math.cos(a) * step; by += Math.sin(a) * step;
|
|
1725
|
+
bp.push({ x: bx, y: by });
|
|
1726
|
+
}
|
|
1727
|
+
const bolt = (upto, w, col) => {
|
|
1728
|
+
ctx.strokeStyle = col; ctx.lineWidth = lw * w;
|
|
1729
|
+
ctx.beginPath(); ctx.moveTo(bp[0].x, bp[0].y);
|
|
1730
|
+
for (let m = 1; m <= upto; m++) ctx.lineTo(bp[m].x, bp[m].y);
|
|
1731
|
+
ctx.stroke();
|
|
1732
|
+
};
|
|
1733
|
+
bolt(legs, 2.4, `rgba(70,190,255,${(0.32 * f).toFixed(3)})`);
|
|
1734
|
+
bolt(legs, 0.95, `rgba(160,235,255,${(0.9 * f).toFixed(3)})`);
|
|
1735
|
+
bolt(Math.max(1, legs - 1), 0.5, `rgba(250,255,255,${(0.95 * f).toFixed(3)})`);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
done();
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function axisLabels(ctx, view, axes, n, k, dpr) {
|
|
1743
|
+
const px = (v) => (v * dpr) / k;
|
|
1744
|
+
const y = axes.y ?? 0;
|
|
1745
|
+
ctx.font = `${px(11)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
|
|
1746
|
+
ctx.textBaseline = 'middle';
|
|
1747
|
+
ctx.textAlign = 'left';
|
|
1748
|
+
for (const t of axes.z ?? []) {
|
|
1749
|
+
const p = project(n + 0.8, y, t.z, view);
|
|
1750
|
+
const w = ctx.measureText(t.label).width;
|
|
1751
|
+
ctx.fillStyle = t.strong ? (t.color ?? 'rgba(40,60,50,1)') : 'rgba(2,12,8,0.78)';
|
|
1752
|
+
ctx.fillRect(p.x - px(4), p.y - px(8), w + px(8), px(16));
|
|
1753
|
+
ctx.fillStyle = t.strong ? 'rgba(255,255,255,1)' : 'rgba(185,255,220,1)';
|
|
1754
|
+
ctx.fillText(t.label, p.x, p.y);
|
|
1755
|
+
}
|
|
1756
|
+
ctx.textAlign = 'center';
|
|
1757
|
+
ctx.fillStyle = 'rgba(160,235,205,1)';
|
|
1758
|
+
for (const t of axes.x ?? []) { const p = project(t.x, 0, 0, view); ctx.fillText(t.label, p.x, p.y + px(13)); }
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// THE STAR FIELD behind a space board. A seeded scatter per canvas size, so the sky never
|
|
1762
|
+
// reshuffles between frames; each star twinkles on its own slow period (2-10 s), a few bright
|
|
1763
|
+
// ones carry a halo and a cross glint. Plain rgba fills and strokes, in device pixels.
|
|
1764
|
+
const STARS = new WeakMap();
|
|
1765
|
+
const NO_CANVAS = {};
|
|
1766
|
+
|
|
1767
|
+
// IS THE SKY DRAWN? Not the same question as "is this a space board", though one option used to
|
|
1768
|
+
// answer both (operator, 2026-09-12: "I don't think the star field toggle works properly for
|
|
1769
|
+
// markets and price"). `space` also decides the deck texture, the translucent floor and the floor
|
|
1770
|
+
// line, so turning the stars off through it RESTYLED THE WHOLE BOARD -- which is not what a switch
|
|
1771
|
+
// labelled "Star field" should do. `stars` gates the sky alone; where it is absent the old
|
|
1772
|
+
// meaning stands, so every caller that never heard of it behaves exactly as before.
|
|
1773
|
+
const starsOn = (o) => !!(o.stars ?? o.space);
|
|
1774
|
+
// THE GALAXY (operator, 2026-09-12: "I want all the starts slowly rotating to form a spiral
|
|
1775
|
+
// galaxy in the background ... Make it a toggle").
|
|
1776
|
+
//
|
|
1777
|
+
// Off, this is the shipped uniform scatter, untouched down to the order the random numbers are
|
|
1778
|
+
// drawn in. On, the same stars are laid on logarithmic arms and carry POLAR coordinates, so the
|
|
1779
|
+
// sky turns by advancing ONE angle at draw time rather than by being rebuilt: the field stays
|
|
1780
|
+
// seeded, the cache stays valid, and each star's own twinkle is unaffected.
|
|
1781
|
+
//
|
|
1782
|
+
// The rotation is RIGID -- the whole pattern turns as one. Differential rotation (inner stars
|
|
1783
|
+
// faster, as a real disc moves) shears spiral arms apart within a few minutes of real time, which
|
|
1784
|
+
// would destroy the one thing being asked for.
|
|
1785
|
+
// Every number here was chosen by rendering the variants side by side and looking at them, after
|
|
1786
|
+
// the first attempt shipped structure without a picture: the tests said the stars clustered on
|
|
1787
|
+
// arms (they did) while the screen showed a faint streak. What the comparison settled:
|
|
1788
|
+
// - FLATTEN 0.42 was the single biggest fault. Edge-on, a spiral reads as a smear across the
|
|
1789
|
+
// frame; from well above the disc it reads as a spiral. 0.80.
|
|
1790
|
+
// - Two arms at that angle fold into one S through the middle. Four are unmistakable.
|
|
1791
|
+
// - The arms were far too wide (+/-0.55 rad of scatter). A wide arm is a smudge; 0.18, and
|
|
1792
|
+
// narrowing outward, draws a line.
|
|
1793
|
+
// - An arm is drawn BY its stars, and at the scattered sky's count there were not enough of
|
|
1794
|
+
// them to make one. Hence GALAXY_BOOST.
|
|
1795
|
+
export const GALAXY_FLATTEN = 0.80; // seen from above the disc, not along it
|
|
1796
|
+
// NEGATIVE, so the arms TRAIL (operator, 2026-09-12: "galaxy is rotating in wrong direction for
|
|
1797
|
+
// the astrophysics to work"). The arms wind outward in +theta -- ang = arm + ln(r/inner)/TWIST --
|
|
1798
|
+
// so an arm's outer end sits ahead of its root in +theta. Turning the disc in +theta as well put
|
|
1799
|
+
// the tips in FRONT of the rotation: leading arms, which is not what disc galaxies do. A density
|
|
1800
|
+
// wave leaves the arms trailing, so the disc has to turn against the way they wind.
|
|
1801
|
+
export const GALAXY_SPIN = -(Math.PI * 2) / 900_000; // one turn in fifteen minutes: "slowly"
|
|
1802
|
+
export const GALAXY_ARMS = 4;
|
|
1803
|
+
export const GALAXY_TWIST = 0.30; // how tightly the arms wind
|
|
1804
|
+
const GALAXY_BOOST = 7; // an arm needs many more stars than a scatter
|
|
1805
|
+
// LOW AND LEFT (operator, 2026-09-12: "We should have the spiral galaxy centers on the lower left
|
|
1806
|
+
// grid location. That should cluster things up enough to be interesting"). The nucleus sits down
|
|
1807
|
+
// in that corner and the arms sweep up across the panel, which crowds the interesting part of the
|
|
1808
|
+
// picture into one place instead of spreading it evenly around the middle.
|
|
1809
|
+
// [x, y, reach, oversample] as fractions of the panel. A corner placement REACHES past the panel
|
|
1810
|
+
// so the arms cross it; the centred one stays inside, so the whole spiral is visible behind the
|
|
1811
|
+
// board.
|
|
1812
|
+
//
|
|
1813
|
+
// `oversample` is how many stars must be MADE for each one that lands on the panel, and it has to
|
|
1814
|
+
// be per placement or the density setting means two different things: measured, a centred disc
|
|
1815
|
+
// puts all of itself on screen while a corner one puts about 40% off it, so generating the same
|
|
1816
|
+
// number either way made the centred galaxy twice as dense for the same slider position.
|
|
1817
|
+
// REACH 3.0 (operator, 2026-09-12: "The spiral galaxy arms need to extend out way farther than
|
|
1818
|
+
// they do, I want to see long arms on the far side of the board"). Measured on a 1265px board with
|
|
1819
|
+
// the middle in a corner: at 1.6 the arm tips died at x=876, well short of the far edge; at 3.0
|
|
1820
|
+
// they carry to x=1400, across it and out. 3.8 was tried and overshoots -- three quarters of the
|
|
1821
|
+
// stars then live off-panel for no more picture.
|
|
1822
|
+
//
|
|
1823
|
+
// The number of WINDINGS does not change with reach, because `inner` scales with maxR: a bigger
|
|
1824
|
+
// disc shows less than one full winding across the panel, which is what makes the arcs read as
|
|
1825
|
+
// long sweeps rather than a tight coil.
|
|
1826
|
+
//
|
|
1827
|
+
// The oversample figures are the measured inverse of "what fraction of this disc lands on the
|
|
1828
|
+
// panel", per placement -- 36% centred, 34% from a corner.
|
|
1829
|
+
export const GALAXY_PLACEMENTS = Object.freeze({
|
|
1830
|
+
'center': [0.50, 0.50, 3.00, 2.76],
|
|
1831
|
+
'top-left': [0.22, 0.22, 3.00, 2.95],
|
|
1832
|
+
'top-right': [0.78, 0.22, 3.00, 2.95],
|
|
1833
|
+
'bottom-left': [0.22, 0.78, 3.00, 2.97],
|
|
1834
|
+
'bottom-right': [0.78, 0.78, 3.00, 2.97],
|
|
1835
|
+
});
|
|
1836
|
+
export const GALAXY_AT_DEFAULT = 'bottom-left';
|
|
1837
|
+
// An off-centre disc puts much of itself off-panel, and it must still be generated there: a
|
|
1838
|
+
// rotating field cannot be sampled to the visible rectangle, or turning it would drag bare gaps
|
|
1839
|
+
// into view. So more stars are made than are ever drawn (see `oversample` above, which is per
|
|
1840
|
+
// placement), and the draw loop skips the ones outside.
|
|
1841
|
+
const GALAXY_MAX = 40000; // the count scales with area; 4K must not run away
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* NEBULAE (operator, 2026-09-12: "We need to improve the star fields. nebulas, more stars between
|
|
1845
|
+
* arms. I needs to look awe-inspiring at the majesty and grandness of the universe with all it's
|
|
1846
|
+
* details").
|
|
1847
|
+
*
|
|
1848
|
+
* Clouds of gas sit ON the arms, because that is where they are: a spiral's colour comes from the
|
|
1849
|
+
* star-forming lanes, so placing them by the same logarithmic rule as the arm stars makes the
|
|
1850
|
+
* colour follow the structure instead of floating over it.
|
|
1851
|
+
*
|
|
1852
|
+
* Each cloud is a heap of overlapping low-alpha ellipses. The natural way to draw a nebula is one
|
|
1853
|
+
* soft radial gradient, and this renderer cannot: no globalAlpha, no composite modes, no
|
|
1854
|
+
* shadowBlur (viewer-canvas-rules.test.js). Many faint fills add up to the same soft edge and
|
|
1855
|
+
* cannot be silently dropped by a software rasteriser -- the same reason the star halos are built
|
|
1856
|
+
* this way, and the reason the painted galactic core had to be deleted rather than tuned.
|
|
1857
|
+
*
|
|
1858
|
+
* They carry polar coordinates like the stars, so they turn with the disc for free.
|
|
1859
|
+
*/
|
|
1860
|
+
export function nebulaClouds(pw, ph, at = GALAXY_AT_DEFAULT, seed = 11) {
|
|
1861
|
+
let s = seed >>> 0;
|
|
1862
|
+
const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
|
1863
|
+
const { maxR, inner } = galaxyGeometry(pw, ph, at);
|
|
1864
|
+
// dusty magenta, cold blue, teal, warm rose: a sky of one colour looks painted
|
|
1865
|
+
const TINTS = [[142, 88, 214], [48, 122, 196], [58, 168, 156], [198, 84, 142]];
|
|
1866
|
+
const out = [];
|
|
1867
|
+
// Nine clouds, kept to the inner half of the disc: the disc reaches three times past the panel
|
|
1868
|
+
// (GALAXY_PLACEMENTS), so a cloud laid anywhere on it was mostly laid off-screen -- the first cut
|
|
1869
|
+
// put six out to the rim and the panel got a faint smudge in one corner. And bright enough to
|
|
1870
|
+
// be SEEN: the first alphas (0.009-0.024) sat a hair above the background.
|
|
1871
|
+
for (let i = 0; i < 9; i++) {
|
|
1872
|
+
const t = 0.05 + 0.45 * rnd();
|
|
1873
|
+
const rad = inner + (maxR - inner) * t;
|
|
1874
|
+
const arm = Math.floor(rnd() * GALAXY_ARMS) * ((Math.PI * 2) / GALAXY_ARMS);
|
|
1875
|
+
const ang = arm + Math.log(rad / inner) / GALAXY_TWIST + (rnd() - 0.5) * 0.5;
|
|
1876
|
+
const size = maxR * (0.07 + 0.09 * rnd());
|
|
1877
|
+
const tint = TINTS[Math.floor(rnd() * TINTS.length)].join(',');
|
|
1878
|
+
const puffs = [];
|
|
1879
|
+
const n = 22 + Math.floor(rnd() * 14);
|
|
1880
|
+
for (let k = 0; k < n; k++) {
|
|
1881
|
+
puffs.push({
|
|
1882
|
+
dx: (rnd() - 0.5) * size * 2.0,
|
|
1883
|
+
dy: (rnd() - 0.5) * size * 1.3,
|
|
1884
|
+
rx: size * (0.30 + 0.55 * rnd()),
|
|
1885
|
+
sq: 0.65 + 0.7 * rnd(), // not circles: a cloud has a shape
|
|
1886
|
+
a: 0.020 + 0.030 * rnd(),
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
out.push({ gr: rad, ga: ang, tint, puffs });
|
|
1890
|
+
}
|
|
1891
|
+
return out;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* DISTANT GALAXIES (operator, 2026-09-12: "Add whatever other universe / galaxy effects to the
|
|
1896
|
+
* scene that you think will make it look even more visually stunning ... A beautiful rendering of
|
|
1897
|
+
* our reaility"). A real deep field is not one galaxy on black: it has others in it, small and
|
|
1898
|
+
* faint and far. Five of them, scattered over the panel, drawn first so everything else stands in
|
|
1899
|
+
* front, and STATIC -- they do not turn with the disc, because they are not part of it. Each is a
|
|
1900
|
+
* few concentric ellipses, tilted, in cool white or the faint blue of distance.
|
|
1901
|
+
*/
|
|
1902
|
+
export function farGalaxies(pw, ph, seed = 23) {
|
|
1903
|
+
let s = seed >>> 0;
|
|
1904
|
+
const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
|
1905
|
+
const out = [];
|
|
1906
|
+
for (let i = 0; i < 5; i++) {
|
|
1907
|
+
out.push({
|
|
1908
|
+
x: rnd() * pw, y: rnd() * ph,
|
|
1909
|
+
rx: 7 + rnd() * 15,
|
|
1910
|
+
ratio: 0.32 + rnd() * 0.5, // a disc seen at some angle
|
|
1911
|
+
rot: rnd() * Math.PI,
|
|
1912
|
+
tint: rnd() < 0.5 ? '222,218,240' : '200,222,255',
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
return out;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* DUST LANES. The most recognisable thing about a real spiral after the arms themselves: a dark
|
|
1920
|
+
* ribbon runs along the INNER (concave) edge of each arm, where the gas is thickest and hides
|
|
1921
|
+
* the stars behind it. Drawn over the nebulae and under the stars, as heaps of faint black
|
|
1922
|
+
* ellipses -- the same layered-fill technique everything else in this sky uses, for the same
|
|
1923
|
+
* canvas-rules reason. Polar, so they turn with the disc.
|
|
1924
|
+
*/
|
|
1925
|
+
export function dustLanes(pw, ph, at = GALAXY_AT_DEFAULT, seed = 17) {
|
|
1926
|
+
let s = seed >>> 0;
|
|
1927
|
+
const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
|
1928
|
+
const { maxR, inner } = galaxyGeometry(pw, ph, at);
|
|
1929
|
+
const out = [];
|
|
1930
|
+
for (let i = 0; i < 14; i++) {
|
|
1931
|
+
const t = 0.06 + 0.5 * rnd();
|
|
1932
|
+
const rad = inner + (maxR - inner) * t;
|
|
1933
|
+
const arm = Math.floor(rnd() * GALAXY_ARMS) * ((Math.PI * 2) / GALAXY_ARMS);
|
|
1934
|
+
// a little INSIDE the arm's centre line: the concave edge, trailing the rotation
|
|
1935
|
+
const ang = arm + Math.log(rad / inner) / GALAXY_TWIST - 0.16 + (rnd() - 0.5) * 0.08;
|
|
1936
|
+
const size = maxR * (0.05 + 0.06 * rnd());
|
|
1937
|
+
const puffs = [];
|
|
1938
|
+
const n = 12 + Math.floor(rnd() * 8);
|
|
1939
|
+
for (let k = 0; k < n; k++) {
|
|
1940
|
+
puffs.push({ dx: (rnd() - 0.5) * size * 2.6, dy: (rnd() - 0.5) * size * 0.8, rx: size * (0.25 + 0.4 * rnd()), sq: 0.5 + 0.5 * rnd(), a: 0.05 + 0.06 * rnd() });
|
|
1941
|
+
}
|
|
1942
|
+
out.push({ gr: rad, ga: ang, puffs });
|
|
1943
|
+
}
|
|
1944
|
+
return out;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* STAR CLUSTERS. Globular clusters live in the halo: tight, ancient knots of a few hundred
|
|
1949
|
+
* thousand stars, seen as a dense speck with a fuzzy edge. Six of them, out past the arms, each
|
|
1950
|
+
* a heap of tiny warm-white points crowded to the middle. Drawn over the stars, turning with the
|
|
1951
|
+
* disc, and NOT twinkling -- a cluster is too far for its stars to scintillate one by one.
|
|
1952
|
+
*/
|
|
1953
|
+
export function starClusters(pw, ph, at = GALAXY_AT_DEFAULT, seed = 29) {
|
|
1954
|
+
let s = seed >>> 0;
|
|
1955
|
+
const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
|
1956
|
+
const { maxR } = galaxyGeometry(pw, ph, at);
|
|
1957
|
+
const out = [];
|
|
1958
|
+
for (let i = 0; i < 6; i++) {
|
|
1959
|
+
const rad = maxR * (0.28 + 0.6 * rnd());
|
|
1960
|
+
const ang = rnd() * Math.PI * 2;
|
|
1961
|
+
const size = 6 + rnd() * 9;
|
|
1962
|
+
const stars = [];
|
|
1963
|
+
const n = 26 + Math.floor(rnd() * 22);
|
|
1964
|
+
for (let k = 0; k < n; k++) {
|
|
1965
|
+
// crowded to the middle: radius drawn as the square of a uniform, angle uniform
|
|
1966
|
+
const rr = size * rnd() * rnd(), aa = rnd() * Math.PI * 2;
|
|
1967
|
+
stars.push({ dx: Math.cos(aa) * rr, dy: Math.sin(aa) * rr, r: 0.35 + rnd() * 0.5, b: 0.35 + rnd() * 0.5 });
|
|
1968
|
+
}
|
|
1969
|
+
out.push({ gr: rad, ga: ang, size, stars });
|
|
1970
|
+
}
|
|
1971
|
+
return out;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/** Where the disc sits and how big it is. One source, so the renderer and the tests agree. */
|
|
1975
|
+
export function galaxyGeometry(pw, ph, at = GALAXY_AT_DEFAULT) {
|
|
1976
|
+
const [fx, fy, reach, oversample] = GALAXY_PLACEMENTS[at] ?? GALAXY_PLACEMENTS[GALAXY_AT_DEFAULT];
|
|
1977
|
+
const maxR = (Math.min(pw, ph / GALAXY_FLATTEN) / 2) * reach;
|
|
1978
|
+
return { cx: pw * fx, cy: ph * fy, maxR, inner: maxR * 0.08, oversample };
|
|
1979
|
+
}
|
|
1980
|
+
export function starField(pw, ph, dpr = 1, seed = 7, density = 1, galaxy = false) {
|
|
1981
|
+
let s = seed >>> 0;
|
|
1982
|
+
const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
|
1983
|
+
// density: the operator's multiplier on the shipped count (settings.js sky.density, which both
|
|
1984
|
+
// this board and the markets board read -- it used to live under `markets` and reach only one)
|
|
1985
|
+
// 8, not 3 (operator, 2026-09-12: "we need to be able to up the star density even more"). The
|
|
1986
|
+
// slider's ceiling lives in settings.js PANEL; this is the renderer's own guard behind it.
|
|
1987
|
+
const d = Number.isFinite(density) ? Math.min(8, Math.max(0, density)) : 1;
|
|
1988
|
+
const base = ((pw * ph) / (2400 * dpr * dpr)) * d;
|
|
1989
|
+
// `galaxy` is false, or WHERE the galaxy goes: a placement key. Carrying the placement in the
|
|
1990
|
+
// same argument keeps it part of what the field is built from, so moving the galaxy rebuilds
|
|
1991
|
+
// the field exactly the way turning it on does.
|
|
1992
|
+
const { cx, cy, maxR, inner, oversample } = galaxy
|
|
1993
|
+
? galaxyGeometry(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy)
|
|
1994
|
+
: { cx: 0, cy: 0, maxR: 0, inner: 0, oversample: 1 };
|
|
1995
|
+
const n = Math.round(galaxy ? Math.min(GALAXY_MAX, base * GALAXY_BOOST * oversample) : base);
|
|
1996
|
+
const out = [];
|
|
1997
|
+
for (let i = 0; i < n; i++) {
|
|
1998
|
+
const big = rnd() > 0.965;
|
|
1999
|
+
const tint = rnd();
|
|
2000
|
+
const star = {
|
|
2001
|
+
x: rnd() * pw, y: rnd() * ph,
|
|
2002
|
+
r: (big ? 1.1 + rnd() * 0.9 : 0.35 + rnd() * 0.6) * dpr,
|
|
2003
|
+
b: big ? 0.85 + rnd() * 0.15 : 0.25 + rnd() * 0.55,
|
|
2004
|
+
// TWINKLE RATE, in radians per millisecond (operator, 2026-09-12: "Some of the large stars
|
|
2005
|
+
// are pulsing relatively quickly. Need to slow all that shit down"). It was 0.0006-0.0030,
|
|
2006
|
+
// which is a cycle every 2.1 to 10.5 seconds -- at the fast end that is a blink, not a
|
|
2007
|
+
// twinkle, and the big stars wear a halo and a cross glint that make it impossible to miss.
|
|
2008
|
+
// Now 10.5 to 39 seconds, and a big star runs slower still.
|
|
2009
|
+
f: (0.00016 + rnd() * 0.00044) * (big ? 0.6 : 1),
|
|
2010
|
+
p: rnd() * Math.PI * 2,
|
|
2011
|
+
c: tint < 0.12 ? [255, 226, 180] : tint < 0.3 ? [180, 205, 255] : [235, 242, 255],
|
|
2012
|
+
big,
|
|
2013
|
+
};
|
|
2014
|
+
if (galaxy) {
|
|
2015
|
+
// Three populations, because arms alone read as a pinwheel drawn on black: a crowded bulge,
|
|
2016
|
+
// the arms themselves, and a thin halo that keeps the outskirts from going empty. Each
|
|
2017
|
+
// carries its own brightness, so the density difference becomes a BRIGHTNESS difference --
|
|
2018
|
+
// without that the arms were only slightly-closer dots of the same value as everything else.
|
|
2019
|
+
const roll = rnd();
|
|
2020
|
+
let rad, ang, lit;
|
|
2021
|
+
if (roll < 0.12) {
|
|
2022
|
+
// the nucleus. Crowded and bright enough to be the middle on its own: the painted core
|
|
2023
|
+
// this replaced was six stacked ellipses that read as hard grey banding, because the
|
|
2024
|
+
// gradient that would have smoothed them is exactly what the canvas rules forbid.
|
|
2025
|
+
rad = maxR * 0.17 * Math.sqrt(rnd());
|
|
2026
|
+
ang = rnd() * Math.PI * 2;
|
|
2027
|
+
lit = 1;
|
|
2028
|
+
// OLD STARS: a bulge is warm. The colour of a real spiral is not one colour -- its middle
|
|
2029
|
+
// is yellow with age and its arms are blue with youth, and giving each population its own
|
|
2030
|
+
// temperature is most of what makes the picture read as a galaxy rather than a pattern.
|
|
2031
|
+
star.c0 = star.c; // the neutral colour, for the switch
|
|
2032
|
+
star.c = tint < 0.55 ? [255, 214, 150] : [255, 234, 196];
|
|
2033
|
+
} else if (roll < 0.78) {
|
|
2034
|
+
const t = Math.pow(rnd(), 0.62); // crowded toward the middle
|
|
2035
|
+
rad = inner + (maxR - inner) * t;
|
|
2036
|
+
const arm = Math.floor(rnd() * GALAXY_ARMS) * ((Math.PI * 2) / GALAXY_ARMS);
|
|
2037
|
+
// three rolls make a rough bell: an arm with soft edges rather than a hard stripe,
|
|
2038
|
+
// narrowing outward so the arm stays a line instead of fanning into a smudge
|
|
2039
|
+
const width = 0.18 * (1.35 - 0.75 * (rad / maxR));
|
|
2040
|
+
ang = arm + Math.log(rad / inner) / GALAXY_TWIST + (rnd() + rnd() + rnd() - 1.5) * width;
|
|
2041
|
+
lit = 1.2 - 0.35 * (rad / maxR);
|
|
2042
|
+
// YOUNG STARS: the arms are where stars are born, and they burn blue-white
|
|
2043
|
+
star.c0 = star.c;
|
|
2044
|
+
star.c = tint < 0.35 ? [160, 198, 255] : tint < 0.72 ? [205, 224, 255] : [242, 246, 255];
|
|
2045
|
+
} else {
|
|
2046
|
+
// BETWEEN THE ARMS (operator, 2026-09-12: "more stars between arms. I needs to look
|
|
2047
|
+
// awe-inspiring at the majesty and grandness of the universe"). This population was 6% of
|
|
2048
|
+
// the sky and it showed: the space between the arms went to black, which reads as a
|
|
2049
|
+
// pinwheel on a void rather than a galaxy standing in a star field. 22% now (operator,
|
|
2050
|
+
// again: "I want to see more twinkling of stuff between each of teh spiral arms"), and
|
|
2051
|
+
// bright enough that their twinkle -- the full swing, since they are small -- actually
|
|
2052
|
+
// shows, while still sitting back so the arms carry.
|
|
2053
|
+
rad = maxR * (0.16 + 0.84 * rnd());
|
|
2054
|
+
ang = rnd() * Math.PI * 2;
|
|
2055
|
+
lit = 0.46;
|
|
2056
|
+
}
|
|
2057
|
+
// fewer haloed giants than an even sky: at seven times the stars they read as clutter
|
|
2058
|
+
star.big = star.big && rnd() < 0.4;
|
|
2059
|
+
star.r *= 0.85;
|
|
2060
|
+
star.b = Math.min(1, star.b * lit);
|
|
2061
|
+
star.gr = rad;
|
|
2062
|
+
star.ga = ang;
|
|
2063
|
+
star.x = cx + rad * Math.cos(ang);
|
|
2064
|
+
star.y = cy + rad * GALAXY_FLATTEN * Math.sin(ang);
|
|
2065
|
+
}
|
|
2066
|
+
out.push(star);
|
|
2067
|
+
}
|
|
2068
|
+
return out;
|
|
2069
|
+
}
|
|
2070
|
+
// THE GAS, BAKED (operator, 2026-09-12: "It really slows down rendering when enabled, I have to
|
|
2071
|
+
// turn galaxy off to get decent framerate"). The nebulae are a few hundred translucent ellipses
|
|
2072
|
+
// hundreds of pixels across, and the dust lanes a few hundred more; filled afresh every frame
|
|
2073
|
+
// they were tens of megapixels of blending -- THAT was the frame, not the stars. The galaxy turns
|
|
2074
|
+
// as a rigid body, so the gas is painted ONCE into an offscreen bitmap in the disc's own
|
|
2075
|
+
// coordinates (unturned, unflattened) and each frame is one drawImage through a matrix that
|
|
2076
|
+
// turns, squashes and places it. The bitmap covers only the part of the disc that can ever
|
|
2077
|
+
// reach the panel (centre to the farthest corner), at a scale that keeps it under GAS_MAX
|
|
2078
|
+
// pixels a side -- the gas is soft, and a quarter-scale bitmap of it drawn up is the same gas.
|
|
2079
|
+
// Rebuilt when the field is (a new f), and when the brightness or a layer switch changes.
|
|
2080
|
+
// A canvas that cannot make an offscreen one (the tests' recording canvas) gets the live loops.
|
|
2081
|
+
const GAS_MAX = 2048;
|
|
2082
|
+
function gasLayer(f, pw, ph, bright, opts) {
|
|
2083
|
+
const nebulae = opts.nebulae !== false && !!f.nebulae, dust = opts.dust !== false && !!f.dust;
|
|
2084
|
+
if (!nebulae && !dust) return null;
|
|
2085
|
+
const key = `${bright.toFixed(3)}|${nebulae}|${dust}`;
|
|
2086
|
+
if (f.gas && f.gas.key === key) return f.gas;
|
|
2087
|
+
if (f.gas === null) return null; // tried once and could not
|
|
2088
|
+
let bmp = null;
|
|
2089
|
+
try {
|
|
2090
|
+
bmp = typeof globalThis.document?.createElement === 'function' ? document.createElement('canvas') : null;
|
|
2091
|
+
if (!bmp && typeof globalThis.OffscreenCanvas === 'function') bmp = new OffscreenCanvas(1, 1);
|
|
2092
|
+
} catch { bmp = null; }
|
|
2093
|
+
const g = bmp?.getContext?.('2d');
|
|
2094
|
+
if (!g || typeof g.ellipse !== 'function') { f.gas = null; return null; }
|
|
2095
|
+
const { cx, cy } = f;
|
|
2096
|
+
const reach = Math.max(Math.hypot(cx, cy), Math.hypot(pw - cx, cy), Math.hypot(cx, ph - cy), Math.hypot(pw - cx, ph - cy)) / GALAXY_FLATTEN + 64;
|
|
2097
|
+
const q = Math.min(1, GAS_MAX / (2 * reach));
|
|
2098
|
+
const D = Math.ceil(2 * reach * q);
|
|
2099
|
+
bmp.width = D; bmp.height = D;
|
|
2100
|
+
g.setTransform(q, 0, 0, q, D / 2, D / 2);
|
|
2101
|
+
if (nebulae) {
|
|
2102
|
+
for (const c of f.nebulae) {
|
|
2103
|
+
const nx = c.gr * Math.cos(c.ga), ny = c.gr * Math.sin(c.ga);
|
|
2104
|
+
for (const p of c.puffs) {
|
|
2105
|
+
g.fillStyle = `rgba(${c.tint},${(p.a * bright).toFixed(4)})`;
|
|
2106
|
+
g.beginPath();
|
|
2107
|
+
g.ellipse(nx + p.dx, ny + p.dy, p.rx, p.rx * p.sq, 0, 0, Math.PI * 2);
|
|
2108
|
+
g.fill();
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
if (dust) {
|
|
2113
|
+
for (const d of f.dust) {
|
|
2114
|
+
const dx0 = d.gr * Math.cos(d.ga), dy0 = d.gr * Math.sin(d.ga);
|
|
2115
|
+
for (const p of d.puffs) {
|
|
2116
|
+
g.fillStyle = `rgba(0,0,0,${p.a.toFixed(3)})`;
|
|
2117
|
+
g.beginPath();
|
|
2118
|
+
g.ellipse(dx0 + p.dx, dy0 + p.dy, p.rx, p.rx * p.sq, d.ga, 0, Math.PI * 2);
|
|
2119
|
+
g.fill();
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
g.setTransform(1, 0, 0, 1, 0, 0);
|
|
2124
|
+
f.gas = { key, bmp, q, D };
|
|
2125
|
+
return f.gas;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// THE STARS, BATCHED (operator, 2026-09-12: "is there any way to improve the galaxy speed behind
|
|
2129
|
+
// the game? ... I have to turn galaxy off to get decent framerate. nothing we can optimize
|
|
2130
|
+
// there?"). There was. The first cut set ctx.fillStyle once PER STAR -- a colour string built
|
|
2131
|
+
// and parsed forty thousand times a frame -- and that, not the arithmetic, was the frame. Now a
|
|
2132
|
+
// star's brightness is rounded to one of STAR_LEVELS steps (a thirtieth of the range, under any
|
|
2133
|
+
// eye's notice on a one-pixel point) and the field is sorted into colour x level BUCKETS with a
|
|
2134
|
+
// counting sort over preallocated typed arrays (no allocation a frame), so the canvas hears one
|
|
2135
|
+
// fillStyle and one fill() per bucket -- a few hundred at most -- with every star of that shade
|
|
2136
|
+
// as one rect() in the path. The picture is the same; the giants keep their halo and glint drawn
|
|
2137
|
+
// one by one, since there are few of them.
|
|
2138
|
+
const STAR_LEVELS = 48;
|
|
2139
|
+
function starBuckets(f, colours) {
|
|
2140
|
+
// built once per field (and once per colour switch): the palette, each star's palette index,
|
|
2141
|
+
// and the scratch arrays the per-frame sort runs in
|
|
2142
|
+
const key = colours ? 'ci' : 'ci0';
|
|
2143
|
+
if (f.bk?.key === key) return f.bk;
|
|
2144
|
+
const pal = [], idx = new Map();
|
|
2145
|
+
const ci = new Int16Array(f.stars.length);
|
|
2146
|
+
f.stars.forEach((s, i) => {
|
|
2147
|
+
const c = (colours || !s.c0 ? s.c : s.c0).join(',');
|
|
2148
|
+
let k = idx.get(c);
|
|
2149
|
+
if (k === undefined) { k = pal.length; idx.set(c, k); pal.push(c); }
|
|
2150
|
+
ci[i] = k;
|
|
2151
|
+
});
|
|
2152
|
+
const B = pal.length * STAR_LEVELS;
|
|
2153
|
+
const styles = new Array(B);
|
|
2154
|
+
for (let b = 0; b < B; b++) styles[b] = `rgba(${pal[b / STAR_LEVELS | 0]},${((b % STAR_LEVELS) / (STAR_LEVELS - 1)).toFixed(3)})`;
|
|
2155
|
+
const n = f.stars.length;
|
|
2156
|
+
f.bk = { key, pal, ci, styles, B, px: new Float32Array(n), py: new Float32Array(n), bucket: new Int32Array(n), count: new Int32Array(B + 1), order: new Int32Array(n) };
|
|
2157
|
+
return f.bk;
|
|
2158
|
+
}
|
|
2159
|
+
function drawStarField(ctx, f, pw, ph, dpr, now, spin, galaxy, bright, opts) {
|
|
2160
|
+
const colours = opts.starColours !== false, glints = opts.starGlints !== false;
|
|
2161
|
+
const stars = f.stars, n = stars.length;
|
|
2162
|
+
const bk = starBuckets(f, colours);
|
|
2163
|
+
const { px, py, bucket, count, order, styles, ci, B } = bk;
|
|
2164
|
+
count.fill(0);
|
|
2165
|
+
const cs = Math.cos(spin), sn = Math.sin(spin);
|
|
2166
|
+
for (let i = 0; i < n; i++) {
|
|
2167
|
+
const s = stars[i];
|
|
2168
|
+
let x, y;
|
|
2169
|
+
if (galaxy) {
|
|
2170
|
+
// one rotation for the whole field: cos(a + spin) expanded, so no trig per star
|
|
2171
|
+
const ca = s.ca ?? (s.ca = Math.cos(s.ga)), sa = s.sa ?? (s.sa = Math.sin(s.ga));
|
|
2172
|
+
x = f.cx + s.gr * (ca * cs - sa * sn);
|
|
2173
|
+
y = f.cy + s.gr * GALAXY_FLATTEN * (sa * cs + ca * sn);
|
|
2174
|
+
// the disc reaches past the panel now that its middle is in the corner: most of it is off
|
|
2175
|
+
// screen at any moment, and the cheapest thing to do with those stars is nothing
|
|
2176
|
+
if (x < -4 || x > pw + 4 || y < -4 || y > ph + 4) { bucket[i] = -1; continue; }
|
|
2177
|
+
} else { x = s.x; y = s.y; }
|
|
2178
|
+
px[i] = x; py[i] = y;
|
|
2179
|
+
const a = Math.min(1, starAlpha(s, now) * bright);
|
|
2180
|
+
const b = ci[i] * STAR_LEVELS + Math.round(a * (STAR_LEVELS - 1));
|
|
2181
|
+
bucket[i] = b;
|
|
2182
|
+
count[b + 1]++;
|
|
2183
|
+
}
|
|
2184
|
+
for (let b = 0; b < B; b++) count[b + 1] += count[b]; // prefix sums: where each bucket starts
|
|
2185
|
+
const at = count.slice(0, B);
|
|
2186
|
+
for (let i = 0; i < n; i++) { const b = bucket[i]; if (b >= 0) order[at[b]++] = i; }
|
|
2187
|
+
for (let b = 0; b < B; b++) {
|
|
2188
|
+
const from = count[b], to = count[b + 1];
|
|
2189
|
+
if (from === to) continue;
|
|
2190
|
+
ctx.fillStyle = styles[b];
|
|
2191
|
+
ctx.beginPath();
|
|
2192
|
+
for (let k = from; k < to; k++) { const i = order[k]; const r = stars[i].r; ctx.rect(px[i] - r, py[i] - r, r * 2, r * 2); }
|
|
2193
|
+
ctx.fill();
|
|
2194
|
+
}
|
|
2195
|
+
if (!glints) return;
|
|
2196
|
+
// the giants' halo and cross, one by one: a few hundred at most
|
|
2197
|
+
for (let i = 0; i < n; i++) {
|
|
2198
|
+
const s = stars[i];
|
|
2199
|
+
if (!s.big || bucket[i] < 0) continue;
|
|
2200
|
+
const a = Math.min(1, starAlpha(s, now) * bright);
|
|
2201
|
+
const c = colours || !s.c0 ? s.c : s.c0;
|
|
2202
|
+
ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${(a * 0.12).toFixed(3)})`;
|
|
2203
|
+
ctx.beginPath(); ctx.arc(px[i], py[i], s.r * 4, 0, Math.PI * 2); ctx.fill();
|
|
2204
|
+
ctx.strokeStyle = `rgba(${c[0]},${c[1]},${c[2]},${(a * 0.5).toFixed(3)})`;
|
|
2205
|
+
ctx.lineWidth = dpr * 0.6;
|
|
2206
|
+
ctx.beginPath();
|
|
2207
|
+
ctx.moveTo(px[i] - s.r * 5, py[i]); ctx.lineTo(px[i] + s.r * 5, py[i]);
|
|
2208
|
+
ctx.moveTo(px[i], py[i] - s.r * 5); ctx.lineTo(px[i], py[i] + s.r * 5);
|
|
2209
|
+
ctx.stroke();
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
export function starAlpha(star, now) {
|
|
2213
|
+
const w = 0.5 + 0.5 * Math.sin(now * star.f + star.p);
|
|
2214
|
+
// How DEEP the pulse goes, not just how fast. A small star may fade to a third of itself and
|
|
2215
|
+
// still read as a twinkle; a big one doing that reads as a light being switched on and off,
|
|
2216
|
+
// because its halo and glint swing with it. So the giants shimmer between roughly half and
|
|
2217
|
+
// full, and the faint ones keep the wider swing that makes a sky look alive.
|
|
2218
|
+
const floor = star.big ? 0.55 : 0.3;
|
|
2219
|
+
return star.b * (floor + (1 - floor) * w * w);
|
|
2220
|
+
}
|
|
2221
|
+
function drawStars(ctx, pw, ph, dpr, now, opts = {}) {
|
|
2222
|
+
const key = ctx.canvas ?? NO_CANVAS;
|
|
2223
|
+
const density = Number.isFinite(opts.starDensity) ? opts.starDensity : 1;
|
|
2224
|
+
const bright = Number.isFinite(opts.starBrightness) ? Math.min(1.5, Math.max(0, opts.starBrightness)) : 1;
|
|
2225
|
+
// false when off, otherwise WHERE it sits -- so the cache key below rebuilds the field when the
|
|
2226
|
+
// operator moves it, the same way it does when they turn it on
|
|
2227
|
+
const galaxy = opts.galaxy
|
|
2228
|
+
? (GALAXY_PLACEMENTS[opts.galaxyAt] ? opts.galaxyAt : GALAXY_AT_DEFAULT)
|
|
2229
|
+
: false;
|
|
2230
|
+
let f = STARS.get(key);
|
|
2231
|
+
// the field is rebuilt when the density or the SHAPE changes as well as the size: it is a seeded
|
|
2232
|
+
// scatter, so the same density always gives the same sky back. Turning is NOT a rebuild -- the
|
|
2233
|
+
// stars carry polar coordinates and only the angle advances, once per frame for all of them.
|
|
2234
|
+
if (!f || f.pw !== pw || f.ph !== ph || f.density !== density || f.galaxy !== galaxy) {
|
|
2235
|
+
const g = galaxyGeometry(pw, ph, galaxy || undefined);
|
|
2236
|
+
f = {
|
|
2237
|
+
pw, ph, density, galaxy, cx: g.cx, cy: g.cy,
|
|
2238
|
+
stars: starField(pw, ph, dpr, 7, density, galaxy),
|
|
2239
|
+
// built once with the field, and turned with it: a cloud is placed in polar coordinates
|
|
2240
|
+
nebulae: galaxy ? nebulaClouds(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy) : null,
|
|
2241
|
+
// and the deep field behind everything, galaxy or not
|
|
2242
|
+
far: farGalaxies(pw, ph),
|
|
2243
|
+
dust: galaxy ? dustLanes(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy) : null,
|
|
2244
|
+
clusters: galaxy ? starClusters(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy) : null,
|
|
2245
|
+
};
|
|
2246
|
+
STARS.set(key, f);
|
|
2247
|
+
}
|
|
2248
|
+
ctx.__starBright = bright;
|
|
2249
|
+
const spin = galaxy ? now * GALAXY_SPIN : 0;
|
|
2250
|
+
// the deep field first: distant galaxies, small and still
|
|
2251
|
+
if (f.far && opts.galaxies !== false && typeof ctx.ellipse === 'function') {
|
|
2252
|
+
for (const g of f.far) {
|
|
2253
|
+
for (const [k, a] of [[1, 0.035], [0.72, 0.05], [0.48, 0.08], [0.22, 0.16]]) {
|
|
2254
|
+
ctx.fillStyle = `rgba(${g.tint},${(a * bright).toFixed(3)})`;
|
|
2255
|
+
ctx.beginPath();
|
|
2256
|
+
ctx.ellipse(g.x, g.y, g.rx * k * dpr, g.rx * k * g.ratio * dpr, g.rot, 0, Math.PI * 2);
|
|
2257
|
+
ctx.fill();
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
// then the gas: the stars stand IN it, not behind it. Baked to a bitmap where the page can
|
|
2262
|
+
// make one (gasLayer) and drawn turned in one call; the live loops below are the fallback.
|
|
2263
|
+
const gas = galaxy ? gasLayer(f, pw, ph, bright, opts) : null;
|
|
2264
|
+
if (gas) {
|
|
2265
|
+
const q = gas.q, cs = Math.cos(spin), sn = Math.sin(spin);
|
|
2266
|
+
// local (unflattened, unturned) disc -> panel: turn by the spin, squash y by the flatten,
|
|
2267
|
+
// scale up by 1/q, land on the centre -- the same map the stars go through, as one matrix
|
|
2268
|
+
ctx.setTransform(cs / q, (GALAXY_FLATTEN * sn) / q, -sn / q, (GALAXY_FLATTEN * cs) / q, f.cx, f.cy);
|
|
2269
|
+
ctx.drawImage(gas.bmp, -gas.D / 2, -gas.D / 2);
|
|
2270
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2271
|
+
}
|
|
2272
|
+
if (!gas && galaxy && f.nebulae && opts.nebulae !== false && typeof ctx.ellipse === 'function') {
|
|
2273
|
+
for (const c of f.nebulae) {
|
|
2274
|
+
const ang = c.ga + spin;
|
|
2275
|
+
const nx = f.cx + c.gr * Math.cos(ang);
|
|
2276
|
+
const ny = f.cy + c.gr * GALAXY_FLATTEN * Math.sin(ang);
|
|
2277
|
+
for (const p of c.puffs) {
|
|
2278
|
+
ctx.fillStyle = `rgba(${c.tint},${(p.a * bright).toFixed(4)})`;
|
|
2279
|
+
ctx.beginPath();
|
|
2280
|
+
ctx.ellipse(nx + p.dx, ny + p.dy * GALAXY_FLATTEN, p.rx, p.rx * GALAXY_FLATTEN * p.sq, 0, 0, Math.PI * 2);
|
|
2281
|
+
ctx.fill();
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
// the dust over the gas and under the stars: dark ribbons on the inner edge of each arm
|
|
2286
|
+
if (!gas && galaxy && f.dust && opts.dust !== false && typeof ctx.ellipse === 'function') {
|
|
2287
|
+
for (const d of f.dust) {
|
|
2288
|
+
const ang = d.ga + spin;
|
|
2289
|
+
const dx0 = f.cx + d.gr * Math.cos(ang), dy0 = f.cy + d.gr * GALAXY_FLATTEN * Math.sin(ang);
|
|
2290
|
+
for (const p of d.puffs) {
|
|
2291
|
+
ctx.fillStyle = `rgba(0,0,0,${p.a.toFixed(3)})`;
|
|
2292
|
+
ctx.beginPath();
|
|
2293
|
+
ctx.ellipse(dx0 + p.dx, dy0 + p.dy * GALAXY_FLATTEN, p.rx, p.rx * GALAXY_FLATTEN * p.sq, ang, 0, Math.PI * 2);
|
|
2294
|
+
ctx.fill();
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
drawStarField(ctx, f, pw, ph, dpr, now, spin, galaxy, bright, opts);
|
|
2299
|
+
// the clusters last, over the field: dense specks with a fuzzy edge, steady (no twinkle)
|
|
2300
|
+
if (galaxy && f.clusters && opts.clusters !== false) {
|
|
2301
|
+
for (const k of f.clusters) {
|
|
2302
|
+
const ang = k.ga + spin;
|
|
2303
|
+
const kx = f.cx + k.gr * Math.cos(ang), ky = f.cy + k.gr * GALAXY_FLATTEN * Math.sin(ang);
|
|
2304
|
+
if (kx < -30 || kx > pw + 30 || ky < -30 || ky > ph + 30) continue;
|
|
2305
|
+
ctx.fillStyle = `rgba(255,240,215,${(0.05 * bright).toFixed(3)})`;
|
|
2306
|
+
ctx.fillRect(kx - k.size, ky - k.size, k.size * 2, k.size * 2);
|
|
2307
|
+
for (const q of k.stars) {
|
|
2308
|
+
ctx.fillStyle = `rgba(255,244,222,${Math.min(1, q.b * bright).toFixed(3)})`;
|
|
2309
|
+
ctx.fillRect(kx + q.dx - q.r, ky + q.dy - q.r, q.r * 2, q.r * 2);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// There is deliberately NO painted core. The first cut stacked six faint ellipses at the middle,
|
|
2316
|
+
// and rendered they read as hard grey rings rather than a glow -- the gradient that would smooth
|
|
2317
|
+
// them is exactly what the canvas rules here forbid (no globalAlpha, no composite modes, no
|
|
2318
|
+
// shadowBlur: viewer-canvas-rules.test.js). The nucleus is stars instead: the bulge population is
|
|
2319
|
+
// dense and at full brightness, which looks like a core because it is one.
|
|
2320
|
+
|
|
2321
|
+
function paintFrame(ctx, geom, frame, opts, view, gridN, blockRows, gridH = gridN) {
|
|
2322
|
+
const { pw, ph, dpr } = geom;
|
|
2323
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2324
|
+
// cleared first, so a TRANSPARENT background (Tetrust's well, laid over its own sky canvas) shows
|
|
2325
|
+
// what is behind the canvas rather than the last frame
|
|
2326
|
+
ctx.clearRect(0, 0, pw, ph);
|
|
2327
|
+
ctx.fillStyle = opts.background;
|
|
2328
|
+
ctx.fillRect(0, 0, pw, ph);
|
|
2329
|
+
if (starsOn(opts)) drawStars(ctx, pw, ph, dpr || 1, view.now ?? 0, opts);
|
|
2330
|
+
// with nothing on the board -- every block in the air between two layouts (a viewer-mode switch)
|
|
2331
|
+
// -- the oblique board still draws itself: its transform is a constant, not fitted to the blocks
|
|
2332
|
+
if (!frame.bounds && !opts.oblique) return;
|
|
2333
|
+
|
|
2334
|
+
// BOTTOM-LEFT IS FLUSH WITH THE PANEL, ALWAYS.
|
|
2335
|
+
//
|
|
2336
|
+
// No centring, no measured bounds, no padding: the board's own corner
|
|
2337
|
+
// (0,0) is mapped to the canvas's bottom-left corner and the far corner to
|
|
2338
|
+
// the top-right. That is why the view cannot slide. Fitting to MEASURED
|
|
2339
|
+
// bounds -- even the grid's -- still moved things, because with rows
|
|
2340
|
+
// flipped the projected y range is NEGATIVE while the vanishing point was
|
|
2341
|
+
// computed as positive, so the growth expansion skewed the bounds and
|
|
2342
|
+
// pushed the picture down.
|
|
2343
|
+
//
|
|
2344
|
+
// Blocks in flight reach past these edges and are drawn off-canvas. The
|
|
2345
|
+
// operator has accepted that twice; a locked view is worth more than
|
|
2346
|
+
// keeping every block inside the frame.
|
|
2347
|
+
const bw = Math.max(1, gridN * opts.unit);
|
|
2348
|
+
const bh = Math.max(1, gridH * opts.unit);
|
|
2349
|
+
// Under the oblique camera height is drawn up and to the right, so the
|
|
2350
|
+
// board keeps its bottom-left corner flush and a CONSTANT strip is left
|
|
2351
|
+
// along the top and right for the tallest cube, the dome and the flight --
|
|
2352
|
+
// constant, so the view still cannot move between frames or refreshes.
|
|
2353
|
+
const of = opts.oblique ? obliqueFit(pw, ph, gridN, gridH, opts) : null;
|
|
2354
|
+
const fit = of ? { scaleX: of.k, scaleY: of.k, tx: of.tx, ty: of.ty } : { scaleX: pw / bw, scaleY: ph / bh, tx: 0, ty: ph };
|
|
2355
|
+
// pinhole: grid x 0..bw -> canvas 0..pw, grid y -bh..0 -> canvas ph..0 (row 0 on the floor)
|
|
2356
|
+
// oblique: the board centred, the sphere filling the rest (obliqueFit)
|
|
2357
|
+
ctx.setTransform(fit.scaleX, 0, 0, fit.scaleY, fit.tx, fit.ty);
|
|
2358
|
+
frame.__fit = { scaleX: fit.scaleX, scaleY: fit.scaleY, tx: fit.tx, ty: fit.ty, ph, unit: opts.unit };
|
|
2359
|
+
ctx.lineWidth = Math.max(0.5, 0.6 / Math.min(fit.scaleX, fit.scaleY));
|
|
2360
|
+
ctx.lineJoin = 'round';
|
|
2361
|
+
|
|
2362
|
+
// The ground first, then the shadows lying on it, then the glowing layer
|
|
2363
|
+
// (drawGrid returns it) -- light, so no shadow dims it -- then the cubes.
|
|
2364
|
+
let glow = opts.grid ? drawGrid(ctx, view, opts, gridN, blockRows, gridH) : null;
|
|
2365
|
+
let wall = opts.axes ? () => drawAxes(ctx, view, opts.axes, gridN) : null;
|
|
2366
|
+
for (const op of frame.ops) {
|
|
2367
|
+
if (glow && op.face !== 'shadow') { glow(); glow = null; }
|
|
2368
|
+
if (wall && op.face !== 'shadow') { wall(); wall = null; }
|
|
2369
|
+
const p = op.points;
|
|
2370
|
+
ctx.beginPath();
|
|
2371
|
+
ctx.moveTo(p[0].x, p[0].y);
|
|
2372
|
+
for (let i = 1; i < p.length; i++) ctx.lineTo(p[i].x, p[i].y);
|
|
2373
|
+
ctx.closePath();
|
|
2374
|
+
ctx.fillStyle = op.fill;
|
|
2375
|
+
ctx.fill();
|
|
2376
|
+
// The seam's colour comes from the op, already scaled by the tile's
|
|
2377
|
+
// alpha, so an outline fades exactly as its tile does. A fixed colour
|
|
2378
|
+
// here is what left black frames hanging where departing blocks had been.
|
|
2379
|
+
// `always`: an op whose stroke IS the point (the neon edge) draws whether or not the dark
|
|
2380
|
+
// seam (Stone edges) is on -- it replaces the seam rather than accompanying it
|
|
2381
|
+
if (op.stroke && (opts.edges || op.always)) {
|
|
2382
|
+
ctx.strokeStyle = op.stroke;
|
|
2383
|
+
if (op.lw) { const lw0 = ctx.lineWidth; ctx.lineWidth = lw0 * op.lw; ctx.stroke(); ctx.lineWidth = lw0; }
|
|
2384
|
+
else ctx.stroke();
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
if (glow) glow(); // a board with no cubes on it
|
|
2388
|
+
if (wall) wall();
|
|
2389
|
+
if (opts.axes?.line) priceLine(ctx, view, opts.axes);
|
|
2390
|
+
if (opts.axes) axisLabels(ctx, view, opts.axes, gridN, fit.scaleX, dpr || 1);
|
|
2391
|
+
// THE AGENT'S OWN GEOMETRY, dispatched from the registry (agents.js). The light cycles' walls
|
|
2392
|
+
// and the lightning ball were two hard-coded calls here; with fifty agents this is the seam.
|
|
2393
|
+
// drawCycles and drawBall stay in this module -- lightcycle-crash.test.js imports drawCycles by
|
|
2394
|
+
// name and calls it with a hand-built view -- and the registry simply points at them.
|
|
2395
|
+
const agentDraw = view.fx?.kind ? AGENTS[view.fx.kind]?.draw : null;
|
|
2396
|
+
if (agentDraw) agentDraw(ctx, view, ctx.lineWidth, { drawCycles, drawBall, project });
|
|
2397
|
+
else { drawCycles(ctx, view, ctx.lineWidth); drawBall(ctx, view, ctx.lineWidth); }
|
|
2398
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// THE LOOK (operator, 2026-09-11: "absolutely spectacular ... make them feel
|
|
2402
|
+
// like they are in The Matrix"): a green-black field, a phosphor floor and
|
|
2403
|
+
// grid, and Tetris cells with a metallic finish for transactions (see
|
|
2404
|
+
// buildScene in blockscene3d.js). The blocks keep the feerate palette
|
|
2405
|
+
// (feepalette.js) -- the environment is themed, the data colours are not.
|
|
2406
|
+
// Digital rain was tried over the top the same day and removed at the
|
|
2407
|
+
// operator's call ("looks stupid and adds nothing practical"): atmosphere that
|
|
2408
|
+
// carries no data is not worth a second animation loop.
|
|
2409
|
+
export const DEFAULTS = {
|
|
2410
|
+
resolution: 44,
|
|
2411
|
+
blockVbytes: 1000000,
|
|
2412
|
+
background: 'rgba(2,9,6,1)',
|
|
2413
|
+
edges: true,
|
|
2414
|
+
seamAlpha: 0.38, // the dark seam round each stone; scaled by its fade
|
|
2415
|
+
idleFx: true, // varied effects across the board at rest (see fxAt)
|
|
2416
|
+
idleEvery: [5000, 9000], // between effects while the board stays still (7-13 s until 2026-09-11)
|
|
2417
|
+
idleFirst: [800, 1600], // the first one after a transition lands ("trigger any effects sooner")
|
|
2418
|
+
// the block template, drawn on the ground
|
|
2419
|
+
grid: true,
|
|
2420
|
+
gridStep: 4,
|
|
2421
|
+
floor: 'rgba(40,82,70,1)', // the deck's slate teal: mid-tone, so a shadow shows (groundLayers)
|
|
2422
|
+
vanish: { fx: 0.5, fy: 0.5 }, // (overhead camera only) where the pinhole sits
|
|
2423
|
+
// THE DEFAULT CAMERA IS OBLIQUE, ON A CURVED BOARD (2026-09-11; see
|
|
2424
|
+
// project()): height is an offset up and a little right, flight altitude is
|
|
2425
|
+
// compressed into `headroom`, and the board domes toward the viewer --
|
|
2426
|
+
// operator: "it would be more compelling if it was a partial spherical board,
|
|
2427
|
+
// instead of a flat surface. Would really help with the 3D". Zero on every
|
|
2428
|
+
// edge, so the board stays flush; set oblique: null for the overhead pinhole.
|
|
2429
|
+
// Tuned from photographs of four settings side by side (2026-09-11): 0.28 /
|
|
2430
|
+
// 0.55 with a 12-unit dome made the largest cubes towers and heaped the
|
|
2431
|
+
// board up off its own grid. A steeper camera still shows every cube's top
|
|
2432
|
+
// and two sides, foreshortened, and a 7-unit dome bows the board visibly.
|
|
2433
|
+
// ...and at full panel size 0.15 / 0.36 with a 7-unit dome still heaped
|
|
2434
|
+
// the tall cubes up the middle; the calmer 0.13 / 0.32 with a 5-unit dome
|
|
2435
|
+
// keeps the board reading as a board while it still visibly bows.
|
|
2436
|
+
// (headroom 8 -> 10 when the board was centred: the margin is now the same
|
|
2437
|
+
// on every side, and the tallest back-row cube on the curve needs 10)
|
|
2438
|
+
oblique: { ox: 0.13, oy: 0.32, headroom: 10, flight: 120 }, // flight: the highest a reshuffle climbs, where the panel has room
|
|
2439
|
+
dome: 5,
|
|
2440
|
+
gridGlow: 'rgba(40,255,140,0.05)',
|
|
2441
|
+
gridColor: 'rgba(60,255,150,0.16)',
|
|
2442
|
+
gridEdgeColor: 'rgba(120,255,190,1)',
|
|
2443
|
+
// the neon grid inside the board (boardGridLayers)
|
|
2444
|
+
// SOLID cores (operator, 2026-09-11: "the glowing grid still has shadows cast on
|
|
2445
|
+
// it. I want that neon light cutting through darkness entirely"): a
|
|
2446
|
+
// see-through line reads dimmer wherever the floor under it is shadowed, and
|
|
2447
|
+
// additive blending is off the table (no composite modes), so the lines
|
|
2448
|
+
// themselves are opaque and read the same in light and shadow; only the halo
|
|
2449
|
+
// and glow round them are translucent.
|
|
2450
|
+
neonCell: 'rgba(50,190,125,1)',
|
|
2451
|
+
neonHalo: 'rgba(40,255,140,0.07)', // a wide faint halo round each plate line: the glow
|
|
2452
|
+
neonGlow: 'rgba(40,255,140,0.2)',
|
|
2453
|
+
neonLine: 'rgba(170,255,210,1)',
|
|
2454
|
+
blockLineColor: 'rgba(200,255,120,0.95)',
|
|
2455
|
+
// level of detail, in DEVICE pixels of a stone's side: below facetPx a
|
|
2456
|
+
// stone is a plain slab, below crownPx it has facets but no crown or glint
|
|
2457
|
+
facetPx: 9,
|
|
2458
|
+
crownPx: 18,
|
|
2459
|
+
unit: 6,
|
|
2460
|
+
zUnit: 6,
|
|
2461
|
+
// how hard a receding block shrinks; the choreography drives `depth`
|
|
2462
|
+
persp: 0.55,
|
|
2463
|
+
edgeMargin: 0.06, // reserved by the fit AND the ceiling on the flight swell
|
|
2464
|
+
pad: 3, // canvas padding; small, because the board should fill the panel
|
|
2465
|
+
};
|
|
2466
|
+
|
|
2467
|
+
// --- hover ---------------------------------------------------------------
|
|
2468
|
+
// Which block is under the pointer. The transform is a plain scale plus a
|
|
2469
|
+
// translate, so inverting it is arithmetic rather than a search: screen ->
|
|
2470
|
+
// grid, then the one resting tile whose footprint contains that cell. Resting
|
|
2471
|
+
// tiles never overlap (the packer guarantees it), so there is exactly one
|
|
2472
|
+
// answer and no z-order to resolve.
|
|
2473
|
+
//
|
|
2474
|
+
// Only while the picture is SETTLED. Mid-flight a block is not where its
|
|
2475
|
+
// footprint says, and reporting a transaction the pointer is not over is
|
|
2476
|
+
// worse than reporting nothing.
|
|
2477
|
+
// IS THE BOARD AT REST? (operator, 2026-09-11: "a button for 'Trigger Refresh
|
|
2478
|
+
// Now' that is only enabled when the animation is idle"). True once a board
|
|
2479
|
+
// has been drawn, its transition has landed, and no newer layout is waiting
|
|
2480
|
+
// its turn; idle effects do not count as motion.
|
|
2481
|
+
export function viewerIdle(canvas) {
|
|
2482
|
+
const st = canvas ? STATE.get(canvas) : null;
|
|
2483
|
+
return !!(st && st.plan && st.settled && !st.pending);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// The topmost face (not a shadow) whose polygon holds the point, by the even-odd rule. Pure.
|
|
2487
|
+
export function hitOps(ops, x, y) {
|
|
2488
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
2489
|
+
const op = ops[i];
|
|
2490
|
+
const p = op.points;
|
|
2491
|
+
if (op.face === 'shadow' || !p || p.length < 3) continue;
|
|
2492
|
+
let inside = false;
|
|
2493
|
+
for (let a = 0, b = p.length - 1; a < p.length; b = a++) {
|
|
2494
|
+
if ((p[a].y > y) !== (p[b].y > y) && x < ((p[b].x - p[a].x) * (y - p[a].y)) / (p[b].y - p[a].y) + p[a].x) inside = !inside;
|
|
2495
|
+
}
|
|
2496
|
+
if (inside) return op.txid;
|
|
2497
|
+
}
|
|
2498
|
+
return null;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
export function hitTest(canvas, clientX, clientY) {
|
|
2502
|
+
const st = STATE.get(canvas);
|
|
2503
|
+
if (!st || !st.lastFit || !st.restTiles || !st.settled) return null;
|
|
2504
|
+
const rect = canvas.getBoundingClientRect ? canvas.getBoundingClientRect() : { left: 0, top: 0, width: canvas.clientWidth, height: canvas.clientHeight };
|
|
2505
|
+
const dpr = (globalThis.window && window.devicePixelRatio) || 1;
|
|
2506
|
+
const px = (clientX - rect.left) * dpr;
|
|
2507
|
+
const py = (clientY - rect.top) * dpr;
|
|
2508
|
+
const f = st.lastFit;
|
|
2509
|
+
// WHAT IS DRAWN THERE, not whose footprint is under it: a market candle floats far above its
|
|
2510
|
+
// footprint, and a tall cube covers the floor behind it. The topmost face drawn at the pointer.
|
|
2511
|
+
if (st.lastOps) {
|
|
2512
|
+
const id = hitOps(st.lastOps, (px - (f.tx ?? 0)) / f.scaleX, (py - (f.ty ?? f.ph)) / f.scaleY);
|
|
2513
|
+
return id == null ? null : st.restTiles.find((t) => String(t.txid) === String(id)) ?? null;
|
|
2514
|
+
}
|
|
2515
|
+
const gx = ((px - (f.tx ?? 0)) / f.scaleX) / f.unit;
|
|
2516
|
+
const gy = -((py - (f.ty ?? f.ph)) / f.scaleY) / f.unit;
|
|
2517
|
+
for (const tile of st.restTiles) {
|
|
2518
|
+
if (gx >= tile.x && gx < tile.x + tile.s && gy >= tile.y && gy < tile.y + tile.s) return tile;
|
|
2519
|
+
}
|
|
2520
|
+
return null;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
function fmtVb(n) { return n >= 1000 ? `${(n / 1000).toFixed(1)} kvB` : `${Math.round(n)} vB`; }
|
|
2524
|
+
|
|
2525
|
+
// HOVER GLOW AND CLICK-THROUGH (operator, 2026-09-11: "when I mouse-over on a block in the block
|
|
2526
|
+
// space, I want it to glow and then fade out when I move the mouse off it. if I click a glowing
|
|
2527
|
+
// box, I want it to take me to the txid in the explorer view"). The tile under the pointer lights
|
|
2528
|
+
// up over GLOW_IN ms and, released, fades over GLOW_OUT ms -- a re-hover mid-fade resumes from
|
|
2529
|
+
// where it is. The loop runs only while a glow is changing; a steadily lit tile costs nothing.
|
|
2530
|
+
const GLOW_IN = 120, GLOW_OUT = 600;
|
|
2531
|
+
const TXID = /^[0-9a-f]{64}$/i;
|
|
2532
|
+
const perfNow = () => (globalThis.performance && performance.now()) || 0;
|
|
2533
|
+
export function glowLevel(g, t) {
|
|
2534
|
+
if (!g) return 0;
|
|
2535
|
+
const up = (on) => Math.min(1, (g.from ?? 0) + Math.max(0, on - g.on) / GLOW_IN);
|
|
2536
|
+
if (g.off == null) return up(t);
|
|
2537
|
+
return Math.max(0, up(g.off) * (1 - Math.max(0, t - g.off) / GLOW_OUT));
|
|
2538
|
+
}
|
|
2539
|
+
function setHover(st, id) {
|
|
2540
|
+
const now = perfNow();
|
|
2541
|
+
st.glow ??= new Map();
|
|
2542
|
+
if (st.hoverId) { const g = st.glow.get(st.hoverId); if (g && g.off == null) g.off = now; }
|
|
2543
|
+
st.hoverId = id;
|
|
2544
|
+
if (id) st.glow.set(id, { on: now, off: null, from: glowLevel(st.glow.get(id), now) });
|
|
2545
|
+
st.dirty = true;
|
|
2546
|
+
st.wake?.();
|
|
2547
|
+
}
|
|
2548
|
+
function glowMap(st, t) {
|
|
2549
|
+
if (!st.glow?.size) return null;
|
|
2550
|
+
const m = new Map();
|
|
2551
|
+
for (const [id, g] of st.glow) {
|
|
2552
|
+
const v = glowLevel(g, t);
|
|
2553
|
+
if (v > 0.005) m.set(id, v);
|
|
2554
|
+
else if (g.off != null) st.glow.delete(id);
|
|
2555
|
+
}
|
|
2556
|
+
return m.size ? m : null;
|
|
2557
|
+
}
|
|
2558
|
+
function glowAnimating(st, t) {
|
|
2559
|
+
for (const g of st.glow?.values() ?? []) {
|
|
2560
|
+
if (g.off != null ? glowLevel(g, t) > 0.005 : glowLevel(g, t) < 1) return true;
|
|
2561
|
+
}
|
|
2562
|
+
return false;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
function bindHover(canvas, st) {
|
|
2566
|
+
if (st.hoverBound || !canvas.addEventListener) return;
|
|
2567
|
+
st.hoverBound = true;
|
|
2568
|
+
const tip = canvas.parentElement?.querySelector?.('.goggles-tip') ?? null;
|
|
2569
|
+
const hide = () => tip?.classList?.add('hidden');
|
|
2570
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
2571
|
+
// A BOARD CAN REFUSE HOVER (`hover: false`; operator, 2026-09-12: "In blockout, don't highlite
|
|
2572
|
+
// the paddle block controller when I mouse over it"). On a data board the pointer picks out a
|
|
2573
|
+
// transaction; on a playfield it is holding the bat, so lighting whatever is under it -- and
|
|
2574
|
+
// offering a tooltip and a pointer cursor for it -- is noise. The flag is read from the state
|
|
2575
|
+
// rather than captured here, so it follows the option rather than whichever draw bound first.
|
|
2576
|
+
if (st.noHover) return;
|
|
2577
|
+
const hit = hitTest(canvas, e.clientX ?? 0, e.clientY ?? 0);
|
|
2578
|
+
const hid = hit ? String(hit.txid) : null;
|
|
2579
|
+
if (hid !== (st.hoverId ?? null)) setHover(st, hid);
|
|
2580
|
+
if (canvas.style) canvas.style.cursor = hit && TXID.test(hid) ? 'pointer' : '';
|
|
2581
|
+
if (!tip) return;
|
|
2582
|
+
if (!hit) { hide(); return; }
|
|
2583
|
+
if (hit.label) { tip.textContent = hit.label; tip.classList.remove('hidden'); return; }
|
|
2584
|
+
const rate = hit.rate ?? 0;
|
|
2585
|
+
const id = String(hit.txid);
|
|
2586
|
+
const label = id.startsWith('aggregate@')
|
|
2587
|
+
? 'aggregated small transactions'
|
|
2588
|
+
: (id.length > 20 ? id.slice(0, 12) + '…' + id.slice(-6) : id);
|
|
2589
|
+
tip.textContent = `${label} · ${fmtVb(hit.vsize ?? 0)} · ${rate.toFixed(rate < 10 ? 2 : 1)} sat/vB`;
|
|
2590
|
+
tip.classList.remove('hidden');
|
|
2591
|
+
});
|
|
2592
|
+
canvas.addEventListener('pointerleave', () => { hide(); setHover(st, null); if (canvas.style) canvas.style.cursor = ''; });
|
|
2593
|
+
// a transaction opens in the explorer; aggregate pieces and market candles only glow
|
|
2594
|
+
canvas.addEventListener('click', (e) => {
|
|
2595
|
+
if (st.noHover) return; // a click on a playfield serves the ball, it does not navigate
|
|
2596
|
+
const hit = hitTest(canvas, e.clientX ?? 0, e.clientY ?? 0);
|
|
2597
|
+
if (hit && TXID.test(String(hit.txid)) && globalThis.location) globalThis.location.hash = `#explorer/tx/${String(hit.txid).toLowerCase()}`;
|
|
2598
|
+
});
|
|
2599
|
+
canvas.addEventListener('pointercancel', hide);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
export function render3d(canvas, cells, options = {}) {
|
|
2603
|
+
if (!canvas || !canvas.getContext) return null;
|
|
2604
|
+
const opts = { ...DEFAULTS, ...options };
|
|
2605
|
+
const ctx = canvas.getContext('2d');
|
|
2606
|
+
if (!ctx) return null;
|
|
2607
|
+
|
|
2608
|
+
let st = STATE.get(canvas);
|
|
2609
|
+
if (!st) {
|
|
2610
|
+
st = { prev: [], raf: null, plan: null, dirty: false };
|
|
2611
|
+
STATE.set(canvas, st);
|
|
2612
|
+
}
|
|
2613
|
+
st.noHover = opts.hover === false; // see bindHover: playfields do not light up under the pointer
|
|
2614
|
+
|
|
2615
|
+
// FLUSH WITH THE VIEWPORT (operator, 2026-09-11: "Blocks are being shown
|
|
2616
|
+
// off the viewport. I want the grid bounds flush with the viewport"). One
|
|
2617
|
+
// block's worth packs to very nearly resolution x resolution, but square
|
|
2618
|
+
// sides round UP to whole units, so the packing can come out a row or two
|
|
2619
|
+
// taller than the grid, and the board transform (a constant, see below)
|
|
2620
|
+
// then draws the top of it past the edge. Dropping the overflow was tried
|
|
2621
|
+
// and cost over 10% of the block on a realistic pool, so the block is
|
|
2622
|
+
// re-packed at a slightly smaller scale instead -- every square shrinks
|
|
2623
|
+
// alike, so relative areas stay exact -- until it fits. The scale moves in
|
|
2624
|
+
// 5% steps and is remembered per canvas: similar data keeps the same scale
|
|
2625
|
+
// and does not set every tile moving on a refresh.
|
|
2626
|
+
// Stable where it can be (packStable keeps every surviving transaction in
|
|
2627
|
+
// its square), fresh where it must be -- and only at the scale the previous
|
|
2628
|
+
// layout was made at, since at another scale every square's side changes.
|
|
2629
|
+
const laid = Array.isArray(opts.laid) ? opts.laid : null; // board3d: tiles the caller laid out
|
|
2630
|
+
// THE EXACT FILL for the Simple board (operator, 2026-09-12: "Simple viewer mode is what I use
|
|
2631
|
+
// as default. It needs to be fucking perfect" ... "packer is still fucked up"). One block's
|
|
2632
|
+
// worth with a summed tail is packed to the last cell with the scale SOLVED (blockpack.js
|
|
2633
|
+
// packExact) instead of shrunk 5% at a time until it happened to fit -- which left the last row
|
|
2634
|
+
// partial by construction: measured, the top row 55% full and 92 cells stranded. Chosen by the
|
|
2635
|
+
// presence of the tail, which only the Simple board carries; the dithered per-transaction
|
|
2636
|
+
// Detailed board has no tail to fill with and keeps its own pack below, as does a board the
|
|
2637
|
+
// caller laid out (Markets).
|
|
2638
|
+
const agg = laid || opts.dither ? null : ((cells || []).find((c) => (Number(c?.aggregate) || 0) > 1) ?? null);
|
|
2639
|
+
const pack = (k) => {
|
|
2640
|
+
const txs = toTxs(cells, vbytesPerUnit(opts.blockVbytes * k, opts.resolution));
|
|
2641
|
+
const cfg = { resolution: opts.resolution, blockLimit: opts.blockVbytes * k, dither: !!opts.dither }; // dither: Detailed, area-true sides
|
|
2642
|
+
// Fresh every time. The stable packer (blockpack.js packStable) moved far
|
|
2643
|
+
// fewer blocks but left the resting board ragged -- columns half a cell
|
|
2644
|
+
// out of step, holes (operator: "I mean what even is this?"). Kept for
|
|
2645
|
+
// later; the oblique camera makes crossing paths read as depth instead.
|
|
2646
|
+
return packBlock(txs, cfg);
|
|
2647
|
+
};
|
|
2648
|
+
const tallest = (p) => p.tiles.reduce((m, t) => Math.max(m, t.y + t.s), 0);
|
|
2649
|
+
let fitK = st.fitK ?? 1;
|
|
2650
|
+
let packed;
|
|
2651
|
+
if (agg) {
|
|
2652
|
+
const plain = (cells || []).filter((c) => c !== agg).map((c, i) => ({
|
|
2653
|
+
txid: c.txid || `cell-${i}`, vsize: Math.max(1, Number(c.vbytes ?? c.vsize) || 0), rate: Math.max(0, Number(c.rate) || 0),
|
|
2654
|
+
}));
|
|
2655
|
+
// the tail's pieces take the feerate of the stratum they fall in, richest first -- the same
|
|
2656
|
+
// rule toTxs applies, so the colours are unchanged; only the layout is
|
|
2657
|
+
const tail = { vbytes: Math.max(1, Number(agg.vbytes ?? agg.vsize) || 0), rateAt: strataRates(agg.strata, Math.max(0, Number(agg.rate) || 0)) };
|
|
2658
|
+
packed = packExact(plain, tail, { resolution: opts.resolution, cap: 3 });
|
|
2659
|
+
fitK = 1; // nothing to shrink: the scale was solved
|
|
2660
|
+
} else {
|
|
2661
|
+
packed = laid ? { tiles: laid, vbytesPerUnit: 0, gridWidth: 1 } : pack(fitK);
|
|
2662
|
+
while (!laid && tallest(packed) > opts.resolution && fitK < 2) { fitK *= 1.05; packed = pack(fitK); }
|
|
2663
|
+
while (!laid && fitK > 1.0001) {
|
|
2664
|
+
const smaller = pack(fitK / 1.05);
|
|
2665
|
+
if (tallest(smaller) > opts.resolution) break;
|
|
2666
|
+
fitK /= 1.05; packed = smaller;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
st.fitK = fitK;
|
|
2670
|
+
const tiles0 = laid ?? packed.tiles.filter((t) => t.y + t.s <= opts.resolution);
|
|
2671
|
+
// SLABS (Viewer Mode 2): with every transaction on the board, a cube as tall as it is wide
|
|
2672
|
+
// hides its neighbours, so each stands no taller than opts.slab
|
|
2673
|
+
const tiles = opts.slab ? tiles0.map((t) => ({ ...t, tall: Math.min(t.s, opts.slab) })) : tiles0; // belt and braces past 2x
|
|
2674
|
+
// The grid must contain EVERY block and fill the space, so it spans the
|
|
2675
|
+
// packed extent rather than one block's worth. A tile outside its own grid
|
|
2676
|
+
// would be a transaction standing on nothing.
|
|
2677
|
+
// The tail pieces are interchangeable -- equal size, equal colour, and they
|
|
2678
|
+
// stand for transactions we were never sent individually. Numbering them by
|
|
2679
|
+
// ARRIVAL order meant one change early in the packing shifted every piece
|
|
2680
|
+
// after it, so the whole green field took flight on a round where nothing
|
|
2681
|
+
// about it had actually changed (operator: "if there is no movement
|
|
2682
|
+
// forecast for the larger green blocks, don't animate them"). Numbering
|
|
2683
|
+
// them by the SLOT they land in makes a piece in an unchanged cell the same
|
|
2684
|
+
// piece, so it holds still and only genuine churn moves.
|
|
2685
|
+
for (const t2 of tiles) if (String(t2.txid).startsWith('aggregate-')) t2.txid = `aggregate@${t2.x},${t2.y}`;
|
|
2686
|
+
// THE GRID IS A CONSTANT, AND THAT IS WHAT MAKES THE VIEW HOLD STILL.
|
|
2687
|
+
//
|
|
2688
|
+
// It used to be the EXACT packed extent -- gridH = packed.gridHeight. The
|
|
2689
|
+
// flush corner mapping below then maps grid row 0 to the canvas floor and
|
|
2690
|
+
// row gridH to the ceiling, so bottom-left is pinned... but the SCALE is
|
|
2691
|
+
// ph/(gridH*unit), and gridH changed on every refresh as the packing came
|
|
2692
|
+
// out a row taller or shorter. A taller packing shrinks scaleY, the whole
|
|
2693
|
+
// picture compresses toward the floor, and the top edge walks down the
|
|
2694
|
+
// panel. That is the "it shifts down on the first transition" the operator
|
|
2695
|
+
// kept seeing, and it survived both earlier attempts at the fit because
|
|
2696
|
+
// neither of them touched the thing that was actually varying.
|
|
2697
|
+
//
|
|
2698
|
+
// One block at this resolution packs to very nearly resolution x
|
|
2699
|
+
// resolution by construction (vbytesPerUnit is derived from exactly that),
|
|
2700
|
+
// so a fixed square grid is also the right grid: it is full, it is square
|
|
2701
|
+
// as asked, and it never moves. A packing that overflows it draws past the
|
|
2702
|
+
// top edge, the same licence blocks in flight already have.
|
|
2703
|
+
bindHover(canvas, st);
|
|
2704
|
+
st.restTiles = tiles; // resting footprints, for the pointer
|
|
2705
|
+
st.axes = opts.axes ?? null; // the price board's axes and line, so an agent's build() knows which board it is on
|
|
2706
|
+
st.gridW = Math.max(1, laid ? (opts.gridW ?? opts.resolution) : opts.resolution);
|
|
2707
|
+
st.gridH = laid ? Math.max(1, opts.gridH ?? st.gridW) : st.gridW;
|
|
2708
|
+
st.gridN = st.gridW;
|
|
2709
|
+
// where one block's worth of vbytes ends, as a row on this grid: the
|
|
2710
|
+
// template line, kept meaningful now that the grid is the whole pool
|
|
2711
|
+
st.blockRows = opts.blockVbytes > 0 && packed.vbytesPerUnit > 0
|
|
2712
|
+
? Math.min(st.gridN, Math.round((1000000 / packed.vbytesPerUnit) / Math.max(1, packed.gridWidth)))
|
|
2713
|
+
: 0;
|
|
2714
|
+
|
|
2715
|
+
const now = (globalThis.performance && performance.now()) || 0;
|
|
2716
|
+
// STILL, by request as well as by preference (Tetrust, 2026-09-12: "Treat as groupings of blocks
|
|
2717
|
+
// making up a shape that move in unison. not drop at different rates. This is a different
|
|
2718
|
+
// application of our engine"). The choreography schedules every tile on its own -- a stagger of
|
|
2719
|
+
// seconds between one cube's drop and the next is the whole point of the block board's
|
|
2720
|
+
// reshuffle -- so a tetromino handed to it came down one cell at a time. A still board is drawn
|
|
2721
|
+
// AS LAID, every frame, with nothing planned: a piece moves as one shape because nothing moves
|
|
2722
|
+
// it, the frame simply changes.
|
|
2723
|
+
const still = reducedMotion() || opts.still === true;
|
|
2724
|
+
|
|
2725
|
+
// THE BUG THIS GUARDS (2026-09-10, operator: "redrawn instead of ...
|
|
2726
|
+
// smoothly moving"): mining.js calls this on every fast-tier paint, about
|
|
2727
|
+
// once a second. Re-planning on each call cancelled the running rAF and
|
|
2728
|
+
// started a fresh 5.4 s choreography from the CURRENT positions, so the
|
|
2729
|
+
// transition never got past its first second and the picture looked like a
|
|
2730
|
+
// redraw. A plan is only made when the layout actually differs; otherwise
|
|
2731
|
+
// the in-flight animation is left alone to finish.
|
|
2732
|
+
const sig = tiles.map((t) => `${t.txid}:${t.x},${t.y},${t.s}${t.tall != null ? `^${t.tall}${t.color}` : ''}${t.floor != null ? `_${t.floor}` : ''}`).join('|');
|
|
2733
|
+
// how it is DRAWN, not what is drawn: the display settings (settings.js). A change here has to
|
|
2734
|
+
// reach the board without waiting for the next poll, and without setting every block flying.
|
|
2735
|
+
// The sky belongs in here too. It was missing, so every star control was a latent dead control:
|
|
2736
|
+
// the field is cached on density and shape (drawStars), and nothing asked for a repaint when
|
|
2737
|
+
// either changed -- the galaxy toggle and both sliders would have sat there doing nothing until
|
|
2738
|
+
// some unrelated poll happened to replan the board, which reads exactly like a broken switch.
|
|
2739
|
+
const optSig = [opts.shadows !== false, opts.edges !== false, opts.grid !== false, !!opts.space,
|
|
2740
|
+
starsOn(opts),
|
|
2741
|
+
opts.seamAlpha, opts.facetPx, opts.crownPx, opts.dome, opts.idleFx !== false,
|
|
2742
|
+
opts.starDensity, opts.starBrightness, opts.galaxy === true, opts.galaxyAt,
|
|
2743
|
+
opts.nebulae !== false, opts.galaxies !== false, opts.dust !== false, opts.clusters !== false,
|
|
2744
|
+
opts.starColours !== false, opts.starGlints !== false,
|
|
2745
|
+
opts.neon === true, opts.sheen === true, opts.sheenStyle, opts.overheadLight === true, opts.light,
|
|
2746
|
+
opts.neonSource, opts.neonColour, opts.neonBrightness, opts.wireWidth,
|
|
2747
|
+
opts.transition ? `${opts.transition.rise}/${opts.transition.travel}/${opts.transition.drop}` : 'default'].join('|');
|
|
2748
|
+
const lookChanged = st.optSig !== undefined && st.optSig !== optSig;
|
|
2749
|
+
st.optSig = optSig;
|
|
2750
|
+
const unchanged = sig === st.sig && !lookChanged;
|
|
2751
|
+
// `still` governs the TILES, never the SKY (2026-09-12, operator: "why can't we get the galaxy
|
|
2752
|
+
// smoothly animating in the background for tetrust?" -- measured: zero repaints in three
|
|
2753
|
+
// seconds, frozen, not slow). A still board skipped this cheap path, and the park below
|
|
2754
|
+
// returned before requestAnimationFrame, so a board that asked for no choreography also got no
|
|
2755
|
+
// twinkle and no galaxy spin: it repainted only when its page happened to call board3d again.
|
|
2756
|
+
if (unchanged && st.plan && (!still || starsOn(opts))) {
|
|
2757
|
+
if (st.raf == null && (st.dirty || starsOn(opts) || !frameAt(st.plan, now, { unit: opts.unit, zUnit: opts.zUnit, vanishX: st.gridW * opts.unit / 2, vanishY: -st.gridH * opts.unit / 2, persp: opts.persp }).settled)) st.wake?.();
|
|
2758
|
+
return { tiles, settled: now >= st.plan.settleAt, yaw: st.yaw, replanned: false };
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// A NEW LAYOUT WAITS FOR THE RUNNING ONE TO LAND (2026-09-11, with the
|
|
2762
|
+
// transition at 40 s). Replanning mid-flight starts the new plan from the
|
|
2763
|
+
// previous TARGETS, so every block in the air would jump to where it was
|
|
2764
|
+
// going and set off again. The newest layout is parked and planned the
|
|
2765
|
+
// moment the current one settles; a still newer one simply replaces it.
|
|
2766
|
+
//
|
|
2767
|
+
// ...AND IT NOW WAITS FOR THE RUNNING EFFECT TOO (operator, 2026-09-13: "for the block space
|
|
2768
|
+
// panel, we need to defer a refresh until the active effect has finished its sequence").
|
|
2769
|
+
// Accepting a layout does `st.fx = null` below -- a transition takes the stage -- so a refresh
|
|
2770
|
+
// arriving mid-effect cut the effect off wherever it had got to. On this board that was most of
|
|
2771
|
+
// them: the pool refreshes on a timer, the signature changes, and effects run 3.2-7.4 s. The
|
|
2772
|
+
// scheduler's own comment already recorded the damage from the other side ("a 7 s pulse is
|
|
2773
|
+
// interrupted nearly every time it is chosen").
|
|
2774
|
+
//
|
|
2775
|
+
// TWO LIMITS, so this cannot become its own bug:
|
|
2776
|
+
// - only a TILE change waits. A look change (a switch flipped in settings) still applies at
|
|
2777
|
+
// once, because a control that appears dead for seven seconds is worse than an interrupted
|
|
2778
|
+
// effect. That is why this tests `sig !== st.sig` rather than `!unchanged`.
|
|
2779
|
+
// - it waits at most FX_DEFER_MAX. fxNow is bounded by the effect's own `ms`, so an effect
|
|
2780
|
+
// always ends on its own; the cap is there so that a bug in an effect cannot freeze the
|
|
2781
|
+
// data on screen indefinitely. Deferring is a courtesy to the animation, never a reason to
|
|
2782
|
+
// show stale figures for ever.
|
|
2783
|
+
//
|
|
2784
|
+
// Nothing can overtake the parked layout: scheduleFx treats a pending render as `busy` and
|
|
2785
|
+
// re-arms its timer instead of starting another effect, so an effect cannot chain ahead of a
|
|
2786
|
+
// refresh that is already waiting.
|
|
2787
|
+
const fxHolding = !!fxNow(st, now) && sig !== st.sig
|
|
2788
|
+
&& (st.pendingAt == null || now - st.pendingAt < FX_DEFER_MAX);
|
|
2789
|
+
if (!unchanged && st.plan && !still && st.raf != null && (now < st.plan.settleAt || fxHolding)) {
|
|
2790
|
+
st.pending = { cells, options };
|
|
2791
|
+
st.pendingAt ??= now;
|
|
2792
|
+
return { tiles: st.prev, settled: false, deferred: true };
|
|
2793
|
+
}
|
|
2794
|
+
st.pending = null;
|
|
2795
|
+
st.pendingAt = null;
|
|
2796
|
+
st.atRest = false; // a new layout: the next settle arms the next effect
|
|
2797
|
+
|
|
2798
|
+
// The FIRST paint never animates. With arrivals no longer drawn until they
|
|
2799
|
+
// fall, a first render treated as "everything is arriving" would leave the
|
|
2800
|
+
// board empty until the drop phase -- twenty-five seconds of blank canvas.
|
|
2801
|
+
// Nothing was there before, so nothing has moved.
|
|
2802
|
+
const firstPaint = !st.prev.length;
|
|
2803
|
+
// a look change on the same tiles is not a transition: land it where it already is
|
|
2804
|
+
const lookOnly = lookChanged && sig === st.sig;
|
|
2805
|
+
const plan = (still || firstPaint || lookOnly)
|
|
2806
|
+
// gridN matters even for a no-op plan: the camera constant is derived
|
|
2807
|
+
// from it, and a first paint on a different camera than every later
|
|
2808
|
+
// frame is exactly the load-time artefact this guards.
|
|
2809
|
+
? planTransition(tiles, tiles, { now, gridN: st.gridN, maxGrowth: opts.edgeMargin, ...(opts.transition || {}) })
|
|
2810
|
+
: planTransition(st.prev, tiles, { now, gridN: st.gridN, maxGrowth: opts.edgeMargin, ...(opts.transition || {}) });
|
|
2811
|
+
|
|
2812
|
+
st.prev = tiles;
|
|
2813
|
+
st.sig = sig;
|
|
2814
|
+
st.plan = plan;
|
|
2815
|
+
st.fx = null; // a transition takes the stage; idle effects wait for rest
|
|
2816
|
+
|
|
2817
|
+
if (st.raf != null && globalThis.cancelAnimationFrame) cancelAnimationFrame(st.raf);
|
|
2818
|
+
st.raf = null;
|
|
2819
|
+
|
|
2820
|
+
const geom = sizeCanvas(canvas, Number.isFinite(opts.maxDpr) ? Math.max(0.5, opts.maxDpr) : Infinity);
|
|
2821
|
+
// Device pixels per grid unit, from the CONSTANT board transform (pw across
|
|
2822
|
+
// gridW units), so a stone's level of detail can never flicker mid-flight.
|
|
2823
|
+
const pxPerUnit = geom.pw / Math.max(1, st.gridW);
|
|
2824
|
+
const draw = (t) => {
|
|
2825
|
+
const view = {
|
|
2826
|
+
unit: opts.unit, zUnit: opts.zUnit, risePerUnit: st.plan?.cfg?.risePerUnit,
|
|
2827
|
+
// the camera: where the vanishing point sits, as fractions of the board
|
|
2828
|
+
// (0.5, 0.5 is straight overhead, as it always was), and how far the
|
|
2829
|
+
// surface domes toward the viewer (0 = flat). Both are options so the
|
|
2830
|
+
// placements can be compared on the same data before one is chosen.
|
|
2831
|
+
vanishX: st.gridW * opts.unit * opts.vanish.fx, vanishY: -st.gridH * opts.unit * (1 - opts.vanish.fy), persp: opts.persp,
|
|
2832
|
+
boardW: st.gridW * opts.unit, boardH: st.gridH * opts.unit, dome: opts.dome, gridW: st.gridW, gridH: st.gridH,
|
|
2833
|
+
// the camera, plus settings.js space.perspective folded in: `rise` is how much height
|
|
2834
|
+
// foreshortens, and 0 (the default) is the parallel camera this board has always drawn
|
|
2835
|
+
seamAlpha: opts.seamAlpha, fx: fxNow(st, t), now: t, light: opts.light, order: opts.order, hoverGlow: glowMap(st, t),
|
|
2836
|
+
axes: opts.axes ?? null, // the price board's axes: buildScene lights candle sides where these exist
|
|
2837
|
+
oblique: opts.obliqueRise ? { ...opts.oblique, rise: opts.obliqueRise } : opts.oblique,
|
|
2838
|
+
// the departure path (settings.js space.departures): it reaches the geometry AND the paint
|
|
2839
|
+
// order through the same view object, which is the only way those two can agree
|
|
2840
|
+
departures: opts.departures,
|
|
2841
|
+
facetMinUnits: opts.facetPx / pxPerUnit, crownMinUnits: opts.crownPx / pxPerUnit,
|
|
2842
|
+
shadows: opts.shadows !== false, // settings.js: the board can be drawn without them
|
|
2843
|
+
// the finishes (settings.js space.neon / space.sheen). They were in the look signature and
|
|
2844
|
+
// in buildScene from the first cut, but not HERE, so a flipped switch repainted the same
|
|
2845
|
+
// picture (2026-09-12: "I don't see neon blocks working, nor the metallic sheen")
|
|
2846
|
+
neon: opts.neon === true, sheen: opts.sheen === true, sheenStyle: opts.sheenStyle,
|
|
2847
|
+
overheadLight: opts.overheadLight === true, // the lamp straight above (Tetrust)
|
|
2848
|
+
light: opts.light, // or wherever settings.js space.light puts it
|
|
2849
|
+
neonSource: opts.neonSource, neonColour: opts.neonColour, neonBrightness: opts.neonBrightness,
|
|
2850
|
+
wireWidth: opts.wireWidth,
|
|
2851
|
+
// the paint order's memory across frames (blockscene3d obliqueOrder): a tangle keeps the
|
|
2852
|
+
// relative order it had last frame, so nothing flickers in and out of one
|
|
2853
|
+
orderMemo: (st.orderMemo ??= new Map()),
|
|
2854
|
+
};
|
|
2855
|
+
// the panel's extent in grid units, from the same constant fit paintFrame
|
|
2856
|
+
// uses: the textured sphere is laid over all of it (drawGrid), and an
|
|
2857
|
+
// arrival starts wholly outside it (offscreenLift)
|
|
2858
|
+
if (opts.oblique) view.viewRect = obliqueFit(geom.pw, geom.ph, st.gridW, st.gridH, opts).rect;
|
|
2859
|
+
const frame = frameAt(st.plan, t, view);
|
|
2860
|
+
paintFrame(ctx, geom, frame, opts, view, st.gridW, st.blockRows, st.gridH);
|
|
2861
|
+
st.lastFit = frame.__fit ?? st.lastFit;
|
|
2862
|
+
st.lastOps = frame.ops;
|
|
2863
|
+
st.settled = frame.settled;
|
|
2864
|
+
st.dirty = false;
|
|
2865
|
+
return frame;
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2868
|
+
const step = () => {
|
|
2869
|
+
const t = (globalThis.performance && performance.now()) || 0;
|
|
2870
|
+
// THE SKY keeps the loop alive for the twinkle -- about 24 frames a second once the board is
|
|
2871
|
+
// still -- and stops it while the canvas is hidden (another tab of the app); the next
|
|
2872
|
+
// render3d call wakes it (see the unchanged-layout branch). It is the STARS that need the
|
|
2873
|
+
// repaint, not the board style, so a space-styled board with the sky off parks like any other.
|
|
2874
|
+
if (starsOn(opts)) {
|
|
2875
|
+
// ABSENT COUNTS AS HIDDEN, and this is the sky loop's only exit: 2218 re-arms when the
|
|
2876
|
+
// frame is throttled, 2242 re-arms otherwise, and the park at 2234 is gated behind
|
|
2877
|
+
// !starsOn -- so a starry board that cannot answer "am I visible?" never unwinds. The
|
|
2878
|
+
// strict `=== false` / `=== null` form could only fire where the properties exist, and
|
|
2879
|
+
// the day space.stars shipped ON by default that turned an unreachable branch into an
|
|
2880
|
+
// infinite one (renderMining: Maximum call stack size exceeded, wherever rAF runs inline
|
|
2881
|
+
// rather than deferring to a real frame). In a browser this is a no-op: isConnected is
|
|
2882
|
+
// always a boolean and offsetParent always an Element or null.
|
|
2883
|
+
if (!canvas.isConnected || !canvas.offsetParent) { st.raf = null; return; }
|
|
2884
|
+
if (st.settled && !st.dirty && !st.pending && !fxNow(st, t) && t - (st.lastPaint ?? 0) < 33) { st.raf = requestAnimationFrame(step); return; }
|
|
2885
|
+
st.lastPaint = t;
|
|
2886
|
+
}
|
|
2887
|
+
const frame = draw(t);
|
|
2888
|
+
// THE FLUSH WAITS FOR THE EFFECT AS WELL. `frame.settled` is about the TRANSITION, not the
|
|
2889
|
+
// effect, so without the fxNow test this let a parked refresh through the moment the board
|
|
2890
|
+
// landed -- cutting the effect off exactly as before. It is also the live path: stars ship on
|
|
2891
|
+
// by default, so this is the branch a real board takes, and guarding only the entry above
|
|
2892
|
+
// would have looked correct and done nothing.
|
|
2893
|
+
if (starsOn(opts) && frame.settled && st.pending && !fxNow(st, t)) { const p = st.pending; st.pending = null; st.pendingAt = null; st.raf = null; render3d(canvas, p.cells, p.options); return; }
|
|
2894
|
+
// keep the loop alive while the choreography runs OR the camera is moving;
|
|
2895
|
+
// park otherwise, because repainting a still picture is a heater
|
|
2896
|
+
if (frame.settled && !st.dirty && !fxNow(st, t)) {
|
|
2897
|
+
// the board is at rest: arm the next effect whether or not the loop is about to park --
|
|
2898
|
+
// but ONCE, on the frame it arrives, or a running loop would re-arm the timer for ever
|
|
2899
|
+
if (!st.atRest) {
|
|
2900
|
+
const afterEffect = st.fx != null; // an effect just ended -- or it has only now settled
|
|
2901
|
+
st.fx = null;
|
|
2902
|
+
st.atRest = true;
|
|
2903
|
+
scheduleFx(canvas, st, opts, !afterEffect);
|
|
2904
|
+
}
|
|
2905
|
+
if (!starsOn(opts) && !glowAnimating(st, t)) {
|
|
2906
|
+
st.raf = null;
|
|
2907
|
+
if (st.pending) { const p = st.pending; st.pending = null; st.pendingAt = null; render3d(canvas, p.cells, p.options); return; }
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
if (st.pending) { const p = st.pending; st.pending = null; st.pendingAt = null; st.raf = null; render3d(canvas, p.cells, p.options); return; }
|
|
2911
|
+
}
|
|
2912
|
+
else st.atRest = false; // something is moving again: the next rest re-arms
|
|
2913
|
+
st.raf = requestAnimationFrame(step);
|
|
2914
|
+
};
|
|
2915
|
+
st.wake = () => {
|
|
2916
|
+
if (st.raf == null && globalThis.requestAnimationFrame) st.raf = requestAnimationFrame(step);
|
|
2917
|
+
};
|
|
2918
|
+
|
|
2919
|
+
const first = draw(now);
|
|
2920
|
+
if (first.settled) { st.atRest = true; scheduleFx(canvas, st, opts, true); } // at rest already, stars or not
|
|
2921
|
+
// Park when nothing is moving: a settled board, or one that asked for no choreography at all.
|
|
2922
|
+
// Stars are motion in their own right, so a sky keeps the loop whatever the tiles are doing.
|
|
2923
|
+
if (!globalThis.requestAnimationFrame || (!starsOn(opts) && (still || first.settled))) {
|
|
2924
|
+
return { tiles, settled: true };
|
|
2925
|
+
}
|
|
2926
|
+
st.raf = requestAnimationFrame(step);
|
|
2927
|
+
return { tiles, settled: false };
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
export function block3d(canvas, visual, economy, options = {}) {
|
|
2931
|
+
const weightLimit = options.weightLimit ?? 4000000;
|
|
2932
|
+
return render3d(canvas, visual?.cells ?? [], { ...options, blockVbytes: weightLimit / 4 });
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// The pool scales to the POOL, not to a block. Measured 2026-09-10: a 3.4 MB
|
|
2936
|
+
// pool drawn against a 1 MB block packs 44 wide by ~140 tall, which in
|
|
2937
|
+
// isometric is a long thin diamond. Scaling to the pool's own total makes the
|
|
2938
|
+
// grid square at any resolution. The cost, stated rather than hidden: a pool
|
|
2939
|
+
// tile is then not comparable in size with a block tile.
|
|
2940
|
+
export function mempool3d(canvas, dist, options = {}) {
|
|
2941
|
+
const block = options.blockVbytes ?? DEFAULTS.blockVbytes;
|
|
2942
|
+
const cells = takeOneBlock(dist?.cells ?? [], block);
|
|
2943
|
+
return render3d(canvas, cells, { ...options, blockVbytes: block });
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
export { TRANSITION, SLAB_H };
|
|
2947
|
+
|
|
2948
|
+
// THE BOARD FOR OTHER DATA (operator, 2026-09-11: "leverage the 3D view we have established with
|
|
2949
|
+
// the block space panel ... If we're going to render a price chart, it would be good to see it on
|
|
2950
|
+
// a similar grid structure, with similar effects, just tasked to show market data ... We should be
|
|
2951
|
+
// flexible with how we can use that 3D view"). The same sphere, neon grid, cubes, shadows,
|
|
2952
|
+
// choreography and idle effects; the caller lays the tiles out itself on a gridW x gridH board:
|
|
2953
|
+
// { txid (a stable id -- the same id is the same tile, so it moves rather than re-arrives),
|
|
2954
|
+
// x, y, s (footprint side), tall (height; default s), color (#rrggbb), label (hover text) }
|
|
2955
|
+
export function board3d(canvas, tiles, options = {}) {
|
|
2956
|
+
return render3d(canvas, null, { ...options, laid: tiles ?? [] });
|
|
2957
|
+
}
|