blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,4342 @@
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, fxHash } 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, lensFlare, saucerAbove } 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: 16800, // 8400 -> 16800: top-down it pans OUT AND BACK, so two traversals at the same unhurried speed // scan 4200 -> 6000 -> 8400: the sweep now starts and ends off-panel, so the span grew and the duration follows it (below)
149
+ xray: 6500, // the board goes x-ray behind a sweeping front, and develops back
150
+ lightcycle: 6500, ball: 5600, pulse: 9000, // pulse 7000 -> 9000 (2026-09-14: "make it a bit slower")
151
+ bulge: 16000, // a sphere rolling through the price line; half speed (was 8000)
152
+ breathe: 9000, // the price line breathes: three slow swells between the wire and the pulse's heat
153
+ saber: 8000, // the price line ignites as a light saber, hums, and retracts
154
+ blackhole: 26000, // the chart collapses into a black hole and is let go again
155
+ shockwave: 4200, nova: 5200, firework: 5600, flare: 16000, wave: 6000, quake: 3200, // flare 3600 -> 8000 -> 16000 (2026-09-15: it is a supernova now, and the cloud disperses slowly)
156
+ rain: 6400, sparkle: 4600, checker: 4400, radar: 6000, vortex: 6400, powerup: 5000, combo: 4800, aurora: 7200, plasma: 6400,
157
+ // THE AGENTS (agents.js): effects that are a thing MOVING rather than a pattern over the board.
158
+ // Longer than the fields, because something that travels needs time to be watched -- a field
159
+ // reads at a glance, an agent has to arrive, do something, and leave.
160
+ // Most of these were removed on the operator's call after seeing them on the board. The seven
161
+ // that stayed are the ones worth the second animation loop: two riders (lightcycle, ball), a
162
+ // splitter, a thief, an interception, a collapse and a gateway. The count is deliberately not
163
+ // written as a number here -- it went stale twice as effects were culled.
164
+ // tractor 6800 -> 8000 (2026-09-14): the drop is timed by real gravity now and needs the room
165
+ centipede: 7400, tractor: 8000, missile: 7400,
166
+ // stormball replaced portal (2026-09-14); slow on purpose -- it drifts across the whole view
167
+ boulderdash: 6400, stormball: 11000,};
168
+ export const FX_KINDS = Object.keys(FX_MS);
169
+ // the price board's own lengths, where they differ: ball lightning crosses it at a third of the
170
+ // block board's speed -- 11 s there; a third slower (16.5 s, operator 2026-09-15: "cut the speed
171
+ // by 33% now that it's slower"), then 40% slower again (27.5 s: "Slow it down movement by 40%")
172
+ // THE DURATION FOLLOWS THE SPAN, or widening the travel speeds up the picture (operator,
173
+ // 2026-09-15: "why does it transit the market view so quick? I didn't ask for that"). When the
174
+ // sweep was made to start and end beyond the panel, the distance grew but the clock did not, so
175
+ // the part you actually watch -- the crossing of the board -- got 1.52x faster on Markets and
176
+ // 1.40x on Block space. These restore the crossing to the speed it had: measured, 9145 ms and
177
+ // 8400 ms for the two boards. Markets needs the longer one because its panel is wide and its board
178
+ // is shallow, so proportionally more of the journey happens off-screen.
179
+ // ...and then slower still on Markets (operator, 2026-09-15: "It's still way too fast on the
180
+ // markets display. Slow it down at least 50%"). 9150 restored the pacing the effect had BEFORE the
181
+ // travel change, which turns out not to have been slow enough either -- restoring a speed is not
182
+ // the same as it being right. 14600 makes the on-screen crossing 8451 ms against 5297, i.e. 60%
183
+ // slower, which clears the "at least 50%" asked for.
184
+ // AN EFFECT'S SIZE ON A SMALL BOARD (2026-09-15, the Kiosk): a radius in grid units, capped at a
185
+ // share of the board's width, so the same effect on a small panel never covers it
186
+ export function boundedRadius(units, share, gridW) { return Math.min(units, share * gridW); }
187
+ // effects that read as the same thing: never one right after the other
188
+ const FX_KIN = { scan: ['tractor'], tractor: ['scan'] };
189
+ export const MARKET_MS = { stormball: 27500, firework: 14000, flare: 24000, scan: 29200 }; // and a fireworks display of five shells, each with its smoke, needs the time
190
+ // The longest a refresh will ever wait for an effect to finish, plus a second of slack. Taken from
191
+ // the table rather than written as a number, so culling or adding an effect cannot leave the cap
192
+ // shorter than the effect it is meant to outlast. See the deferral in render3d.
193
+ const FX_DEFER_MAX = Math.max(...Object.values(FX_MS)) + 1000;
194
+ // A BREATH BETWEEN AN EFFECT AND THE REFRESH IT HELD BACK (operator, 2026-09-15, of the black
195
+ // hole: "snapping into place on last frame to drop in"): the parked layout took the stage on the
196
+ // very frame the effect ended, so the board's cubes were seen arriving home and, in the same
197
+ // instant, lifting off for the refresh -- one motion read as the other's last frame. The layout
198
+ // now waits a moment and a half after the effect, with the board whole and still
199
+ const FX_REST_MS = 1500;
200
+ const fxResting = (st, t) => st.fxEndedAt != null && t - st.fxEndedAt < FX_REST_MS;
201
+ // THE PULSE RIDES THE PRICE LINE (operator, 2026-09-12: "the energy pulse effect needs to run
202
+ // across the yellow line, not through space on an invisible grid ... travel the yellow line from
203
+ // one end to the other leaving a electric blue tint on the yellow line that starts fading back to
204
+ // normal yellow after 3 seconds"), then, watching it: "the blue line just changes color back to
205
+ // yellow, it doesn't fade out along a path from left to right. Shorten the blue tail ... it should
206
+ // fade out blue and fade back into yellow".
207
+ //
208
+ // So the tint is a TAIL, not a timer. The first cut held every segment blue for three seconds
209
+ // after the head passed -- and the head crosses the line in under three, so the whole line went
210
+ // blue together and snapped back together. Now the blue is strongest at the head and fades to
211
+ // yellow over the quarter of the line behind it, and the head takes most of the effect to cross
212
+ // so the tail can be watched sliding along. The head runs on past the far end until its tail has
213
+ // left too, which is why PULSE_TRAVEL is less than 1 -- and 1/PULSE_TRAVEL must exceed
214
+ // 1 + PULSE_TAIL or the tail is cut off at the edge.
215
+ //
216
+ // Then: "Make the tail at least 2 seconds before it fades out and back towards yellow". 0.66 of
217
+ // 7000 ms is a 4.6 s crossing; a tail 0.45 of the line long therefore lasts 2.1 s at any point.
218
+ const PULSE_TRAVEL = 0.66;
219
+ const PULSE_TAIL = 0.45;
220
+ // a cheap deterministic 0..1 from an integer, for the pulse's particle motes
221
+ const hash01 = (n) => { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); };
222
+ // A board with a price line is not a grid to sweep across. The straight-front effects (outline,
223
+ // scan, tide), the light cycles and the lightning ball all travel the FLOOR, which on the candle
224
+ // board is empty space -- which is what "through space on an invisible grid" describes. Where a
225
+ // line exists, the effects that run are the ones with something to run along.
226
+ // ...and ball lightning crosses the price board too (operator, 2026-09-14: "Add rare ball lightning
227
+ // travelling the 3D Markets from one side to the other and electrifying any elements it comes
228
+ // near. Same rarity as the other effects for the markets")
229
+ // LIGHT CYCLES AND THE LIGHTNING BALL RIDE THE CANDLE GRID TOO (operator, 2026-09-14: "Lightcycles
230
+ // don't work anymore on the market view" -- the line-only list had cut them, while the Markets
231
+ // switch still promised them)
232
+ // EVERY EFFECT PLAYS ON THE PRICE BOARD except the two that ALTER the board (operator, 2026-09-14:
233
+ // "a lot of effects that don't work on the market display"): the fields are light patterns over
234
+ // tiles and light candles as well as cubes; boulder dash collapses tiles and the tractor beam
235
+ // lifts one, and a candle is a price, not a thing to move. The line effects stay the board's own.
236
+ // THE MARKETS LIST IS THE TWELVE THAT TRANSLATE (operator, 2026-09-14: "I think the selection I
237
+ // have made for the market effects is what we should ship with, and remove all the unchecked
238
+ // options from selection. Many of the effects don't translate over to the market chart"). The
239
+ // board is eight units deep and as wide as the hours: what works on it runs along the hours
240
+ // (the fronts, the wave), spreads from the candle row (ripple, the bursts), lights candles
241
+ // where they stand (cascade, twinkle, the flare), or is the price line's own (pulse, bulge, ball
242
+ // lightning). The rest -- riders and walkers, the field patterns, anything that falls down the
243
+ // depth or turns in place, anything that moves a tile -- stays on the block board.
244
+ const ON_CANDLES = new Set(['ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'pulse', 'bulge', 'breathe', 'saber', 'blackhole', 'firework', 'flare', 'wave', 'stormball']);
245
+ // effects that are drawn on the price line and nowhere else: never offered to a board of blocks
246
+ // the black hole is back on the price board only (operator, 2026-09-15, after a day of it hovering
247
+ // over the block board: "still snapping into place. Just remove the black hole effect from block
248
+ // space if you can't fix it")
249
+ const LINE_ONLY = new Set(['pulse', 'bulge', 'breathe', 'saber', 'blackhole']);
250
+ // EACH BOARD ITS OWN LIST (operator, 2026-09-14: "I want the markets tab to have a separate effects
251
+ // list ... the Block Space effects specific to that panel, and settings specific to market panel"):
252
+ // settings.js keeps one group of switches per list (`effects` for the block board, `marketEffects`
253
+ // for the price board), and test/effects.test.js holds each group to its list here.
254
+ export const SPACE_FX = FX_KINDS.filter((k) => !LINE_ONLY.has(k));
255
+ export const MARKET_FX = FX_KINDS.filter((k) => ON_CANDLES.has(k));
256
+ const DEREZ_MS = 800; // how long a crashed light cycle takes to shatter and fade
257
+
258
+ /** A board with a price line: the Markets board (and Kiosk's), never the block board. */
259
+ export const onPriceBoard = (st) => (st?.axes?.line?.length ?? 0) > 1;
260
+ /** 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. */
261
+ export function fxDirection(kind, st, rnd = Math.random) {
262
+ if (onPriceBoard(st)) return rnd() < 0.5 ? [1, 0] : [-1, 0];
263
+ const dirs = kind === 'scan' ? [[0, 1], [0, -1], [1, 0], [-1, 0]]
264
+ : [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7071, 0.7071], [-0.7071, -0.7071], [0.7071, -0.7071]];
265
+ return dirs[(rnd() * dirs.length) | 0];
266
+ }
267
+ /** 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. */
268
+ export function fxOrigin(st, rnd = Math.random) {
269
+ const x = rnd() * st.gridW;
270
+ if (onPriceBoard(st)) return { x, y: Number.isFinite(st.axes.y) ? st.axes.y : st.gridH / 2 };
271
+ return { x, y: rnd() * st.gridH };
272
+ }
273
+
274
+ function startFx(st, kind, now) {
275
+ const d = fxDirection(kind, st);
276
+ const origin = fxOrigin(st);
277
+ let rank = null;
278
+ if (kind === 'cascade') {
279
+ const byRate = [...(st.restTiles || [])].sort((a, b) => (b.rate ?? 0) - (a.rate ?? 0));
280
+ rank = new Map(byRate.map((t, i) => [t.txid, byRate.length > 1 ? i / (byRate.length - 1) : 0]));
281
+ }
282
+ const seed = Math.floor(Math.random() * 1e6);
283
+ // LIGHT CYCLES and DATA PACKETS (see cyclePath): the routes and the height of every
284
+ // stretch are fixed when the effect starts -- the board is still -- so a frame only moves
285
+ // the heads along them. Two cycles in TRON's blue and orange from opposite edges.
286
+ // AGENTS BUILD THEIR OWN WORLD, once, here (agents.js). The board is STILL while an effect
287
+ // runs, so a route, a formation or a sprite's flight is decided now and a frame only moves
288
+ // along it -- which is what makes these cheap enough to have fifty of.
289
+ let agent = null;
290
+ const spec = AGENTS[kind];
291
+ if (spec?.build) {
292
+ const W = st.gridW, H = st.gridH;
293
+ const tiles = st.restTiles || [];
294
+ agent = spec.build({ st, seed, W, H, tiles, tops: cellTops(tiles, W, H), rnd: rng(seed) });
295
+ }
296
+ st.fx = { kind, t0: now, ms: (onPriceBoard(st) ? MARKET_MS[kind] : null) ?? FX_MS[kind] ?? 4500, x: origin.x, y: origin.y, dx: d[0], dy: d[1], rank, seed, agent,
297
+ // `paths`/`crashes` stay on the record because drawCycles reads view.fx.cycles, which the
298
+ // agent's frame() produces from them; nothing outside agents.js builds them any more.
299
+ paths: agent?.paths ?? null, crashes: agent?.crashes ?? null };
300
+ st.lastFx = kind;
301
+ }
302
+
303
+ function fxNow(st, t) {
304
+ const f = st.fx;
305
+ if (!f) return null;
306
+ const u = (t - f.t0) / f.ms;
307
+ if (!(u >= 0 && u < 1)) return null;
308
+ // `ms` rides along: a consumer that needs wall-clock age (the price-line pulse holds its tint
309
+ // for three SECONDS, not for a share of the effect) has to know how long the effect is. It was
310
+ // missing, so the pulse computed NaN colours and the canvas kept the yellow it already had.
311
+ // x and y -- where the effect is centred -- ride along for EVERY kind: the radial arrivals
312
+ // (shockwave, nova, radar, vortex) need an origin, and they were once ripple's alone.
313
+ 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,
314
+ x: f.x, y: f.y, amp: Math.min(1, u * 6) * Math.pow(1 - u, 0.8) };
315
+ if (f.kind === 'ripple') {
316
+ const reach = Math.hypot(Math.max(f.x, st.gridW - f.x), Math.max(f.y, st.gridH - f.y));
317
+ Object.assign(out, { x: f.x, y: f.y, r: reach * (1 - Math.pow(1 - u, 2)), w: 2.2 + 2.4 * u });
318
+ }
319
+ // THE FIREWORKS LIGHT THEIR SURROUNDINGS IN THEIR OWN COLOUR (operator, 2026-09-15: "emissive
320
+ // colored lighting illuminating the explosion area in the explosion color"): every live shell is a
321
+ // head at its burst point, in its colour, as wide as the shell has spread, fading as it dies
322
+ if (f.kind === 'firework') {
323
+ const price = (st.axes?.line?.length ?? 0) > 1;
324
+ const heads = [];
325
+ for (const sh of fireworkShells(f.seed, st.gridW, st.gridH, price, 1, price ? (st.axes?.zTop ?? 34) : 16)) {
326
+ const v = (u - sh.t0) / sh.life;
327
+ if (!(v > 0.2 && v < 1.15)) continue;
328
+ const s2 = Math.min(1, (v - 0.2) / 0.8);
329
+ const spread = 1 - Math.exp(-3.4 * s2);
330
+ heads.push({ x: sh.bx, y: sh.by, color: sh.cols[0], alpha: Math.pow(Math.max(0, 1 - s2), 1.2) * (v < 0.28 ? 1 : 0.85), r: (price ? 7 : 8) * sh.size * (0.5 + 0.9 * spread) });
331
+ }
332
+ out.heads = heads;
333
+ }
334
+ // THE BLACK HOLE (2026-09-15): where it sits on the chart, how big its horizon is this frame,
335
+ // and -- through a head that HIDES -- which candles it has swallowed; the tiles near it glow the
336
+ // disk's orange. Everything the renderer needs rides on `out.blackhole`.
337
+ if (f.kind === 'blackhole' && st.axes?.line?.length > 1) {
338
+ const line = st.axes.line;
339
+ // IT CROSSES (operator, 2026-09-15: "get the black hole effect on the market display to slowly
340
+ // move from one side to the other"): from a fifth of the way in on one side to a fifth from
341
+ // the other, the way the front effects go (f.dx), easing in and out so it is still while it
342
+ // opens and while it closes; the candles' orbits ride along with it
343
+ // ...AND IT STOPS BEFORE IT LETS GO (operator, 2026-09-15: "the front faces of the lowest
344
+ // blocks just snap into place instead of dropping"): the crossing ran to 0.9 while the release
345
+ // began at 0.82, so a cube was let go not by the release curve but by the hole moving out of
346
+ // reach of it -- dropped in a few frames as the hole passed. The crossing is done by 0.8 now,
347
+ // and the release is the only thing that lets anything go
348
+ const travel = u < 0.1 ? 0 : u > 0.8 ? 1 : (() => { const t = (u - 0.1) / 0.7; return t * t * (3 - 2 * t); })();
349
+ const dir = (f.dx || 0) !== 0 ? Math.sign(f.dx) : hash01(f.seed + 5) < 0.5 ? 1 : -1;
350
+ const xa = st.gridW * (dir > 0 ? 0.18 : 0.82), xb = st.gridW * (dir > 0 ? 0.82 : 0.18);
351
+ const hx = xa + (xb - xa) * travel;
352
+ let hy, hz, lo = 0, hi = 0;
353
+ if (line) {
354
+ // THROUGH SPACE, NOT ALONG THE LINE (operator, 2026-09-15: "I don't want the black hole
355
+ // moving along the yellow line. I want it moving through space from point A to point B, and
356
+ // have everything warp around it ... riding the line hides that amazing effect"): a straight
357
+ // run from one height to another, both drawn from the seed inside the line's own range, so
358
+ // the hole crosses the line's path rather than sitting on it and the line is seen bending
359
+ // round the shadow as it passes
360
+ lo = Infinity; hi = -Infinity; for (const p of line) { lo = Math.min(lo, p.z); hi = Math.max(hi, p.z); }
361
+ const R = Math.max(1, hi - lo);
362
+ const za = lo + R * (0.15 + 0.85 * hash01(f.seed + 6)), zb = lo + R * (0.15 + 0.85 * hash01(f.seed + 7));
363
+ hz = za + (zb - za) * travel;
364
+ hy = st.axes.y ?? st.gridH / 2;
365
+ }
366
+ // ...AND ALL THE WAY HOME BEFORE THE END (operator, 2026-09-15: "still snapping into place on
367
+ // last frame to drop in"): the release curve reached zero only at u = 1, so on the last frame
368
+ // a cube was still a few pixels off home at nine tenths size, and the effect's end put it
369
+ // there in one step. It is a smoothstep now, done by 0.97 -- zero speed as it arrives -- and
370
+ // the last three hundredths draw nothing at all
371
+ const growIn = u < 0.22 ? Math.pow(u / 0.22, 1.6) : 1;
372
+ const growOut = u <= 0.82 ? 1 : u >= 0.97 ? 0 : (() => { const t = 1 - (u - 0.82) / 0.15; return t * t * (3 - 2 * t); })();
373
+ const grow = growIn * growOut;
374
+ const rs = boundedRadius(2.3, 0.047, st.gridW) * grow; // the horizon, in grid units (4.8 on 2026-09-15 -- "double the size" -- then 3.4: "obscuring too much" -- then 2.3: "shrink up the black hole by 33%"), and never more than 4.7% of the board
375
+ out.blackhole = { x: hx, y: hy, z: hz, rs, grow, lo, hi };
376
+ // SWALLOWED SMOOTHLY (operator, 2026-09-15: "the candles just blinking out of existence looks
377
+ // bad"): not `hide`, which is a threshold, but `scale: 0`, which fxAt applies by reach -- a
378
+ // candle shrinks toward nothing as it nears the horizon and grows back as the hole recedes
379
+ const reach = 1.5;
380
+ // LIFTED OUT WHOLE, NOT SHEARED, on the block board (operator, 2026-09-15: "too much bad
381
+ // shearing of the blocks around the black hole on the block space page. instead of stretching,
382
+ // can it just lift the blocks out?"): no corner lean there (lean 0 -- buildScene's orbit
383
+ // carries the cube toward the hole as one piece, its own shape, and it shrinks away into it).
384
+ // Not `lift` either: that extrudes a cube into a column from the floor, which read as an even
385
+ // longer stretch
386
+ // ...and `shrink` on the block board: the scale alone flattens a cube into a plate (it is a
387
+ // height override), and a plate sliding toward the hole was the "shearing"; shrunk in
388
+ // footprint as well it stays a cube, a smaller one, all the way in
389
+ // LET GO SMOOTHLY (operator, 2026-09-15: "The boxes are snapping into view when they come to
390
+ // rest on the board"): the release used to come from the head's reach shrinking with the
391
+ // hole, which let go of the outer cubes gently and the ones right under it all at once in the
392
+ // last frames -- they stayed on their rings at nothing, then popped home full size. The reach
393
+ // now holds its full size through the release and `release` (growOut) fades the pull and the
394
+ // scale themselves, so every cube glides home and grows back over the whole last fifth
395
+ const R = boundedRadius(2.3, 0.047, st.gridW) * reach;
396
+ out.heads = grow > 0.02 ? [{ x: hx, y: hy, z: hz, color: [255, 160, 60], alpha: grow, r: R * growIn, rPeak: R, scale: 0, pull: 1, release: growOut, lean: line ? 1 : 0, shrink: !line }] : [];
397
+ }
398
+ // THE PULSE LIGHTS WHAT IT PASSES (operator, 2026-09-15: "interfering with the affected areas"):
399
+ // its head is a light on the board, so the candles under it glow warm as it goes by (fxAt's
400
+ // heads branch, as the agents do)
401
+ if (f.kind === 'pulse' && st.axes?.line?.length > 1) {
402
+ const line = st.axes.line, headAt = u / PULSE_TRAVEL;
403
+ const k = Math.max(0, Math.min(1, headAt)) * (line.length - 1), i = Math.min(line.length - 2, Math.floor(k)), fr = k - i;
404
+ const x = line[i].x + (line[i + 1].x - line[i].x) * fr;
405
+ const fade = headAt <= 1 ? 1 : Math.max(0, 1 - (headAt - 1) / 0.34);
406
+ out.heads = [{ x, y: st.axes.y ?? st.gridH / 2, color: [255, 225, 150], alpha: fade, r: 3.2 }];
407
+ }
408
+ // AN AGENT'S FRAME comes from its own spec (agents.js). Everything it publishes lands on the
409
+ // frame object: `heads` (how it lights the cubes -- fxAt's heads branch), plus whatever its
410
+ // draw reads, such as `cycles` for the light walls or `ball` for the plasma ball.
411
+ if (f.agent && isAgent(f.kind)) {
412
+ const spec = AGENTS[f.kind];
413
+ if (spec?.frame) Object.assign(out, spec.frame(f.agent, u, { ms: f.ms, derezMs: DEREZ_MS, seed: f.seed }) ?? {});
414
+ }
415
+ return out;
416
+ }
417
+
418
+ // WHICH EFFECT PLAYS NEXT. Pure apart from `st` (recentFx) and `rnd`, so the tests
419
+ // drive the real rule instead of a copy of it.
420
+ //
421
+ // NO EFFECT WAITS ITS TURN. There was a rare set here -- the pulse, the bulge and ball lightning
422
+ // on the price board each held to a clock of 2.5-6 minutes between plays (operator, 2026-09-14:
423
+ // "much too often", then "should be rare") -- and it is gone (operator, later that day: "Make the
424
+ // pipe 'special rare' effects no longer special, and bake them into the regular round of choosing
425
+ // effects for the market"). The price board has its own list now, and the operator trims it;
426
+ // rarity is the no-repeat window and the length of the list, the same as on the block board.
427
+ //
428
+ // NO EFFECT REPEATS WITHIN THE LAST `noRepeat` PLAYED (operator, 2026-09-14: "add a config field that
429
+ // defaults to 12 ... never pick one that has been played in the last 12 sequences"). This replaces
430
+ // "not the same one twice running", which was the same rule with a window of one. `st.recentFx` keeps
431
+ // the kinds played, newest last. Where fewer kinds are switched on than the window, no kind can be
432
+ // twelve plays clear, so the pick is among those that have waited longest: the rule degrades to
433
+ // taking turns, never to a repeat while something else is waiting.
434
+ export function chooseIdleFx(kinds, st, now, rnd = Math.random, noRepeat = 12) {
435
+ void now; // the clock was the rare set's; kept in the signature so the callers and tests read the same
436
+ if (!kinds.length) return null;
437
+ const recent = st.recentFx ?? (st.recentFx = []);
438
+ const window = Math.max(0, Math.min(Math.floor(noRepeat), kinds.length - 1));
439
+ // FROM THE START, NOT FROM THE END (operator, 2026-09-15: "I keep seeing the same effect being
440
+ // played before new ones"). This was `recent.slice(recent.length - window)`, and while fewer
441
+ // effects had played than the window is wide that index was negative -- which slice counts from
442
+ // the END: nine plays under a window of eleven blocked only the last two. The rule was weakest
443
+ // right after the page opened, which is exactly when anyone is watching. Measured before the
444
+ // fix on the Markets list: a repeat at the tenth pick; after it: every effect plays once before
445
+ // any comes round again.
446
+ const blocked = new Set(recent.slice(Math.max(0, recent.length - window)));
447
+ // KINDS THAT LOOK ALIKE SHARE THE WINDOW (operator, 2026-09-15: "I just saw the UFO effect get
448
+ // triggered twice in a row on the block space" -- the scan's searchlight and the tractor beam
449
+ // fly the same saucer, so one after the other read as a repeat though the rule saw two kinds)
450
+ for (const k of [...blocked]) for (const kin of FX_KIN[k] ?? []) blocked.add(kin);
451
+ let pool = kinds.filter((k) => !blocked.has(k));
452
+ if (!pool.length) {
453
+ // every kind is inside the window: the ones played least recently
454
+ const lastSeen = (k) => recent.lastIndexOf(k);
455
+ const oldest = Math.min(...kinds.map(lastSeen));
456
+ pool = kinds.filter((k) => lastSeen(k) === oldest);
457
+ }
458
+ // NO FAVOURITES (operator, 2026-09-13: "the tron lightcycles effect happens way too often").
459
+ // There used to be a rule here: the first effect after the board came to rest was a light-cycle
460
+ // race HALF THE TIME. That was written when there were nine effects and it read as a flourish.
461
+ // Measured with fifty-six: 33.2% of every first-after-landing pick was the light cycles,
462
+ // against 1.8% for an even split -- an eighteenfold bias. And the block-space board re-lays on
463
+ // every pool refresh, so `soon` fires constantly, which is why it felt relentless.
464
+ //
465
+ // A hardcoded favourite also contradicts the scheduling the operator actually chose (flat, so
466
+ // any one effect is a genuine surprise), so it is gone rather than merely reduced.
467
+ const from = pool.length ? pool : kinds;
468
+ const kind = from[(rnd() * from.length) | 0];
469
+ recent.push(kind);
470
+ if (recent.length > 64) recent.splice(0, recent.length - 64);
471
+ return kind;
472
+ }
473
+
474
+ // SOONER (operator, 2026-09-11: "trigger any effects sooner when the board comes to rest,
475
+ // rather than later"): the first effect after a transition lands -- or on a board already
476
+ // still when first drawn -- comes within opts.idleFirst (about a second), and replaces any
477
+ // longer timer left over from before; the ones after it keep opts.idleEvery.
478
+ function scheduleFx(canvas, st, opts, soon = false) {
479
+ if (soon && st.fxTimer) { clearTimeout(st.fxTimer); st.fxTimer = null; }
480
+ if (!opts.idleFx || st.fxTimer || !globalThis.document || typeof setTimeout !== 'function' || reducedMotion()) return;
481
+ const [a, b] = soon ? (opts.idleFirst ?? [800, 1600]) : opts.idleEvery;
482
+ st.fxTimer = setTimeout(() => {
483
+ st.fxTimer = null;
484
+ if (canvas.isConnected === false) return;
485
+ const now = (globalThis.performance && performance.now()) || 0;
486
+ const busy = (st.plan && now < st.plan.settleAt) || st.dirty || !!st.pending;
487
+ if (busy || !canvas.clientWidth) { scheduleFx(canvas, st, opts, soon); return; }
488
+ // a board with a price line gets the effects that follow it; every other board gets the grid
489
+ const onALine = (opts.axes?.line?.length ?? 0) > 1;
490
+ // every effect is switchable (settings.js `effects`): opts.fxKinds is the operator's list, and
491
+ // an empty one means the board rests in peace -- idleFx off is not the only way to say so
492
+ const allowed = Array.isArray(opts.fxKinds) ? new Set(opts.fxKinds) : null;
493
+ const kinds = (onALine ? MARKET_FX : SPACE_FX).filter((k) => !allowed || allowed.has(k));
494
+ if (!kinds.length) return;
495
+ const kind = chooseIdleFx(kinds, st, now, Math.random, opts.fxNoRepeat ?? 12);
496
+ if (!kind) { scheduleFx(canvas, st, opts); return; } // only the pulse is on, and it is still waiting
497
+ startFx(st, kind, now);
498
+ st.wake?.();
499
+ }, a + Math.random() * (b - a));
500
+ st.fxTimer?.unref?.();
501
+ }
502
+
503
+ // Play one idle effect now -- for the demo page and the tests, which cannot
504
+ // wait on a timer. Returns false for a canvas the renderer has not seen.
505
+ export function triggerIdle(canvas, kind = 'ripple') {
506
+ const st = STATE.get(canvas);
507
+ if (!st || !FX_MS[kind]) return false;
508
+ startFx(st, kind, (globalThis.performance && performance.now()) || 0);
509
+ st.wake?.();
510
+ return true;
511
+ }
512
+
513
+ function reducedMotion() {
514
+ try { return !!(globalThis.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches); }
515
+ catch { return false; }
516
+ }
517
+
518
+ // The block template, drawn on the ground as a white grid. This is the
519
+ // reference the whole picture hangs off: one block is `n` x `n` cells at this
520
+ // resolution, so a transaction sitting inside the square is one that fits in
521
+ // the next block and anything beyond it is queued behind. It is drawn FIRST,
522
+ // at z = 0, so every tile stands on it -- and because the tiles are packed on
523
+ // exactly this grid, they line up with it cell for cell.
524
+ // A GROUND WORTH CASTING A SHADOW ON (operator, 2026-09-11: "Can we have a more
525
+ // interesting ground texture. Something that looks higher res and shows the
526
+ // shadows well?"). A lit deck of tiled plates, every piece a solid rgba polygon
527
+ // or stroke laid through the projection, so all of it lies on the sphere:
528
+ // * a slate-teal cell grain -- each cell a fixed shade of its own, lit by the
529
+ // sphere's slope toward the light (upper left, above), so the curve reads
530
+ // and the base is light enough for a shadow to darken (the old floor was
531
+ // near black under a black checker, and a shadow had nothing to take away);
532
+ // * dark hairline seams between cells: the "higher res";
533
+ // * 4x4 plates, each a touch different in tone, bevelled -- lit along the
534
+ // edges facing the light, shaded along the others -- with corner rivets;
535
+ // * on some plates a circuit trace running to a pad.
536
+ // Its geometry cannot change while the view does not, so it is built once into
537
+ // Path2D objects and a frame is a couple of dozen fills; without Path2D (node
538
+ // tests) the same polygons are drawn directly. Batched by colour, the ground is
539
+ // painted without a far-to-near order -- safe because at the shipped curvature
540
+ // (radius several views wide) no part of the surface hides another.
541
+ const GROUND = new Map();
542
+ const GROUND_LIGHT = (() => { const v = [-0.5, 0.5, 1], l = Math.hypot(...v); return v.map((c) => c / l); })();
543
+ function hash2(a, b, salt = 0) {
544
+ let h = (Math.imul(a | 0, 73856093) ^ Math.imul(b | 0, 19349663) ^ Math.imul(salt | 0, 83492791)) >>> 0;
545
+ h ^= h >>> 13; h = Math.imul(h, 0x5bd1e995) >>> 0; h ^= h >>> 15;
546
+ return (h >>> 0) / 4294967296;
547
+ }
548
+ function rgbOf(c) { const m = String(c).match(/rgba?\(([^)]+)\)/); return m ? m[1].split(',').slice(0, 3).map(Number) : [40, 82, 70]; }
549
+
550
+ // a cell's brightness: the sphere's light on it, its plate's tone, its own grain
551
+ export function groundShade(cx, cy, view, opts) {
552
+ const nrm = surfaceNormal(cx + 0.5, cy + 0.5, view);
553
+ const L = GROUND_LIGHT;
554
+ const lit = 1 + 1.6 * (nrm.x * L[0] + nrm.y * L[1] + nrm.z * L[2] - L[2]);
555
+ const plate = opts.gridStep || 4;
556
+ 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));
557
+ }
558
+
559
+ // THE SPHERE REACHES EVERY EDGE (operator, 2026-09-11: "The surface of the sphere is not being
560
+ // drawn high enough. You can see black when there should be at least another grid row"). The
561
+ // panel's rect is in FLAT grid units, but the ground is drawn on the cap: near the middle it rises
562
+ // toward the viewer (up and right on screen), and past the cap it lies `sink` below the flat plane
563
+ // (down and left) -- so a fixed three-unit margin left the far edge short of the top of the panel.
564
+ // Each edge is pushed outward until every point along it projects outside the panel, then one
565
+ // more unit for good measure. Cached per view: it depends on nothing that moves.
566
+ const EXTENT = new Map();
567
+ export function groundExtent(view, vr) {
568
+ const u = view.unit ?? 12, dS = view.oblique?.dy ?? 1;
569
+ 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('|');
570
+ const hit = EXTENT.get(key);
571
+ if (hit) return hit;
572
+ const P = (gx, gy) => project(gx, gy, 0, view);
573
+ // the panel in the board's drawing units (rows flipped: the far edge is the top, y grows downward)
574
+ const L = vr.x0 * u, R = vr.x1 * u, T = -vr.y1 * u * dS, B = -vr.y0 * u * dS;
575
+ 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;
576
+ const any = (a, b, f) => { for (let t = a; t < b; t += 0.5) if (f(t)) return true; return f(b); };
577
+ for (let i = 0; i < 120; i++) {
578
+ let grew = false;
579
+ if (any(X0, X1, (x) => P(x, Y1).y > T)) { Y1++; grew = true; }
580
+ if (any(X0, X1, (x) => P(x, Y0).y < B)) { Y0--; grew = true; }
581
+ if (any(Y0, Y1, (y) => P(X1, y).x < R)) { X1++; grew = true; }
582
+ if (any(Y0, Y1, (y) => P(X0, y).x > L)) { X0--; grew = true; }
583
+ if (!grew) break;
584
+ }
585
+ const ext = { X0: X0 - 1, X1: X1 + 1, Y0: Y0 - 1, Y1: Y1 + 1 };
586
+ if (EXTENT.size > 8) EXTENT.delete(EXTENT.keys().next().value);
587
+ EXTENT.set(key, ext);
588
+ return ext;
589
+ }
590
+
591
+ export function groundLayers(view, opts, X0, X1, Y0, Y1) {
592
+ 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('|');
593
+ const hit = GROUND.get(key);
594
+ if (hit) return hit;
595
+ const P = (gx, gy) => project(gx, gy, 0, view);
596
+ const W = X1 - X0 + 1;
597
+ const lat = new Array(W * (Y1 - Y0 + 1));
598
+ const L = (gx, gy) => lat[(gy - Y0) * W + (gx - X0)] ??= P(gx, gy); // integer lattice points, shared
599
+ const layers = [];
600
+ const layer = (kind, style) => { const l = { kind, ...style, polys: [], lines: [] }; layers.push(l); return l; };
601
+
602
+ // the cells, grouped by quantised shade
603
+ const base = rgbOf(opts.floor);
604
+ const shades = new Map();
605
+ for (let cy = Y0; cy < Y1; cy++) {
606
+ for (let cx = X0; cx < X1; cx++) {
607
+ const q = Math.round(groundShade(cx, cy, view, opts) * 40);
608
+ let l = shades.get(q);
609
+ 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); }
610
+ l.polys.push([L(cx, cy), L(cx + 1, cy), L(cx + 1, cy + 1), L(cx, cy + 1)]);
611
+ }
612
+ }
613
+ // hairline seams between cells (the plate edges are the phosphor lines)
614
+ const plate = opts.gridStep || 4;
615
+ const seams = layer('seam', { stroke: 'rgba(0,0,0,0.32)', lw: 0.7 });
616
+ 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); }
617
+ 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); }
618
+ // the plates: bevels, rivets, traces
619
+ const lit = layer('bevel-lit', { fill: 'rgba(190,255,225,0.10)' });
620
+ const dark = layer('bevel-dark', { fill: 'rgba(0,0,0,0.24)' });
621
+ const rivet = layer('rivet', { fill: 'rgba(0,0,0,0.40)' });
622
+ const glint = layer('rivet-glint', { fill: 'rgba(210,255,235,0.32)' });
623
+ const trace = layer('trace', { fill: 'rgba(110,255,210,0.14)' });
624
+ const pad = layer('pad', { fill: 'rgba(130,255,215,0.24)' });
625
+ const core = layer('pad-core', { fill: 'rgba(0,0,0,0.36)' });
626
+ 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)];
627
+ const bw = 0.16, e = plate;
628
+ for (let py = Math.ceil(Y0 / plate) * plate; py + plate <= Y1; py += plate) {
629
+ for (let px = Math.ceil(X0 / plate) * plate; px + plate <= X1; px += plate) {
630
+ // rows are flipped: the far edge (py + e) is the top of the screen, toward the light
631
+ lit.polys.push([P(px, py), P(px + bw, py + bw), P(px + bw, py + e - bw), P(px, py + e)]); // left
632
+ 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
633
+ 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
634
+ dark.polys.push([P(px + e, py), P(px + e - bw, py + bw), P(px + bw, py + bw), P(px, py)]); // near
635
+ 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]]) {
636
+ rivet.polys.push(sq(px + rx, py + ry, 0.13));
637
+ glint.polys.push(sq(px + rx - 0.04, py + ry + 0.04, 0.055));
638
+ }
639
+ if (hash2(px, py, 3) < 0.4) {
640
+ const r = 1 + Math.floor(hash2(px, py, 4) * (e - 1)), c = 1 + Math.floor(hash2(px, py, 5) * (e - 1));
641
+ let r2 = 1 + Math.floor(hash2(px, py, 6) * (e - 1));
642
+ if (r2 === r) r2 = r > 1 ? r - 1 : r + 1;
643
+ const w = 0.05;
644
+ 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)]);
645
+ 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)]);
646
+ pad.polys.push(sq(px + c, py + r2, 0.2));
647
+ core.polys.push(sq(px + c, py + r2, 0.08));
648
+ }
649
+ }
650
+ }
651
+ if (GROUND.size > 8) GROUND.delete(GROUND.keys().next().value);
652
+ GROUND.set(key, layers);
653
+ return layers;
654
+ }
655
+
656
+ // THE NEON GRID ON THE BOARD (operator, 2026-09-11: "i want to see the green
657
+ // neon grid explicitly drawn within the block bounds. Right now its just
658
+ // drawing the outer border"). The phosphor lines ran over the whole sphere at
659
+ // the ground's own faint strength, so on the lighter deck only the bright outer
660
+ // edge still read. Inside the board the grid is neon now: a thin green line
661
+ // between every pair of cells, a bright line in a wide glow on every plate
662
+ // boundary. Outside it the ground keeps its quieter texture, so the board reads
663
+ // as a lit template set into the deck. On the floor, so cubes cover it: it
664
+ // shows in every gap and round the edges. Built once per view, like the ground.
665
+ export function boardGridLayers(view, opts, n, rows) {
666
+ 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('|');
667
+ const hit = GROUND.get(key);
668
+ if (hit) return hit;
669
+ const P = (gx, gy) => project(gx, gy, 0, view);
670
+ const step = opts.gridStep || 4;
671
+ const col = (i) => { const pts = []; for (let j = 0; j <= rows; j++) pts.push(P(i, j)); return pts; };
672
+ const row = (j) => { const pts = []; for (let i = 0; i <= n; i++) pts.push(P(i, j)); return pts; };
673
+ const cell = { kind: 'neon-cell', stroke: opts.neonCell, lw: 0.8, polys: [], lines: [] };
674
+ const line = { kind: 'neon', stroke: opts.neonLine, lw: 1.3, polys: [], lines: [] };
675
+ for (let i = 1; i < n; i++) (i % step ? cell : line).lines.push(col(i));
676
+ for (let j = 1; j < rows; j++) (j % step ? cell : line).lines.push(row(j));
677
+ const halo = { kind: 'neon-halo', stroke: opts.neonHalo, lw: 11, polys: [], lines: line.lines };
678
+ const glow = { kind: 'neon-glow', stroke: opts.neonGlow, lw: 5, polys: [], lines: line.lines };
679
+ const layers = [cell, halo, glow, line];
680
+ if (GROUND.size > 8) GROUND.delete(GROUND.keys().next().value);
681
+ GROUND.set(key, layers);
682
+ return layers;
683
+ }
684
+
685
+ // LIGHT CYCLES AND DATA PACKETS, drawn over the cubes (see startFx / cyclePath). Each trail
686
+ // is stroked unit stretch by unit stretch -- a wide glow, a hot core, white near the head --
687
+ // brightest at the head and gone a trail-length behind it. A stretch rides at its own height
688
+ // and a change of height at a corner is a vertical wall, so every turn is a right angle, over
689
+ // the blocks as well as across them. The head is a small glowing data block. Plain rgba
690
+ // strokes and fills only: no composite modes, no shadowBlur.
691
+ export function drawCycles(ctx, view, lw) {
692
+ const fx = view.fx;
693
+ if (!fx?.cycles) return;
694
+ const P = (x, y, z) => project(x, y, z, view);
695
+ const line = (a, b, color, alpha, width) => {
696
+ ctx.strokeStyle = `rgba(${color.join(',')},${Math.max(0, Math.min(1, alpha)).toFixed(3)})`;
697
+ ctx.lineWidth = lw * width;
698
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
699
+ };
700
+ const fill = (q, color, alpha) => {
701
+ ctx.fillStyle = `rgba(${color.join(',')},${Math.max(0, Math.min(1, alpha)).toFixed(3)})`;
702
+ 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();
703
+ };
704
+ // DE-RES: the crashed cycle's wall breaks into shards that burst up and out under gravity and
705
+ // fade, with a white flash and an expanding ring where it hit. Deterministic per crash.
706
+ 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)];
707
+ for (const c of fx.cycles) {
708
+ if (c.derez == null || c.derez >= 1 || !c.crash) continue;
709
+ const t = c.derez, fade = 1 - t;
710
+ let seed = ((c.crash.d * 7919 + c.crash.at.x * 131 + c.crash.at.y * 17) >>> 0) || 1;
711
+ const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
712
+ const at = c.crash.at;
713
+ const hz = (c.hs[Math.max(0, Math.min(c.hs.length - 1, c.crash.d - 1))] ?? 0) + 1;
714
+ const ring = [];
715
+ 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)); }
716
+ const loop = (w, rgb, alpha) => {
717
+ ctx.strokeStyle = `rgba(${rgb.join(',')},${Math.max(0, alpha).toFixed(3)})`;
718
+ ctx.lineWidth = lw * w;
719
+ ctx.beginPath(); ring.forEach((p, k) => (k ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); ctx.stroke();
720
+ };
721
+ loop(9, c.color, 0.5 * fade);
722
+ loop(2.4, [255, 255, 255], 0.9 * fade);
723
+ 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));
724
+ const shardFrom = Math.max(0, c.crash.d - 120); // the whole wall, bounded so a long route cannot flood the frame
725
+ for (let k = shardFrom; k < c.crash.d && k < c.pts.length - 1; k++) {
726
+ const a = c.pts[k], b = c.pts[k + 1], h = c.hs[k] ?? 0;
727
+ for (let q = 0; q < 3; q++) {
728
+ const f0 = (q + rnd()) / 3;
729
+ const vx = (rnd() - 0.5) * 6, vy = (rnd() - 0.5) * 6, vz = 2 + rnd() * 6, r = 0.18 + rnd() * 0.22;
730
+ const pt = { x: a.x + (b.x - a.x) * f0 + vx * t, y: a.y + (b.y - a.y) * f0 + vy * t };
731
+ const z = h + 1.5 + vz * t - 4 * t * t;
732
+ fill(sqAt(pt, z, r), c.color, 0.95 * fade);
733
+ fill(sqAt(pt, z, r * 0.45), [255, 255, 255], 0.8 * fade);
734
+ }
735
+ }
736
+ }
737
+ ctx.lineWidth = lw;
738
+ for (const c of fx.cycles) {
739
+ const end = c.pts.length - 1;
740
+ if (!(c.alpha > 0.01) || !(c.d > c.from) || end < 1) continue;
741
+ // in world units; the camera draws height at about a third (oblique.oy), so a 0.95-unit
742
+ // wall came out ~5 px tall and read as a scratch -- 3 units stands ~16 px off the blocks
743
+ const wallH = c.small ? 1.4 : 3;
744
+ const at = (dist) => {
745
+ const k = Math.max(0, Math.min(end - 1, Math.floor(dist)));
746
+ const f = Math.min(1, dist - k), a = c.pts[k], b = c.pts[k + 1];
747
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, k };
748
+ };
749
+ const segs = [];
750
+ for (let k = Math.floor(c.from); k < Math.ceil(c.d) && k < end; k++) {
751
+ const s0 = Math.max(k, c.from), s1 = Math.min(k + 1, c.d);
752
+ if (s1 <= s0) continue;
753
+ const a = at(s0), b = at(s1), h = c.hs[k];
754
+ // a wall that holds: full strength along its length, with the stretch nearest the head
755
+ // brighter still, rather than a 12-unit fade that erased the route behind the rider
756
+ const back = c.d - (s0 + s1) / 2;
757
+ const near = Math.pow(Math.max(0, 1 - back / 6), 1.5); // the hot end, just behind the head
758
+ const bright = Math.min(1, 0.62 + 0.38 * near) * c.alpha;
759
+ 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 });
760
+ 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 });
761
+ }
762
+ // THE LIGHT WALL: a translucent ribbon standing up off the route, bright along its top
763
+ // and glowing along its foot -- the first cut was a bare line and read as a scratch
764
+ // across the blocks, not a light cycle's wall
765
+ for (const g of segs) if (!g.step) fill([g.a, g.b, g.bt, g.at], c.color, 0.42 * g.br);
766
+ for (const g of segs) line(g.a, g.b, c.color, 0.26 * g.br, c.small ? 8 : 14);
767
+ 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);
768
+ 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);
769
+ 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);
770
+ 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);
771
+ // NO CHARGE ON A LIGHT CYCLE (operator, 2026-09-12: "the lightcycles shouldn't have the
772
+ // electricity effect"). It was given the Markets pulse's blue -- puffs, crackle, motes -- along
773
+ // with the lightning ball the same day; on a rider whose whole point is a clean light wall it
774
+ // read as static. The ball keeps it (drawBall): it IS electricity.
775
+ if (c.d < end && c.alpha > 0.3) {
776
+ const hd = at(c.d), hz = (c.hs[hd.k] ?? 0) + wallH * 0.5, r = c.small ? 0.75 : 1.25;
777
+ 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)];
778
+ fill(sq(1.1 * r, hz + 0.3), c.color, 0.22 * c.alpha);
779
+ fill(sq(0.42 * r, hz + 0.45), c.color, 0.95 * c.alpha);
780
+ fill(sq(0.2 * r, hz + 0.5), [255, 255, 255], 0.9 * c.alpha);
781
+ }
782
+ }
783
+ ctx.lineWidth = lw;
784
+ }
785
+
786
+ // THE CHARGE TRAIL (operator, 2026-09-12: "We need the block space energy effect also emit a
787
+ // blue line and dust trail just like the markets view"). What the Markets pulse leaves on the
788
+ // price line, for the board's own riders: the light cycles' wall tops and the lightning ball's
789
+ // trace. `segs` are screen-space stretches just behind a head, each with its `tint` (1 at the
790
+ // head, 0 at the trail's end) and `age` (0..1 the other way). Drawn as the pulse is drawn --
791
+ // an emitter of soft blue puffs behind, a fat electric-blue tube over the stretch, a flickering
792
+ // white-blue core with crackle forks re-rolled every frame, and a spray of hashed motes -- so
793
+ // the two effects are visibly the same energy.
794
+ // `pale`: the lightning ball's charge (operator, 2026-09-14: "The energy ball blue plasma is way too
795
+ // dark. Need to make it way subtler like the ball lightning stuff"). The cloud below is 22 puffs of
796
+ // deep blue (48,110,255) per segment over sixteen segments, which stacks into a dark blue mass with
797
+ // the ball lost in it; the ball asks for the same cloud in the stormball's pale tones and at a
798
+ // fraction of the alpha, so it reads as a haze the ball lights rather than a shadow it drags.
799
+ function chargeTrail(ctx, segs, lw, now, seedBase = 0, pale = false) {
800
+ if (!segs.length) return;
801
+ const body = pale ? '150,200,255' : '48,110,255', core = pale ? '215,238,255' : '120,180,255', dim = pale ? 0.45 : 1;
802
+ for (const g of segs) {
803
+ if (g.tint < 0.04) continue;
804
+ const i = g.i + seedBase;
805
+ const mx = (g.a.x + g.b.x) / 2, my = (g.a.y + g.b.y) / 2;
806
+ // A CLOUD WITH SOMETHING IN IT (operator, 2026-09-13: "the nebula emissions are not
807
+ // substantial enough"). Ten puffs at 0.09 alpha over a 7-21 line-width radius is a haze you
808
+ // have to look for. Now: 22 puffs, half again as wide, at more than double the alpha, and in
809
+ // TWO tones -- a deep blue body with a lighter core drawn over its inner half -- so the cloud
810
+ // has depth rather than being one flat wash. Alpha stays low per puff because the substance
811
+ // comes from LAYERING; a single fat translucent disc reads as a bubble.
812
+ for (let k = 0; k < 22; k++) {
813
+ const a1 = hash01(i * 47 + k * 11 + 3) * Math.PI * 2;
814
+ const spread = lw * (5 + 46 * g.age) * (0.35 + 0.65 * hash01(i * 13 + k * 5 + 29));
815
+ const rad = lw * (9 + 22 * hash01(i * 7 + k * 17 + 61)) * (1 + 1.35 * g.age);
816
+ const al = 0.2 * dim * g.tint * (1 - 0.6 * g.age) * (0.5 + 0.5 * hash01(i * 3 + k * 23 + 97));
817
+ const px = mx + Math.cos(a1) * spread, py = my + Math.sin(a1) * spread;
818
+ ctx.fillStyle = `rgba(${body},${al.toFixed(3)})`;
819
+ ctx.beginPath(); ctx.arc(px, py, rad, 0, Math.PI * 2); ctx.fill();
820
+ if (k % 2 === 0) {
821
+ ctx.fillStyle = `rgba(${core},${(al * 0.7).toFixed(3)})`;
822
+ ctx.beginPath(); ctx.arc(px, py, rad * 0.5, 0, Math.PI * 2); ctx.fill();
823
+ }
824
+ }
825
+ }
826
+ 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(); };
827
+ for (const g of segs) {
828
+ if (g.tint < 0.04) continue;
829
+ line(g, 14 * (1 + 0.8 * g.tint), `rgba(${pale ? '140,200,255' : '30,130,255'},${(0.16 * dim * g.tint).toFixed(3)})`);
830
+ line(g, 5 * (1 + 0.8 * g.tint), `rgba(${pale ? '170,222,255' : '110,200,255'},${(0.7 * g.tint).toFixed(3)})`);
831
+ }
832
+ for (const g of segs) {
833
+ if (g.tint < 0.08) continue;
834
+ const i = g.i + seedBase;
835
+ const flick = 0.55 + 0.45 * Math.abs(Math.sin(now * 0.023 + i * 1.7) * Math.sin(now * 0.041 + i * 0.9));
836
+ line(g, 2.2, `rgba(210,240,255,${(0.9 * g.tint * flick).toFixed(3)})`);
837
+ // LIGHTNING THAT LOOKS LIKE LIGHTNING (operator, 2026-09-13: "the lightning still looks
838
+ // terrible"). What was here scattered 1-3 forks at a uniformly random ANGLE off the wire and
839
+ // let each leg wander +-0.8 rad, which is a random walk, not a discharge: legs doubled back,
840
+ // crossed the wire, and the whole thing read as scribble at a constant width.
841
+ //
842
+ // A real arc has direction and it tapers. So: every fork leaves the wire roughly PERPENDICULAR
843
+ // (+-0.55 rad off the normal, sign picked per fork), holds that heading with only a small
844
+ // per-leg deviation, and is drawn in three passes -- a wide dim halo, the arc, and a hot thin
845
+ // core -- each shorter and brighter than the last, so it comes to a point instead of ending in
846
+ // a stub. Still re-rolled every frame: electrical precisely because it never draws twice.
847
+ const forks = 1 + ((Math.random() * 4 * g.tint) | 0);
848
+ const nx = -(g.b.y - g.a.y), ny = g.b.x - g.a.x;
849
+ const nlen = Math.hypot(nx, ny) || 1;
850
+ for (let k = 0; k < forks; k++) {
851
+ const f0 = Math.random();
852
+ const ox = g.a.x + (g.b.x - g.a.x) * f0, oy = g.a.y + (g.b.y - g.a.y) * f0;
853
+ const side = Math.random() < 0.5 ? 1 : -1;
854
+ const base = Math.atan2((ny / nlen) * side, (nx / nlen) * side) + (Math.random() - 0.5) * 1.1;
855
+ const reach = lw * (10 + Math.random() * 9) * (0.45 + 0.55 * g.tint);
856
+ // SHORT AND JAGGED. Five legs, each shorter than the last, with a hard alternating zig of
857
+ // +-0.9 rad about the heading -- a discharge kinks, it does not curve. The first cut held a
858
+ // heading over four long smooth legs and drew whiskers halfway across the board.
859
+ const legs = 5;
860
+ const pts = [{ x: ox, y: oy }];
861
+ let x = ox, y = oy, ang = base;
862
+ for (let m = 0; m < legs; m++) {
863
+ ang = base + (m % 2 ? -1 : 1) * (0.35 + Math.random() * 0.55) + (Math.random() - 0.5) * 0.3;
864
+ const step = (reach / legs) * (1 - 0.15 * m);
865
+ x += Math.cos(ang) * step; y += Math.sin(ang) * step;
866
+ pts.push({ x, y });
867
+ }
868
+ const arc = (upto, w, col) => {
869
+ ctx.strokeStyle = col; ctx.lineWidth = lw * w;
870
+ ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
871
+ for (let m = 1; m <= upto; m++) ctx.lineTo(pts[m].x, pts[m].y);
872
+ ctx.stroke();
873
+ };
874
+ arc(legs, 2.2, `rgba(90,170,255,${(0.3 * g.tint).toFixed(3)})`);
875
+ arc(legs, 0.9, `rgba(185,230,255,${(0.92 * g.tint).toFixed(3)})`);
876
+ arc(Math.max(1, legs - 1), 0.5, `rgba(245,252,255,${(0.95 * g.tint).toFixed(3)})`);
877
+ }
878
+ // SPARKS, NOT BLOBS (operator, 2026-09-13: "the particles are too fat"). They were discs of
879
+ // 1.4-4.2 line-widths, which at this line width read as a spray of dots rather than a spray of
880
+ // sparks. Halved in radius, doubled in number, and each one now flies FURTHER as it ages, so
881
+ // the trail is a fine mist that thins out instead of a clump of fat circles.
882
+ for (let k = 0; k < 16; k++) {
883
+ const f0 = hash01(i * 31 + k * 7);
884
+ const bx = g.a.x + (g.b.x - g.a.x) * f0, by = g.a.y + (g.b.y - g.a.y) * f0;
885
+ const ang = hash01(i * 17 + k * 13 + 101) * Math.PI * 2;
886
+ const dist = lw * (3 + 52 * g.age) * (0.6 + 0.4 * hash01(i + k * 3 + 7));
887
+ const r = lw * (0.55 + 1.15 * hash01(i * 5 + k + 41)) * (1 - 0.45 * g.age);
888
+ ctx.fillStyle = `rgba(200,236,255,${(0.75 * g.tint * (1 - 0.5 * g.age)).toFixed(3)})`;
889
+ ctx.beginPath(); ctx.arc(bx + Math.cos(ang) * dist, by + Math.sin(ang) * dist, r, 0, Math.PI * 2); ctx.fill();
890
+ }
891
+ }
892
+ ctx.lineWidth = lw;
893
+ }
894
+
895
+ // A FIREWORKS DISPLAY (operator, 2026-09-15: "the fireworks effect doesn't work at all on the
896
+ // market screen", then "Make the fireworks much more impressive with further effects. Make them
897
+ // Arc. instead of just going straight up. Add the nebula smoke effect that fades out as it
898
+ // disperses ... Make a visually stunning fireworks display for the markets page"). The effect
899
+ // only ever LIT tiles -- fxAt's ring over the cubes was the picture on the block board, and on
900
+ // eight rows of candles it was a candle blinking. Every shell is drawn now, on both boards:
901
+ // * the LAUNCH is an arc, not a line: the rocket leaves the floor to one side and climbs a
902
+ // parabola to its burst point, a comet with a glowing tail and exhaust sparks falling behind;
903
+ // * the BURST is a white flash and a shockwave ring racing out and thinning;
904
+ // * the SHELL is one of four kinds by turn -- peony (round, two colours), chrysanthemum (long
905
+ // glittering trails that crackle white late), willow (heavy droop under gravity, long
906
+ // trails), ring (a hoop that keeps its shape as it falls) -- sixty to ninety sparks, each a
907
+ // curved trail of the last few positions, slowing and pulled down, twinkling, fading;
908
+ // * the SMOKE is a nebula: soft gradient blobs that leave the burst outward, growing, drifting
909
+ // up, in the shell's colour gone grey, and fade as they disperse -- on after the sparks;
910
+ // * up to ten shells on the price board, five on the block board (fireworkShells), overlapping,
911
+ // of every size, at every height of the picture, each lighting the candles under it in its
912
+ // own colour. Deterministic in fx.seed, as every effect is, so it replays and can be tested;
913
+ // only the twinkle reads the clock.
914
+ const SHELL_COLS = [[[255, 170, 110], [255, 235, 160]], [[140, 220, 255], [220, 245, 255]], [[220, 160, 255], [255, 200, 240]], [[130, 255, 190], [235, 255, 220]], [[255, 120, 150], [255, 210, 120]], [[255, 215, 90], [255, 250, 210]], [[120, 200, 255], [255, 140, 200]]];
915
+
916
+ // THE SHELLS OF A DISPLAY, from the seed: where and when each bursts, how big, what kind, what
917
+ // colour. Shared by the drawing (drawFireworks) and the lighting (fxNow's heads), so the candles
918
+ // glow in a shell's own colour exactly where and when it bursts. Up to ten on the price board
919
+ // (operator, 2026-09-15: "have more than 1 firework going off at a time. 10 max. Vary the size ...
920
+ // vary the height. Use the entire vertical viewport"), five on the block board; each launches at
921
+ // its own moment in the first half of the run and lives long enough to overlap the next.
922
+ export const FIREWORK_KINDS = ['peony', 'chrysanthemum', 'willow', 'ring', 'crossette', 'strobe']; // no pinwheel: sparks fly straight (2026-09-15)
923
+ export function fireworkShells(seed, gridW, gridH, price, zLo, zHi) {
924
+ const count = price ? 10 : 5, life = price ? 0.28 : 0.33, SMOKE = 1.35;
925
+ const out = [];
926
+ for (let i = 0; i < count; i++) {
927
+ const H = (k) => hash01(seed + i * 131 + k);
928
+ out.push({
929
+ i, life, smoke: SMOKE,
930
+ t0: 0.02 + H(0) * (price ? 0.5 : 0.4),
931
+ bx: 0.08 * gridW + H(1) * 0.84 * gridW, by: H(2) * gridH,
932
+ zc: zLo + (zHi - zLo) * (0.15 + 0.8 * H(3)),
933
+ size: 0.45 + 0.85 * H(4), // of the board's shell radius: small to big
934
+ kind: FIREWORK_KINDS[Math.floor(H(8) * FIREWORK_KINDS.length)],
935
+ cols: SHELL_COLS[Math.floor(H(9) * SHELL_COLS.length)],
936
+ drift: (H(5) < 0.5 ? -1 : 1) * (0.12 + 0.2 * H(6)) * gridW,
937
+ H,
938
+ });
939
+ }
940
+ return out;
941
+ }
942
+ // THE SHELL KINDS, by turn: how the sparks fly. (operator, 2026-09-15: "Make more varied
943
+ // explosions. More effects. Lensflare? Really think stunning to see.")
944
+ // peony round, two colours
945
+ // chrysanthemum long glittering trails that crackle white late
946
+ // willow heavy droop under gravity, the longest trails
947
+ // ring a hoop that keeps its shape as it falls
948
+ // crossette a peony whose sparks each split four ways partway out
949
+ // strobe the sparks blink in unison, white, on a beat
950
+ // pinwheel every spark curls the same way -- a spiral opening out
951
+ const SHELL_KINDS = ['peony', 'chrysanthemum', 'willow', 'ring', 'crossette', 'strobe']; // pinwheel is gone (2026-09-15: "a spiral effect ... defies physics of fireworks")
952
+ // SMOOTH BY LAYERING, NOT BY GRADIENT (operator, 2026-09-15: "gradient visible! We need smooth
953
+ // fills. no gradient shit!" -- then, of the block board: "what is still with the gradient fill
954
+ // shit?!"). createRadialGradient bands on this rasteriser: the rings are plainly visible, and
955
+ // worst on exactly the large soft blobs the effects are made of. Nested flat discs do not band --
956
+ // each is a plain rgba fill and the SUM is the curve.
957
+ //
958
+ // Takes the same `stops` a radial gradient would, so a conversion is mechanical and the colours
959
+ // are unchanged: it walks outward-in, interpolating the stop list, painting N flat rings.
960
+ export function softStops(ctx, x, y, r, stops, N = 0) {
961
+ // THE STEP MUST LAND UNDER A PIXEL, or the cure is the disease (operator, 2026-09-15: "What is
962
+ // with the gradient garbage?" -- of fills I had already converted). Twenty flat discs over a
963
+ // 300px radius is a 15px step: concentric rings, which is exactly the banding a gradient makes,
964
+ // only coarser. The layer count therefore comes from the RADIUS, not from a constant: about one
965
+ // ring per 1.2px, floored so a small sprite is not wasteful and capped so a huge nebula cannot
966
+ // cost thousands of fills.
967
+ if (!N) N = Math.max(16, Math.min(220, Math.round(Math.abs(r) / 1.2)));
968
+ const at = (t) => {
969
+ let a = stops[0], b = stops[stops.length - 1];
970
+ for (let i = 0; i < stops.length - 1; i++) if (t >= stops[i][0] && t <= stops[i + 1][0]) { a = stops[i]; b = stops[i + 1]; break; }
971
+ const span = b[0] - a[0], k = span > 1e-6 ? (t - a[0]) / span : 0;
972
+ const pa = String(a[1]).match(/rgba?\(([^)]+)\)/), pb = String(b[1]).match(/rgba?\(([^)]+)\)/);
973
+ if (!pa || !pb) return String(a[1]);
974
+ const va = pa[1].split(',').map(Number), vb = pb[1].split(',').map(Number);
975
+ const m = (i, d) => (va[i] ?? d) + (((vb[i] ?? d) - (va[i] ?? d)) * k);
976
+ return `rgba(${Math.round(m(0, 0))},${Math.round(m(1, 0))},${Math.round(m(2, 0))},${m(3, 1).toFixed(4)})`;
977
+ };
978
+ // THE DISCS ADD UP (2026-09-15: "far too much solid white" on the Kiosk -- the supernova's pool
979
+ // and glow were opaque discs). Each nested disc was painted at the stop's whole alpha, and a
980
+ // point at radius t lies under EVERY disc of radius >= t: two hundred discs at 0.08 stack to
981
+ // solid. What the caller means by a stop is the opacity of everything painted at that radius,
982
+ // so each disc carries only the INCREMENT -- painted from the outside in, the disc at t adds
983
+ // 1 - (1 - a(t)) / (1 - a(t_outer)), and the cumulative opacity at every radius is exactly a(t).
984
+ const alphaAt = (t) => { const m = at(t).match(/rgba\([^,]+,[^,]+,[^,]+,([^)]+)\)/); return m ? Math.min(0.999, Math.max(0, Number(m[1]))) : 1; };
985
+ let outer = 0; // the cumulative opacity just outside the current disc
986
+ for (let i = N; i >= 1; i--) {
987
+ const t = i / N, want = alphaAt(t);
988
+ const inc = want > outer ? 1 - (1 - want) / (1 - outer) : 0;
989
+ if (want > outer) outer = want;
990
+ if (inc < 0.0005) continue;
991
+ ctx.fillStyle = at(t).replace(/,([^,]+)\)$/, `,${inc.toFixed(4)})`);
992
+ ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r * t), 0, Math.PI * 2); ctx.fill();
993
+ }
994
+ }
995
+
996
+ function drawFireworks(ctx, view, lw) {
997
+ const fx = view.fx;
998
+ if (!fx || fx.kind !== 'firework') return;
999
+ const U = view.unit ?? 8;
1000
+ const P = (x, y, z) => project(x, y, z, view);
1001
+ const line = view.axes?.line;
1002
+ const price = line?.length > 1;
1003
+ // THE WHOLE HEIGHT of the picture: the floor to the chart's top on the price board, the floor
1004
+ // to well above the cubes on the block board
1005
+ const zLo = 1, zHi = price ? (view.axes?.zTop ?? 34) : 16;
1006
+ const gf = Math.min(1, (1 - fx.u) / 0.1); // the run's own fade-out
1007
+ // still a real gradient maker, because gasCloud() takes one as a callback and uses it for the
1008
+ // smoke puffs; every FILL in this function is layered flat discs now (softStops)
1009
+ const gradientOf = (x, y, r, stops) => {
1010
+ const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null;
1011
+ if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
1012
+ for (const [o, col] of stops) g.addColorStop(o, col);
1013
+ return g;
1014
+ };
1015
+ const linearOf = (x0, y0, x1, y1, stops) => {
1016
+ const g = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(x0, y0, x1, y1) : null;
1017
+ if (!g || typeof g.addColorStop !== 'function') return stops[Math.floor(stops.length / 2)][1];
1018
+ for (const [o, col] of stops) g.addColorStop(o, col);
1019
+ return g;
1020
+ };
1021
+ const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
1022
+ const seg = (a, b, col, w) => { ctx.strokeStyle = col; ctx.lineWidth = Math.max(lw * 0.8, w); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); };
1023
+ const now = view.now ?? 0;
1024
+ const cx0 = (view.boardW ?? 0) / 2, cy0 = -(view.boardH ?? 0) / 4;
1025
+ for (const sh of fireworkShells(fx.seed, fx.gridW, fx.gridH, price, zLo, zHi)) {
1026
+ const { H, life, smoke: SMOKE, kind } = sh;
1027
+ const v = (fx.u - sh.t0) / life;
1028
+ if (!(v > 0 && v < SMOKE)) continue;
1029
+ const [col, col2] = sh.cols;
1030
+ const c1 = col.join(','), c2 = col2.join(',');
1031
+ const top = P(sh.bx, sh.by, sh.zc);
1032
+ const R0 = U * boundedRadius(price ? 4.5 : 6, 0.09, fx.gridW) * sh.size; // 7 and 8 until 2026-09-15 ("same with fireworks" -- on the Kiosk's panel ten shells carpeted it); never more than 9% of the board
1033
+ // --- the launch: a parabola from the floor, off to one side, to the burst point
1034
+ const RISE = 0.2;
1035
+ const arc = (f) => P(sh.bx - sh.drift * (1 - f), sh.by, zLo + (sh.zc - zLo) * (1 - (1 - f) * (1 - f)));
1036
+ if (v < RISE) {
1037
+ const f = v / RISE;
1038
+ const head = arc(f);
1039
+ const tail = [];
1040
+ for (let k = 0; k <= 8; k++) tail.push(arc(Math.max(0, f - (k / 8) * 0.22)));
1041
+ for (let k = 0; k < 8; k++) {
1042
+ const a = (1 - k / 8) * gf;
1043
+ seg(tail[k], tail[k + 1], `rgba(${c1},${(0.35 * a).toFixed(3)})`, U * 0.26 * a * sh.size);
1044
+ seg(tail[k], tail[k + 1], `rgba(255,240,210,${(0.9 * a).toFixed(3)})`, U * 0.08 * a);
1045
+ }
1046
+ for (let k = 0; k < 10; k++) {
1047
+ const age = ((f * 6 + k / 10) % 1), back = arc(Math.max(0, f - age * 0.18));
1048
+ const sx = back.x + (H(50 + k) - 0.5) * U * 0.8, sy = back.y + age * U * 1.6;
1049
+ disc(sx, sy, U * 0.06 * (1 - age), `rgba(255,220,160,${(0.8 * (1 - age) * gf).toFixed(3)})`);
1050
+ }
1051
+ softStops(ctx, head.x, head.y, U * 0.32, [[0, `rgba(255,255,255,${gf.toFixed(3)})`], [0.4, `rgba(${c2},${(0.9 * gf).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
1052
+ continue;
1053
+ }
1054
+ const s = Math.min(1, (v - RISE) / (1 - RISE)); // 0 at the burst, 1 when the sparks are out
1055
+ const ss = (v - RISE) / (SMOKE - RISE); // the smoke's own clock, 0..1 over the longer life
1056
+ // --- THE DISSIPATION (operator: "The dissipation effects need to be more detailed"): a nebula
1057
+ // of sixteen lumps, each two gradient discs offset so it is not round, leaving the burst
1058
+ // outward and curling as it goes, the shell's colour gone grey with the colour still in its
1059
+ // heart; embers falling slowly through it, flickering; ash sinking after them; all of it
1060
+ // fading as it disperses and gone before the run ends
1061
+ const smokeRc = [Math.round(col[0] * 0.45 + 95), Math.round(col[1] * 0.45 + 95), Math.round(col[2] * 0.45 + 105)];
1062
+ const smokeCol = smokeRc.join(',');
1063
+ // THE SMOKE IS A VOLUME OF GAS (operator, 2026-09-15: "Use the gas cloud for the fireworks
1064
+ // smoke too"): the supernova's renderer, smaller -- a sphere of soft blobs carried out from
1065
+ // the burst, drifting up, going from the shell's colour toward grey as it disperses
1066
+ {
1067
+ const sr = R0 * (0.3 + 1.1 * (1 - Math.exp(-2.2 * ss))), rise = R0 * 0.3 * ss;
1068
+ // TONED DOWN (operator, 2026-09-15: "the fireworks nebulas are too prominent. they need to be
1069
+ // toned down by half at least. Too opaque for too long"): half the alpha, a steeper fade,
1070
+ // fewer blobs, and the smoke lives 1.35 shell-lives rather than 1.7
1071
+ const sa = 0.45 * Math.pow(1 - ss, 2.2) * (0.4 + 0.6 * Math.min(1, ss * 6)) * gf;
1072
+ if (sa > 0.01) gasCloud(ctx, top.x, top.y - rise, sr, ss, now, fx.seed + sh.i * 977, sa * 1.1, smokeRc, gradientOf, disc, 60, [50, 50, 58]);
1073
+ }
1074
+ if (ss > 0.15) {
1075
+ const em = (ss - 0.15) / 0.85;
1076
+ for (let k = 0; k < 18; k++) {
1077
+ const ang = H(300 + k) * Math.PI * 2, d = R0 * (0.25 + 0.75 * H(320 + k));
1078
+ const x = top.x + Math.cos(ang) * d + Math.sin(now * 0.002 + k) * U * 0.35, y = top.y + Math.sin(ang) * d * 0.7 + R0 * (0.15 + 1.2 * em) * (0.5 + 0.5 * H(340 + k));
1079
+ const flick = 0.5 + 0.5 * Math.sin(now * 0.02 + k * 2.3);
1080
+ const a = (1 - em) * gf;
1081
+ if (a > 0.02) {
1082
+ softStops(ctx, x, y, U * 0.11 * (1 - 0.5 * em), [[0, `rgba(255,255,255,${(0.9 * a * flick).toFixed(3)})`], [0.5, `rgba(${c2},${(0.7 * a * flick).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
1083
+ if (k % 3 === 0) disc(x + U * 0.2, y + U * 0.5 * em, U * 0.03, `rgba(${smokeCol},${(0.6 * a).toFixed(3)})`); // ash
1084
+ }
1085
+ }
1086
+ }
1087
+ if (v >= 1) continue; // only the dissipation is left
1088
+ // --- the burst: a flash, a lens flare, and a shockwave ring racing out and thinning
1089
+ if (s < 0.14) {
1090
+ const f = (1 - s / 0.14) * gf;
1091
+ const r = R0 * 0.5 * (1.3 - f * 0.6);
1092
+ softStops(ctx, top.x, top.y, r, [[0, `rgba(255,255,255,${(0.95 * f).toFixed(3)})`], [0.3, `rgba(${c2},${(0.7 * f).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
1093
+ }
1094
+ if (s < 0.35) {
1095
+ const f = Math.pow(1 - s / 0.35, 1.5) * gf;
1096
+ const L = R0 * 2.6;
1097
+ ctx.strokeStyle = linearOf(top.x - L, top.y, top.x + L, top.y, [[0, `rgba(${c2},0)`], [0.35, `rgba(${c2},${(0.35 * f).toFixed(3)})`], [0.5, `rgba(255,255,255,${(0.9 * f).toFixed(3)})`], [0.65, `rgba(${c2},${(0.35 * f).toFixed(3)})`], [1, `rgba(${c2},0)`]]);
1098
+ ctx.lineWidth = Math.max(lw, U * 0.09); ctx.beginPath(); ctx.moveTo(top.x - L, top.y); ctx.lineTo(top.x + L, top.y); ctx.stroke();
1099
+ ctx.lineWidth = Math.max(lw * 3, U * 0.3); ctx.strokeStyle = linearOf(top.x - L, top.y, top.x + L, top.y, [[0, `rgba(${c1},0)`], [0.5, `rgba(${c1},${(0.25 * f).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
1100
+ ctx.beginPath(); ctx.moveTo(top.x - L * 0.7, top.y); ctx.lineTo(top.x + L * 0.7, top.y); ctx.stroke();
1101
+ const rays = 4, spin = now * 0.0004 + H(11) * Math.PI;
1102
+ for (let k = 0; k < rays; k++) {
1103
+ const ang = spin + (k / rays) * Math.PI, rl = R0 * (0.9 + 0.5 * H(12 + k));
1104
+ const x0 = top.x - Math.cos(ang) * rl, y0 = top.y - Math.sin(ang) * rl, x1 = top.x + Math.cos(ang) * rl, y1 = top.y + Math.sin(ang) * rl;
1105
+ ctx.strokeStyle = linearOf(x0, y0, x1, y1, [[0, 'rgba(255,255,255,0)'], [0.5, `rgba(255,255,255,${(0.5 * f).toFixed(3)})`], [1, 'rgba(255,255,255,0)']]);
1106
+ ctx.lineWidth = Math.max(lw * 0.8, U * 0.03); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
1107
+ }
1108
+ for (let k = 1; k <= 5; k++) {
1109
+ const t = k * 0.35, gx = top.x + (cx0 - top.x) * t, gy = top.y + (cy0 - top.y) * t;
1110
+ const gr = R0 * (0.08 + 0.1 * H(20 + k)) * (0.6 + t), ga = 0.16 * f * (1 - k / 7);
1111
+ softStops(ctx, gx, gy, gr, [[0, `rgba(${k % 2 ? c1 : c2},${(ga * 0.5).toFixed(3)})`], [0.7, `rgba(${k % 2 ? c1 : c2},${ga.toFixed(3)})`], [1, `rgba(${k % 2 ? c1 : c2},0)`]]);
1112
+ }
1113
+ }
1114
+ if (s < 0.4) {
1115
+ const f = s / 0.4;
1116
+ ctx.strokeStyle = `rgba(255,255,255,${(0.55 * (1 - f) * gf).toFixed(3)})`; ctx.lineWidth = Math.max(lw, U * 0.12 * (1 - f));
1117
+ ctx.beginPath(); ctx.arc(top.x, top.y, R0 * 1.25 * f, 0, Math.PI * 2); ctx.stroke();
1118
+ }
1119
+ // --- EMISSIVE LIGHT on the explosion's surroundings (operator: "emissive colored lighting
1120
+ // illuminating the explosion area in the explosion color"): a wide soft pool of the shell's
1121
+ // colour over everything near the burst, strongest at the burst and fading with the shell
1122
+ // (the candles under it glow in the colour too, through fxNow's heads)
1123
+ {
1124
+ const f = Math.pow(1 - s, 1.3) * gf, r = R0 * 2.4;
1125
+ softStops(ctx, top.x, top.y + R0 * 0.2 * s, r, [[0, `rgba(${c1},${(0.32 * f).toFixed(3)})`], [0.4, `rgba(${c1},${(0.14 * f).toFixed(3)})`], [1, `rgba(${c1},0)`]]);
1126
+ }
1127
+ // --- the shell
1128
+ const N = kind === 'ring' ? 60 : kind === 'crossette' ? 36 : 84;
1129
+ const fade = Math.pow(1 - s, kind === 'willow' ? 0.8 : 1.2) * gf;
1130
+ const spreadAt = (sq) => (kind === 'ring' ? Math.min(1, sq * 3) : 1 - Math.exp(-3.4 * sq));
1131
+ const gscale = kind === 'willow' ? 1.9 : kind === 'ring' ? 0.7 : 1;
1132
+ const pos = (k, sq, scale) => {
1133
+ const jitter = kind === 'ring' ? 0.03 : 0.25;
1134
+ const curl = kind === 'pinwheel' ? 2.4 * sq : 0;
1135
+ const ang = (k / N) * Math.PI * 2 + (H(100 + k) - 0.5) * jitter + curl;
1136
+ const len = R0 * (kind === 'ring' ? 0.9 : 0.6 + 0.5 * H(200 + k)) * scale;
1137
+ const sp = spreadAt(sq);
1138
+ return { x: top.x + Math.cos(ang) * len * sp, y: top.y + Math.sin(ang) * len * sp * (kind === 'ring' ? 0.55 : 1) + R0 * 0.9 * gscale * sq * sq };
1139
+ };
1140
+ const blink = kind === 'strobe' ? (Math.sin(now * 0.03) > 0.2 ? 1 : 0.15) : 1;
1141
+ for (let k = 0; k < N; k++) {
1142
+ const inner = k % 3 === 0;
1143
+ const c = inner ? c2 : c1;
1144
+ const scale = inner ? 0.62 : 1;
1145
+ const long = kind === 'chrysanthemum' || kind === 'willow';
1146
+ const steps = long ? 5 : 3, back = long ? 0.22 : 0.12;
1147
+ let prev = null;
1148
+ for (let q = steps; q >= 0; q--) {
1149
+ const p = pos(k, Math.max(0, s - (q / steps) * back), scale);
1150
+ if (prev) {
1151
+ const a = (1 - q / (steps + 1)) * blink;
1152
+ seg(prev, p, `rgba(${c},${(0.28 * fade * a).toFixed(3)})`, U * 0.2 * a * sh.size);
1153
+ seg(prev, p, `rgba(${c},${(0.95 * fade * a).toFixed(3)})`, U * 0.07 * a);
1154
+ }
1155
+ prev = p;
1156
+ }
1157
+ const tw = 0.55 + 0.45 * Math.sin(now * 0.02 + k * 1.7);
1158
+ const pop = kind === 'chrysanthemum' && s > 0.3 && hash01(fx.seed + k * 7 + Math.floor(now / 50)) < 0.12;
1159
+ disc(prev.x, prev.y, U * (pop ? 0.2 : 0.075) * scale, `rgba(255,255,255,${(fade * blink * (pop ? 1 : 0.9 * tw)).toFixed(3)})`);
1160
+ if (kind === 'crossette' && s > 0.42) {
1161
+ const s2 = (s - 0.42) / 0.58, f2 = Math.pow(1 - s2, 1.3) * gf;
1162
+ for (let m = 0; m < 4; m++) {
1163
+ const ang = (m / 4) * Math.PI * 2 + H(400 + k) * 1.5, d = R0 * 0.22 * (1 - Math.exp(-3 * s2));
1164
+ const e = { x: prev.x + Math.cos(ang) * d, y: prev.y + Math.sin(ang) * d + R0 * 0.2 * s2 * s2 };
1165
+ seg(prev, e, `rgba(${c2},${(0.8 * f2).toFixed(3)})`, U * 0.05);
1166
+ disc(e.x, e.y, U * 0.06, `rgba(255,255,255,${(0.9 * f2).toFixed(3)})`);
1167
+ }
1168
+ }
1169
+ }
1170
+ disc(top.x, top.y + R0 * 0.25 * s, U * 0.16 * fade, `rgba(255,255,255,${(0.7 * fade).toFixed(3)})`);
1171
+ }
1172
+ ctx.lineWidth = lw;
1173
+ }
1174
+
1175
+ // THE SCAN IS A LIGHT CURTAIN (operator, 2026-09-15: "Suggest a way to drastically improve the
1176
+ // visuals on the scan line effect. Again, create new render tools" -- then "do it"). The front
1177
+ // was a tight line on the floor that lit each tile for a frame. Now it is a vertical SHEET of
1178
+ // light standing on the board, floor to the chart's top, sweeping across: a hard white core
1179
+ // with soft cyan falloff on both faces, faint raster lines rippling down it on the clock, a bar
1180
+ // of light along its top edge it hangs from with glints at the ends, a glowing foot on the floor
1181
+ // with a green-cyan phosphor tail fading behind it, motes drifting in the beam, sparks thrown up
1182
+ // from the foot, and a thin volumetric haze inside its thickness. The tiles keep lighting through
1183
+ // fxAt as before, and the price line flares white where the curtain crosses it (scanFlare).
1184
+ function curtainEnds(fx, z) {
1185
+ // the front is the set of board points with x*dx + y*dy = p; its ends are where that line
1186
+ // leaves the board, at height z
1187
+ const n = fx.gridW, rows = fx.gridH, p = fxFront(fx);
1188
+ const c0x = n / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dx;
1189
+ const c0y = rows / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dy;
1190
+ const L = Math.hypot(n, rows);
1191
+ let a = null, b = null;
1192
+ for (let i = -60; i <= 60; i++) {
1193
+ const gx = c0x - fx.dy * (L * i) / 60, gy = c0y + fx.dx * (L * i) / 60;
1194
+ if (gx >= -0.01 && gx <= n + 0.01 && gy >= -0.01 && gy <= rows + 0.01) { if (!a) a = { x: gx, y: gy, z }; b = { x: gx, y: gy, z }; }
1195
+ }
1196
+ return a && b && (a.x !== b.x || a.y !== b.y) ? [a, b] : null;
1197
+ }
1198
+ // THE LINE UNDER THE CURTAIN: the stretch the scan is crossing flares white and rings out
1199
+ // either side, so the wire reads as struck by the sheet of light as it passes
1200
+ function scanFlare(ctx, pts, axes, view, lw) {
1201
+ const fx = view.fx, line = axes.line ?? [];
1202
+ if (!fx || fx.dx === 0 || line.length !== pts.length) return;
1203
+ const xf = fxFront(fx) * fx.dx; // the front's x on the board (dy is 0 on the price board)
1204
+ for (let i = 0; i < pts.length - 1; i++) {
1205
+ const mx = (line[i].x + line[i + 1].x) / 2, d = Math.abs(mx - xf);
1206
+ const k = Math.exp(-(d * d) / 6);
1207
+ if (k < 0.03) continue;
1208
+ ctx.strokeStyle = `rgba(160,230,255,${(0.6 * k).toFixed(3)})`; ctx.lineWidth = lw * 14 * k;
1209
+ ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i + 1].x, pts[i + 1].y); ctx.stroke();
1210
+ ctx.strokeStyle = `rgba(255,255,255,${(0.95 * k).toFixed(3)})`; ctx.lineWidth = lw * 4;
1211
+ ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i + 1].x, pts[i + 1].y); ctx.stroke();
1212
+ }
1213
+ }
1214
+ // THE BEAM'S ALTITUDE AND WIDTH, decided once. fxAt lights the swath while buildScene runs, and
1215
+ // drawScanCurtain draws the cone afterwards; if each worked its own numbers out they would drift
1216
+ // apart and the lit blocks would not be the ones under the light.
1217
+ function scanBeamGeom(view) {
1218
+ const price = (view.axes?.line?.length ?? 0) > 1;
1219
+ const top = price ? (view.axes?.zTop ?? 34) : 9;
1220
+ const line = view.axes?.line ?? [];
1221
+ const peak = line.length > 1 ? line.reduce((h, q) => Math.max(h, q.z ?? 0), 0) : top * 0.8;
1222
+ const clearance = Math.max(2.5, top * 0.1);
1223
+ const z = Math.max(peak + 1, Math.min(peak + clearance, top * 0.95));
1224
+ const spread = price ? Math.max(3, Math.min(11, z * 0.32)) : Math.max(2, Math.min(5, z * 0.45));
1225
+ return { price, top, z, spread };
1226
+ }
1227
+
1228
+ function drawScanCurtain(ctx, view, lw) {
1229
+ const fx = view.fx;
1230
+ if (!fx || fx.kind !== 'scan') return;
1231
+ const U = view.unit ?? 8, now = view.now ?? 0;
1232
+ const { price, top } = scanBeamGeom(view);
1233
+ const P = (q) => project(q.x, q.y, q.z, view);
1234
+ // NO FADE WHILE IT IS IN SIGHT (operator, 2026-09-15: "it still fades in and out ... with the
1235
+ // effects properly transitioning before being killed"). fx.amp is the shared envelope every
1236
+ // effect dims by at its start and end -- which for a thing that TRAVELS means it materialises in
1237
+ // the middle of the picture and dissolves there. The craft and its beam now hold full strength
1238
+ // and let POSITION do the transition: the front enters and leaves beyond the panel's edge
1239
+ // (fxFront's margin), so there is nothing on screen to fade.
1240
+ const amp = 1;
1241
+ void fx.amp;
1242
+ // IT MUST LEAVE THE VIEWPORT, NOT BLINK OUT AT THE EDGE (operator, 2026-09-15: "it disappears at
1243
+ // the edge of the screen. Have it move entirely off the viewport before you delete it").
1244
+ // curtainEnds returns null the moment the front stops crossing the board, so everything below --
1245
+ // the craft included -- simply stopped being drawn while it was still in frame. The craft's
1246
+ // position comes from the front itself now, which fxFront carries four units past each edge, so
1247
+ // the saucer flies in from off-board and out the far side under its own power.
1248
+ const fp = fxFront(fx);
1249
+ const fcx = fx.gridW / 2 + (fp - (fx.gridW / 2) * fx.dx - (fx.gridH / 2) * fx.dy) * fx.dx;
1250
+ const fcy = fx.gridH / 2 + (fp - (fx.gridW / 2) * fx.dx - (fx.gridH / 2) * fx.dy) * fx.dy;
1251
+ const ends = curtainEnds(fx, 0);
1252
+ const [A, B] = ends ?? [{ x: fcx, y: fcy, z: 0 }, { x: fcx, y: fcy, z: 0 }];
1253
+
1254
+ // A CONE, NOT A STACK OF SHEETS (operator, 2026-09-15: "that leaning portion and the
1255
+ // cross-hatching is not working. Can we make it a conical beam. instead, and do something
1256
+ // volumetric effect?").
1257
+ //
1258
+ // WHY THE OLD ONE HATCHED. The curtain was twenty-one PARALLEL flat quads (k = -10..10, each
1259
+ // offset k*0.5 along the sweep) with fourteen raster lines drawn across them. Under the oblique
1260
+ // camera parallel sheets project to parallelograms offset by a constant, so their translucent
1261
+ // edges land on near-parallel screen lines and read as a lattice -- a leaning cross-hatch. No
1262
+ // amount of alpha tuning fixes that; the geometry itself is a grid.
1263
+ //
1264
+ // A cone has no parallel edges. Its body is a fan of triangles that all converge on the apex,
1265
+ // so overlap -- and therefore density -- rises smoothly toward the axis on its own. That is the
1266
+ // volumetric part: nothing is faked with a gradient across a flat face, the light is thick where
1267
+ // the geometry is thick. Three nested shells give the falloff depth, a bright pool marks where
1268
+ // it lands, and motes drift inside the volume rather than on a sheet.
1269
+ const mx = fcx, my = fcy; // the front's own centre, on or off the board
1270
+ const half = Math.hypot(B.x - A.x, B.y - A.y) / 2; // reach along the front line
1271
+ const ax = -fx.dy, ay = fx.dx; // unit vector ALONG the front
1272
+ const sx = fx.dx, sy = fx.dy; // unit vector along the SWEEP
1273
+ // PROPORTIONS ARE THE WHOLE EFFECT (operator, 2026-09-15: the first cut "is fucked"). It put the
1274
+ // apex at top*1.5 -- z = 51 on the price board -- over a base barely 5 units across, which draws
1275
+ // 49px wide by 147px tall: a needle, not a beam. A spotlight reads as a spotlight when its height
1276
+ // and the width where it lands are comparable, so the apex sits just under the chart's own top
1277
+ // and the pool is wide enough to look like something landed in it.
1278
+ // ABOVE THE PEAK, not level with it (operator, 2026-09-15: "It needs to fly a bit higher than
1279
+ // the price line peak"). `top` is the chart's axis ceiling, which the line can come close to --
1280
+ // a craft at 0.9 of it flies through the high candles. So the ceiling is the taller of the axis
1281
+ // top and the line's own highest point, and the saucer clears it by 15%.
1282
+ // ABOVE THE PEAK, BUT UNDER THE CEILING (operator, 2026-09-15: "It needs to fly a bit higher
1283
+ // than the price line peak", then -- when I raised it past the chart's own top -- "It's flying at
1284
+ // the top of teh screen. don't do that ... set it lower with some above tolerances").
1285
+ //
1286
+ // Both mistakes were the same one: taking a single bound and scaling it. Too low and the craft
1287
+ // flies through the high candles; too high and it leaves the frame. It wants a clearance over the
1288
+ // line AND a hard ceiling under the chart's top, so it rides above the price wherever the price
1289
+ // happens to be and never reaches the edge.
1290
+ const apex = { x: mx, y: my, z: scanBeamGeom(view).z };
1291
+ const SPREAD = scanBeamGeom(view).spread;
1292
+ const RING = 44;
1293
+ // a point on the base ellipse: `f` scales the shell, `th` runs around it
1294
+ const ring = (th, f) => ({
1295
+ x: mx + Math.cos(th) * half * f * ax + Math.sin(th) * SPREAD * f * sx,
1296
+ y: my + Math.cos(th) * half * f * ay + Math.sin(th) * SPREAD * f * sy,
1297
+ z: 0,
1298
+ });
1299
+ const tri = (a, b, c, fill) => {
1300
+ const p0 = P(a), p1 = P(b), p2 = P(c);
1301
+ ctx.fillStyle = fill;
1302
+ ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.closePath(); ctx.fill();
1303
+ };
1304
+ const seg = (a, b, col, w) => { const p = P(a), q = P(b); ctx.strokeStyle = col; ctx.lineWidth = Math.max(lw * 0.8, w); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); };
1305
+ // SMOOTH BY LAYERING, NOT BY GRADIENT (operator, 2026-09-15: "gradient visible! We need smooth
1306
+ // fills. no gradient shit!"). A radial gradient bands on this rasteriser -- the rings are
1307
+ // plainly visible, and worst on a large fill like the beam's pool. Many nested flat discs do not
1308
+ // band: each is a plain rgba fill and the SUM is the curve. Same doctrine as bloom() in
1309
+ // agents.js, with far more steps because the pool is large.
1310
+ // one ring per ~1.2px, same rule as softStops: a fixed count bands on a large pool
1311
+ const soft = (x, y, r, rgb, a) => {
1312
+ const N = Math.max(16, Math.min(220, Math.round(Math.abs(r) / 1.2)));
1313
+ for (let i = N; i >= 1; i--) {
1314
+ ctx.fillStyle = `rgba(${rgb},${(a / N).toFixed(4)})`;
1315
+ ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r * (i / N)), 0, Math.PI * 2); ctx.fill();
1316
+ }
1317
+ };
1318
+ const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
1319
+
1320
+ // off the board there is nothing to light, so the beam is not drawn -- but the craft still is,
1321
+ // which is what carries it out of the viewport
1322
+ const onBoard = !!ends;
1323
+ // --- the volume: nested cone shells, widest and faintest first
1324
+ // DIMMER (operator, 2026-09-15: "the brightness of the scanner cone in the block space view needs
1325
+ // to be toned down. It's too bright. Actually tone down beam brightness on both"): the shells,
1326
+ // the axis, the pool and the rim all at a little over half of what they were
1327
+ const DIM = 0.55;
1328
+ if (onBoard) for (const [f, a] of [[1, 0.018], [0.88, 0.019], [0.76, 0.021], [0.64, 0.023], [0.53, 0.026], [0.43, 0.029], [0.34, 0.033], [0.26, 0.038], [0.19, 0.044], [0.13, 0.05]]) {
1329
+ const fill = `rgba(120,220,255,${(a * DIM * amp).toFixed(3)})`;
1330
+ for (let i = 0; i < RING; i++) {
1331
+ tri(apex, ring((i / RING) * Math.PI * 2, f), ring(((i + 1) / RING) * Math.PI * 2, f), fill);
1332
+ }
1333
+ }
1334
+ // the hot axis: a thin cone down the middle, and the shaft itself
1335
+ if (onBoard) for (let i = 0; i < RING; i++) {
1336
+ tri(apex, ring((i / RING) * Math.PI * 2, 0.08), ring(((i + 1) / RING) * Math.PI * 2, 0.08), `rgba(235,250,255,${(0.10 * DIM * amp).toFixed(3)})`);
1337
+ }
1338
+
1339
+ // --- where it lands: a pool on the floor, brightest at the axis
1340
+ if (onBoard) {
1341
+ const c = P({ x: mx, y: my, z: 0 });
1342
+ const r = U * Math.max(half, SPREAD) * 1.35;
1343
+ soft(c.x, c.y, r, '150,225,255', 1.5 * DIM * amp);
1344
+ soft(c.x, c.y, r * 0.42, '225,248,255', 1.1 * DIM * amp);
1345
+ }
1346
+ // the rim of the pool, so the cone reads as landing on something
1347
+ if (onBoard) for (let i = 0; i < RING; i++) {
1348
+ seg(ring((i / RING) * Math.PI * 2, 1), ring(((i + 1) / RING) * Math.PI * 2, 1), `rgba(170,235,255,${(0.28 * DIM * amp).toFixed(3)})`, U * 0.05);
1349
+ }
1350
+
1351
+ // --- the source: the UFO, flying the beam (operator, 2026-09-15: "The top-down view in block
1352
+ // space coming from a sphere does not look good. Can we re-purpose the UFO"). A glowing ball
1353
+ // seen from directly above is just a blob -- it has no orientation, so top-down it reads as a
1354
+ // smudge where the light starts. The saucer has a rim and a dome, so it stays a craft at any
1355
+ // camera angle. Same craft the tractor beam flies (agents.js saucer()), so the board has one
1356
+ // ship rather than two unrelated light sources.
1357
+ {
1358
+ const p = P(apex);
1359
+ // (no glow round the craft: operator, 2026-09-15, "remove the light around the UFO. We have
1360
+ // enough lights for that effect")
1361
+ // TOP-DOWN (operator, 2026-09-15: "We need a top-down rendering of the UFO for this scene").
1362
+ // The board's camera looks DOWN, so a dome-and-rim craft drawn edge-on is a smear; from above
1363
+ // a saucer is concentric, and `squash` is the camera's own depth foreshortening so its circles
1364
+ // are the ellipses everything else on the board is drawn in.
1365
+ saucerAbove(ctx, p.x, p.y, U * 1.9, amp, view.oblique?.dy ?? 1, now * 0.0009);
1366
+ }
1367
+
1368
+ // --- motes INSIDE the volume: placed by angle and depth, so they sit in the cone rather than
1369
+ // on a face. Each drifts up the shaft and is reseeded by its own hash, never by Math.random.
1370
+ // ...and only while the beam is on the board (operator, 2026-09-15: "I don't like how the
1371
+ // particles are kept when the scanner is off"): off it, the craft flies alone
1372
+ if (onBoard) for (let k = 0; k < 30; k++) {
1373
+ const H = (q) => hash01(fx.seed + 700 + k * 13 + q);
1374
+ const climb = ((H(1) + now * 0.00007 * (0.5 + H(2))) % 1); // 0 at the floor, 1 at the apex
1375
+ const th = H(3) * Math.PI * 2, rad = Math.sqrt(H(4)) * (1 - climb);
1376
+ const b = ring(th, rad);
1377
+ const q = { x: b.x + (apex.x - b.x) * climb, y: b.y + (apex.y - b.y) * climb, z: apex.z * climb };
1378
+ const p = P(q), tw = 0.5 + 0.5 * Math.sin(now * 0.008 + k * 1.7);
1379
+ disc(p.x, p.y, U * 0.06 * (1 - 0.5 * climb), `rgba(230,250,255,${(0.75 * tw * (1 - climb) * amp).toFixed(3)})`);
1380
+ }
1381
+
1382
+ // --- sparks thrown from where it lands, as before: the beam is doing something to the board
1383
+ const STEP = 160, LIFE = 900, kNow = Math.floor(now / STEP);
1384
+ if (onBoard) for (let k = kNow - Math.ceil(LIFE / STEP); k <= kNow; k++) {
1385
+ if (hash01(fx.seed + k * 5) > 0.5) continue;
1386
+ const age = (now - k * STEP) / LIFE;
1387
+ if (age < 0 || age >= 1) continue;
1388
+ const t = hash01(fx.seed + k * 5 + 1);
1389
+ const q = { x: A.x + (B.x - A.x) * t, y: A.y + (B.y - A.y) * t, z: 0 };
1390
+ const p = P(q), rise = U * 2.2 * age * (1 - age) * 4, drift = (hash01(fx.seed + k * 5 + 2) - 0.5) * U * 1.2 * age;
1391
+ disc(p.x + drift, p.y - rise, U * 0.07 * (1 - age), `rgba(255,255,255,${(0.9 * (1 - age) * amp).toFixed(3)})`);
1392
+ }
1393
+ ctx.lineWidth = lw;
1394
+ }
1395
+
1396
+ // THE BLACK HOLE (operator, 2026-09-15: "Simulate the price chart turning into a black hole",
1397
+ // reference svs.gsfc.nasa.gov/14576 -- NASA's visualisation: an orange accretion disk of hot gas
1398
+ // spiralling in, brighter on the side coming toward us, a photon ring inside it, the far side of
1399
+ // the disk lensed into an arch over the top of the shadow and a thinner one under it, the sky
1400
+ // behind bent into arcs). On the chart:
1401
+ // collapse 0 - 0.22 a point of darkness opens on the price line; the line bends round it
1402
+ // (lensPoints) and the candles nearest are swallowed (fxNow's head hides
1403
+ // them) as the horizon grows
1404
+ // accretion 0.22 - 0.82 the disk: the chart's own colours -- gold, and the candles' green and
1405
+ // red -- spiralling in, Keplerian (faster inside), Doppler-bright on
1406
+ // the approaching side; the far side arched over and under the shadow;
1407
+ // the photon ring; lensed starlight in arcs round the horizon; the
1408
+ // shadow, black, soft-edged
1409
+ // release 0.82 - 1 the hole shrinks to nothing, the disk spins away, the line straightens
1410
+ // and the candles come back
1411
+ // Screen-space, gradients and strokes under transforms, no clip or composite modes.
1412
+ function holeOnScreen(view) {
1413
+ const b = view.fx?.blackhole;
1414
+ if (!b) return null;
1415
+ const c = project(b.x, b.y, b.z, view);
1416
+ const U = view.unit ?? 8;
1417
+ return { c, U, rs: b.rs * U, grow: b.grow };
1418
+ }
1419
+ function lensPoints(pts, view) {
1420
+ const h = holeOnScreen(view);
1421
+ if (!h || h.rs <= 0.5) return pts;
1422
+ const rE = h.rs * 1.9;
1423
+ return pts.map((p) => {
1424
+ const dx = p.x - h.c.x, dy = p.y - h.c.y, r = Math.hypot(dx, dy) || 1e-6;
1425
+ const r2 = Math.sqrt(r * r + rE * rE);
1426
+ return { x: h.c.x + (dx / r) * r2, y: h.c.y + (dy / r) * r2 };
1427
+ });
1428
+ }
1429
+ function drawBlackHole(ctx, view, lw) {
1430
+ const fx = view.fx;
1431
+ if (!fx || fx.kind !== 'blackhole') return;
1432
+ const h = holeOnScreen(view);
1433
+ if (!h || h.rs <= 0.5) return;
1434
+ const { c, U, rs, grow } = h;
1435
+ const now = view.now ?? 0;
1436
+ const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
1437
+ const canT = typeof ctx.save === 'function' && typeof ctx.rotate === 'function' && typeof ctx.scale === 'function';
1438
+ // THE LOOK OF THE REFERENCE (operator, 2026-09-15, with NASA's still of the disk: "Is there any
1439
+ // way we can get it looking closer to this?"): the disk seen well above its plane, not edge-on;
1440
+ // a broad continuous body, white-yellow at its inner edge through orange to a dark red rim that
1441
+ // fades to nothing; FIBROUS -- hundreds of thin streaks spiralling inward over it, sheared by
1442
+ // the Keplerian flow, brightest on the approaching side; the shadow in the middle with a thin
1443
+ // photon ring, a gap, and the far side's thin lensed arch hugging it over the top.
1444
+ // ...then the Interstellar view (operator, with a second still: "need it to look more like this"):
1445
+ // the disk nearly edge-on, a thin band crossing in front of the shadow, the far side thrown
1446
+ // into a great arch over the top and a smaller one beneath
1447
+ const TILT = 0.04, SQUASH = 0.17;
1448
+ const inner = rs * 1.5, outer = rs * 5; // 6.4 horizons at the largest ("twice as large"), then 5 ("obscuring too much")
1449
+ const doppler = (ang) => 0.55 + 0.75 * Math.max(0, Math.cos(ang - Math.PI)); // the left side comes toward us: brightest at ang = pi
1450
+ const H = (k) => hash01(fx.seed + k);
1451
+ softStops(ctx, c.x, c.y, outer * 1.15, [[0, `rgba(255,170,70,${(0.22 * grow).toFixed(3)})`], [0.5, `rgba(255,130,50,${(0.08 * grow).toFixed(3)})`], [1, 'rgba(255,100,40,0)']]);
1452
+ const toScreen = (rr, ang) => {
1453
+ const px = rr * Math.cos(ang), py = rr * Math.sin(ang) * SQUASH;
1454
+ return { x: c.x + px * Math.cos(TILT) - py * Math.sin(TILT), y: c.y + px * Math.sin(TILT) + py * Math.cos(TILT) };
1455
+ };
1456
+ // the fibres: thin streaks spiralling inward, each sheared along the flow, in the disk's plane
1457
+ const streaks = [];
1458
+ for (let k = 0; k < 520; k++) {
1459
+ const hk = (q) => hash01(fx.seed + 400 + k * 13 + q);
1460
+ const r0 = inner + (outer - inner) * Math.pow(hk(1), 1.3);
1461
+ const omega = 0.0016 * Math.pow(inner / r0, 1.5);
1462
+ const a0 = hk(2) * Math.PI * 2 + now * omega;
1463
+ const span = (0.25 + 0.9 * hk(3)) * Math.pow(inner / r0, 0.4), heat = 1 - (r0 - inner) / (outer - inner);
1464
+ streaks.push({ r0, a0, span, heat, w: rs * (0.02 + 0.045 * hk(4)), bright: 0.4 + 0.6 * hk(5), kind: hk(6), k });
1465
+ }
1466
+ const drawStreaks = (far) => {
1467
+ if (!canT) return;
1468
+ ctx.save(); ctx.translate(c.x, c.y); ctx.rotate(TILT); ctx.scale(1, SQUASH); ctx.lineCap = 'round';
1469
+ for (const st of streaks) {
1470
+ const mid = st.a0 + st.span / 2, isFar = Math.sin(mid) < 0;
1471
+ if (isFar !== far) continue;
1472
+ const dop = doppler(mid), heat = st.heat;
1473
+ const col = st.kind < 0.06 ? '120,240,170' : st.kind < 0.12 ? '255,110,110' : heat > 0.7 ? '255,250,225' : heat > 0.35 ? '255,215,120' : '255,120,50';
1474
+ ctx.strokeStyle = `rgba(${col},${(0.7 * grow * st.bright * Math.min(1.2, dop) / 1.2 * (0.35 + 0.65 * heat)).toFixed(3)})`; ctx.lineWidth = st.w * (0.8 + 0.6 * heat);
1475
+ ctx.beginPath();
1476
+ for (let i = 0; i <= 10; i++) { const t = i / 10, rr = st.r0 * (1 - 0.07 * t), ang = st.a0 + st.span * t; const x = rr * Math.cos(ang), y = rr * Math.sin(ang); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }
1477
+ ctx.stroke();
1478
+ }
1479
+ ctx.restore();
1480
+ };
1481
+ // --- THE FAR HALF, LIFTED INTO THE ARCH (operator, 2026-09-15: "the disc above is not merging
1482
+ // with the sides, and looks really bad" -- the arch was a circle drawn round the shadow while
1483
+ // the disk is an ellipse in its own plane, two surfaces that met by accident). The arch IS the
1484
+ // far half of the disk now: every point keeps its x on the disk (rr cos ang) and is raised over
1485
+ // the top by an amount that grows with its radius -- 1.1 horizons at the inner edge, 2.7 at the
1486
+ // outer -- so at the sides, where sin ang -> 0, the arch comes down exactly onto the flat band.
1487
+ // The second image under the hole is the same, mirrored, smaller and dimmer.
1488
+ const lift = (rr) => rs * (1.1 + 1.6 * (rr - inner) / (outer - inner));
1489
+ const farPt = (rr, ang, under) => {
1490
+ // the second image is a MIRROR, x as well as y: it turns with the band above it, not against
1491
+ // (2026-09-15: "the two discs are spinning in opposite directions")
1492
+ // CONTINUOUS INTO THE BAND (2026-09-15: "The transition between the two discs is not steady
1493
+ // or continuous"): the arch's height starts at the flat band's own slope (rr * SQUASH per
1494
+ // radian) and the lift takes over with |sin|^2.4, so the surface bends smoothly up from the
1495
+ // band rather than kinking where the two meet
1496
+ const sn = Math.abs(Math.sin(ang)), top = under ? rs * (1.05 + 0.45 * (rr - inner) / (outer - inner)) : lift(rr);
1497
+ const sx = Math.cos(ang) * rr * (under ? -0.78 : 1), sy = rr * SQUASH * sn + Math.max(0, top - rr * SQUASH) * Math.pow(sn, 2.4);
1498
+ return { x: c.x + sx * Math.cos(TILT) - sy * (under ? 1 : -1) * Math.sin(TILT), y: c.y + sx * Math.sin(TILT) + (under ? sy : -sy) * Math.cos(TILT) };
1499
+ };
1500
+ // ONE SET OF STRIPS FOR THE WHOLE DISK (operator, 2026-09-15, of a hard line across the middle:
1501
+ // "that harsh line does not work"): the near half was a radial gradient in the disk's plane
1502
+ // and the arch forty colour strips, and where they met at the midline the two disagreed. The
1503
+ // near half is the same strips now, laid on the flat ellipse, so the colour at any radius is
1504
+ // the same on both sides of the line and there is no line.
1505
+ const archBody = (mode) => {
1506
+ // 'ring': ONE CLOSED POLYGON PER STRIP round the whole disk -- the flat mapping over the near
1507
+ // half, the lifted mapping over the far -- so there is no interior edge at the midline at all
1508
+ // (2026-09-15: "still see harsh line bisecting" -- two fills sharing an edge draw that edge,
1509
+ // antialiased twice, however well their colours agree). 'under' is the second image alone.
1510
+ const under = mode === 'under';
1511
+ const STRIPS = under ? 16 : 40, dim = under ? 0.55 : 1;
1512
+ const at = (rr, ang) => (under ? farPt(rr, ang, true) : ang <= Math.PI ? toScreen(rr, ang) : farPt(rr, ang, false));
1513
+ const a0 = under ? Math.PI : 0, aSpan = under ? Math.PI : Math.PI * 2, N = under ? 24 : 72;
1514
+ // the second image FADES IN FROM THE MIDLINE (2026-09-15: "Still seeing the gradient" -- its
1515
+ // top edge lay exactly on the disk's midline): it is drawn in sectors, each at an alpha that
1516
+ // rises with |sin| from nothing at the line, so it has no edge there
1517
+ const SECT = under ? N : 1;
1518
+ for (let k = 0; k < STRIPS; k++) {
1519
+ const t0 = k / STRIPS, t1 = (k + 1) / STRIPS, r0 = inner + (outer - inner) * t0, r1 = inner + (outer - inner) * t1, heat = 1 - (t0 + t1) / 2;
1520
+ for (let sct = 0; sct < SECT; sct++) {
1521
+ const s0 = a0 + (aSpan * sct) / SECT, s1 = a0 + (aSpan * (sct + 1)) / SECT, n = under ? 1 : N;
1522
+ const fadeIn = under ? Math.pow(Math.abs(Math.sin((s0 + s1) / 2)), 0.8) : 1;
1523
+ ctx.beginPath();
1524
+ for (let i = 0; i <= n; i++) { const ang = s0 + ((s1 - s0) * i) / n; const p = at(r1, ang); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
1525
+ for (let i = n; i >= 0; i--) { const ang = s0 + ((s1 - s0) * i) / n; const p = at(r0, ang); ctx.lineTo(p.x, p.y); }
1526
+ ctx.closePath();
1527
+ // bright, never brown: the rim stays a saturated orange rather than a dark red at low alpha
1528
+ const R = 255, G = Math.round(95 + 160 * Math.pow(heat, 0.7)), B = Math.round(25 + 200 * Math.pow(heat, 2.2));
1529
+ const alpha = grow * dim * fadeIn * (0.55 + 0.42 * Math.pow(heat, 0.8));
1530
+ // the Doppler side across the strip: a linear gradient, bright left, dark right
1531
+ const dg = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(c.x - outer, 0, c.x + outer, 0) : null;
1532
+ if (dg && typeof dg.addColorStop === 'function') { dg.addColorStop(0, `rgba(${Math.min(255, R)},${Math.min(255, G + 50)},${Math.min(255, B + 70)},${alpha.toFixed(3)})`); dg.addColorStop(0.5, `rgba(${R},${G},${B},${(alpha * 0.85).toFixed(3)})`); dg.addColorStop(1, `rgba(${Math.round(R * 0.7)},${Math.round(G * 0.45)},${Math.round(B * 0.4)},${(alpha * 0.7).toFixed(3)})`); ctx.fillStyle = dg; }
1533
+ else ctx.fillStyle = `rgba(${R},${G},${B},${alpha.toFixed(3)})`;
1534
+ ctx.fill();
1535
+ }
1536
+ }
1537
+ };
1538
+ const archStreaks = (under) => {
1539
+ ctx.lineCap = 'round';
1540
+ for (const st of streaks) {
1541
+ const mid = st.a0 + st.span / 2;
1542
+ if (Math.sin(mid) >= 0) continue; // the far half only
1543
+ const dop = doppler(mid), heat = st.heat;
1544
+ const col = st.kind < 0.06 ? '120,240,170' : st.kind < 0.12 ? '255,110,110' : heat > 0.7 ? '255,250,225' : heat > 0.35 ? '255,215,120' : '255,120,50';
1545
+ const fadeIn = under ? Math.pow(Math.abs(Math.sin(mid)), 0.8) : 1;
1546
+ ctx.strokeStyle = `rgba(${col},${(0.7 * grow * (under ? 0.5 : 1) * fadeIn * st.bright * Math.min(1.2, dop) / 1.2 * (0.35 + 0.65 * heat)).toFixed(3)})`; ctx.lineWidth = Math.max(lw * 0.8, st.w * (0.8 + 0.6 * heat) * 1.6);
1547
+ ctx.beginPath();
1548
+ for (let i = 0; i <= 10; i++) { const t = i / 10, rr = st.r0 * (1 - 0.07 * t), ang = st.a0 + st.span * t; const p = farPt(rr, ang, under); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }
1549
+ ctx.stroke();
1550
+ }
1551
+ };
1552
+ archBody('under'); archStreaks(true); // the second image, under
1553
+ // the shadow, then the whole disk over it -- the far half is lifted clear of the shadow, the
1554
+ // near half crosses in front of it -- then the fibres, then the photon ring over everything
1555
+ softStops(ctx, c.x, c.y, rs * 1.1, [[0, 'rgba(0,0,0,1)'], [0.88, 'rgba(0,0,0,1)'], [1, 'rgba(0,0,0,0)']]);
1556
+ archBody('ring');
1557
+ archStreaks(false);
1558
+ drawStreaks(false);
1559
+ ctx.strokeStyle = `rgba(255,245,215,${(0.95 * grow).toFixed(3)})`; ctx.lineWidth = Math.max(lw, rs * 0.045);
1560
+ ctx.beginPath(); ctx.arc(c.x, c.y, rs * 1.07, 0, Math.PI * 2); ctx.stroke();
1561
+ ctx.strokeStyle = `rgba(255,190,100,${(0.45 * grow).toFixed(3)})`; ctx.lineWidth = Math.max(lw * 2, rs * 0.14);
1562
+ ctx.beginPath(); ctx.arc(c.x, c.y, rs * 1.11, 0, Math.PI * 2); ctx.stroke();
1563
+ // --- lensed starlight: short arcs of white round the horizon, the sky behind bent into rings
1564
+ for (let k = 0; k < 26; k++) {
1565
+ const hk = (q) => hash01(fx.seed + 800 + k * 7 + q);
1566
+ const rr = rs * (1.15 + 1.6 * hk(1)), a0 = hk(2) * Math.PI * 2 + now * 0.00008 * (hk(3) - 0.5), span = 0.08 + 0.35 * hk(4);
1567
+ ctx.strokeStyle = `rgba(255,255,255,${(0.45 * grow * (0.4 + 0.6 * hk(5))).toFixed(3)})`; ctx.lineWidth = Math.max(lw * 0.8, U * 0.03);
1568
+ ctx.beginPath(); ctx.arc(c.x, c.y, rr, a0, a0 + span); ctx.stroke();
1569
+ }
1570
+ ctx.lineWidth = lw;
1571
+ }
1572
+
1573
+ // A STAR'S GLINT (for the supernova's progenitor; 2026-09-15, operator: "The flashing dot as a
1574
+ // supernova looks bad. The star is blue-shifted before it blows up. Can we have a better glint
1575
+ // effect there?"). A soft halo, a hot core, four long diffraction spikes and two shorter
1576
+ // diagonals, each a gradient fading to its tip, all of it twinkling on the clock; `col` is the
1577
+ // star's colour and `f` its strength. Gradients only.
1578
+ function starGlint(ctx, x, y, size, col, f, now, lw) {
1579
+ if (f <= 0.003) return;
1580
+ const c = col.join(','), tw = 0.85 + 0.15 * Math.sin(now * 0.017) * Math.sin(now * 0.031 + 1);
1581
+ const grad = (r, stops) => { const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null; if (!g || typeof g.addColorStop !== 'function') return stops[0][1]; for (const [o, cc] of stops) g.addColorStop(o, cc); return g; };
1582
+ const lin = (x0, y0, x1, y1, stops) => { const g = typeof ctx.createLinearGradient === 'function' ? ctx.createLinearGradient(x0, y0, x1, y1) : null; if (!g || typeof g.addColorStop !== 'function') return stops[1][1]; for (const [o, cc] of stops) g.addColorStop(o, cc); return g; };
1583
+ ctx.fillStyle = grad(size * 3, [[0, `rgba(255,255,255,${(0.9 * f * tw).toFixed(3)})`], [0.12, `rgba(${c},${(0.7 * f * tw).toFixed(3)})`], [0.4, `rgba(${c},${(0.18 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]);
1584
+ ctx.beginPath(); ctx.arc(x, y, size * 3, 0, Math.PI * 2); ctx.fill();
1585
+ const spikes = [[0, 1, 0.09], [Math.PI / 2, 1, 0.09], [Math.PI / 4, 0.45, 0.05], [-Math.PI / 4, 0.45, 0.05]];
1586
+ for (const [ang, len, w] of spikes) {
1587
+ const L = size * 9 * len * tw, x0 = x - Math.cos(ang) * L, y0 = y - Math.sin(ang) * L, x1 = x + Math.cos(ang) * L, y1 = y + Math.sin(ang) * L;
1588
+ ctx.strokeStyle = lin(x0, y0, x1, y1, [[0, `rgba(${c},0)`], [0.5, `rgba(255,255,255,${(0.85 * f * tw).toFixed(3)})`], [1, `rgba(${c},0)`]]);
1589
+ ctx.lineWidth = Math.max(lw * 0.8, size * w); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
1590
+ ctx.strokeStyle = lin(x0, y0, x1, y1, [[0, `rgba(${c},0)`], [0.5, `rgba(${c},${(0.4 * f).toFixed(3)})`], [1, `rgba(${c},0)`]]);
1591
+ ctx.lineWidth = Math.max(lw * 2, size * w * 3.5); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
1592
+ }
1593
+ ctx.fillStyle = `rgba(255,255,255,${(f * tw).toFixed(3)})`; ctx.beginPath(); ctx.arc(x, y, size * 0.45, 0, Math.PI * 2); ctx.fill();
1594
+ }
1595
+
1596
+ // A VOLUME OF GAS (for the supernova's debris, the fireworks' smoke, the pulsar's wind; 2026-09-15).
1597
+ // `n` blobs with fixed places in the unit sphere -- radius biased toward the rim, so the limb reads
1598
+ // brighter, the way a shell does when seen through -- projected with the sphere's depth kept: the
1599
+ // blobs are drawn back to front, the back ones darker, the front ones paler and hotter. The whole
1600
+ // thing expands homologously (every blob's distance in proportion to its own), grows, boils
1601
+ // slowly on the clock, and cools from `rc` toward `dark` as `f` runs 0..1. No strokes: gradients.
1602
+ // ORGANIC, NOT CIRCULAR (operator, 2026-09-15: "Everything is too circular RN. It needs to look
1603
+ // more organic like the video"): the blobs gather in CLUMPS rather than spreading evenly, each is
1604
+ // an ELLIPSE stretched along its own radial line (a streak of gas blown outward) and turned to
1605
+ // it, and the sphere's silhouette is LUMPY -- every blob's radius carries a low-order wobble by
1606
+ // its direction, so the cloud has lobes and bays rather than an outline. `colorAt(front, f, H)`,
1607
+ // if given, decides each blob's colour from its depth, the run and its own hashes.
1608
+ function gasCloud(ctx, cx, cy, shell, f, now, seed, bright, rc, grad, disc, n = 150, dark = [90, 20, 25], colorAt = null, blobScale = 1, stretchMax = 2.4) {
1609
+ const blobs = [];
1610
+ const clumps = Math.max(4, Math.round(n / 14));
1611
+ const cdir = [];
1612
+ for (let q = 0; q < clumps; q++) { const H = (k) => hash01(seed + 900 + q * 17 + k); cdir.push({ th: H(1) * Math.PI * 2, ph: Math.acos(2 * H(2) - 1), w: 0.35 + 0.5 * H(3) }); }
1613
+ const lumpA = hash01(seed + 77) * Math.PI * 2, lumpB = hash01(seed + 78) * Math.PI * 2;
1614
+ for (let k = 0; k < n; k++) {
1615
+ const H = (q) => hash01(seed + 5000 + k * 37 + q);
1616
+ const cl = cdir[Math.floor(H(6) * clumps)];
1617
+ // a gaussian-ish scatter round the clump's direction, wider for some clumps
1618
+ const th = cl.th + (H(2) + H(7) - 1) * cl.w * 1.6, ph = Math.max(0.05, Math.min(Math.PI - 0.05, cl.ph + (H(3) + H(8) - 1) * cl.w * 1.2));
1619
+ const lump = 1 + 0.22 * Math.sin(3 * th + lumpA) * Math.sin(2 * ph + lumpB) + 0.14 * Math.sin(5 * th - lumpB) + 0.1 * Math.cos(4 * ph + lumpA);
1620
+ const rho = (0.3 + 0.7 * Math.sqrt(H(1))) * lump;
1621
+ blobs.push({ x: rho * Math.sin(ph) * Math.cos(th), y: rho * Math.sin(ph) * Math.sin(th), z: rho * Math.cos(ph), s: 0.16 + 0.2 * H(4), w: H(5) * Math.PI * 2, stretch: 1 + (stretchMax - 1) * (0.3 + 0.7 * H(9)), H, k });
1622
+ }
1623
+ blobs.sort((a, b) => a.z - b.z); // back to front
1624
+ const canTransform = typeof ctx.save === 'function' && typeof ctx.rotate === 'function' && typeof ctx.scale === 'function';
1625
+ for (const b of blobs) {
1626
+ const wob = 0.035 * Math.sin(now * 0.0013 + b.w) + 0.02 * Math.sin(now * 0.0029 + b.k);
1627
+ const x = cx + (b.x + wob) * shell, y = cy + (b.y - wob * 0.6) * shell * 0.85;
1628
+ const r = shell * b.s * (0.8 + 0.7 * f) * blobScale;
1629
+ const front = (b.z + 1) / 2; // 0 at the back, 1 at the front
1630
+ let col, core;
1631
+ if (colorAt) { col = colorAt(front, f, b.H); core = col.map((v) => Math.round(v + (255 - v) * 0.45)); }
1632
+ else {
1633
+ const heat = Math.max(0, Math.min(1, 0.5 + 0.55 * front - 0.45 * f));
1634
+ col = rc.map((v, i) => Math.round(dark[i] + (v - dark[i]) * heat));
1635
+ core = col.map((v) => Math.round(v + (255 - v) * 0.5 * heat));
1636
+ }
1637
+ const a = bright * (0.32 + 0.3 * front) * (1 - 0.25 * f);
1638
+ if (a < 0.004) continue;
1639
+ const fill = (rr) => grad(0, 0, rr, [[0, `rgba(${core.join(',')},${a.toFixed(3)})`], [0.45, `rgba(${col.join(',')},${(a * 0.7).toFixed(3)})`], [1, `rgba(${col.join(',')},0)`]]);
1640
+ if (canTransform) {
1641
+ const ang = Math.atan2(y - cy, x - cx); // stretched along its own radial line
1642
+ ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.scale(b.stretch, 1 / Math.sqrt(b.stretch));
1643
+ ctx.fillStyle = grad(0, 0, r, [[0, `rgba(${core.join(',')},${a.toFixed(3)})`], [0.45, `rgba(${col.join(',')},${(a * 0.7).toFixed(3)})`], [1, `rgba(${col.join(',')},0)`]]);
1644
+ ctx.beginPath(); ctx.arc(0, 0, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill();
1645
+ ctx.restore();
1646
+ } else softStops(ctx, x, y, r, [[0, `rgba(${core.join(',')},${a.toFixed(3)})`], [0.45, `rgba(${col.join(',')},${(a * 0.7).toFixed(3)})`], [1, `rgba(${col.join(',')},0)`]]);
1647
+ void fill;
1648
+ }
1649
+ // the limb: a soft brightening at the sphere's edge
1650
+ const R = shell * 1.02;
1651
+ softStops(ctx, cx, cy, R, [[0, `rgba(${rc.join(',')},0)`], [0.78, `rgba(${rc.join(',')},0)`], [0.92, `rgba(${rc.join(',')},${(0.16 * bright).toFixed(3)})`], [1, `rgba(${rc.join(',')},0)`]]);
1652
+ }
1653
+
1654
+ // A SUPERNOVA (operator, 2026-09-15: "build the supernova" -- the solar flare only ever lit a cube
1655
+ // and its neighbours, and on a chart of candles that was one candle blinking). MODELLED ON NASA
1656
+ // GODDARD'S ANIMATION (svs.gsfc.nasa.gov/20413, the operator's reference, 2026-09-15: "Look at
1657
+ // this video of the explosive supernova. Model something similar"): a star, a blinding flash
1658
+ // that whites out the whole picture, a turbulent debris cloud of knots and filaments expanding
1659
+ // and reddening as it dims, and, as the cloud thins, a PULSAR -- a point of light pulsing at the
1660
+ // centre inside a small blue pulsar-wind nebula that grows. Drawn on both boards, on the same
1661
+ // star fxAt lights (fxHash(seed + 7, 8)), whose shockwave throws the candles it crosses:
1662
+ // ignition 0 - 0.12 the star swells white-hot, crackle rising over it
1663
+ // breakout 0.12 - 0.34 the white-out: the whole picture goes white and comes back (no flare)
1664
+ // debris 0.14 - 1 the cloud: a volume of gas (gasCloud), a sphere of soft blobs
1665
+ // carried out by the expansion, white-gold, orange, then deep red,
1666
+ // the shock band at its leading edge
1667
+ // pulsar 0.45 - 1 the point pulsing at the centre, its blue nebula growing
1668
+ // Everything is a function of fx.u, the seed and the clock; the run fades in its last tenth.
1669
+ // a radial-gradient maker and a disc filler bound to a given context (gasCloud takes both)
1670
+ function gradFor(c) {
1671
+ return (x, y, r, stops) => {
1672
+ const g = typeof c.createRadialGradient === 'function' ? c.createRadialGradient(x, y, 0, x, y, r) : null;
1673
+ if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
1674
+ for (const [o, col] of stops) g.addColorStop(o, col);
1675
+ return g;
1676
+ };
1677
+ }
1678
+ function discFor(c) { return (x, y, r, fill) => { c.fillStyle = fill; c.beginPath(); c.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); c.fill(); }; }
1679
+ // a half-size offscreen layer matching the board's canvas and transform, cleared for this frame;
1680
+ // null where there is no real canvas to make one from
1681
+ function plumeLayer(ctx, fx) {
1682
+ const cv = ctx.canvas;
1683
+ if (!cv || !cv.width || typeof ctx.getTransform !== 'function' || typeof ctx.drawImage !== 'function' || typeof document === 'undefined') return null;
1684
+ const w = Math.ceil(cv.width / 2), h = Math.ceil(cv.height / 2);
1685
+ let L = fx._plumes;
1686
+ if (!L || L.canvas.width !== w || L.canvas.height !== h) {
1687
+ const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
1688
+ const c2 = canvas.getContext('2d');
1689
+ if (!c2) return null;
1690
+ L = fx._plumes = { canvas, ctx: c2 };
1691
+ }
1692
+ const m = ctx.getTransform();
1693
+ L.ctx.setTransform(1, 0, 0, 1, 0, 0); L.ctx.clearRect(0, 0, w, h);
1694
+ L.ctx.setTransform(m.a / 2, m.b / 2, m.c / 2, m.d / 2, m.e / 2, m.f / 2);
1695
+ return L;
1696
+ }
1697
+ function drawSupernova(ctx, view, lw) {
1698
+ const fx = view.fx;
1699
+ if (!fx || fx.kind !== 'flare') return;
1700
+ const U = view.unit ?? 8, u = fx.u, now = view.now ?? 0;
1701
+ const line = view.axes?.line, price = line?.length > 1;
1702
+ const sx = fxHash(fx.seed + 7) * fx.gridW, sy = price ? (view.axes.y ?? fx.gridH / 2) : fxHash(fx.seed + 8) * fx.gridH;
1703
+ let sz = 3;
1704
+ if (price) { // the star sits on the price line at that hour
1705
+ let best = line[0]; for (const p of line) if (Math.abs(p.x - sx) < Math.abs(best.x - sx)) best = p;
1706
+ sz = best.z;
1707
+ }
1708
+ const c = project(sx, sy, sz, view);
1709
+ // BOUNDED (operator, 2026-09-15: "supernova and black hole nebula effects take up too much
1710
+ // screen space ... it's obscuring too much"): the shell was sized at half the board and the
1711
+ // white-out was the whole frame, which on the Kiosk's panel was the whole panel; the shell is
1712
+ // about a third of the board now and the flash a soft disc round the star
1713
+ // ...and never more than 7% of the board's width, whatever the board (the Kiosk's block-space
1714
+ // panel is 44 units across and small on screen: at 4.5 units the cloud covered most of it)
1715
+ const R0 = U * boundedRadius(price ? 3.4 : 4.5, 0.07, fx.gridW);
1716
+ const gf = Math.min(1, (1 - u) / 0.1);
1717
+ // still a real gradient maker, because gasCloud() takes one as a callback for its puffs; every
1718
+ // FILL drawn directly by this function is layered flat discs now (softStops)
1719
+ const grad = (x, y, r, stops) => {
1720
+ const g = typeof ctx.createRadialGradient === 'function' ? ctx.createRadialGradient(x, y, 0, x, y, r) : null;
1721
+ if (!g || typeof g.addColorStop !== 'function') return stops[0][1];
1722
+ for (const [o, col] of stops) g.addColorStop(o, col);
1723
+ return g;
1724
+ };
1725
+ const disc = (x, y, r, fill) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, Math.max(0.5, r), 0, Math.PI * 2); ctx.fill(); };
1726
+ const mixc = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
1727
+ // the cloud's colour with the run, as in the reference (operator: "the cloud is white at the
1728
+ // start, shifting towards blue, then purple/violet"): white, blue, then violet
1729
+ const cool = Math.max(0, Math.min(1, (u - 0.14) / 0.86));
1730
+ const rc = cool < 0.3 ? mixc([255, 255, 255], [150, 195, 255], cool / 0.3) : mixc([150, 195, 255], [175, 110, 255], (cool - 0.3) / 0.7);
1731
+ const rcs = rc.join(',');
1732
+ // --- ignition: a white nebula, not a ball (operator, 2026-09-15: "Don't use a white ball visual
1733
+ // at the beginning of the animation. Use white nebula effects"): a small cloud of white gas
1734
+ // gathering and brightening round the star, blue-shifting as the blast nears
1735
+ if (u < 0.14) {
1736
+ const f = Math.min(1, u / 0.12), ease = f * f;
1737
+ const col = [Math.round(255 - 70 * ease), Math.round(255 - 30 * ease), 255];
1738
+ gasCloud(ctx, c.x, c.y, R0 * (0.35 + 0.9 * ease), 0.2, now, fx.seed + 555, 0.25 + 0.75 * ease, col, grad, disc, 70, [120, 150, 230]);
1739
+ }
1740
+ // --- THE DEBRIS CLOUD AS A VOLUME OF GAS (operator, 2026-09-15: "Overall it needs to look
1741
+ // more like the NASA video. I don't think the ejecta/fireworks is working well ... The thin
1742
+ // white lines shifting at the edges does not hold up well"). No lines of any kind. The cloud
1743
+ // is gasCloud: a sphere of soft gradient blobs, each with a fixed place in the unit sphere
1744
+ // (denser toward the rim, so the limb is brighter, as a shell seen through is), carried
1745
+ // outward by the expansion -- every blob at a radius in proportion to its own, as a real
1746
+ // remnant expands -- growing as it goes, the front ones hotter and paler than the ones behind,
1747
+ // the whole cooling white-gold -> orange -> red as it dims, its surface boiling on the clock.
1748
+ const band = (r, w, col, a) => {
1749
+ if (r <= 0 || a <= 0.003) return;
1750
+ const R = r + w;
1751
+ softStops(ctx, c.x, c.y, R, [[0, `rgba(${col},0)`], [Math.max(0, (r - w) / R), `rgba(${col},0)`], [Math.max(0, (r - w * 0.35) / R), `rgba(${col},${a.toFixed(3)})`], [r / R, `rgba(240,245,255,${(a * 0.8).toFixed(3)})`], [Math.min(1, (r + w * 0.5) / R), `rgba(${col},${(a * 0.6).toFixed(3)})`], [1, `rgba(${col},0)`]]);
1752
+ };
1753
+ if (u >= 0.14) {
1754
+ const f = (u - 0.14) / 0.86;
1755
+ const shell = R0 * (0.5 + 4.2 * (1 - Math.exp(-1.3 * f))); // the cloud's radius: slower than the first cut (operator: "the cloud dispersion is too fast")
1756
+ // LINGERING (operator, 2026-09-15: "make the violet nebula linger longer into the blink
1757
+ // phase"): the cloud holds full weight until four fifths of the run -- well into the pulsar's
1758
+ // beat -- and only thins over the last fifth, so the purple stays round the blinking star
1759
+ const bright = (f < 0.08 ? f / 0.08 : f < 0.8 ? 1 : Math.pow(1 - (f - 0.8) / 0.2, 1.2)) * gf;
1760
+ // THE MORPHING (operator, 2026-09-15: "as it shifts from blue to white initially, then the
1761
+ // purples and violets start appearing, as the blue clouds continue to disperse ahead of
1762
+ // everything else"): three clouds. The BLUE one is out in front, expanding fastest and
1763
+ // thinning first; the MAIN one behind it is white at the start, and violet grows into it
1764
+ // patch by patch, each blob turning on its own schedule, until the whole is purple; the
1765
+ // INNER one is the deep violet heart that shows as the others thin.
1766
+ const blueShell = R0 * (0.6 + 4.6 * (1 - Math.exp(-1.9 * f)));
1767
+ const blueBright = bright * 0.7 * Math.pow(1 - f, 1.6);
1768
+ // SMOOTH (operator, 2026-09-15: "We see too much of the radial texture. Lets try to smoothen
1769
+ // that up"): more and larger blobs at a lower weight, barely stretched, so they melt into one
1770
+ // another instead of reading as radial streaks
1771
+ gasCloud(ctx, c.x, c.y, blueShell, f, now, fx.seed + 31337, blueBright * 0.32, [150, 195, 255], grad, disc, 140, [70, 110, 220], (front, ff, H) => {
1772
+ const t = Math.min(1, ff / 0.5);
1773
+ return [Math.round(215 - 65 * t), Math.round(235 - 40 * t), 255].map((v, i) => Math.round(v * (0.75 + 0.25 * front) + (i === 2 ? 0 : 0)));
1774
+ }, 0.85, 1.25);
1775
+ // THIN, NOT SOLID (2026-09-15, the Kiosk: "far too much solid white"): overlapping discs sum
1776
+ // toward opaque however faint each is -- 320 blobs saturated, and 170 at a third still did --
1777
+ // so the cloud is ninety blobs at an eighth of the weight, which peaks near half opacity at the
1778
+ // centre and reads as gas the chart shows through
1779
+ gasCloud(ctx, c.x, c.y, shell, f, now, fx.seed, bright * 0.17, rc, grad, disc, 220, [60, 30, 120], (front, ff, H) => {
1780
+ // SOONER (operator, 2026-09-15: "we need more of the third stage purple and violet nebula
1781
+ // becoming prominent as it starts the blink phase"): the patches turn violet between a tenth
1782
+ // and four tenths of the run, so the cloud is mostly purple by the time the pulsar wakes
1783
+ const start = 0.1 + 0.3 * H(11), on = Math.max(0, Math.min(1, (ff - start) / 0.2)); // when this patch turns violet
1784
+ const white = [225, 235, 255], violet = [170, 105, 255], deep = [95, 45, 170]; // blue-white, not solid white (2026-09-15)
1785
+ const c1 = white.map((v, i) => v + (violet[i] - v) * on);
1786
+ const shade = 0.55 + 0.45 * front;
1787
+ return c1.map((v, i) => Math.round(deep[i] + (v - deep[i]) * shade));
1788
+ }, 0.85, 1.25);
1789
+ if (f > 0.2) {
1790
+ // the violet heart, sooner and heavier (the same request): rising from a fifth of the run,
1791
+ // full by the blink, and a second wider violet cloud behind it that keeps growing
1792
+ const inner = Math.min(1, (f - 0.2) / 0.18);
1793
+ gasCloud(ctx, c.x, c.y, shell * 0.7, f, now, fx.seed + 777, bright * 0.3 * inner, [160, 90, 255], grad, disc, 90, [60, 25, 130], null, 0.9, 1.25);
1794
+ gasCloud(ctx, c.x, c.y, shell * 1.05, f, now, fx.seed + 778, bright * 0.16 * inner, [175, 115, 255], grad, disc, 90, [70, 30, 140], null, 1.1, 1.3);
1795
+ }
1796
+ // and the interior glow: the cloud lit from within
1797
+ softStops(ctx, c.x, c.y, shell * 0.8, [[0, `rgba(${rcs},${(0.1 * bright).toFixed(3)})`], [0.6, `rgba(${rcs},${(0.05 * bright).toFixed(3)})`], [1, `rgba(${rcs},0)`]]);
1798
+ // the shock band at the cloud's leading edge, soft, in the cloud's colour
1799
+ if (u < 0.6) {
1800
+ const fr = (u - 0.14) / 0.46, r = R0 * 4.5 * (1 - Math.pow(1 - fr, 2.2));
1801
+ softStops(ctx, c.x, c.y, r, [[0, 'rgba(235,240,255,0)'], [0.7, `rgba(235,240,255,${(0.05 * (1 - fr) * gf).toFixed(3)})`], [1, `rgba(240,245,255,${(0.14 * (1 - fr) * gf).toFixed(3)})`]]);
1802
+ band(r, R0 * (0.3 + 0.6 * (1 - fr)), rcs, 0.4 * (1 - fr) * gf);
1803
+ }
1804
+ // a wide pool of the cloud's colour on everything near
1805
+ softStops(ctx, c.x, c.y, shell * 1.9, [[0, `rgba(${rcs},${(0.08 * bright).toFixed(3)})`], [1, `rgba(${rcs},0)`]]);
1806
+ }
1807
+ // --- the breakout: the whole picture goes white and comes back
1808
+ if (u >= 0.12 && u < 0.4) {
1809
+ const t = (u - 0.12) / 0.28, f = t < 0.1 ? t / 0.1 : Math.pow(1 - (t - 0.1) / 0.9, 1.5);
1810
+ // THE FLASH ITSELF (operator, 2026-09-15: "It's missing the bright white flash!"): a bright,
1811
+ // brief one -- full white at the heart for an instant, gone in a third of the breakout -- so
1812
+ // it reads as a flash, not as a solid glow that sits on the Kiosk's panel
1813
+ const fl = t < 0.04 ? t / 0.04 : t < 0.12 ? 1 : Math.pow(Math.max(0, 1 - (t - 0.12) / 0.3), 2);
1814
+ // WHITE-HOT (operator, 2026-09-15: "We need to add the white-hot-flash to the explosion
1815
+ // again"): the heart goes fully white and holds for a moment, and a wide veil of light
1816
+ // washes twice as far out for the same instant -- bounded to the star's neighbourhood, so
1817
+ // the Kiosk's panel flashes bright but never sits solid
1818
+ if (fl > 0) {
1819
+ const V = R0 * 8;
1820
+ disc(c.x, c.y, V, grad(c.x, c.y, V, [[0, `rgba(255,255,252,${(0.45 * fl).toFixed(3)})`], [0.4, `rgba(255,252,246,${(0.18 * fl).toFixed(3)})`], [1, 'rgba(255,248,240,0)']]));
1821
+ }
1822
+ // a soft flash round the star, not the whole frame -- and NOT SOLID (operator, 2026-09-15, via
1823
+ // his wife on the Kiosk: "far too much solid white"): a modest disc, at most half opaque at its
1824
+ // heart, thin by three shells out, and the white gas thrown with it at a third of the weight
1825
+ const W = R0 * 4;
1826
+ // PRESENCE BACK (operator, 2026-09-15: "We've removed so much of the initial white nebula
1827
+ // emission that it's lost much of its presence and intensity") -- now that the discs add up
1828
+ // honestly, the flash and the white gas can carry weight again without going solid
1829
+ disc(c.x, c.y, W, grad(c.x, c.y, W, [[0, `rgba(255,255,252,${Math.min(1, 0.3 * f + 1.0 * fl).toFixed(3)})`], [0.35, `rgba(255,252,244,${Math.min(1, 0.15 * f + 0.6 * fl).toFixed(3)})`], [0.7, `rgba(255,248,235,${(0.05 * f + 0.06 * fl).toFixed(3)})`], [1, 'rgba(255,245,230,0)']]));
1830
+ // MORE GAS OFF THE FLASH (operator, 2026-09-15: "Opening white flash needs more dispersing
1831
+ // nebula gas"): two clouds thrown with it -- a dense one at the flash and a wider, thinner
1832
+ // one racing ahead of it -- both flying out faster than the flash fades, so the white
1833
+ // disperses into the dark rather than shrinking back into the star
1834
+ // A BURST OF PLUMES, not two clouds (operator, 2026-09-15: "need much more than two clouds of
1835
+ // white gas in the initial explosion"): the dense cloud at the flash, and the plumes of
1836
+ // white gas thrown from it in every direction (below), each its own cloud flying outward on
1837
+ // its own speed and growing as it goes, the fast ones out ahead and thinning first
1838
+ gasCloud(ctx, c.x, c.y, R0 * (1.0 + 2.2 * t), t, now, fx.seed + 999, 0.1 * f, [255, 255, 255], grad, disc, 80, [200, 210, 240], null, 0.9, 1.25);
1839
+ // (no lens flare here: its turning rays were the "opening rotating glints" the operator had
1840
+ // taken out on 2026-09-15; the white-out alone is the breakout)
1841
+ }
1842
+ // FURTHER AND LONGER (operator, 2026-09-15: "the plumes need to disperse further out and linger
1843
+ // longer ... Double the amount"): thirty-two plumes on their own clock, outliving the flash by
1844
+ // half again -- they reach nine star radii, keep flying after the flash has gone, and only
1845
+ // thin out slowly, so the white gas is still drifting outward when the blue debris rises
1846
+ // AND AGAIN (operator, 2026-09-15: "the white plumes still fade too fast, make them linger
1847
+ // longer. Double the amount again" -- then "Double the white ejecta again. We're getting
1848
+ // closer!"): a hundred and twenty-eight plumes, holding full for the first fifth of
1849
+ // their flight and thinning slowly over the rest, still drifting at nine tenths of the effect
1850
+ if (u >= 0.12 && u < 0.9) {
1851
+ const t = (u - 0.12) / 0.78, g = t < 0.04 ? t / 0.04 : t < 0.2 ? 1 : Math.pow(1 - (t - 0.2) / 0.8, 0.7);
1852
+ // AT HALF RESOLUTION: a hundred and twenty-eight clouds of soft gradient discs is the costliest
1853
+ // thing any effect draws, so the plumes go onto a half-size layer (a quarter of the pixels to
1854
+ // fill) and are blitted up -- gas is blurry to begin with, so nothing is lost; where there is
1855
+ // no offscreen canvas (the tests' stub context) they draw straight onto the board
1856
+ const layer = plumeLayer(ctx, fx), pc = layer ? layer.ctx : ctx;
1857
+ const pgrad = layer ? gradFor(pc) : grad, pdisc = layer ? discFor(pc) : disc;
1858
+ for (let k = 0; k < 128; k++) {
1859
+ const H = (q) => hash01(fx.seed + 4400 + k * 19 + q);
1860
+ const ang = (k / 128) * Math.PI * 2 + (H(1) - 0.5) * 0.2, sp = 0.4 + 1.2 * H(2);
1861
+ const reach = R0 * (0.6 + 9 * t) * sp, ease = 1 - Math.exp(-2.2 * t);
1862
+ const px = c.x + Math.cos(ang) * reach * ease, py = c.y + Math.sin(ang) * 0.75 * reach * ease;
1863
+ const shell = R0 * (0.5 + 2.8 * t) * (0.7 + 0.6 * H(3));
1864
+ gasCloud(pc, px, py, shell, t, now, fx.seed + 5000 + k * 131, 0.09 * g * (1 - 0.3 * sp), [245, 248, 255], pgrad, pdisc, 10, [180, 195, 235], null, 1.3, 1.3);
1865
+ }
1866
+ if (layer) { ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(layer.canvas, 0, 0, ctx.canvas.width, ctx.canvas.height); ctx.restore(); }
1867
+ }
1868
+ // --- the pulsar: as the cloud dims, a point pulsing at the centre, its blue nebula growing
1869
+ if (u >= 0.45) {
1870
+ const f = (u - 0.45) / 0.55, on = Math.min(1, f / 0.1) * gf;
1871
+ const period = 140, ph = ((now % period) / period), pulse = Math.pow(Math.max(0, 1 - Math.abs(ph - 0.5) * 6), 2); // a sharp beat, seven a second (operator: "Pulsar needs to pulse quicker")
1872
+ const neb = R0 * (0.25 + 1.1 * f);
1873
+ // THE WIND NEBULA IS A GAS CLOUD TOO (operator, 2026-09-15: "The pulsar nebula needs the gas
1874
+ // cloud too"): a small blue-white sphere of blobs blown outward from the pulsar, growing with
1875
+ // it, lit from the centre, the pulsar's beat showing in its brightness
1876
+ softStops(ctx, c.x, c.y, neb, [[0, `rgba(190,225,255,${(0.28 * on).toFixed(3)})`], [0.5, `rgba(120,175,255,${(0.12 * on).toFixed(3)})`], [1, 'rgba(80,130,255,0)']]);
1877
+ gasCloud(ctx, c.x, c.y, neb, f, now, fx.seed + 4242, on * (0.75 + 0.25 * pulse), [170, 215, 255], grad, disc, 70, [40, 70, 170]);
1878
+ const rp = R0 * (0.12 + 0.25 * pulse);
1879
+ softStops(ctx, c.x, c.y, rp * 2.4, [[0, `rgba(255,255,255,${((0.5 + 0.5 * pulse) * on).toFixed(3)})`], [0.3, `rgba(200,235,255,${(0.55 * pulse * on).toFixed(3)})`], [1, 'rgba(140,200,255,0)']]);
1880
+ disc(c.x, c.y, rp * 0.5, `rgba(255,255,255,${on.toFixed(3)})`);
1881
+ // the beam: two thin rays sweeping round with the beat
1882
+ const sweep = now * 0.004;
1883
+ for (const side of [0, Math.PI]) {
1884
+ const ang = sweep + side, L = neb * (0.8 + 0.8 * pulse);
1885
+ ctx.strokeStyle = `rgba(220,240,255,${(0.35 * pulse * on).toFixed(3)})`; ctx.lineWidth = Math.max(lw, U * 0.04);
1886
+ ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(c.x + Math.cos(ang) * L, c.y + Math.sin(ang) * L); ctx.stroke();
1887
+ }
1888
+ }
1889
+ ctx.lineWidth = lw;
1890
+ }
1891
+
1892
+ // THE LIGHTNING BALL, over the cubes: the grid line it has traced burning behind it and cooling
1893
+ // over 16 units, a plasma ball of one pale gradient with a white-hot heart, and bolts jumping from it
1894
+ // to the grid crossings round it, new every frame. Plain rgba fills and strokes only.
1895
+ function drawBall(ctx, view, lw) {
1896
+ const b = view.fx?.ball;
1897
+ if (!b) return;
1898
+ const P = (x, y, z) => project(x, y, z, view);
1899
+ const U = view.unit ?? 6;
1900
+ const stroke = (pts, w, col) => {
1901
+ if (pts.length < 2) return;
1902
+ ctx.strokeStyle = col; ctx.lineWidth = lw * w;
1903
+ ctx.beginPath();
1904
+ pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
1905
+ ctx.stroke();
1906
+ };
1907
+ const at = (s) => {
1908
+ 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];
1909
+ return { x: a.x + (c.x - a.x) * f, y: a.y + (c.y - a.y) * f };
1910
+ };
1911
+ const TR = 16, from = Math.max(0, b.d - TR);
1912
+ const charged = [];
1913
+ for (let k = Math.floor(from); k < b.d && k < b.pts.length - 1; k++) {
1914
+ const s0 = Math.max(k, from), s1 = Math.min(k + 1, b.d);
1915
+ if (s1 <= s0) continue;
1916
+ const a = at(s0), c = at(s1), h = b.hs[k] ?? 0;
1917
+ const heat = Math.pow(Math.max(0, 1 - (b.d - (s0 + s1) / 2) / TR), 1.6);
1918
+ const seg = [P(a.x, a.y, h), P(c.x, c.y, h)];
1919
+ // the burn behind it is pale too: a wide wash of the ball's own light, not a dark blue bar
1920
+ stroke(seg, 12, `rgba(150,200,255,${(0.09 * heat).toFixed(3)})`);
1921
+ stroke(seg, 4, `rgba(170,222,255,${(0.5 * heat).toFixed(3)})`);
1922
+ stroke(seg, 1.6, `rgba(235,248,255,${(0.95 * heat).toFixed(3)})`);
1923
+ charged.push({ a: seg[0], b: seg[1], tint: heat, age: 1 - heat, i: k });
1924
+ }
1925
+ // the dust and the crackle behind the ball, the same charge the light cycles and the Markets
1926
+ // pulse carry (chargeTrail)
1927
+ chargeTrail(ctx, charged, lw, view.now ?? 0, 977, true);
1928
+ const c = P(b.x, b.y, b.z);
1929
+ // ONE GRADIENT, NOT STACKED DISCS (operator, 2026-09-14: "The energy ball blue plasma is way too
1930
+ // dark. Need to make it way subtler like the ball lightning stuff"). Five discs of deepening blue
1931
+ // read as a dark blue blot with a dot in it; this is the stormball's recipe -- a white-hot heart
1932
+ // falling off through pale cyan to nothing, one radial gradient -- with the blue kept pale and
1933
+ // thin so the ball is light on the board rather than a shadow over it.
1934
+ const flick = 0.85 + 0.15 * Math.random();
1935
+ const R = 4.2 * U;
1936
+ // layered flat discs, not a radial gradient: this is the biggest soft blob on the board and it
1937
+ // banded visibly (operator, 2026-09-15: "what is still with the gradient fill shit?!")
1938
+ softStops(ctx, c.x, c.y, R, [
1939
+ [0, 'rgba(255,255,255,1)'],
1940
+ [0.1, 'rgba(240,250,255,1)'],
1941
+ [0.22, 'rgba(200,236,255,0.85)'],
1942
+ [0.4, `rgba(170,220,255,${(0.32 * flick).toFixed(3)})`],
1943
+ [0.68, `rgba(160,205,255,${(0.1 * flick).toFixed(3)})`],
1944
+ [1, 'rgba(160,200,255,0)'],
1945
+ ], 26);
1946
+ const z0 = b.z - 0.9;
1947
+ const bolts = 4 + ((Math.random() * 4) | 0);
1948
+ for (let i = 0; i < bolts; i++) {
1949
+ const end = P(Math.round(b.x + (Math.random() - 0.5) * 7), Math.round(b.y + (Math.random() - 0.5) * 7), z0);
1950
+ const pts = [c];
1951
+ for (let s = 1; s < 5; s++) {
1952
+ const f = s / 5;
1953
+ 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 });
1954
+ }
1955
+ pts.push(end);
1956
+ stroke(pts, 5, 'rgba(110,180,255,0.18)');
1957
+ stroke(pts, 1.8, 'rgba(170,220,255,0.7)');
1958
+ stroke(pts, 0.8, 'rgba(255,255,255,0.95)');
1959
+ ctx.fillStyle = 'rgba(200,235,255,0.5)';
1960
+ ctx.beginPath(); ctx.arc(end.x, end.y, 0.35 * U, 0, Math.PI * 2); ctx.fill();
1961
+ }
1962
+ ctx.lineWidth = lw;
1963
+ }
1964
+
1965
+ function drawGround(ctx, layers, lw) {
1966
+ const P2 = typeof Path2D === 'function';
1967
+ const trace = (t, l) => {
1968
+ 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(); }
1969
+ 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); }
1970
+ };
1971
+ for (const l of layers) {
1972
+ if (!l.polys.length && !l.lines.length) continue;
1973
+ if (l.fill) ctx.fillStyle = l.fill;
1974
+ else { ctx.strokeStyle = l.stroke; ctx.lineWidth = lw * (l.lw ?? 1); }
1975
+ if (P2) {
1976
+ if (!l.path) { l.path = new Path2D(); trace(l.path, l); }
1977
+ if (l.fill) ctx.fill(l.path); else ctx.stroke(l.path);
1978
+ } else {
1979
+ ctx.beginPath();
1980
+ trace(ctx, l);
1981
+ if (l.fill) ctx.fill(); else ctx.stroke();
1982
+ }
1983
+ }
1984
+ ctx.lineWidth = lw;
1985
+ }
1986
+
1987
+ function drawGrid(ctx, view, opts, n, blockRows, rows = n) {
1988
+ const P = (gx, gy) => project(gx, gy, 0, view);
1989
+ // on a curved board a grid line is a curve, so it is drawn as a polyline
1990
+ // (one segment every 1.5 units, so a long line on the sphere follows it)
1991
+ const seg = (x0, y0, x1, y1) => {
1992
+ const S = view.dome ? Math.max(8, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / 1.5)) : 1;
1993
+ ctx.beginPath();
1994
+ for (let i = 0; i <= S; i++) {
1995
+ const p = P(x0 + ((x1 - x0) * i) / S, y0 + ((y1 - y0) * i) / S);
1996
+ if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y);
1997
+ }
1998
+ ctx.stroke();
1999
+ };
2000
+ const lw = ctx.lineWidth;
2001
+ // SPACE (the Markets board; operator, 2026-09-11: "make the floor transparent black against a
2002
+ // twinkling star field. Get rid of the texture entirely for the markets page"). No deck, no
2003
+ // plates, no sphere past the board: the board alone, a sheet of translucent black the stars
2004
+ // show through (paintFrame draws them first), with its neon grid and its glowing edge.
2005
+ if (opts.space && opts.floorLine) {
2006
+ // ONE LINE (operator, 2026-09-11: "lets get rid of the green ground grid for the markets view.
2007
+ // Just draw the one single line between the candles and the date/time"): no floor, no grid,
2008
+ // no frame -- a single neon line along the front edge of the board, under the volume and over
2009
+ // the hours.
2010
+ return () => {
2011
+ ctx.strokeStyle = 'rgba(40,255,140,0.07)'; ctx.lineWidth = lw * 12; seg(0, 0, n, 0);
2012
+ ctx.strokeStyle = opts.neonGlow; ctx.lineWidth = lw * 5; seg(0, 0, n, 0);
2013
+ ctx.strokeStyle = opts.gridEdgeColor; ctx.lineWidth = lw * 1.6; seg(0, 0, n, 0);
2014
+ ctx.lineWidth = lw;
2015
+ };
2016
+ }
2017
+ if (opts.space) {
2018
+ const S = 24, ring = [];
2019
+ for (let i = 0; i <= S; i++) ring.push(P((n * i) / S, 0));
2020
+ for (let i = 1; i <= S; i++) ring.push(P(n, (rows * i) / S));
2021
+ for (let i = S - 1; i >= 0; i--) ring.push(P((n * i) / S, rows));
2022
+ for (let i = S - 1; i > 0; i--) ring.push(P(0, (rows * i) / S));
2023
+ ctx.fillStyle = opts.spaceFloor ?? 'rgba(0,0,0,0.62)';
2024
+ ctx.beginPath();
2025
+ ring.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
2026
+ ctx.closePath();
2027
+ ctx.fill();
2028
+ return () => {
2029
+ drawGround(ctx, boardGridLayers(view, opts, n, rows), lw);
2030
+ ctx.strokeStyle = opts.neonGlow;
2031
+ ctx.lineWidth = lw * 6;
2032
+ seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
2033
+ ctx.lineWidth = lw;
2034
+ ctx.strokeStyle = opts.gridEdgeColor;
2035
+ seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
2036
+ };
2037
+ }
2038
+ // The floor: a SURFACE, not a void (operator, 2026-09-11: "make the grid
2039
+ // surface have some texture/non-black area so we can properly see shadows
2040
+ // on the grid"). A dim green was still too near black for a shadow to have
2041
+ // anything to darken. Now a lighter base, a faint checker between cells,
2042
+ // and 4x4 plates in two tones -- floor panels under the phosphor lines. All
2043
+ // solid rgba quads (no pattern fills), each drawn through the projection, so
2044
+ // on a curved board the texture curves with it.
2045
+ const quad = (x0, y0, x1, y1, fill) => {
2046
+ const q = [P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)];
2047
+ ctx.fillStyle = fill;
2048
+ ctx.beginPath();
2049
+ ctx.moveTo(q[0].x, q[0].y);
2050
+ for (let i = 1; i < 4; i++) ctx.lineTo(q[i].x, q[i].y);
2051
+ ctx.closePath();
2052
+ ctx.fill();
2053
+ };
2054
+ // THE SPHERE CARRIES ON PAST THE BOARD (operator, 2026-09-11: "grid surface
2055
+ // fills the panel", "sphere continues rendering off the screen, textured
2056
+ // ... viewport only obviously"). Under the oblique camera the same texture
2057
+ // covers everything the panel can see (view.viewRect, pushed out by
2058
+ // groundExtent until the curve covers it); otherwise it is the board alone.
2059
+ const vr = view.oblique && view.viewRect;
2060
+ const { X0, X1, Y0, Y1 } = vr ? groundExtent(view, vr) : { X0: 0, X1: n, Y0: 0, Y1: rows };
2061
+ drawGround(ctx, groundLayers(view, opts, X0, X1, Y0, Y1), lw);
2062
+ // Phosphor glow: the same lines once wide and faint, then thin and bright.
2063
+ // Two strokes of plain rgba -- no shadowBlur, which a software rasteriser
2064
+ // may skip, and no composite mode.
2065
+ const lines = () => {
2066
+ for (let i = Math.ceil(X0 / opts.gridStep) * opts.gridStep; i <= X1; i += opts.gridStep) seg(i, Y0, i, Y1);
2067
+ for (let i = Math.ceil(Y0 / opts.gridStep) * opts.gridStep; i <= Y1; i += opts.gridStep) seg(X0, i, X1, i);
2068
+ };
2069
+ ctx.strokeStyle = opts.gridGlow;
2070
+ ctx.lineWidth = lw * 5;
2071
+ lines();
2072
+ ctx.lineWidth = lw;
2073
+ ctx.strokeStyle = opts.gridColor;
2074
+ lines();
2075
+ // THE GLOWING LAYER is returned, not drawn: paintFrame lays it after the
2076
+ // shadows and before the cubes, so the neon grid, the board edge, the block
2077
+ // line and the idle-effect marks are light on the floor that no shadow dims
2078
+ // (operator, 2026-09-11: "the grid should be glowing and not affected by
2079
+ // shadows"). Everything above this line is the ground the shadows fall on.
2080
+ return () => {
2081
+ drawGround(ctx, boardGridLayers(view, opts, n, rows), lw);
2082
+ // the edge glows too
2083
+ ctx.strokeStyle = opts.neonGlow;
2084
+ ctx.lineWidth = lw * 6;
2085
+ seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
2086
+ ctx.lineWidth = lw;
2087
+ // the template's own edge, brighter: this is where one block ends
2088
+ // ...traced ON the sphere, edge by edge. It was a straight rectangle between
2089
+ // the four corners -- the only points of the edge that lie on the plane -- so
2090
+ // it floated flat over the bulging surface (operator, 2026-09-11: "the grid
2091
+ // is not snapped to the sphere surface. Everything should snap to the
2092
+ // sphere surface on the grid").
2093
+ ctx.strokeStyle = opts.gridEdgeColor;
2094
+ seg(0, 0, n, 0); seg(n, 0, n, rows); seg(n, rows, 0, rows); seg(0, rows, 0, 0);
2095
+ // the idle effect's mark on the floor, under the blocks it lights
2096
+ const fx = view.fx;
2097
+ const polyline = (pts, w, color) => {
2098
+ if (pts.length < 2) return;
2099
+ ctx.strokeStyle = color;
2100
+ ctx.lineWidth = lw * w;
2101
+ ctx.beginPath();
2102
+ pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
2103
+ ctx.stroke();
2104
+ };
2105
+ if (fx && fx.kind === 'ripple') {
2106
+ const pts = [];
2107
+ for (let i = 0; i <= 96; i++) {
2108
+ const a = (i / 96) * Math.PI * 2;
2109
+ 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))));
2110
+ }
2111
+ polyline(pts, 16, `rgba(80,255,190,${(0.12 * fx.amp).toFixed(3)})`);
2112
+ polyline(pts, 3, `rgba(180,255,230,${(0.45 * fx.amp).toFixed(3)})`);
2113
+ }
2114
+ if (fx && (fx.kind === 'outline' || fx.kind === 'tide')) { // the scan draws its own foot (drawScanCurtain)
2115
+ // the front itself, drawn across the floor where it is
2116
+ const p = fxFront(fx);
2117
+ const L = Math.hypot(n, rows);
2118
+ const c0x = n / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dx;
2119
+ const c0y = rows / 2 + (p - (n / 2) * fx.dx - (rows / 2) * fx.dy) * fx.dy;
2120
+ const pts = [];
2121
+ for (let i = -40; i <= 40; i++) {
2122
+ const gx = c0x - fx.dy * (L * i) / 40, gy = c0y + fx.dx * (L * i) / 40;
2123
+ if (gx >= 0 && gx <= n && gy >= 0 && gy <= rows) pts.push(P(gx, gy));
2124
+ }
2125
+ const col = fx.kind === 'tide' ? '140,255,180' : (fx.kind === 'scan' ? '120,220,255' : '90,230,255');
2126
+ polyline(pts, 18, `rgba(${col},${(0.10 * fx.amp).toFixed(3)})`);
2127
+ polyline(pts, 3, `rgba(${col},${(0.55 * fx.amp).toFixed(3)})`);
2128
+ }
2129
+ if (fx && fx.ball) {
2130
+ const b = fx.ball;
2131
+ 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)']]) {
2132
+ ctx.fillStyle = col;
2133
+ ctx.beginPath();
2134
+ 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); }
2135
+ ctx.closePath();
2136
+ ctx.fill();
2137
+ }
2138
+ }
2139
+ ctx.lineWidth = lw;
2140
+ // one block's worth: everything below this line fits in the next block
2141
+ if (blockRows > 0 && blockRows < n) {
2142
+ ctx.strokeStyle = opts.blockLineColor;
2143
+ seg(0, blockRows, n, blockRows);
2144
+ }
2145
+ ctx.lineWidth = lw;
2146
+ };
2147
+ }
2148
+
2149
+ // THE GRID IS CENTRED ON A SPHERE THAT FILLS THE PANEL (operator, 2026-09-11:
2150
+ // "I want our grid centered within the view-space", "grid surface fills the
2151
+ // panel", "sphere continues rendering off the screen, textured ... viewport
2152
+ // only obviously"). This supersedes "Bottom left should be absolutely flush
2153
+ // with bottom left of the panel" for the oblique camera: the board is centred
2154
+ // with the same margin on opposite sides -- headroom + dome, room for the
2155
+ // tallest cube and for flight -- and drawGrid carries the textured sphere on
2156
+ // past the board to every edge. Still a function of nothing but the fixed
2157
+ // grid and the panel, so the view cannot slide between frames or refreshes.
2158
+ // `rect` is the panel's extent in grid units (x0..x1 across, y0..y1 up).
2159
+ export function obliqueFit(pw, ph, gridW, gridH, opts) {
2160
+ const ob = opts.oblique;
2161
+ const head = ob.headroom + (opts.dome || 0);
2162
+ const u = opts.unit;
2163
+ // anchor 'bottom' (the market chart, facing the board): the board along the bottom with the
2164
+ // headroom above it only, and room on the right for the price labels the axes draw there
2165
+ const low = ob.anchor === 'bottom';
2166
+ const dS = ob.dy ?? 1; // the board's depth as drawn (a lower camera draws it shorter)
2167
+ const extra = low ? 9 : 0;
2168
+ // the strip under the front edge where the axes write the hours: a share of the panel, not a
2169
+ // number of grid units -- in a short panel 2.4 units was ~14 px and the labels were cut off
2170
+ // (operator, 2026-09-11: "The bottom of the chart is getting cut off by the text")
2171
+ const foot = low ? 0.055 * ph : 0;
2172
+ const k = low
2173
+ ? Math.min(pw / ((gridW + extra + 2 * head * ob.ox) * u), (ph - foot) / ((gridH * dS + head * ob.oy + 1.2) * u))
2174
+ : Math.min(pw / ((gridW + 2 * head * ob.ox) * u), ph / ((gridH * dS + 2 * head * ob.oy) * u));
2175
+ const tx = pw / 2 - (k * (gridW + extra) * u) / 2;
2176
+ const ty = low ? ph - foot : ph / 2 + (k * gridH * dS * u) / 2;
2177
+ 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) } };
2178
+ }
2179
+
2180
+ // AXES ON THE BOARD (the market chart; operator: "we can alter the perspective"). The price
2181
+ // levels are lines across the board at the candles' own depth, drawn after the glowing grid and
2182
+ // before the cubes so the candles stand in front of them; the labels go on last, at the right
2183
+ // end, and the hours along the front edge -- every point through the same projection as the
2184
+ // cubes, so a level and a candle at the same price meet. axes = { y, zTop, z: [{ z, label,
2185
+ // color?, strong? }], x: [{ x, label }] }.
2186
+ function drawAxes(ctx, view, axes, n) {
2187
+ const y = axes.y ?? 0;
2188
+ const lw = ctx.lineWidth;
2189
+ 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(); };
2190
+ 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)');
2191
+ for (const t of axes.z ?? []) {
2192
+ const a = project(0, y, t.z, view), b = project(n, y, t.z, view);
2193
+ line(a, b, 6, t.strong ? 'rgba(255,255,255,0.06)' : 'rgba(40,255,140,0.05)');
2194
+ line(a, b, t.strong ? 1.6 : 1, t.strong ? (t.color ?? 'rgba(255,255,255,0.9)') : 'rgba(120,255,190,0.3)');
2195
+ }
2196
+ ctx.lineWidth = lw;
2197
+ }
2198
+
2199
+ // THE PRICE LINE (operator, 2026-09-11: "we need a bright neon yellow line running in 3D showing
2200
+ // the price chart in the Markets display"). The close, hour by hour, at the candles' own depth and
2201
+ // through the same projection, drawn after them in the grid's light style -- a wide faint halo,
2202
+ // a glow, then a bright core -- so it cuts through the scene the way the neon grid does.
2203
+ // ONE CONTINUOUS PIPE, not a run of segments (operator, 2026-09-13: "the yellow price line is
2204
+ // made of line segments. Make it one continuous curved pipe").
2205
+ //
2206
+ // WHAT WAS WRONG. Every layer was stroked as a polyline through the projected closes and, worse,
2207
+ // during the pulse each SEGMENT was stroked separately so it could carry its own colour. Six
2208
+ // layers x forty segments is 240 strokes whose translucent ends overlap at every join: the line
2209
+ // beaded at each candle and the corners came to points. It read as forty things, because it was.
2210
+ //
2211
+ // WHAT IT IS NOW. The closes are a monotonic, evenly-spaced series in x (markets.js builds
2212
+ // `axes.line` at each candle's centre), so they interpolate cleanly: a centripetal-ish
2213
+ // Catmull-Rom through the points, converted to cubic Beziers, gives one smooth curve that passes
2214
+ // exactly through every close -- the data is not smoothed away, only the path between the readings
2215
+ // is. Each layer is then ONE stroke of that curve with round joins and caps, so a layer overlaps
2216
+ // itself nowhere and the whole thing reads as a tube.
2217
+ //
2218
+ // THE TAIL, which is why this needed a gradient. A single stroke cannot change colour along its
2219
+ // length without one, and the pulse's whole point is blue at the head easing back to yellow. The
2220
+ // canvas rules here forbid clip, globalAlpha, composite modes and shadowBlur -- gradients are not
2221
+ // on that list, and charts.js has used them all along. Because x is monotonic, a linear gradient
2222
+ // across the line's x-extent IS distance along the line, so the tint becomes continuous instead of
2223
+ // forty discrete steps.
2224
+ //
2225
+ // AND IT DEGRADES. A context that cannot build a gradient (every recording stub in test/, and any
2226
+ // software rasteriser that refuses) gets a flat colour instead of a throw -- goggles.js learned
2227
+ // that the hard way: "createLinearGradient once turned a green unit suite into a page that painted
2228
+ // nothing". Same doctrine here.
2229
+ function gradientOr(ctx, build, stops, fallback) {
2230
+ try {
2231
+ const g = build();
2232
+ if (!g || typeof g.addColorStop !== 'function') return fallback;
2233
+ for (const [o, c] of stops) g.addColorStop(o, c);
2234
+ return g;
2235
+ } catch { return fallback; }
2236
+ }
2237
+
2238
+ /**
2239
+ * The closes as one smooth curve, traced into `t` (a Path2D or the context itself).
2240
+ *
2241
+ * Catmull-Rom with a tension of 1/6 converted to cubic Beziers. The curve passes THROUGH every
2242
+ * point -- an approximating spline would quietly redraw the prices -- and the ends duplicate the
2243
+ * terminal points so the first and last stretches curve like the rest.
2244
+ */
2245
+ function traceCurve(t, pts) {
2246
+ t.moveTo(pts[0].x, pts[0].y);
2247
+ if (pts.length === 2) { t.lineTo(pts[1].x, pts[1].y); return; }
2248
+ for (let i = 0; i < pts.length - 1; i++) {
2249
+ const p0 = pts[i - 1] ?? pts[i];
2250
+ const p1 = pts[i];
2251
+ const p2 = pts[i + 1];
2252
+ const p3 = pts[i + 2] ?? p2;
2253
+ t.bezierCurveTo(
2254
+ p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
2255
+ p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
2256
+ p2.x, p2.y,
2257
+ );
2258
+ }
2259
+ }
2260
+
2261
+ // The price curve, kept between frames: one entry, because there is one price line on screen.
2262
+ const CURVE = { key: null, path: null };
2263
+
2264
+ /**
2265
+ * Where the pulse's head is, INCLUDING its flight past the end of the line.
2266
+ *
2267
+ * `at` is 0..1 along the line and then beyond. Up to 1 it interpolates the points as before. Past
2268
+ * 1 it continues along the direction of the FINAL SEGMENT -- the last vector -- for `overrun` of
2269
+ * the line's length, fading linearly to nothing. Returns null once it is gone, so the caller draws
2270
+ * nothing rather than drawing something transparent.
2271
+ */
2272
+ function headPoint(pts, at, overrun) {
2273
+ const n = pts.length - 1;
2274
+ if (at < 0) return null;
2275
+ if (at <= 1) {
2276
+ const d = at * n;
2277
+ const k = Math.min(n - 1, Math.floor(d)), fr = d - k;
2278
+ 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 };
2279
+ }
2280
+ const past = at - 1;
2281
+ if (past >= overrun) return null; // faded out before the edge
2282
+ const a = pts[n - 1], b = pts[n];
2283
+ const vx = b.x - a.x, vy = b.y - a.y;
2284
+ const len = Math.hypot(vx, vy) || 1;
2285
+ // the whole line's length, so the overrun is a share of the line and not of one candle
2286
+ let total = 0;
2287
+ for (let i = 0; i < n; i++) total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
2288
+ const travel = past * total;
2289
+ return { x: b.x + (vx / len) * travel, y: b.y + (vy / len) * travel, fade: 1 - past / overrun };
2290
+ }
2291
+
2292
+ // THE PIPE BULGE (operator, 2026-09-14: "Add a new effect on the markets yellow line. Moves along the
2293
+ // yellow bar from left to right. I want it to look like a sphere is moving through the pipe, and the
2294
+ // pipe bulges outward as the sphere moves through the pipe, and contracts back into shape when it
2295
+ // moves onward. Have it cleanly roll in and roll out with no pops, and limit it to within the limits
2296
+ // of the yellow price line").
2297
+ //
2298
+ // SECOND CUT (the same day: "slow it down to half the current speed. The bulge does not look like
2299
+ // it's passing a large sphere being forced through the tube, deforming a spherical bulge as it moves.
2300
+ // Also, make the spawn in and fade out bulge up much sooner on the line, and last longer before it
2301
+ // shrinks. the bulge and shrink effects should take 1 second each at the extents"). The first cut
2302
+ // was a Gaussian swell -- a soft hump, not a ball -- that eased in and out over 18% of the run at each
2303
+ // end. Now:
2304
+ // * the tube's wall is the SHAPE A BALL MAKES in a hose: round over the ball (a circle of radius
2305
+ // R), then an ARC of stretched skin, tangent to the ball and tangent to the tube (bulgeProfile)
2306
+ // * the ball travels the WHOLE line at constant speed, and its size is whatever fits: at the
2307
+ // line's first point the tube is exactly the tube, the ball grows as fast as its own bulge can
2308
+ // stay on the line, is full size for everything between, and shrinks back to exactly the tube
2309
+ // at the last point (bulgeFit). No clock: the size is a function of where the ball is.
2310
+ // The timing went through 1000 ms ramps, 3000 ("time it so the ball shrinks to normal tube size at
2311
+ // the last movement frame"), 500, 100, and then geometry (operator, 2026-09-14: "the bulge is
2312
+ // appearing/disappearing too far from the edge, I want to time it so that it's exactly normal pipe
2313
+ // sized starts and finishes. Have the bulge last as long as possible before it's no longer
2314
+ // discernable"): full for as long as a ball that enters and leaves at the line's ends can be.
2315
+ // The tapering was straight flanks off the ball at 24 degrees (an oval), then 58 (a sphere with a
2316
+ // hard edge: "Now the sphere is too obvious. Can we arc the tapering?"), now a concave arc: the skin
2317
+ // leaving the ball and easing down onto the tube, the way a stretched hose does.
2318
+ const BULGE_FILLET = 1.6; // the skin arc's radius, in ball radii
2319
+ const BULGE_BALL = 4.2; // the ball's radius, in tube radii (of the core)
2320
+
2321
+ // At effect progress u of an `ms`-long run: travel 0..1 along the line. (The size is bulgeFit's.)
2322
+ export function bulgeAt(u, ms = FX_MS.bulge) {
2323
+ return { t: Math.max(0, Math.min(1, u)) };
2324
+ }
2325
+
2326
+ // the arc's geometry: its centre sits rf above the tube wall and R + rf from the ball's centre, so
2327
+ // it touches both
2328
+ function fillet(R, r0) {
2329
+ const rf = R * BULGE_FILLET, cy = r0 + rf;
2330
+ const cx = Math.sqrt((R + rf) * (R + rf) - cy * cy);
2331
+ return { rf, cx, cy, xt: (cx * R) / (R + rf) };
2332
+ }
2333
+
2334
+ // The wall's distance from the axis at d pixels from the ball's centre, for a ball of radius R in a
2335
+ // tube of radius r0. Continuous, with a continuous slope: round over the ball, then the arc, then
2336
+ // the tube.
2337
+ export function bulgeProfile(d, R, r0) {
2338
+ const x = Math.abs(d);
2339
+ if (R <= r0) return r0;
2340
+ const { rf, cx, cy, xt } = fillet(R, r0);
2341
+ if (x <= xt) return Math.sqrt(R * R - x * x);
2342
+ if (x >= cx) return r0;
2343
+ return cy - Math.sqrt(rf * rf - (x - cx) * (x - cx));
2344
+ }
2345
+ // how far from the ball's centre the bulge reaches, in pixels
2346
+ export const bulgeReach = (R, r0) => (R <= r0 ? 0 : fillet(R, r0).cx);
2347
+ // the largest ball, up to Rfull, whose bulge stays within `room` pixels of its centre
2348
+ export function bulgeFit(room, Rfull, r0) {
2349
+ if (room <= 0 || Rfull <= r0) return r0;
2350
+ if (bulgeReach(Rfull, r0) <= room) return Rfull;
2351
+ let lo = r0, hi = Rfull;
2352
+ for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2; if (bulgeReach(mid, r0) <= room) lo = mid; else hi = mid; }
2353
+ return lo;
2354
+ }
2355
+
2356
+ // A point on the drawn curve, and its unit normal: the same Catmull-Rom Beziers traceCurve strokes, so
2357
+ // the swell is laid on exactly the line the eye sees and never separates from it at a candle.
2358
+ function curveAt(pts, s) {
2359
+ const n = pts.length - 1;
2360
+ const d = Math.max(0, Math.min(n, s * n));
2361
+ const i = Math.min(n - 1, Math.floor(d)), t = d - i;
2362
+ const p0 = pts[i - 1] ?? pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] ?? p2;
2363
+ const c1 = { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 };
2364
+ const c2 = { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 };
2365
+ const mt = 1 - t;
2366
+ const x = mt * mt * mt * p1.x + 3 * mt * mt * t * c1.x + 3 * mt * t * t * c2.x + t * t * t * p2.x;
2367
+ const y = mt * mt * mt * p1.y + 3 * mt * mt * t * c1.y + 3 * mt * t * t * c2.y + t * t * t * p2.y;
2368
+ const dx = 3 * mt * mt * (c1.x - p1.x) + 6 * mt * t * (c2.x - c1.x) + 3 * t * t * (p2.x - c2.x);
2369
+ const dy = 3 * mt * mt * (c1.y - p1.y) + 6 * mt * t * (c2.y - c1.y) + 3 * t * t * (p2.y - c2.y);
2370
+ const len = Math.hypot(dx, dy) || 1;
2371
+ return { x, y, nx: -dy / len, ny: dx / len };
2372
+ }
2373
+
2374
+ // GRAVITY (operator, 2026-09-14: "Can we have gravity working against it? Faster to drop down on the
2375
+ // pipe and slower to rise up the pipe?"). The ball's speed along the pipe follows the pipe's slope
2376
+ // on screen: a run downhill (screen y increasing) is quicker, a climb slower, the level pipe at the
2377
+ // base speed. The speed is averaged over a stretch of pipe either side, so the ball carries some
2378
+ // momentum through a candle rather than snapping to each new slope; and the whole trip still takes
2379
+ // the effect's time, so a steep line simply spends more of it climbing than falling.
2380
+ // v = 1 + BULGE_GRAVITY * (dy/ds), held within BULGE_SPEED (dy/ds: +1 straight down, -1 straight up)
2381
+ const BULGE_GRAVITY = 1.6;
2382
+ const BULGE_SPEED = [0.35, 2.6];
2383
+ const BULGE_MOMENTUM = 0.03; // averaged over this much of the line each way
2384
+
2385
+ /**
2386
+ * Where a ball is at time t (0..1) along a line sampled as [{ y, len }] (screen y, and distance along
2387
+ * the line), given gravity: returns a distance in the same units as `len`, 0 at t=0, the full length
2388
+ * at t=1, never going backwards.
2389
+ */
2390
+ export function bulgeTravel(table) {
2391
+ const n = table.length - 1;
2392
+ if (n < 1) return () => 0;
2393
+ const total = table[n].len;
2394
+ const raw = [];
2395
+ for (let k = 0; k < n; k++) {
2396
+ const a = table[k], b = table[k + 1], ds = b.len - a.len;
2397
+ const slope = ds > 0 ? Math.max(-1, Math.min(1, (b.y - a.y) / ds)) : 0;
2398
+ raw.push(Math.max(BULGE_SPEED[0], Math.min(BULGE_SPEED[1], 1 + BULGE_GRAVITY * slope)));
2399
+ }
2400
+ const win = Math.max(0, Math.round(n * BULGE_MOMENTUM));
2401
+ const T = [0];
2402
+ for (let k = 0; k < n; k++) {
2403
+ let sum = 0, cnt = 0;
2404
+ for (let j = Math.max(0, k - win); j <= Math.min(n - 1, k + win); j++) { sum += raw[j]; cnt++; }
2405
+ T.push(T[k] + (table[k + 1].len - table[k].len) / (sum / cnt));
2406
+ }
2407
+ const span = T[n] || 1;
2408
+ return (t) => {
2409
+ const want = Math.max(0, Math.min(1, t)) * span;
2410
+ let lo = 0, hi = n;
2411
+ while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (T[mid] < want) lo = mid; else hi = mid; }
2412
+ const f = T[hi] > T[lo] ? (want - T[lo]) / (T[hi] - T[lo]) : 0;
2413
+ return Math.min(total, table[lo].len + (table[hi].len - table[lo].len) * f);
2414
+ };
2415
+ }
2416
+
2417
+ // The curve by arc length, sampled once per draw: `at(px)` is the point that far along the line,
2418
+ // `travel(t)` how far along a ball under gravity has got at time t (0..1).
2419
+ function curveByLength(pts, samples = 400) {
2420
+ const table = [{ s: 0, len: 0, ...curveAt(pts, 0) }];
2421
+ for (let k = 1; k <= samples; k++) {
2422
+ const p = curveAt(pts, k / samples), q = table[k - 1];
2423
+ table.push({ s: k / samples, len: q.len + Math.hypot(p.x - q.x, p.y - q.y), ...p });
2424
+ }
2425
+ const total = table[samples].len;
2426
+ const at = (px) => {
2427
+ const L = Math.max(0, Math.min(total, px));
2428
+ let lo = 0, hi = samples;
2429
+ while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (table[mid].len < L) lo = mid; else hi = mid; }
2430
+ const a = table[lo], b = table[hi], f = b.len > a.len ? (L - a.len) / (b.len - a.len) : 0;
2431
+ // never exactly 1: curveAt's last segment is pts[n-1]..pts[n], and s = 1 would ask for pts[n+1]
2432
+ // (the pulse's head bulge sits at the very end of the line once the head has run off it)
2433
+ return curveAt(pts, Math.min(0.999999, a.s + (b.s - a.s) * f));
2434
+ };
2435
+ // the length along the curve at parameter s (0..1 by point index, as headPoint and curveAt count):
2436
+ // the pulse's bulge is placed with this, so it sits ON the head rather than a share of the length
2437
+ // along -- which drifted from the head wherever the candles' hops were unequal (operator,
2438
+ // 2026-09-15: "the bulge effect is lagging behind the energy. They should be on top of each other")
2439
+ const lenAt = (sv) => {
2440
+ const S = Math.max(0, Math.min(1, sv));
2441
+ let lo = 0, hi = samples;
2442
+ while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (table[mid].s < S) lo = mid; else hi = mid; }
2443
+ const a = table[lo], b = table[hi], f = b.s > a.s ? (S - a.s) / (b.s - a.s) : 0;
2444
+ return a.len + (b.len - a.len) * f;
2445
+ };
2446
+ return { total, at, lenAt, travel: bulgeTravel(table) };
2447
+ }
2448
+
2449
+ function drawBulge(ctx, pts, lw0, u, layers, ms = FX_MS.bulge) {
2450
+ const lw = Number.isFinite(lw0) && lw0 > 0 ? lw0 : 1; // a canvas that will not say: one pixel
2451
+ const { t } = bulgeAt(u, ms);
2452
+ const coreR = (lw * 5.5) / 2;
2453
+ const curve = curveByLength(pts);
2454
+ // the ball's centre travels the whole line, quicker downhill and slower up (bulgeTravel); the ball
2455
+ // is as big as fits between it and the nearer end
2456
+ drawBulgeAt(ctx, pts, lw, curve.travel(t), coreR * BULGE_BALL, layers, curve);
2457
+ }
2458
+ /** The swell itself, at `centre` along the curve with a full radius of `Rfull`: the pulse's head borrows it. */
2459
+ function drawBulgeAt(ctx, pts, lw0, centre, Rfull, layers, curve = curveByLength(pts)) {
2460
+ const lw = Number.isFinite(lw0) && lw0 > 0 ? lw0 : 1; // a canvas that will not say: one pixel
2461
+ const coreR = (lw * 5.5) / 2;
2462
+ const R = bulgeFit(Math.min(centre, curve.total - centre), Rfull, coreR);
2463
+ const amp = (R - coreR) / (Rfull - coreR || 1);
2464
+ if (amp < 0.002) return;
2465
+ const span = bulgeReach(R, coreR);
2466
+ const STEPS = 72;
2467
+ const samples = [];
2468
+ for (let k = 0; k <= STEPS; k++) {
2469
+ const d = -span + (2 * span * k) / STEPS;
2470
+ samples.push({ ...curve.at(centre + d), r: bulgeProfile(d, R, coreR) });
2471
+ }
2472
+ // NO SPHERE, ONLY THE TUBE (operator, 2026-09-14: "I don't want to see the sphere. I want to see the
2473
+ // obvious deformation of the tube"). What sells a hose being forced wide is the hose: its skin
2474
+ // stretches into the round profile and thins, its hot core keeps running straight through the
2475
+ // middle, and light catches the stretched outline.
2476
+ // glow layers follow the swollen wall at their own constant offset (the skin keeps its halo)
2477
+ // the outer core the WALL: filled out to the profile, as solid as the rest of the line
2478
+ // inner cores MAGNIFIED with the wall, as through a fish-eye lens: each keeps its share of the
2479
+ // tube's width, so the bright thread swells out through the middle of the bulge
2480
+ // (operator: "the yellow line running through the center of the bulge needs to
2481
+ // fish-eye lense distort out instead of staying uniformly thin")
2482
+ // Only the part beyond the tube already stroked is filled, so translucent layers never double up.
2483
+ const band = (rOf, fill, side) => {
2484
+ ctx.fillStyle = fill;
2485
+ ctx.beginPath();
2486
+ 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); });
2487
+ 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(); };
2488
+ };
2489
+ for (const [w, c, a, kind] of layers) {
2490
+ const r0 = (lw * w) / 2;
2491
+ const isWall = kind === 'core' && w === Math.max(...layers.filter((l) => l[3] === 'core').map((l) => l[0]));
2492
+ if (kind === 'core' && !isWall) {
2493
+ const lens = (q) => r0 * (q.r / coreR);
2494
+ for (const side of [1, -1]) band(lens, `rgba(${c[0]},${c[1]},${c[2]},${a})`, side)(r0);
2495
+ continue;
2496
+ }
2497
+ const off = r0 - coreR;
2498
+ // OPAQUE (operator: "Get rid of the transparency effect and keep the yellow line opaque, not clear"):
2499
+ // the swollen wall is filled solid, where it had been drawn as thinner see-through skin
2500
+ const alpha = isWall ? 1 : a;
2501
+ for (const side of [1, -1]) band((q) => q.r + off, `rgba(${c[0]},${c[1]},${c[2]},${alpha})`, side)(r0);
2502
+ }
2503
+ // the stretched outline catching the light, brightest where it is stretched furthest, and on the
2504
+ // upper side a highlight running along the swell, on the lower a faint shadow -- the roundness
2505
+ const liftOf = (q) => Math.max(0, (q.r - coreR) / (R - coreR || 1));
2506
+ const upSide = samples[samples.length >> 1].ny < 0 ? 1 : -1; // the side whose normal points up the screen
2507
+ const trace = (side, rOf, colOf, width) => {
2508
+ for (let k = 0; k < samples.length - 1; k++) {
2509
+ const q = samples[k], r = samples[k + 1], lift = liftOf(q);
2510
+ if (lift < 0.02) continue;
2511
+ ctx.strokeStyle = colOf(lift);
2512
+ ctx.lineWidth = width;
2513
+ ctx.beginPath();
2514
+ ctx.moveTo(q.x + q.nx * side * rOf(q), q.y + q.ny * side * rOf(q));
2515
+ ctx.lineTo(r.x + r.nx * side * rOf(r), r.y + r.ny * side * rOf(r));
2516
+ ctx.stroke();
2517
+ }
2518
+ };
2519
+ 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));
2520
+ 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));
2521
+ 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));
2522
+ }
2523
+
2524
+ // THE LINE, ELECTRIFIED where ball lightning passes: every segment within reach of the ball takes
2525
+ // a blue charge that fades with distance, plus a few sparks jumping off it -- the line is one of
2526
+ // the "elements it comes near"
2527
+ function electrifyLine(ctx, pts, view, lw) {
2528
+ const s = view.fx.stormball;
2529
+ const U = view.unit ?? 8;
2530
+ const c = project(s.at.x, s.at.y, s.at.z, view);
2531
+ const reach = U * 9;
2532
+ const charged = [];
2533
+ for (let i = 0; i < pts.length - 1; i++) {
2534
+ const mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2;
2535
+ const d = Math.hypot(mx - c.x, my - c.y);
2536
+ if (d < reach) charged.push([i, 1 - d / reach]);
2537
+ }
2538
+ if (!charged.length) return;
2539
+ 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(); };
2540
+ for (const [i, k] of charged) {
2541
+ const a = k * k;
2542
+ seg(i, lw * 16, `rgba(40,120,255,${(0.28 * a).toFixed(3)})`);
2543
+ seg(i, lw * 7, `rgba(90,190,255,${(0.7 * a).toFixed(3)})`);
2544
+ seg(i, lw * 2.4, `rgba(220,245,255,${(0.95 * a).toFixed(3)})`);
2545
+ // sparks off the charged wire, new every frame
2546
+ if (a > 0.35 && Math.random() < 0.6) {
2547
+ const p = pts[i], ang = Math.random() * Math.PI * 2, len = U * (0.6 + Math.random() * 1.4);
2548
+ ctx.strokeStyle = `rgba(200,240,255,${(0.9 * a).toFixed(3)})`; ctx.lineWidth = Math.max(lw, U * 0.05);
2549
+ ctx.beginPath(); ctx.moveTo(p.x, p.y);
2550
+ ctx.lineTo(p.x + Math.cos(ang) * len * 0.5 + (Math.random() - 0.5) * U, p.y + Math.sin(ang) * len * 0.5);
2551
+ ctx.lineTo(p.x + Math.cos(ang) * len, p.y + Math.sin(ang) * len);
2552
+ ctx.stroke();
2553
+ }
2554
+ }
2555
+ }
2556
+
2557
+
2558
+ // A LIGHT SABER (operator, 2026-09-15: "add another markets effect that really rips off a star wars
2559
+ // light saber visual somehow"). The price line is the blade. It IGNITES from the left end -- the
2560
+ // blade extends along the line with a flash at the hilt and a bright tip -- HUMS for most of the
2561
+ // run (the glow breathes on two fast sines, the core shimmers, and now and then a spark spits off
2562
+ // the blade), and RETRACTS to the hilt. One of four colours a run, hashed from the seed: blue,
2563
+ // green, red, purple. The unlit rest of the line stays as the faint wire. Nothing here is the
2564
+ // price line's resting look, which is the steady neon glow (markets.test.js keeps it so).
2565
+ const SABER_COLS = [[80, 170, 255], [90, 255, 130], [255, 70, 70], [200, 100, 255]];
2566
+ function drawSaberLine(ctx, pts, lw, fx, view, GLOW, CORE) {
2567
+ const u = fx.u, now = view.now ?? 0;
2568
+ const col = SABER_COLS[Math.floor(hash01(fx.seed + 7) * SABER_COLS.length)];
2569
+ const c = col.join(',');
2570
+ const pale = col.map((v) => Math.round(v + (255 - v) * 0.55)).join(',');
2571
+ const IGN = 0.14, RET = 0.86;
2572
+ const ease = (t) => 1 - Math.pow(1 - t, 3);
2573
+ const ext = u < IGN ? ease(u / IGN) : u > RET ? 1 - ease((u - RET) / (1 - RET)) : 1;
2574
+ // the faint wire under it all
2575
+ const stroke = (w, colour, path) => { ctx.strokeStyle = colour; ctx.lineWidth = lw * w; ctx.beginPath(); path(); ctx.stroke(); };
2576
+ const wire = () => traceCurve(ctx, pts);
2577
+ for (const [w, cc, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${cc[0]},${cc[1]},${cc[2]},${(a * 0.3).toFixed(3)})`, wire);
2578
+ if (ext <= 0.002) return;
2579
+ // the lit part: the curve up to `ext` of its length
2580
+ const curve = curveByLength(pts);
2581
+ const len = curve.total * ext;
2582
+ const N = Math.max(2, Math.ceil(64 * ext));
2583
+ const blade = [];
2584
+ for (let k = 0; k <= N; k++) blade.push(curve.at((len * k) / N));
2585
+ const along = () => { ctx.moveTo(blade[0].x, blade[0].y); for (let k = 1; k < blade.length; k++) ctx.lineTo(blade[k].x, blade[k].y); };
2586
+ // the hum: the glow breathes on two fast sines, never still
2587
+ const hum = 1 + 0.06 * Math.sin(now * 0.05) + 0.035 * Math.sin(now * 0.131 + 1);
2588
+ const bright = 0.9 + 0.1 * Math.sin(now * 0.021);
2589
+ // a third thicker than the first cut (operator, 2026-09-15: "make the saber effect a bit thicker")
2590
+ stroke(44 * hum, `rgba(${c},${(0.11 * bright).toFixed(3)})`, along);
2591
+ stroke(27 * hum, `rgba(${c},${(0.24 * bright).toFixed(3)})`, along);
2592
+ stroke(15 * hum, `rgba(${c},${(0.58 * bright).toFixed(3)})`, along);
2593
+ stroke(8.8, `rgba(${pale},0.92)`, along);
2594
+ stroke(4.4, `rgba(255,255,255,${(0.95 * bright).toFixed(3)})`, along);
2595
+ // the core shimmers: a thin white thread whose brightness runs along the blade
2596
+ for (let k = 0; k < blade.length - 1; k++) {
2597
+ const f = 0.5 + 0.5 * Math.sin(now * 0.03 + k * 0.9);
2598
+ ctx.strokeStyle = `rgba(255,255,255,${(0.35 * f).toFixed(3)})`; ctx.lineWidth = lw * 1.9;
2599
+ ctx.beginPath(); ctx.moveTo(blade[k].x, blade[k].y); ctx.lineTo(blade[k + 1].x, blade[k + 1].y); ctx.stroke();
2600
+ }
2601
+ // the tip: a bright point with the colour's bloom round it
2602
+ const tip = blade[blade.length - 1];
2603
+ for (const [r, colour, a] of [[12, c, 0.25], [6.8, pale, 0.7], [3.2, '255,255,255', 1]]) {
2604
+ ctx.fillStyle = `rgba(${colour},${a})`; ctx.beginPath(); ctx.arc(tip.x, tip.y, lw * r, 0, Math.PI * 2); ctx.fill();
2605
+ }
2606
+ // the hilt flashes at ignition and again as the blade comes home
2607
+ const flash = u < IGN ? 1 - u / IGN : u > RET ? (u - RET) / (1 - RET) : 0;
2608
+ if (flash > 0) {
2609
+ const h = blade[0];
2610
+ for (const [r, colour, a] of [[26, c, 0.18], [14, pale, 0.45], [6, '255,255,255', 0.9]]) {
2611
+ ctx.fillStyle = `rgba(${colour},${(a * flash).toFixed(3)})`; ctx.beginPath(); ctx.arc(h.x, h.y, lw * r * (0.6 + 0.4 * flash), 0, Math.PI * 2); ctx.fill();
2612
+ }
2613
+ }
2614
+ // now and then a spark spits off the blade: a few short lines from one point, gone in a beat
2615
+ const beat = Math.floor(now / 90);
2616
+ if (ext >= 1 && hash01(fx.seed + beat) < 0.3) {
2617
+ const at = curve.at(curve.total * hash01(fx.seed + beat * 3 + 1));
2618
+ const srnd = (k) => hash01(fx.seed + beat * 7 + k);
2619
+ for (let k = 0; k < 6; k++) {
2620
+ const ang = srnd(k) * Math.PI * 2, r = lw * (6 + 12 * srnd(k + 20));
2621
+ ctx.strokeStyle = `rgba(${pale},${(0.8 * (0.5 + 0.5 * srnd(k + 40))).toFixed(3)})`; ctx.lineWidth = lw * 1.2;
2622
+ ctx.beginPath(); ctx.moveTo(at.x, at.y); ctx.lineTo(at.x + Math.cos(ang) * r, at.y + Math.sin(ang) * r); ctx.stroke();
2623
+ }
2624
+ }
2625
+ }
2626
+ function priceLine(ctx, view, axes) {
2627
+ let pts = (axes.line ?? []).map((q) => project(q.x, axes.y ?? 0, q.z, view));
2628
+ if (pts.length < 2) return;
2629
+ // THE LINE BENDS ROUND THE BLACK HOLE (2026-09-15): every point is pushed out from the hole's
2630
+ // centre the way a lens pushes a background image -- r' = sqrt(r^2 + rE^2), the Einstein
2631
+ // radius rE growing with the hole -- so the chart warps into an arc round the shadow, and
2632
+ // the stretch inside the horizon is drawn under the shadow, which covers it
2633
+ if (view.fx?.kind === 'blackhole' && view.fx.blackhole) pts = lensPoints(pts, view);
2634
+ const lw = ctx.lineWidth;
2635
+ const join = ctx.lineJoin, cap = ctx.lineCap;
2636
+ ctx.lineJoin = 'round';
2637
+ ctx.lineCap = 'round';
2638
+ // Built once and CACHED, not rebuilt every frame. All six layers stroke the same shape, and the
2639
+ // shape only changes when the projected closes do -- which is when the series or the camera
2640
+ // changes, not when a frame ticks. drawGround caches its Path2D the same way (l.path); doing it
2641
+ // per frame here would allocate and re-trace thirty Beziers sixty times a second underneath an
2642
+ // effect that is already the expensive thing on screen.
2643
+ const P2 = typeof Path2D === 'function';
2644
+ let path = null;
2645
+ if (P2) {
2646
+ 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)}`;
2647
+ if (CURVE.key === key && CURVE.path) path = CURVE.path;
2648
+ else {
2649
+ try { path = new Path2D(); traceCurve(path, pts); CURVE.key = key; CURVE.path = path; }
2650
+ catch { path = null; CURVE.key = null; CURVE.path = null; }
2651
+ }
2652
+ }
2653
+ const stroke = (w, col) => {
2654
+ ctx.strokeStyle = col; ctx.lineWidth = lw * w;
2655
+ if (path) { ctx.stroke(path); return; }
2656
+ ctx.beginPath(); traceCurve(ctx, pts); ctx.stroke();
2657
+ };
2658
+ // A BRIGHT NEON GLOW (operator, 2026-09-11, of the crackling light saber that was here: "that
2659
+ // effect is terrible. Remove it. I was hoping for a bright neon glow"). Steady -- no pulses, no
2660
+ // crackle, no flicker: a wide soft bloom, a hot yellow tube and a white core.
2661
+ // the bloom, the tube and the core -- ALL of which the pulse tints. The first cut turned only
2662
+ // the thin core blue and left the fat yellow bloom round it untouched, so the surge read as a
2663
+ // faint whitening of the line (operator: "much too small on the yellow line, it's not obvious
2664
+ // it's a large energy pulse travelling the line"). The bloom goes a deep saturated blue, the
2665
+ // core an electric one, and every pass SWELLS behind the head.
2666
+ const GLOW = [[30, [255, 225, 40], 0.05, 'glow'], [18, [255, 228, 45], 0.10, 'glow'], [10, [255, 232, 55], 0.22, 'glow']];
2667
+ const CORE = [[5.5, [255, 236, 70], 0.78, 'core'], [3, [255, 246, 150], 1, 'core'], [1.3, [255, 255, 240], 1, 'core']];
2668
+ const fx = view.fx && view.fx.kind === 'pulse' ? view.fx : null;
2669
+ const done = () => { ctx.lineWidth = lw; ctx.lineJoin = join; ctx.lineCap = cap; };
2670
+ const HOT = [255, 255, 215]; // the flash: whiter than the wire
2671
+ // THE LINE BREATHES (operator, 2026-09-15: "an effect for the markets page that makes the line
2672
+ // breathe in-and-out between those two extents"): three slow swells over the run, each easing
2673
+ // the tube from the plain wire to the pulse's heat -- wider, whiter, brighter -- and back.
2674
+ if (view.fx?.kind === 'breathe') {
2675
+ const bre = 0.5 - 0.5 * Math.cos(view.fx.u * Math.PI * 2 * 3);
2676
+ const env = Math.sin(Math.PI * view.fx.u); // in from nothing and out to nothing over the run
2677
+ const k = bre * env;
2678
+ for (const [w, c, a] of [...GLOW, ...CORE]) {
2679
+ const mix = (j) => Math.round(c[j] + (HOT[j] - c[j]) * 0.8 * k);
2680
+ stroke(w * (1 + 0.9 * k), `rgba(${mix(0)},${mix(1)},${mix(2)},${Math.min(1, a * (1 + 0.6 * k))})`);
2681
+ }
2682
+ done();
2683
+ return;
2684
+ }
2685
+ if (view.fx?.kind === 'saber') { drawSaberLine(ctx, pts, lw, view.fx, view, GLOW, CORE); done(); return; }
2686
+ if (view.fx?.kind === 'scan') {
2687
+ for (const [w, c, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${c[0]},${c[1]},${c[2]},${a})`);
2688
+ scanFlare(ctx, pts, axes, view, lw);
2689
+ done();
2690
+ return;
2691
+ }
2692
+ if (view.fx?.kind === 'bulge') {
2693
+ for (const [w, c, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${c[0]},${c[1]},${c[2]},${a})`);
2694
+ drawBulge(ctx, pts, lw, view.fx.u, [...GLOW, ...CORE], view.fx.ms);
2695
+ done();
2696
+ return;
2697
+ }
2698
+ if (!fx) {
2699
+ for (const [w, c, a] of [...GLOW, ...CORE]) stroke(w, `rgba(${c[0]},${c[1]},${c[2]},${a})`);
2700
+ if (view.fx?.kind === 'stormball' && view.fx.stormball) electrifyLine(ctx, pts, view, lw);
2701
+ done();
2702
+ return;
2703
+ }
2704
+ const n = pts.length - 1;
2705
+ const headAt = fx.u / PULSE_TRAVEL; // 0..1 along the line, then past it
2706
+ // BRIGHT NEON BLUE (operator, 2026-09-13: "make the energy pulse bright neon blue as it travels
2707
+ // along the line"). Neon is saturation and luminance TOGETHER, so both move: the core goes
2708
+ // brighter and further toward cyan, and the bloom drops its red channel to nothing so the
2709
+ // surround reads as pure blue instead of the washed periwinkle it was. The tail's shape is
2710
+ // untouched -- every pass still blends wire-yellow through these to HOT at the overshoot; only
2711
+ // how blue "blue" is has changed.
2712
+ // NO BLUE (operator, 2026-09-15: "during the energy pulse, I don't like the blue shift/tint it's
2713
+ // adding on everything. Get rid of that, and add a bulge effect around the energy pulse"). The
2714
+ // surge is heat now: behind the head the tube goes white-hot through hot gold and cools back to
2715
+ // the wire, the cloud behind it is warm, the crackle and the motes are gold and white, and the
2716
+ // head is a swell in the pipe (drawBulgeAt) with a white-hot core, not a blue dot.
2717
+ const BLUE = [255, 255, 235]; // the core behind the head: white-hot
2718
+ const DEEP = [255, 205, 90]; // the bloom behind the head: hot gold
2719
+ // THE NEBULA BEHIND THE SURGE (operator, 2026-09-12: "like a blue nebula behind the energy pulse
2720
+ // that starts expanding and fading out to black"). Drawn FIRST, so it sits behind the wire: soft
2721
+ // nested discs on every charged segment, their radius growing with that stretch's age and their
2722
+ // alpha falling with it -- tight and bright just behind the head, spread wide and gone to black
2723
+ // by the tail's end. Plain fills, layered, as every glow in this renderer is.
2724
+ // A TRUE EMITTER (operator, 2026-09-12: "make the nebula an emitter; still seeing concentric
2725
+ // circles when the nebula's/trails are drawn behind the energy ball"). Emitting PER SEGMENT was
2726
+ // the cause: neighbouring segments are almost the same age, so their puffs shared a distance and
2727
+ // a radius and lined up into arcs -- rings made of many little circles are still rings. The
2728
+ // cloud is emitted over the charged SPAN instead. Each puff picks its own place along the trail,
2729
+ // its own angle, its own distance and its own size from four INDEPENDENT hashes, so no two share
2730
+ // a centre or a radius and there is no common edge for the eye to join up. Hashed, not random,
2731
+ // so a puff keeps its place from frame to frame instead of boiling.
2732
+ // (operator, 2026-09-13: "the nebula emissions are not substantial enough") -- more puffs, wider,
2733
+ // and at more than double the alpha, with a lighter core on every other one so the cloud reads as
2734
+ // having depth. Layering is what makes it substantial; a single fat translucent disc is a bubble.
2735
+ // SMALLER AND FAINTER (operator, 2026-09-14: "tune the nebula tails down a bit. They are too
2736
+ // large"). Puffs spread about half as far from the line (4 + 78 * age line-widths -> 3 + 38), are
2737
+ // a bit over half the radius and grow less with age, and sit at three-quarters of the alpha, so the
2738
+ // cloud hugs the wire instead of billowing over the chart. Fewer of them, since each covers less.
2739
+ // THE MIST, NOT A PATH OF CIRCLES (operator, 2026-09-15: "the energy pulse effect trail looks
2740
+ // really bad. it looks like a path of circles rather than emissive nebula/mist"). The cloud was
2741
+ // 160 flat discs, and flat discs of one alpha have edges the eye joins into circles however they
2742
+ // are scattered. Each puff is a radial gradient now -- bright at its heart, gone at its rim, so
2743
+ // no puff has an edge -- larger, fewer, and laid so they overlap into one glowing haze behind
2744
+ // the head that thins and cools down the tail. Hashed, so it holds its shape frame to frame.
2745
+ const MIST = 54;
2746
+ for (let k = 0; k < MIST; k++) {
2747
+ const u = hash01(k * 7 + 13); // how far back down the tail it sits
2748
+ const at = headAt - u * PULSE_TAIL;
2749
+ if (at < 0) continue;
2750
+ const off = at > 1 ? (at - 1) / 0.34 : 0;
2751
+ if (off >= 1) continue;
2752
+ const tint = Math.pow(1 - u, 1.3) * (1 - off);
2753
+ const hp = headPoint(pts, at, 0.34);
2754
+ if (!hp) continue;
2755
+ const a1 = hash01(k * 31 + 101) * Math.PI * 2;
2756
+ const spread = lw * (2 + 22 * u) * (0.2 + 0.8 * hash01(k * 17 + 5));
2757
+ const rad = lw * (9 + 16 * hash01(k * 13 + 67)) * (1 + 0.9 * u);
2758
+ const al = 0.16 * tint * (0.5 + 0.5 * hash01(k * 5 + 29));
2759
+ const px = hp.x + Math.cos(a1) * spread, py = hp.y + Math.sin(a1) * spread;
2760
+ const warm = u < 0.35 ? '255,236,170' : '255,185,70';
2761
+ ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(px, py, 0, px, py, rad),
2762
+ [[0, `rgba(${warm},${al.toFixed(3)})`], [0.4, `rgba(255,185,70,${(al * 0.55).toFixed(3)})`], [1, 'rgba(255,150,40,0)']],
2763
+ `rgba(255,185,70,${(al * 0.5).toFixed(3)})`);
2764
+ ctx.beginPath(); ctx.arc(px, py, rad, 0, Math.PI * 2); ctx.fill();
2765
+ }
2766
+ // THE TUBE, AS ONE STROKE PER LAYER WITH A GRADIENT ALONG IT.
2767
+ //
2768
+ // This is what the operator's "one continuous curved pipe" costs and buys. Each layer used to be
2769
+ // stroked segment by segment so a segment could carry its own colour -- that is how the blue tail
2770
+ // was built, and it is also why the line beaded at every candle. Because the closes are evenly
2771
+ // spaced and monotonic in x, a linear gradient across the line's x-extent IS distance along the
2772
+ // line, so the same tail can be expressed as colour stops on ONE stroke of the curve.
2773
+ //
2774
+ // The stops are placed at the head, a short way behind it, and at the tail's end, with the same
2775
+ // easing the per-segment version used (pow(1 - passed/TAIL, 1.4)) sampled at those places -- plus
2776
+ // the overshoot to a hotter, whiter yellow just past the tail, which was a sine bump per segment
2777
+ // and is a stop here. Fewer than forty steps, and continuous between them instead of stepped.
2778
+ const x0 = pts[0].x, x1 = pts[pts.length - 1].x;
2779
+ // where the head and the tail's end fall as a fraction of the x-extent, clamped into 0..1 so a
2780
+ // head that has run off the far end still anchors its stops legally
2781
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
2782
+ const headStop = clamp01(headAt);
2783
+ const tailStop = clamp01(headAt - PULSE_TAIL);
2784
+ for (const [w, c, a, kind] of [...GLOW, ...CORE]) {
2785
+ const B = kind === 'glow' ? DEEP : BLUE;
2786
+ // NO WHOLE-LINE SWELL (operator, 2026-09-15: "the energy pulse effect makes the yellow line
2787
+ // entirely grow at the start of the animation, and instantly snap back into the prior size.
2788
+ // I want the energy pulse effect growing the thickness of the yellow line in an arced radius
2789
+ // around it"). One stroke has one width, so a swell here was the whole wire jumping fat at
2790
+ // the start and thin at the end; the swell is the head's bulge (drawBulgeAt), round the head
2791
+ // and nowhere else, and the wire keeps its width.
2792
+ ctx.lineWidth = lw * w;
2793
+ const at = (passed) => {
2794
+ const tint = passed >= 0 && passed < PULSE_TAIL ? Math.pow(1 - passed / PULSE_TAIL, 1.4) : 0;
2795
+ const past = passed - PULSE_TAIL * 0.55;
2796
+ const hot = past > 0 && past < PULSE_TAIL ? Math.sin((past / PULSE_TAIL) * Math.PI) * (1 - tint) : 0;
2797
+ const mix = (j) => c[j] + (B[j] - c[j]) * tint + (HOT[j] - c[j]) * hot * 0.85;
2798
+ return `rgba(${Math.round(Math.min(255, mix(0)))},${Math.round(Math.min(255, mix(1)))},${Math.round(Math.min(255, mix(2)))},${a})`;
2799
+ };
2800
+ const yellow = `rgba(${c[0]},${c[1]},${c[2]},${a})`;
2801
+ const col = gradientOr(
2802
+ ctx,
2803
+ () => ctx.createLinearGradient(x0, pts[0].y, x1, pts[pts.length - 1].y),
2804
+ [
2805
+ [0, yellow], // far behind the tail: plain wire again
2806
+ [Math.max(0, tailStop - 0.001), at(PULSE_TAIL)],
2807
+ [tailStop, at(PULSE_TAIL * 0.999)],
2808
+ [clamp01(tailStop + (headStop - tailStop) * 0.45), at(PULSE_TAIL * 0.55)],
2809
+ [clamp01(tailStop + (headStop - tailStop) * 0.8), at(PULSE_TAIL * 0.2)],
2810
+ [headStop, at(0)],
2811
+ [Math.min(1, headStop + 0.001), yellow], // ahead of the head: untouched wire
2812
+ [1, yellow],
2813
+ ].filter(([o], i, arr) => i === 0 || o >= arr[i - 1][0]), // stops must not go backwards
2814
+ // no gradient available: the flat wire, which is what every recording stub records
2815
+ yellow,
2816
+ );
2817
+ ctx.strokeStyle = col;
2818
+ if (path) ctx.stroke(path);
2819
+ else { ctx.beginPath(); traceCurve(ctx, pts); ctx.stroke(); }
2820
+ }
2821
+ // THE BULGE AT THE HEAD: the pipe swells round the surge as it passes, the same stretched skin
2822
+ // and fish-eyed core as the bulge effect, at a smaller radius, in the heat's own colours
2823
+ {
2824
+ const headFrac = Math.max(0, Math.min(1, headAt));
2825
+ const curve = curveByLength(pts);
2826
+ const coreR = (lw * 5.5) / 2;
2827
+ const hotLayers = [...GLOW, ...CORE].map(([w, c, a, kind]) => [w, c.map((v, j) => Math.round(v + (HOT[j] - v) * 0.75)), a, kind]);
2828
+ const fadeIn = headAt < 0 ? 0 : headAt > 1 ? Math.max(0, 1 - (headAt - 1) / 0.2) : 1;
2829
+ // PROMINENT (operator, 2026-09-15: "the bulging effect is not prominent enough ... It needs a
2830
+ // lot more vibrance, presence, and effect"): the swell is the bulge effect's own full size now
2831
+ // ...then THREE TIMES THAT (operator: "make the bulge 3 times larger for the energy pulse. It's
2832
+ // not obvious the line is being deformed"), then half of that ("reduce the bulge by half")
2833
+ // ...then a third bigger ("the bulge effect on the energy pulse is getting lost in the animation.
2834
+ // Increase in size by 33%")
2835
+ if (fadeIn > 0) drawBulgeAt(ctx, pts, lw, curve.lenAt(headFrac), (Number.isFinite(coreR) && coreR > 0 ? coreR : 2.75) * (1 + 2 * (BULGE_BALL - 1) * fadeIn), hotLayers, curve);
2836
+ }
2837
+ // THE HAZE (operator, 2026-09-15: "haze effects of some sort for the energy blurring and
2838
+ // interfering with the affected areas"): three ghost copies of the charged stretch, each thrown
2839
+ // off the wire by a slow wave that runs along it and drifts with the clock, faint and warm, so
2840
+ // the wire seems to shimmer through hot air -- and a wide soft light over the whole stretch
2841
+ {
2842
+ const now0 = view.now ?? 0;
2843
+ const i0 = Math.max(0, Math.floor((headAt - PULSE_TAIL) * n)), i1 = Math.min(n, Math.ceil(Math.min(1, headAt) * n));
2844
+ if (i1 - i0 >= 1) {
2845
+ for (let g = 0; g < 3; g++) {
2846
+ ctx.strokeStyle = `rgba(255,220,140,${(0.10 + 0.04 * g).toFixed(3)})`; ctx.lineWidth = lw * (7 - 2 * g);
2847
+ ctx.beginPath();
2848
+ for (let i = i0; i <= i1; i++) {
2849
+ const passed = headAt - i / n;
2850
+ const tint = passed >= 0 && passed < PULSE_TAIL ? Math.pow(1 - passed / PULSE_TAIL, 1.4) : 0;
2851
+ const wob = lw * (3.5 + 2 * g) * tint * Math.sin(now0 * (0.004 + 0.0015 * g) + i * 0.9 + g * 2.1);
2852
+ const p = pts[i];
2853
+ if (i === i0) ctx.moveTo(p.x, p.y + wob); else ctx.lineTo(p.x, p.y + wob);
2854
+ }
2855
+ ctx.stroke();
2856
+ }
2857
+ // the light over the affected area: a wide soft warm glow along the charged stretch
2858
+ const mid = pts[Math.floor((i0 + i1) / 2)], span = Math.hypot(pts[i1].x - pts[i0].x, pts[i1].y - pts[i0].y) * 0.7 + lw * 20;
2859
+ ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(mid.x, mid.y, 0, mid.x, mid.y, span), [[0, 'rgba(255,225,160,0.14)'], [0.5, 'rgba(255,200,110,0.06)'], [1, 'rgba(255,180,80,0)']], 'rgba(255,215,140,0.04)');
2860
+ ctx.beginPath(); ctx.arc(mid.x, mid.y, span, 0, Math.PI * 2); ctx.fill();
2861
+ }
2862
+ }
2863
+ // SHIMMER on the charged stretch: a thin white-gold core whose brightness flickers per segment
2864
+ // on the frame clock, scaled by that segment's tint so it dies out exactly as the blue does.
2865
+ //
2866
+ // These were removed on 2026-09-12 ("the particle effects ... looks terrible ... get rid of the
2867
+ // lightning bolts effect") and asked for again the same day ("we need to add the energy and
2868
+ // crackle and particle effects back to the electrical pulse that travels the market screen"), so
2869
+ // they are back as they were. The nebula behind them keeps its span emitter -- that part of the
2870
+ // rework stands, because it is what stopped the trail reading as concentric rings.
2871
+ const now = view.now ?? 0;
2872
+ for (let i = 0; i < n; i++) {
2873
+ const at = n > 1 ? i / (n - 1) : 0;
2874
+ const passed = headAt - at;
2875
+ const tint = passed >= 0 && passed < PULSE_TAIL ? Math.pow(1 - passed / PULSE_TAIL, 1.4) : 0;
2876
+ if (tint < 0.08) continue;
2877
+ const p = pts[i], q = pts[i + 1];
2878
+ const flick = 0.55 + 0.45 * Math.abs(Math.sin(now * 0.023 + i * 1.7) * Math.sin(now * 0.041 + i * 0.9));
2879
+ ctx.strokeStyle = `rgba(255,250,225,${(0.9 * tint * flick).toFixed(3)})`;
2880
+ ctx.lineWidth = lw * 2.4;
2881
+ ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
2882
+
2883
+ // CRACKLE: short jagged branches off the wire, re-rolled EVERY frame -- electrical precisely
2884
+ // because it never draws the same twice. Reach is in line-widths; the line is under a pixel
2885
+ // wide, so 5-14 was invisible when this was first tried.
2886
+ // LIGHTNING WITH A DIRECTION (operator, 2026-09-13: "the lightning still looks terrible").
2887
+ // Forks now leave the wire near-perpendicular and hold their heading, tapering over four legs,
2888
+ // drawn halo/arc/core so they come to a point. The old version picked a uniformly random angle
2889
+ // and let each leg wander +-0.8 rad, which doubled back across the wire and read as scribble.
2890
+ const forks = 1 + ((Math.random() * 4 * tint) | 0);
2891
+ const nx = -(q.y - p.y), ny = q.x - p.x;
2892
+ const nlen = Math.hypot(nx, ny) || 1;
2893
+ for (let k = 0; k < forks; k++) {
2894
+ const f0 = Math.random();
2895
+ const ox = p.x + (q.x - p.x) * f0, oy = p.y + (q.y - p.y) * f0;
2896
+ const side = Math.random() < 0.5 ? 1 : -1;
2897
+ let ang = Math.atan2((ny / nlen) * side, (nx / nlen) * side) + (Math.random() - 0.5) * 1.1;
2898
+ const reach = lw * (11 + Math.random() * 10) * (0.45 + 0.55 * tint);
2899
+ // SHORT AND JAGGED (see chargeTrail): five kinked legs close to the wire, not four long
2900
+ // smooth ones. The screenshot of the first cut showed pale whiskers curving off the board.
2901
+ const legs = 5;
2902
+ const base = ang;
2903
+ const pl = [{ x: ox, y: oy }];
2904
+ let x = ox, y = oy;
2905
+ for (let m = 0; m < legs; m++) {
2906
+ ang = base + (m % 2 ? -1 : 1) * (0.35 + Math.random() * 0.55) + (Math.random() - 0.5) * 0.3;
2907
+ const step = (reach / legs) * (1 - 0.15 * m);
2908
+ x += Math.cos(ang) * step; y += Math.sin(ang) * step;
2909
+ pl.push({ x, y });
2910
+ }
2911
+ const arc = (upto, w, col) => {
2912
+ ctx.strokeStyle = col; ctx.lineWidth = lw * w;
2913
+ ctx.beginPath(); ctx.moveTo(pl[0].x, pl[0].y);
2914
+ for (let m = 1; m <= upto; m++) ctx.lineTo(pl[m].x, pl[m].y);
2915
+ ctx.stroke();
2916
+ };
2917
+ arc(legs, 2.4, `rgba(255,170,60,${(0.3 * tint).toFixed(3)})`);
2918
+ arc(legs, 0.95, `rgba(255,225,150,${(0.92 * tint).toFixed(3)})`);
2919
+ arc(Math.max(1, legs - 1), 0.5, `rgba(255,255,240,${(0.95 * tint).toFixed(3)})`);
2920
+ }
2921
+ // PARTICLES: a spray of motes streaming off the charged wire, each on its own heading,
2922
+ // spreading wider and fading as its stretch of the line ages. Placed by a HASH of segment and
2923
+ // mote plus the age -- not Math.random -- so a mote moves coherently frame to frame instead of
2924
+ // jittering in place.
2925
+ const age = passed / PULSE_TAIL;
2926
+ // SPARKS, NOT BLOBS (operator, 2026-09-13: "the particles are too fat"): half the radius,
2927
+ // more of them, thrown further as the stretch ages -- a mist that thins, not a clump of discs.
2928
+ for (let k = 0; k < 18; k++) {
2929
+ const f0 = hash01(i * 31 + k * 7);
2930
+ const bx = p.x + (q.x - p.x) * f0, by = p.y + (q.y - p.y) * f0;
2931
+ const ang = hash01(i * 17 + k * 13 + 101) * Math.PI * 2;
2932
+ const dist = lw * (4 + 62 * age) * (0.6 + 0.4 * hash01(i + k * 3 + 7));
2933
+ const r = lw * (0.6 + 1.3 * hash01(i * 5 + k + 41)) * (1 - 0.45 * age);
2934
+ ctx.fillStyle = `rgba(255,235,190,${(0.75 * tint * (1 - 0.5 * age)).toFixed(3)})`;
2935
+ ctx.beginPath(); ctx.arc(bx + Math.cos(ang) * dist, by + Math.sin(ang) * dist, r, 0, Math.PI * 2); ctx.fill();
2936
+ }
2937
+ }
2938
+ // THE SMOKE LEAVES WITH IT (operator, 2026-09-13: "The ball just disappears as does the smoke
2939
+ // particles"). The spray above is placed per wire SEGMENT, so it can only exist where the wire
2940
+ // does -- at the last candle it stops, which is half of what the operator saw vanish. This is
2941
+ // the same mist, positioned along the head's flight instead, so it follows the ball off the end
2942
+ // and thins out with it.
2943
+ {
2944
+ const OVERRUN = 0.34;
2945
+ for (let m = 0; m < 26; m++) {
2946
+ const back = hash01(m * 11 + 7) * PULSE_TAIL * 0.5; // how far behind the head it trails
2947
+ const hp = headPoint(pts, headAt - back, OVERRUN);
2948
+ if (!hp || hp.fade >= 1) continue; // only past the end: the wire has its own
2949
+ const ang = hash01(m * 17 + 41) * Math.PI * 2;
2950
+ const spread = lw * (5 + 40 * (back / (PULSE_TAIL * 0.5))) * (0.4 + 0.6 * hash01(m * 5 + 3));
2951
+ const r = lw * (0.6 + 1.3 * hash01(m * 7 + 19));
2952
+ const a = 0.7 * hp.fade * (1 - back / (PULSE_TAIL * 0.6));
2953
+ if (a <= 0.01) continue;
2954
+ ctx.fillStyle = `rgba(255,235,190,${a.toFixed(3)})`;
2955
+ ctx.beginPath();
2956
+ ctx.arc(hp.x + Math.cos(ang) * spread, hp.y + Math.sin(ang) * spread, r, 0, Math.PI * 2);
2957
+ ctx.fill();
2958
+ }
2959
+ }
2960
+ // THE HEAD FLIES ON (operator, 2026-09-13: "When the ball gets to the end of the line, it should
2961
+ // keep going along it's last vector, and fade out before reaching the edge of the screen").
2962
+ // It used to be `if (headAt < 1)` and nothing else -- the bead simply stopped existing at the
2963
+ // last candle, which is the disappearance the operator saw. Past the end it now carries on along
2964
+ // the direction of the final segment and fades, so it leaves rather than blinks out.
2965
+ //
2966
+ // OFF THE END, NOT OFF THE SCREEN: the flight is capped at a fraction of the line's own length,
2967
+ // so on any board width it goes dark before the edge rather than sailing into the panel border.
2968
+ {
2969
+ const OVERRUN = 0.34; // of the line's length, past the last point
2970
+ const hp = headPoint(pts, headAt, OVERRUN);
2971
+ if (hp) {
2972
+ // a head you cannot miss: a wide blue corona, a bright core, a white point -- all of it
2973
+ // scaled and faded together once it is off the wire
2974
+ const f = hp.fade;
2975
+ // THE BALL ITSELF IS BLUE, not just the tint behind it (operator, 2026-09-13: "The 3d Price
2976
+ // chart still doesn't have a neon blue leading pulse like I asked for. It's white").
2977
+ //
2978
+ // The earlier pass turned the TAIL neon blue and left this stack alone -- and this stack is
2979
+ // the head: four discs, of which the inner two were rgba(235,250,255,0.92) over pure
2980
+ // rgba(255,255,255,1). A white disc at full alpha painted on top of a blue corona is a white
2981
+ // ball with a blue halo, which is exactly what was reported. Every layer is blue-dominant
2982
+ // now, the hot centre included -- it is the brightest, palest blue rather than white, so the
2983
+ // head still reads as the hottest point on the line without going colourless.
2984
+ // AN UNSTABLE, CRACKLING BALL OF PLASMA (operator, 2026-09-15: "It needs a lot more vibrance,
2985
+ // presence, and effect. An unstable crackling ball of plasma"): a sphere of one gradient,
2986
+ // white-hot heart through gold to a rim that is not quite there, breathing on two quick
2987
+ // sines; three warm wisps of nebula wobbling round it; filaments of discharge skittering over
2988
+ // its surface, re-rolled on a 60 ms beat; and every so often it SPITS -- a few sparks thrown
2989
+ // up and out that arc under gravity, slow, and fade (their flights are pure functions of the
2990
+ // clock and the moment they were spat, so nothing is kept between frames).
2991
+ const nowH = view.now ?? 0;
2992
+ const breathe = 1 + 0.08 * Math.sin(nowH * 0.011) + 0.05 * Math.sin(nowH * 0.023 + 1);
2993
+ const RB = lw * 16 * (0.6 + 0.4 * f) * breathe;
2994
+ const wispsH = [['255,170,60', 0.34, 1.0], ['255,120,80', 0.26, -0.7], ['255,230,150', 0.22, 0.5]];
2995
+ wispsH.forEach(([wc, wa, spin], k) => {
2996
+ const t = nowH * 0.0011;
2997
+ const wx = hp.x + Math.cos(t * spin * 0.6 + k * 2.1) * RB * 0.5, wy = hp.y + Math.sin(t * spin * 0.45 + k * 1.3) * RB * 0.4;
2998
+ const base = RB * (1.7 + 0.4 * k);
2999
+ ctx.beginPath();
3000
+ for (let i = 0; i <= 36; i++) {
3001
+ const th = (i / 36) * Math.PI * 2;
3002
+ const wob = 1 + 0.26 * Math.sin(th * 3 + t * spin * 3 + k) + 0.15 * Math.sin(th * 5 - t * 2.4 * spin + k * 3);
3003
+ const rr = base * wob, x = wx + Math.cos(th) * rr, y = wy + Math.sin(th) * rr;
3004
+ if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
3005
+ }
3006
+ ctx.closePath();
3007
+ ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(wx, wy, 0, wx, wy, base * 1.4), [[0, `rgba(${wc},${(wa * f).toFixed(3)})`], [0.4, `rgba(${wc},${(wa * 0.6 * f).toFixed(3)})`], [1, `rgba(${wc},0)`]], `rgba(${wc},${(wa * 0.3 * f).toFixed(3)})`);
3008
+ ctx.fill();
3009
+ });
3010
+ ctx.fillStyle = gradientOr(ctx, () => ctx.createRadialGradient(hp.x, hp.y, 0, hp.x, hp.y, RB * 1.35), [
3011
+ [0, `rgba(255,255,255,${f.toFixed(3)})`], [0.16, `rgba(255,250,225,${f.toFixed(3)})`], [0.38, `rgba(255,232,150,${(0.95 * f).toFixed(3)})`],
3012
+ [0.6, `rgba(255,190,80,${(0.8 * f).toFixed(3)})`], [0.82, `rgba(255,140,50,${(0.4 * f).toFixed(3)})`], [1, 'rgba(255,120,40,0)'],
3013
+ ], `rgba(255,232,150,${(0.95 * f).toFixed(3)})`);
3014
+ ctx.beginPath(); ctx.arc(hp.x, hp.y, RB * 1.35, 0, Math.PI * 2); ctx.fill();
3015
+ // the discs the tests know the head by: its hot centre and its white point, over the sphere
3016
+ for (const [r, c0, a0] of [[7, '255,232,150', 0.95], [3, '255,252,225', 1]]) {
3017
+ ctx.fillStyle = `rgba(${c0},${(a0 * f).toFixed(3)})`;
3018
+ ctx.beginPath(); ctx.arc(hp.x, hp.y, lw * r * (0.55 + 0.45 * f), 0, Math.PI * 2); ctx.fill();
3019
+ }
3020
+ // the crackle over its surface: short filaments between two points on and just off the ball
3021
+ {
3022
+ const beat = Math.floor(nowH / 60);
3023
+ let x = ((fx.seed ?? 1) ^ (beat * 2654435761)) >>> 0 || 1;
3024
+ const rr = () => ((x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296);
3025
+ for (let i = 0; i < 12; i++) {
3026
+ const ang = rr() * Math.PI * 2, r0 = RB * (0.35 + 0.5 * rr()), r1 = RB * (1.05 + 0.6 * rr());
3027
+ const p = { x: hp.x + Math.cos(ang) * r0, y: hp.y + Math.sin(ang) * r0 };
3028
+ const q = { x: hp.x + Math.cos(ang + (rr() - 0.5) * 0.9) * r1, y: hp.y + Math.sin(ang + (rr() - 0.5) * 0.9) * r1 };
3029
+ const mid = { x: (p.x + q.x) / 2 + (rr() - 0.5) * RB * 0.35, y: (p.y + q.y) / 2 + (rr() - 0.5) * RB * 0.35 };
3030
+ for (const [w, col] of [[2.2, `rgba(255,170,60,${(0.55 * f).toFixed(3)})`], [0.9, `rgba(255,250,225,${(0.95 * f).toFixed(3)})`]]) {
3031
+ ctx.strokeStyle = col; ctx.lineWidth = lw * w; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(mid.x, mid.y); ctx.lineTo(q.x, q.y); ctx.stroke();
3032
+ }
3033
+ }
3034
+ }
3035
+ // THE SPIT: every 140 ms there is a one-in-three chance the ball throws three sparks; each
3036
+ // flight is (birth, angle, speed) and the clock, so it is drawn from those alone
3037
+ {
3038
+ const STEP = 140, LIFE = 1500;
3039
+ const t0 = nowH - (fx.u ?? 0) * (fx.ms ?? FX_MS.pulse);
3040
+ const kNow = Math.floor(nowH / STEP);
3041
+ for (let k = kNow - Math.ceil(LIFE / STEP); k <= kNow; k++) {
3042
+ if (hash01((fx.seed ?? 1) + k * 3) > 0.33) continue;
3043
+ const born = k * STEP, age = (nowH - born) / LIFE;
3044
+ if (age < 0 || age >= 1) continue;
3045
+ const uk = (born - t0) / (fx.ms ?? FX_MS.pulse);
3046
+ const bp = headPoint(pts, uk / PULSE_TRAVEL, OVERRUN);
3047
+ if (!bp) continue;
3048
+ for (let m = 0; m < 3; m++) {
3049
+ const hh = (q) => hash01((fx.seed ?? 1) + k * 31 + m * 7 + q);
3050
+ const ang = -Math.PI / 2 + (hh(1) - 0.5) * 2.2, sp = lw * (14 + 16 * hh(2));
3051
+ const ease = 1 - Math.exp(-3 * age);
3052
+ const sx = bp.x + Math.cos(ang) * sp * ease * 2.2, sy = bp.y + Math.sin(ang) * sp * ease * 2.2 + lw * 34 * age * age;
3053
+ const px = bp.x + Math.cos(ang) * sp * Math.max(0, ease - 0.12) * 2.2, py = bp.y + Math.sin(ang) * sp * Math.max(0, ease - 0.12) * 2.2 + lw * 34 * Math.max(0, age - 0.08) ** 2;
3054
+ const al = (1 - age) * f;
3055
+ ctx.strokeStyle = `rgba(255,200,100,${(0.7 * al).toFixed(3)})`; ctx.lineWidth = lw * 1.6;
3056
+ ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(sx, sy); ctx.stroke();
3057
+ ctx.fillStyle = `rgba(255,250,225,${(0.95 * al).toFixed(3)})`;
3058
+ ctx.beginPath(); ctx.arc(sx, sy, lw * 1.3 * (1 - 0.5 * age), 0, Math.PI * 2); ctx.fill();
3059
+ }
3060
+ }
3061
+ }
3062
+ // THE HEAD CRACKLES (operator, 2026-09-13: "Did you copy the energy crackle from the grid
3063
+ // effect, and add to the head of the energy ball travelling along the yellow price line?").
3064
+ // It had not been. The crackle above is emitted per wire SEGMENT, gated on that segment's
3065
+ // tint, so it lights the charged stretch BEHIND the head and -- this is the part that shows --
3066
+ // cannot exist at all once the head runs off the last candle, because out there is no wire to
3067
+ // hang it on. The ball spent its entire flight past the end as four bare discs.
3068
+ //
3069
+ // So this is the grid lightning ball's own vocabulary (drawBall, which strikes bolts from the
3070
+ // ball to the crossings round it): bolts thrown OUTWARD from the head, re-rolled every frame,
3071
+ // each drawn in the same three passes the wire crackle uses -- a wide dim halo, the arc, then
3072
+ // a hot core stopping one leg short so it tapers to a point rather than ending in a stub.
3073
+ // Struck from the rim of the bright core, not the centre, so the ball does not swallow them.
3074
+ const R = lw * 7;
3075
+ const bolts = 3 + ((Math.random() * 4 * f) | 0);
3076
+ for (let k = 0; k < bolts; k++) {
3077
+ const out = Math.random() * Math.PI * 2;
3078
+ const reach = lw * (9 + Math.random() * 14) * (0.45 + 0.55 * f);
3079
+ const legs = 5;
3080
+ const bp = [{ x: hp.x + Math.cos(out) * R * 0.5, y: hp.y + Math.sin(out) * R * 0.5 }];
3081
+ let bx = bp[0].x, by = bp[0].y;
3082
+ for (let m = 0; m < legs; m++) {
3083
+ // the same hard alternating zig as the wire's forks: a discharge kinks, it does not curve
3084
+ const a = out + (m % 2 ? -1 : 1) * (0.3 + Math.random() * 0.5) + (Math.random() - 0.5) * 0.3;
3085
+ const step = (reach / legs) * (1 - 0.15 * m);
3086
+ bx += Math.cos(a) * step; by += Math.sin(a) * step;
3087
+ bp.push({ x: bx, y: by });
3088
+ }
3089
+ const bolt = (upto, w, col) => {
3090
+ ctx.strokeStyle = col; ctx.lineWidth = lw * w;
3091
+ ctx.beginPath(); ctx.moveTo(bp[0].x, bp[0].y);
3092
+ for (let m = 1; m <= upto; m++) ctx.lineTo(bp[m].x, bp[m].y);
3093
+ ctx.stroke();
3094
+ };
3095
+ bolt(legs, 2.4, `rgba(255,170,60,${(0.32 * f).toFixed(3)})`);
3096
+ bolt(legs, 0.95, `rgba(255,225,150,${(0.9 * f).toFixed(3)})`);
3097
+ bolt(Math.max(1, legs - 1), 0.5, `rgba(255,255,245,${(0.95 * f).toFixed(3)})`);
3098
+ }
3099
+ }
3100
+ }
3101
+ done();
3102
+ }
3103
+
3104
+ function axisLabels(ctx, view, axes, n, k, dpr) {
3105
+ const px = (v) => (v * dpr) / k;
3106
+ const y = axes.y ?? 0;
3107
+ ctx.font = `${px(11)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
3108
+ ctx.textBaseline = 'middle';
3109
+ ctx.textAlign = 'left';
3110
+ for (const t of axes.z ?? []) {
3111
+ const p = project(n + 0.8, y, t.z, view);
3112
+ const w = ctx.measureText(t.label).width;
3113
+ ctx.fillStyle = t.strong ? (t.color ?? 'rgba(40,60,50,1)') : 'rgba(2,12,8,0.78)';
3114
+ ctx.fillRect(p.x - px(4), p.y - px(8), w + px(8), px(16));
3115
+ ctx.fillStyle = t.strong ? 'rgba(255,255,255,1)' : 'rgba(185,255,220,1)';
3116
+ ctx.fillText(t.label, p.x, p.y);
3117
+ }
3118
+ ctx.textAlign = 'center';
3119
+ ctx.fillStyle = 'rgba(160,235,205,1)';
3120
+ for (const t of axes.x ?? []) { const p = project(t.x, 0, 0, view); ctx.fillText(t.label, p.x, p.y + px(13)); }
3121
+ }
3122
+
3123
+ // THE STAR FIELD behind a space board. A seeded scatter per canvas size, so the sky never
3124
+ // reshuffles between frames; each star twinkles on its own slow period (2-10 s), a few bright
3125
+ // ones carry a halo and a cross glint. Plain rgba fills and strokes, in device pixels.
3126
+ const STARS = new WeakMap();
3127
+ const NO_CANVAS = {};
3128
+
3129
+ // IS THE SKY DRAWN? Not the same question as "is this a space board", though one option used to
3130
+ // answer both (operator, 2026-09-12: "I don't think the star field toggle works properly for
3131
+ // markets and price"). `space` also decides the deck texture, the translucent floor and the floor
3132
+ // line, so turning the stars off through it RESTYLED THE WHOLE BOARD -- which is not what a switch
3133
+ // labelled "Star field" should do. `stars` gates the sky alone; where it is absent the old
3134
+ // meaning stands, so every caller that never heard of it behaves exactly as before.
3135
+ const starsOn = (o) => !!(o.stars ?? o.space);
3136
+ // THE GALAXY (operator, 2026-09-12: "I want all the starts slowly rotating to form a spiral
3137
+ // galaxy in the background ... Make it a toggle").
3138
+ //
3139
+ // Off, this is the shipped uniform scatter, untouched down to the order the random numbers are
3140
+ // drawn in. On, the same stars are laid on logarithmic arms and carry POLAR coordinates, so the
3141
+ // sky turns by advancing ONE angle at draw time rather than by being rebuilt: the field stays
3142
+ // seeded, the cache stays valid, and each star's own twinkle is unaffected.
3143
+ //
3144
+ // The rotation is RIGID -- the whole pattern turns as one. Differential rotation (inner stars
3145
+ // faster, as a real disc moves) shears spiral arms apart within a few minutes of real time, which
3146
+ // would destroy the one thing being asked for.
3147
+ // Every number here was chosen by rendering the variants side by side and looking at them, after
3148
+ // the first attempt shipped structure without a picture: the tests said the stars clustered on
3149
+ // arms (they did) while the screen showed a faint streak. What the comparison settled:
3150
+ // - FLATTEN 0.42 was the single biggest fault. Edge-on, a spiral reads as a smear across the
3151
+ // frame; from well above the disc it reads as a spiral. 0.80.
3152
+ // - Two arms at that angle fold into one S through the middle. Four are unmistakable.
3153
+ // - The arms were far too wide (+/-0.55 rad of scatter). A wide arm is a smudge; 0.18, and
3154
+ // narrowing outward, draws a line.
3155
+ // - An arm is drawn BY its stars, and at the scattered sky's count there were not enough of
3156
+ // them to make one. Hence GALAXY_BOOST.
3157
+ export const GALAXY_FLATTEN = 0.80; // seen from above the disc, not along it
3158
+ // NEGATIVE, so the arms TRAIL (operator, 2026-09-12: "galaxy is rotating in wrong direction for
3159
+ // the astrophysics to work"). The arms wind outward in +theta -- ang = arm + ln(r/inner)/TWIST --
3160
+ // so an arm's outer end sits ahead of its root in +theta. Turning the disc in +theta as well put
3161
+ // the tips in FRONT of the rotation: leading arms, which is not what disc galaxies do. A density
3162
+ // wave leaves the arms trailing, so the disc has to turn against the way they wind.
3163
+ export const GALAXY_SPIN = -(Math.PI * 2) / 900_000; // one turn in fifteen minutes: "slowly"
3164
+ export const GALAXY_ARMS = 4;
3165
+ export const GALAXY_TWIST = 0.30; // how tightly the arms wind
3166
+ const GALAXY_BOOST = 7; // an arm needs many more stars than a scatter
3167
+ // LOW AND LEFT (operator, 2026-09-12: "We should have the spiral galaxy centers on the lower left
3168
+ // grid location. That should cluster things up enough to be interesting"). The nucleus sits down
3169
+ // in that corner and the arms sweep up across the panel, which crowds the interesting part of the
3170
+ // picture into one place instead of spreading it evenly around the middle.
3171
+ // [x, y, reach, oversample] as fractions of the panel. A corner placement REACHES past the panel
3172
+ // so the arms cross it; the centred one stays inside, so the whole spiral is visible behind the
3173
+ // board.
3174
+ //
3175
+ // `oversample` is how many stars must be MADE for each one that lands on the panel, and it has to
3176
+ // be per placement or the density setting means two different things: measured, a centred disc
3177
+ // puts all of itself on screen while a corner one puts about 40% off it, so generating the same
3178
+ // number either way made the centred galaxy twice as dense for the same slider position.
3179
+ // REACH 3.0 (operator, 2026-09-12: "The spiral galaxy arms need to extend out way farther than
3180
+ // they do, I want to see long arms on the far side of the board"). Measured on a 1265px board with
3181
+ // the middle in a corner: at 1.6 the arm tips died at x=876, well short of the far edge; at 3.0
3182
+ // they carry to x=1400, across it and out. 3.8 was tried and overshoots -- three quarters of the
3183
+ // stars then live off-panel for no more picture.
3184
+ //
3185
+ // The number of WINDINGS does not change with reach, because `inner` scales with maxR: a bigger
3186
+ // disc shows less than one full winding across the panel, which is what makes the arcs read as
3187
+ // long sweeps rather than a tight coil.
3188
+ //
3189
+ // The oversample figures are the measured inverse of "what fraction of this disc lands on the
3190
+ // panel", per placement -- 36% centred, 34% from a corner.
3191
+ export const GALAXY_PLACEMENTS = Object.freeze({
3192
+ 'center': [0.50, 0.50, 3.00, 2.76],
3193
+ 'top-left': [0.22, 0.22, 3.00, 2.95],
3194
+ 'top-right': [0.78, 0.22, 3.00, 2.95],
3195
+ 'bottom-left': [0.22, 0.78, 3.00, 2.97],
3196
+ 'bottom-right': [0.78, 0.78, 3.00, 2.97],
3197
+ });
3198
+ export const GALAXY_AT_DEFAULT = 'bottom-left';
3199
+ // An off-centre disc puts much of itself off-panel, and it must still be generated there: a
3200
+ // rotating field cannot be sampled to the visible rectangle, or turning it would drag bare gaps
3201
+ // into view. So more stars are made than are ever drawn (see `oversample` above, which is per
3202
+ // placement), and the draw loop skips the ones outside.
3203
+ const GALAXY_MAX = 40000; // the count scales with area; 4K must not run away
3204
+
3205
+ /**
3206
+ * NEBULAE (operator, 2026-09-12: "We need to improve the star fields. nebulas, more stars between
3207
+ * arms. I needs to look awe-inspiring at the majesty and grandness of the universe with all it's
3208
+ * details").
3209
+ *
3210
+ * Clouds of gas sit ON the arms, because that is where they are: a spiral's colour comes from the
3211
+ * star-forming lanes, so placing them by the same logarithmic rule as the arm stars makes the
3212
+ * colour follow the structure instead of floating over it.
3213
+ *
3214
+ * Each cloud is a heap of overlapping low-alpha ellipses. The natural way to draw a nebula is one
3215
+ * soft radial gradient, and this renderer cannot: no globalAlpha, no composite modes, no
3216
+ * shadowBlur (viewer-canvas-rules.test.js). Many faint fills add up to the same soft edge and
3217
+ * cannot be silently dropped by a software rasteriser -- the same reason the star halos are built
3218
+ * this way, and the reason the painted galactic core had to be deleted rather than tuned.
3219
+ *
3220
+ * They carry polar coordinates like the stars, so they turn with the disc for free.
3221
+ */
3222
+ export function nebulaClouds(pw, ph, at = GALAXY_AT_DEFAULT, seed = 11) {
3223
+ let s = seed >>> 0;
3224
+ const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
3225
+ const { maxR, inner } = galaxyGeometry(pw, ph, at);
3226
+ // dusty magenta, cold blue, teal, warm rose: a sky of one colour looks painted
3227
+ const TINTS = [[142, 88, 214], [48, 122, 196], [58, 168, 156], [198, 84, 142]];
3228
+ const out = [];
3229
+ // Nine clouds, kept to the inner half of the disc: the disc reaches three times past the panel
3230
+ // (GALAXY_PLACEMENTS), so a cloud laid anywhere on it was mostly laid off-screen -- the first cut
3231
+ // put six out to the rim and the panel got a faint smudge in one corner. And bright enough to
3232
+ // be SEEN: the first alphas (0.009-0.024) sat a hair above the background.
3233
+ for (let i = 0; i < 9; i++) {
3234
+ const t = 0.05 + 0.45 * rnd();
3235
+ const rad = inner + (maxR - inner) * t;
3236
+ const arm = Math.floor(rnd() * GALAXY_ARMS) * ((Math.PI * 2) / GALAXY_ARMS);
3237
+ const ang = arm + Math.log(rad / inner) / GALAXY_TWIST + (rnd() - 0.5) * 0.5;
3238
+ const size = maxR * (0.07 + 0.09 * rnd());
3239
+ const tint = TINTS[Math.floor(rnd() * TINTS.length)].join(',');
3240
+ const puffs = [];
3241
+ const n = 22 + Math.floor(rnd() * 14);
3242
+ for (let k = 0; k < n; k++) {
3243
+ puffs.push({
3244
+ dx: (rnd() - 0.5) * size * 2.0,
3245
+ dy: (rnd() - 0.5) * size * 1.3,
3246
+ rx: size * (0.30 + 0.55 * rnd()),
3247
+ sq: 0.65 + 0.7 * rnd(), // not circles: a cloud has a shape
3248
+ a: 0.020 + 0.030 * rnd(),
3249
+ });
3250
+ }
3251
+ out.push({ gr: rad, ga: ang, tint, puffs });
3252
+ }
3253
+ return out;
3254
+ }
3255
+
3256
+ /**
3257
+ * DISTANT GALAXIES (operator, 2026-09-12: "Add whatever other universe / galaxy effects to the
3258
+ * scene that you think will make it look even more visually stunning ... A beautiful rendering of
3259
+ * our reaility"). A real deep field is not one galaxy on black: it has others in it, small and
3260
+ * faint and far. Five of them, scattered over the panel, drawn first so everything else stands in
3261
+ * front, and STATIC -- they do not turn with the disc, because they are not part of it. Each is a
3262
+ * few concentric ellipses, tilted, in cool white or the faint blue of distance.
3263
+ */
3264
+ export function farGalaxies(pw, ph, seed = 23) {
3265
+ let s = seed >>> 0;
3266
+ const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
3267
+ const out = [];
3268
+ for (let i = 0; i < 5; i++) {
3269
+ out.push({
3270
+ x: rnd() * pw, y: rnd() * ph,
3271
+ rx: 7 + rnd() * 15,
3272
+ ratio: 0.32 + rnd() * 0.5, // a disc seen at some angle
3273
+ rot: rnd() * Math.PI,
3274
+ tint: rnd() < 0.5 ? '222,218,240' : '200,222,255',
3275
+ });
3276
+ }
3277
+ return out;
3278
+ }
3279
+
3280
+ /**
3281
+ * DUST LANES. The most recognisable thing about a real spiral after the arms themselves: a dark
3282
+ * ribbon runs along the INNER (concave) edge of each arm, where the gas is thickest and hides
3283
+ * the stars behind it. Drawn over the nebulae and under the stars, as heaps of faint black
3284
+ * ellipses -- the same layered-fill technique everything else in this sky uses, for the same
3285
+ * canvas-rules reason. Polar, so they turn with the disc.
3286
+ */
3287
+ export function dustLanes(pw, ph, at = GALAXY_AT_DEFAULT, seed = 17) {
3288
+ let s = seed >>> 0;
3289
+ const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
3290
+ const { maxR, inner } = galaxyGeometry(pw, ph, at);
3291
+ const out = [];
3292
+ for (let i = 0; i < 14; i++) {
3293
+ const t = 0.06 + 0.5 * rnd();
3294
+ const rad = inner + (maxR - inner) * t;
3295
+ const arm = Math.floor(rnd() * GALAXY_ARMS) * ((Math.PI * 2) / GALAXY_ARMS);
3296
+ // a little INSIDE the arm's centre line: the concave edge, trailing the rotation
3297
+ const ang = arm + Math.log(rad / inner) / GALAXY_TWIST - 0.16 + (rnd() - 0.5) * 0.08;
3298
+ const size = maxR * (0.05 + 0.06 * rnd());
3299
+ const puffs = [];
3300
+ const n = 12 + Math.floor(rnd() * 8);
3301
+ for (let k = 0; k < n; k++) {
3302
+ 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() });
3303
+ }
3304
+ out.push({ gr: rad, ga: ang, puffs });
3305
+ }
3306
+ return out;
3307
+ }
3308
+
3309
+ /**
3310
+ * STAR CLUSTERS. Globular clusters live in the halo: tight, ancient knots of a few hundred
3311
+ * thousand stars, seen as a dense speck with a fuzzy edge. Six of them, out past the arms, each
3312
+ * a heap of tiny warm-white points crowded to the middle. Drawn over the stars, turning with the
3313
+ * disc, and NOT twinkling -- a cluster is too far for its stars to scintillate one by one.
3314
+ */
3315
+ export function starClusters(pw, ph, at = GALAXY_AT_DEFAULT, seed = 29) {
3316
+ let s = seed >>> 0;
3317
+ const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
3318
+ const { maxR } = galaxyGeometry(pw, ph, at);
3319
+ const out = [];
3320
+ for (let i = 0; i < 6; i++) {
3321
+ const rad = maxR * (0.28 + 0.6 * rnd());
3322
+ const ang = rnd() * Math.PI * 2;
3323
+ const size = 6 + rnd() * 9;
3324
+ const stars = [];
3325
+ const n = 26 + Math.floor(rnd() * 22);
3326
+ for (let k = 0; k < n; k++) {
3327
+ // crowded to the middle: radius drawn as the square of a uniform, angle uniform
3328
+ const rr = size * rnd() * rnd(), aa = rnd() * Math.PI * 2;
3329
+ stars.push({ dx: Math.cos(aa) * rr, dy: Math.sin(aa) * rr, r: 0.35 + rnd() * 0.5, b: 0.35 + rnd() * 0.5 });
3330
+ }
3331
+ out.push({ gr: rad, ga: ang, size, stars });
3332
+ }
3333
+ return out;
3334
+ }
3335
+
3336
+ /** Where the disc sits and how big it is. One source, so the renderer and the tests agree. */
3337
+ export function galaxyGeometry(pw, ph, at = GALAXY_AT_DEFAULT) {
3338
+ const [fx, fy, reach, oversample] = GALAXY_PLACEMENTS[at] ?? GALAXY_PLACEMENTS[GALAXY_AT_DEFAULT];
3339
+ const maxR = (Math.min(pw, ph / GALAXY_FLATTEN) / 2) * reach;
3340
+ return { cx: pw * fx, cy: ph * fy, maxR, inner: maxR * 0.08, oversample };
3341
+ }
3342
+ export function starField(pw, ph, dpr = 1, seed = 7, density = 1, galaxy = false) {
3343
+ let s = seed >>> 0;
3344
+ const rnd = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
3345
+ // density: the operator's multiplier on the shipped count (settings.js sky.density, which both
3346
+ // this board and the markets board read -- it used to live under `markets` and reach only one)
3347
+ // 8, not 3 (operator, 2026-09-12: "we need to be able to up the star density even more"). The
3348
+ // slider's ceiling lives in settings.js PANEL; this is the renderer's own guard behind it.
3349
+ const d = Number.isFinite(density) ? Math.min(8, Math.max(0, density)) : 1;
3350
+ const base = ((pw * ph) / (2400 * dpr * dpr)) * d;
3351
+ // `galaxy` is false, or WHERE the galaxy goes: a placement key. Carrying the placement in the
3352
+ // same argument keeps it part of what the field is built from, so moving the galaxy rebuilds
3353
+ // the field exactly the way turning it on does.
3354
+ const { cx, cy, maxR, inner, oversample } = galaxy
3355
+ ? galaxyGeometry(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy)
3356
+ : { cx: 0, cy: 0, maxR: 0, inner: 0, oversample: 1 };
3357
+ const n = Math.round(galaxy ? Math.min(GALAXY_MAX, base * GALAXY_BOOST * oversample) : base);
3358
+ const out = [];
3359
+ for (let i = 0; i < n; i++) {
3360
+ const big = rnd() > 0.965;
3361
+ const tint = rnd();
3362
+ const star = {
3363
+ x: rnd() * pw, y: rnd() * ph,
3364
+ r: (big ? 1.1 + rnd() * 0.9 : 0.35 + rnd() * 0.6) * dpr,
3365
+ b: big ? 0.85 + rnd() * 0.15 : 0.25 + rnd() * 0.55,
3366
+ // TWINKLE RATE, in radians per millisecond (operator, 2026-09-12: "Some of the large stars
3367
+ // are pulsing relatively quickly. Need to slow all that shit down"). It was 0.0006-0.0030,
3368
+ // which is a cycle every 2.1 to 10.5 seconds -- at the fast end that is a blink, not a
3369
+ // twinkle, and the big stars wear a halo and a cross glint that make it impossible to miss.
3370
+ // Now 10.5 to 39 seconds, and a big star runs slower still.
3371
+ f: (0.00016 + rnd() * 0.00044) * (big ? 0.6 : 1),
3372
+ p: rnd() * Math.PI * 2,
3373
+ c: tint < 0.12 ? [255, 226, 180] : tint < 0.3 ? [180, 205, 255] : [235, 242, 255],
3374
+ big,
3375
+ };
3376
+ if (galaxy) {
3377
+ // Three populations, because arms alone read as a pinwheel drawn on black: a crowded bulge,
3378
+ // the arms themselves, and a thin halo that keeps the outskirts from going empty. Each
3379
+ // carries its own brightness, so the density difference becomes a BRIGHTNESS difference --
3380
+ // without that the arms were only slightly-closer dots of the same value as everything else.
3381
+ const roll = rnd();
3382
+ let rad, ang, lit;
3383
+ if (roll < 0.12) {
3384
+ // the nucleus. Crowded and bright enough to be the middle on its own: the painted core
3385
+ // this replaced was six stacked ellipses that read as hard grey banding, because the
3386
+ // gradient that would have smoothed them is exactly what the canvas rules forbid.
3387
+ rad = maxR * 0.17 * Math.sqrt(rnd());
3388
+ ang = rnd() * Math.PI * 2;
3389
+ lit = 1;
3390
+ // OLD STARS: a bulge is warm. The colour of a real spiral is not one colour -- its middle
3391
+ // is yellow with age and its arms are blue with youth, and giving each population its own
3392
+ // temperature is most of what makes the picture read as a galaxy rather than a pattern.
3393
+ star.c0 = star.c; // the neutral colour, for the switch
3394
+ star.c = tint < 0.55 ? [255, 214, 150] : [255, 234, 196];
3395
+ } else if (roll < 0.78) {
3396
+ const t = Math.pow(rnd(), 0.62); // crowded toward the middle
3397
+ rad = inner + (maxR - inner) * t;
3398
+ const arm = Math.floor(rnd() * GALAXY_ARMS) * ((Math.PI * 2) / GALAXY_ARMS);
3399
+ // three rolls make a rough bell: an arm with soft edges rather than a hard stripe,
3400
+ // narrowing outward so the arm stays a line instead of fanning into a smudge
3401
+ const width = 0.18 * (1.35 - 0.75 * (rad / maxR));
3402
+ ang = arm + Math.log(rad / inner) / GALAXY_TWIST + (rnd() + rnd() + rnd() - 1.5) * width;
3403
+ lit = 1.2 - 0.35 * (rad / maxR);
3404
+ // YOUNG STARS: the arms are where stars are born, and they burn blue-white
3405
+ star.c0 = star.c;
3406
+ star.c = tint < 0.35 ? [160, 198, 255] : tint < 0.72 ? [205, 224, 255] : [242, 246, 255];
3407
+ } else {
3408
+ // BETWEEN THE ARMS (operator, 2026-09-12: "more stars between arms. I needs to look
3409
+ // awe-inspiring at the majesty and grandness of the universe"). This population was 6% of
3410
+ // the sky and it showed: the space between the arms went to black, which reads as a
3411
+ // pinwheel on a void rather than a galaxy standing in a star field. 22% now (operator,
3412
+ // again: "I want to see more twinkling of stuff between each of teh spiral arms"), and
3413
+ // bright enough that their twinkle -- the full swing, since they are small -- actually
3414
+ // shows, while still sitting back so the arms carry.
3415
+ rad = maxR * (0.16 + 0.84 * rnd());
3416
+ ang = rnd() * Math.PI * 2;
3417
+ lit = 0.46;
3418
+ }
3419
+ // fewer haloed giants than an even sky: at seven times the stars they read as clutter
3420
+ star.big = star.big && rnd() < 0.4;
3421
+ star.r *= 0.85;
3422
+ star.b = Math.min(1, star.b * lit);
3423
+ star.gr = rad;
3424
+ star.ga = ang;
3425
+ star.x = cx + rad * Math.cos(ang);
3426
+ star.y = cy + rad * GALAXY_FLATTEN * Math.sin(ang);
3427
+ }
3428
+ out.push(star);
3429
+ }
3430
+ return out;
3431
+ }
3432
+ // THE GAS, BAKED (operator, 2026-09-12: "It really slows down rendering when enabled, I have to
3433
+ // turn galaxy off to get decent framerate"). The nebulae are a few hundred translucent ellipses
3434
+ // hundreds of pixels across, and the dust lanes a few hundred more; filled afresh every frame
3435
+ // they were tens of megapixels of blending -- THAT was the frame, not the stars. The galaxy turns
3436
+ // as a rigid body, so the gas is painted ONCE into an offscreen bitmap in the disc's own
3437
+ // coordinates (unturned, unflattened) and each frame is one drawImage through a matrix that
3438
+ // turns, squashes and places it. The bitmap covers only the part of the disc that can ever
3439
+ // reach the panel (centre to the farthest corner), at a scale that keeps it under GAS_MAX
3440
+ // pixels a side -- the gas is soft, and a quarter-scale bitmap of it drawn up is the same gas.
3441
+ // Rebuilt when the field is (a new f), and when the brightness or a layer switch changes.
3442
+ // A canvas that cannot make an offscreen one (the tests' recording canvas) gets the live loops.
3443
+ const GAS_MAX = 2048;
3444
+ function gasLayer(f, pw, ph, bright, opts) {
3445
+ const nebulae = opts.nebulae !== false && !!f.nebulae, dust = opts.dust !== false && !!f.dust;
3446
+ if (!nebulae && !dust) return null;
3447
+ const key = `${bright.toFixed(3)}|${nebulae}|${dust}`;
3448
+ if (f.gas && f.gas.key === key) return f.gas;
3449
+ if (f.gas === null) return null; // tried once and could not
3450
+ let bmp = null;
3451
+ try {
3452
+ bmp = typeof globalThis.document?.createElement === 'function' ? document.createElement('canvas') : null;
3453
+ if (!bmp && typeof globalThis.OffscreenCanvas === 'function') bmp = new OffscreenCanvas(1, 1);
3454
+ } catch { bmp = null; }
3455
+ const g = bmp?.getContext?.('2d');
3456
+ if (!g || typeof g.ellipse !== 'function') { f.gas = null; return null; }
3457
+ const { cx, cy } = f;
3458
+ 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;
3459
+ const q = Math.min(1, GAS_MAX / (2 * reach));
3460
+ const D = Math.ceil(2 * reach * q);
3461
+ bmp.width = D; bmp.height = D;
3462
+ g.setTransform(q, 0, 0, q, D / 2, D / 2);
3463
+ if (nebulae) {
3464
+ for (const c of f.nebulae) {
3465
+ const nx = c.gr * Math.cos(c.ga), ny = c.gr * Math.sin(c.ga);
3466
+ for (const p of c.puffs) {
3467
+ g.fillStyle = `rgba(${c.tint},${(p.a * bright).toFixed(4)})`;
3468
+ g.beginPath();
3469
+ g.ellipse(nx + p.dx, ny + p.dy, p.rx, p.rx * p.sq, 0, 0, Math.PI * 2);
3470
+ g.fill();
3471
+ }
3472
+ }
3473
+ }
3474
+ if (dust) {
3475
+ for (const d of f.dust) {
3476
+ const dx0 = d.gr * Math.cos(d.ga), dy0 = d.gr * Math.sin(d.ga);
3477
+ for (const p of d.puffs) {
3478
+ g.fillStyle = `rgba(0,0,0,${p.a.toFixed(3)})`;
3479
+ g.beginPath();
3480
+ g.ellipse(dx0 + p.dx, dy0 + p.dy, p.rx, p.rx * p.sq, d.ga, 0, Math.PI * 2);
3481
+ g.fill();
3482
+ }
3483
+ }
3484
+ }
3485
+ g.setTransform(1, 0, 0, 1, 0, 0);
3486
+ f.gas = { key, bmp, q, D };
3487
+ return f.gas;
3488
+ }
3489
+
3490
+ // THE STARS, BATCHED (operator, 2026-09-12: "is there any way to improve the galaxy speed behind
3491
+ // the game? ... I have to turn galaxy off to get decent framerate. nothing we can optimize
3492
+ // there?"). There was. The first cut set ctx.fillStyle once PER STAR -- a colour string built
3493
+ // and parsed forty thousand times a frame -- and that, not the arithmetic, was the frame. Now a
3494
+ // star's brightness is rounded to one of STAR_LEVELS steps (a thirtieth of the range, under any
3495
+ // eye's notice on a one-pixel point) and the field is sorted into colour x level BUCKETS with a
3496
+ // counting sort over preallocated typed arrays (no allocation a frame), so the canvas hears one
3497
+ // fillStyle and one fill() per bucket -- a few hundred at most -- with every star of that shade
3498
+ // as one rect() in the path. The picture is the same; the giants keep their halo and glint drawn
3499
+ // one by one, since there are few of them.
3500
+ const STAR_LEVELS = 48;
3501
+ function starBuckets(f, colours) {
3502
+ // built once per field (and once per colour switch): the palette, each star's palette index,
3503
+ // and the scratch arrays the per-frame sort runs in
3504
+ const key = colours ? 'ci' : 'ci0';
3505
+ if (f.bk?.key === key) return f.bk;
3506
+ const pal = [], idx = new Map();
3507
+ const ci = new Int16Array(f.stars.length);
3508
+ f.stars.forEach((s, i) => {
3509
+ const c = (colours || !s.c0 ? s.c : s.c0).join(',');
3510
+ let k = idx.get(c);
3511
+ if (k === undefined) { k = pal.length; idx.set(c, k); pal.push(c); }
3512
+ ci[i] = k;
3513
+ });
3514
+ const B = pal.length * STAR_LEVELS;
3515
+ const styles = new Array(B);
3516
+ for (let b = 0; b < B; b++) styles[b] = `rgba(${pal[b / STAR_LEVELS | 0]},${((b % STAR_LEVELS) / (STAR_LEVELS - 1)).toFixed(3)})`;
3517
+ const n = f.stars.length;
3518
+ 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) };
3519
+ return f.bk;
3520
+ }
3521
+ function drawStarField(ctx, f, pw, ph, dpr, now, spin, galaxy, bright, opts) {
3522
+ const colours = opts.starColours !== false, glints = opts.starGlints !== false;
3523
+ const stars = f.stars, n = stars.length;
3524
+ const bk = starBuckets(f, colours);
3525
+ const { px, py, bucket, count, order, styles, ci, B } = bk;
3526
+ count.fill(0);
3527
+ const cs = Math.cos(spin), sn = Math.sin(spin);
3528
+ for (let i = 0; i < n; i++) {
3529
+ const s = stars[i];
3530
+ let x, y;
3531
+ if (galaxy) {
3532
+ // one rotation for the whole field: cos(a + spin) expanded, so no trig per star
3533
+ const ca = s.ca ?? (s.ca = Math.cos(s.ga)), sa = s.sa ?? (s.sa = Math.sin(s.ga));
3534
+ x = f.cx + s.gr * (ca * cs - sa * sn);
3535
+ y = f.cy + s.gr * GALAXY_FLATTEN * (sa * cs + ca * sn);
3536
+ // the disc reaches past the panel now that its middle is in the corner: most of it is off
3537
+ // screen at any moment, and the cheapest thing to do with those stars is nothing
3538
+ if (x < -4 || x > pw + 4 || y < -4 || y > ph + 4) { bucket[i] = -1; continue; }
3539
+ } else { x = s.x; y = s.y; }
3540
+ px[i] = x; py[i] = y;
3541
+ const a = Math.min(1, starAlpha(s, now) * bright);
3542
+ const b = ci[i] * STAR_LEVELS + Math.round(a * (STAR_LEVELS - 1));
3543
+ bucket[i] = b;
3544
+ count[b + 1]++;
3545
+ }
3546
+ for (let b = 0; b < B; b++) count[b + 1] += count[b]; // prefix sums: where each bucket starts
3547
+ const at = count.slice(0, B);
3548
+ for (let i = 0; i < n; i++) { const b = bucket[i]; if (b >= 0) order[at[b]++] = i; }
3549
+ for (let b = 0; b < B; b++) {
3550
+ const from = count[b], to = count[b + 1];
3551
+ if (from === to) continue;
3552
+ ctx.fillStyle = styles[b];
3553
+ ctx.beginPath();
3554
+ 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); }
3555
+ ctx.fill();
3556
+ }
3557
+ if (!glints) return;
3558
+ // the giants' halo and cross, one by one: a few hundred at most
3559
+ for (let i = 0; i < n; i++) {
3560
+ const s = stars[i];
3561
+ if (!s.big || bucket[i] < 0) continue;
3562
+ const a = Math.min(1, starAlpha(s, now) * bright);
3563
+ const c = colours || !s.c0 ? s.c : s.c0;
3564
+ ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${(a * 0.12).toFixed(3)})`;
3565
+ ctx.beginPath(); ctx.arc(px[i], py[i], s.r * 4, 0, Math.PI * 2); ctx.fill();
3566
+ ctx.strokeStyle = `rgba(${c[0]},${c[1]},${c[2]},${(a * 0.5).toFixed(3)})`;
3567
+ ctx.lineWidth = dpr * 0.6;
3568
+ ctx.beginPath();
3569
+ ctx.moveTo(px[i] - s.r * 5, py[i]); ctx.lineTo(px[i] + s.r * 5, py[i]);
3570
+ ctx.moveTo(px[i], py[i] - s.r * 5); ctx.lineTo(px[i], py[i] + s.r * 5);
3571
+ ctx.stroke();
3572
+ }
3573
+ }
3574
+ export function starAlpha(star, now) {
3575
+ const w = 0.5 + 0.5 * Math.sin(now * star.f + star.p);
3576
+ // How DEEP the pulse goes, not just how fast. A small star may fade to a third of itself and
3577
+ // still read as a twinkle; a big one doing that reads as a light being switched on and off,
3578
+ // because its halo and glint swing with it. So the giants shimmer between roughly half and
3579
+ // full, and the faint ones keep the wider swing that makes a sky look alive.
3580
+ const floor = star.big ? 0.55 : 0.3;
3581
+ return star.b * (floor + (1 - floor) * w * w);
3582
+ }
3583
+ function drawStars(ctx, pw, ph, dpr, now, opts = {}) {
3584
+ const key = ctx.canvas ?? NO_CANVAS;
3585
+ const density = Number.isFinite(opts.starDensity) ? opts.starDensity : 1;
3586
+ const bright = Number.isFinite(opts.starBrightness) ? Math.min(1.5, Math.max(0, opts.starBrightness)) : 1;
3587
+ // false when off, otherwise WHERE it sits -- so the cache key below rebuilds the field when the
3588
+ // operator moves it, the same way it does when they turn it on
3589
+ const galaxy = opts.galaxy
3590
+ ? (GALAXY_PLACEMENTS[opts.galaxyAt] ? opts.galaxyAt : GALAXY_AT_DEFAULT)
3591
+ : false;
3592
+ let f = STARS.get(key);
3593
+ // the field is rebuilt when the density or the SHAPE changes as well as the size: it is a seeded
3594
+ // scatter, so the same density always gives the same sky back. Turning is NOT a rebuild -- the
3595
+ // stars carry polar coordinates and only the angle advances, once per frame for all of them.
3596
+ if (!f || f.pw !== pw || f.ph !== ph || f.density !== density || f.galaxy !== galaxy) {
3597
+ const g = galaxyGeometry(pw, ph, galaxy || undefined);
3598
+ f = {
3599
+ pw, ph, density, galaxy, cx: g.cx, cy: g.cy,
3600
+ stars: starField(pw, ph, dpr, 7, density, galaxy),
3601
+ // built once with the field, and turned with it: a cloud is placed in polar coordinates
3602
+ nebulae: galaxy ? nebulaClouds(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy) : null,
3603
+ // and the deep field behind everything, galaxy or not
3604
+ far: farGalaxies(pw, ph),
3605
+ dust: galaxy ? dustLanes(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy) : null,
3606
+ clusters: galaxy ? starClusters(pw, ph, galaxy === true ? GALAXY_AT_DEFAULT : galaxy) : null,
3607
+ };
3608
+ STARS.set(key, f);
3609
+ }
3610
+ ctx.__starBright = bright;
3611
+ const spin = galaxy ? now * GALAXY_SPIN : 0;
3612
+ // the deep field first: distant galaxies, small and still
3613
+ if (f.far && opts.galaxies !== false && typeof ctx.ellipse === 'function') {
3614
+ for (const g of f.far) {
3615
+ for (const [k, a] of [[1, 0.035], [0.72, 0.05], [0.48, 0.08], [0.22, 0.16]]) {
3616
+ ctx.fillStyle = `rgba(${g.tint},${(a * bright).toFixed(3)})`;
3617
+ ctx.beginPath();
3618
+ ctx.ellipse(g.x, g.y, g.rx * k * dpr, g.rx * k * g.ratio * dpr, g.rot, 0, Math.PI * 2);
3619
+ ctx.fill();
3620
+ }
3621
+ }
3622
+ }
3623
+ // then the gas: the stars stand IN it, not behind it. Baked to a bitmap where the page can
3624
+ // make one (gasLayer) and drawn turned in one call; the live loops below are the fallback.
3625
+ const gas = galaxy ? gasLayer(f, pw, ph, bright, opts) : null;
3626
+ if (gas) {
3627
+ const q = gas.q, cs = Math.cos(spin), sn = Math.sin(spin);
3628
+ // local (unflattened, unturned) disc -> panel: turn by the spin, squash y by the flatten,
3629
+ // scale up by 1/q, land on the centre -- the same map the stars go through, as one matrix
3630
+ ctx.setTransform(cs / q, (GALAXY_FLATTEN * sn) / q, -sn / q, (GALAXY_FLATTEN * cs) / q, f.cx, f.cy);
3631
+ ctx.drawImage(gas.bmp, -gas.D / 2, -gas.D / 2);
3632
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3633
+ }
3634
+ if (!gas && galaxy && f.nebulae && opts.nebulae !== false && typeof ctx.ellipse === 'function') {
3635
+ for (const c of f.nebulae) {
3636
+ const ang = c.ga + spin;
3637
+ const nx = f.cx + c.gr * Math.cos(ang);
3638
+ const ny = f.cy + c.gr * GALAXY_FLATTEN * Math.sin(ang);
3639
+ for (const p of c.puffs) {
3640
+ ctx.fillStyle = `rgba(${c.tint},${(p.a * bright).toFixed(4)})`;
3641
+ ctx.beginPath();
3642
+ ctx.ellipse(nx + p.dx, ny + p.dy * GALAXY_FLATTEN, p.rx, p.rx * GALAXY_FLATTEN * p.sq, 0, 0, Math.PI * 2);
3643
+ ctx.fill();
3644
+ }
3645
+ }
3646
+ }
3647
+ // the dust over the gas and under the stars: dark ribbons on the inner edge of each arm
3648
+ if (!gas && galaxy && f.dust && opts.dust !== false && typeof ctx.ellipse === 'function') {
3649
+ for (const d of f.dust) {
3650
+ const ang = d.ga + spin;
3651
+ const dx0 = f.cx + d.gr * Math.cos(ang), dy0 = f.cy + d.gr * GALAXY_FLATTEN * Math.sin(ang);
3652
+ for (const p of d.puffs) {
3653
+ ctx.fillStyle = `rgba(0,0,0,${p.a.toFixed(3)})`;
3654
+ ctx.beginPath();
3655
+ ctx.ellipse(dx0 + p.dx, dy0 + p.dy * GALAXY_FLATTEN, p.rx, p.rx * GALAXY_FLATTEN * p.sq, ang, 0, Math.PI * 2);
3656
+ ctx.fill();
3657
+ }
3658
+ }
3659
+ }
3660
+ drawStarField(ctx, f, pw, ph, dpr, now, spin, galaxy, bright, opts);
3661
+ // the clusters last, over the field: dense specks with a fuzzy edge, steady (no twinkle)
3662
+ if (galaxy && f.clusters && opts.clusters !== false) {
3663
+ for (const k of f.clusters) {
3664
+ const ang = k.ga + spin;
3665
+ const kx = f.cx + k.gr * Math.cos(ang), ky = f.cy + k.gr * GALAXY_FLATTEN * Math.sin(ang);
3666
+ if (kx < -30 || kx > pw + 30 || ky < -30 || ky > ph + 30) continue;
3667
+ ctx.fillStyle = `rgba(255,240,215,${(0.05 * bright).toFixed(3)})`;
3668
+ ctx.fillRect(kx - k.size, ky - k.size, k.size * 2, k.size * 2);
3669
+ for (const q of k.stars) {
3670
+ ctx.fillStyle = `rgba(255,244,222,${Math.min(1, q.b * bright).toFixed(3)})`;
3671
+ ctx.fillRect(kx + q.dx - q.r, ky + q.dy - q.r, q.r * 2, q.r * 2);
3672
+ }
3673
+ }
3674
+ }
3675
+ }
3676
+
3677
+ // There is deliberately NO painted core. The first cut stacked six faint ellipses at the middle,
3678
+ // and rendered they read as hard grey rings rather than a glow -- the gradient that would smooth
3679
+ // them is exactly what the canvas rules here forbid (no globalAlpha, no composite modes, no
3680
+ // shadowBlur: viewer-canvas-rules.test.js). The nucleus is stars instead: the bulge population is
3681
+ // dense and at full brightness, which looks like a core because it is one.
3682
+
3683
+ function paintFrame(ctx, geom, frame, opts, view, gridN, blockRows, gridH = gridN) {
3684
+ const { pw, ph, dpr } = geom;
3685
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3686
+ // cleared first, so a TRANSPARENT background (Tetrust's well, laid over its own sky canvas) shows
3687
+ // what is behind the canvas rather than the last frame
3688
+ ctx.clearRect(0, 0, pw, ph);
3689
+ ctx.fillStyle = opts.background;
3690
+ ctx.fillRect(0, 0, pw, ph);
3691
+ if (starsOn(opts)) drawStars(ctx, pw, ph, dpr || 1, view.now ?? 0, opts);
3692
+ // with nothing on the board -- every block in the air between two layouts (a viewer-mode switch)
3693
+ // -- the oblique board still draws itself: its transform is a constant, not fitted to the blocks
3694
+ if (!frame.bounds && !opts.oblique) return;
3695
+
3696
+ // BOTTOM-LEFT IS FLUSH WITH THE PANEL, ALWAYS.
3697
+ //
3698
+ // No centring, no measured bounds, no padding: the board's own corner
3699
+ // (0,0) is mapped to the canvas's bottom-left corner and the far corner to
3700
+ // the top-right. That is why the view cannot slide. Fitting to MEASURED
3701
+ // bounds -- even the grid's -- still moved things, because with rows
3702
+ // flipped the projected y range is NEGATIVE while the vanishing point was
3703
+ // computed as positive, so the growth expansion skewed the bounds and
3704
+ // pushed the picture down.
3705
+ //
3706
+ // Blocks in flight reach past these edges and are drawn off-canvas. The
3707
+ // operator has accepted that twice; a locked view is worth more than
3708
+ // keeping every block inside the frame.
3709
+ const bw = Math.max(1, gridN * opts.unit);
3710
+ const bh = Math.max(1, gridH * opts.unit);
3711
+ // Under the oblique camera height is drawn up and to the right, so the
3712
+ // board keeps its bottom-left corner flush and a CONSTANT strip is left
3713
+ // along the top and right for the tallest cube, the dome and the flight --
3714
+ // constant, so the view still cannot move between frames or refreshes.
3715
+ const of = opts.oblique ? obliqueFit(pw, ph, gridN, gridH, opts) : null;
3716
+ 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 };
3717
+ // pinhole: grid x 0..bw -> canvas 0..pw, grid y -bh..0 -> canvas ph..0 (row 0 on the floor)
3718
+ // oblique: the board centred, the sphere filling the rest (obliqueFit)
3719
+ ctx.setTransform(fit.scaleX, 0, 0, fit.scaleY, fit.tx, fit.ty);
3720
+ frame.__fit = { scaleX: fit.scaleX, scaleY: fit.scaleY, tx: fit.tx, ty: fit.ty, ph, unit: opts.unit };
3721
+ ctx.lineWidth = Math.max(0.5, 0.6 / Math.min(fit.scaleX, fit.scaleY));
3722
+ ctx.lineJoin = 'round';
3723
+
3724
+ // The ground first, then the shadows lying on it, then the glowing layer
3725
+ // (drawGrid returns it) -- light, so no shadow dims it -- then the cubes.
3726
+ let glow = opts.grid ? drawGrid(ctx, view, opts, gridN, blockRows, gridH) : null;
3727
+ let wall = opts.axes ? () => drawAxes(ctx, view, opts.axes, gridN) : null;
3728
+ for (const op of frame.ops) {
3729
+ if (glow && op.face !== 'shadow') { glow(); glow = null; }
3730
+ if (wall && op.face !== 'shadow') { wall(); wall = null; }
3731
+ const p = op.points;
3732
+ ctx.beginPath();
3733
+ ctx.moveTo(p[0].x, p[0].y);
3734
+ for (let i = 1; i < p.length; i++) ctx.lineTo(p[i].x, p[i].y);
3735
+ ctx.closePath();
3736
+ ctx.fillStyle = op.fill;
3737
+ ctx.fill();
3738
+ // The seam's colour comes from the op, already scaled by the tile's
3739
+ // alpha, so an outline fades exactly as its tile does. A fixed colour
3740
+ // here is what left black frames hanging where departing blocks had been.
3741
+ // `always`: an op whose stroke IS the point (the neon edge) draws whether or not the dark
3742
+ // seam (Stone edges) is on -- it replaces the seam rather than accompanying it
3743
+ if (op.stroke && (opts.edges || op.always)) {
3744
+ ctx.strokeStyle = op.stroke;
3745
+ if (op.lw) { const lw0 = ctx.lineWidth; ctx.lineWidth = lw0 * op.lw; ctx.stroke(); ctx.lineWidth = lw0; }
3746
+ else ctx.stroke();
3747
+ }
3748
+ }
3749
+ if (glow) glow(); // a board with no cubes on it
3750
+ if (wall) wall();
3751
+ if (opts.axes?.line) priceLine(ctx, view, opts.axes);
3752
+ if (opts.axes) axisLabels(ctx, view, opts.axes, gridN, fit.scaleX, dpr || 1);
3753
+ // THE AGENT'S OWN GEOMETRY, dispatched from the registry (agents.js). The light cycles' walls
3754
+ // and the lightning ball were two hard-coded calls here; with fifty agents this is the seam.
3755
+ // drawCycles and drawBall stay in this module -- lightcycle-crash.test.js imports drawCycles by
3756
+ // name and calls it with a hand-built view -- and the registry simply points at them.
3757
+ const agentDraw = view.fx?.kind ? AGENTS[view.fx.kind]?.draw : null;
3758
+ if (agentDraw) agentDraw(ctx, view, ctx.lineWidth, { drawCycles, drawBall, project });
3759
+ else { drawCycles(ctx, view, ctx.lineWidth); drawBall(ctx, view, ctx.lineWidth); drawFireworks(ctx, view, ctx.lineWidth); drawSupernova(ctx, view, ctx.lineWidth); drawBlackHole(ctx, view, ctx.lineWidth); drawScanCurtain(ctx, view, ctx.lineWidth); }
3760
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3761
+ }
3762
+
3763
+ // THE LOOK (operator, 2026-09-11: "absolutely spectacular ... make them feel
3764
+ // like they are in The Matrix"): a green-black field, a phosphor floor and
3765
+ // grid, and Tetris cells with a metallic finish for transactions (see
3766
+ // buildScene in blockscene3d.js). The blocks keep the feerate palette
3767
+ // (feepalette.js) -- the environment is themed, the data colours are not.
3768
+ // Digital rain was tried over the top the same day and removed at the
3769
+ // operator's call ("looks stupid and adds nothing practical"): atmosphere that
3770
+ // carries no data is not worth a second animation loop.
3771
+ export const DEFAULTS = {
3772
+ resolution: 44,
3773
+ blockVbytes: 1000000,
3774
+ background: 'rgba(2,9,6,1)',
3775
+ edges: true,
3776
+ seamAlpha: 0.38, // the dark seam round each stone; scaled by its fade
3777
+ idleFx: true, // varied effects across the board at rest (see fxAt)
3778
+ idleEvery: [5000, 9000], // between effects while the board stays still (7-13 s until 2026-09-11)
3779
+ idleFirst: [800, 1600], // the first one after a transition lands ("trigger any effects sooner")
3780
+ // the block template, drawn on the ground
3781
+ grid: true,
3782
+ gridStep: 4,
3783
+ floor: 'rgba(40,82,70,1)', // the deck's slate teal: mid-tone, so a shadow shows (groundLayers)
3784
+ vanish: { fx: 0.5, fy: 0.5 }, // (overhead camera only) where the pinhole sits
3785
+ // THE DEFAULT CAMERA IS OBLIQUE, ON A CURVED BOARD (2026-09-11; see
3786
+ // project()): height is an offset up and a little right, flight altitude is
3787
+ // compressed into `headroom`, and the board domes toward the viewer --
3788
+ // operator: "it would be more compelling if it was a partial spherical board,
3789
+ // instead of a flat surface. Would really help with the 3D". Zero on every
3790
+ // edge, so the board stays flush; set oblique: null for the overhead pinhole.
3791
+ // Tuned from photographs of four settings side by side (2026-09-11): 0.28 /
3792
+ // 0.55 with a 12-unit dome made the largest cubes towers and heaped the
3793
+ // board up off its own grid. A steeper camera still shows every cube's top
3794
+ // and two sides, foreshortened, and a 7-unit dome bows the board visibly.
3795
+ // ...and at full panel size 0.15 / 0.36 with a 7-unit dome still heaped
3796
+ // the tall cubes up the middle; the calmer 0.13 / 0.32 with a 5-unit dome
3797
+ // keeps the board reading as a board while it still visibly bows.
3798
+ // (headroom 8 -> 10 when the board was centred: the margin is now the same
3799
+ // on every side, and the tallest back-row cube on the curve needs 10)
3800
+ oblique: { ox: 0.13, oy: 0.32, headroom: 10, flight: 120 }, // flight: the highest a reshuffle climbs, where the panel has room
3801
+ dome: 5,
3802
+ gridGlow: 'rgba(40,255,140,0.05)',
3803
+ gridColor: 'rgba(60,255,150,0.16)',
3804
+ gridEdgeColor: 'rgba(120,255,190,1)',
3805
+ // the neon grid inside the board (boardGridLayers)
3806
+ // SOLID cores (operator, 2026-09-11: "the glowing grid still has shadows cast on
3807
+ // it. I want that neon light cutting through darkness entirely"): a
3808
+ // see-through line reads dimmer wherever the floor under it is shadowed, and
3809
+ // additive blending is off the table (no composite modes), so the lines
3810
+ // themselves are opaque and read the same in light and shadow; only the halo
3811
+ // and glow round them are translucent.
3812
+ neonCell: 'rgba(50,190,125,1)',
3813
+ neonHalo: 'rgba(40,255,140,0.07)', // a wide faint halo round each plate line: the glow
3814
+ neonGlow: 'rgba(40,255,140,0.2)',
3815
+ neonLine: 'rgba(170,255,210,1)',
3816
+ blockLineColor: 'rgba(200,255,120,0.95)',
3817
+ // level of detail, in DEVICE pixels of a stone's side: below facetPx a
3818
+ // stone is a plain slab, below crownPx it has facets but no crown or glint
3819
+ facetPx: 9,
3820
+ crownPx: 18,
3821
+ unit: 6,
3822
+ zUnit: 6,
3823
+ // how hard a receding block shrinks; the choreography drives `depth`
3824
+ persp: 0.55,
3825
+ edgeMargin: 0.06, // reserved by the fit AND the ceiling on the flight swell
3826
+ pad: 3, // canvas padding; small, because the board should fill the panel
3827
+ };
3828
+
3829
+ // --- hover ---------------------------------------------------------------
3830
+ // Which block is under the pointer. The transform is a plain scale plus a
3831
+ // translate, so inverting it is arithmetic rather than a search: screen ->
3832
+ // grid, then the one resting tile whose footprint contains that cell. Resting
3833
+ // tiles never overlap (the packer guarantees it), so there is exactly one
3834
+ // answer and no z-order to resolve.
3835
+ //
3836
+ // Only while the picture is SETTLED. Mid-flight a block is not where its
3837
+ // footprint says, and reporting a transaction the pointer is not over is
3838
+ // worse than reporting nothing.
3839
+ // IS THE BOARD AT REST? (operator, 2026-09-11: "a button for 'Trigger Refresh
3840
+ // Now' that is only enabled when the animation is idle"). True once a board
3841
+ // has been drawn, its transition has landed, and no newer layout is waiting
3842
+ // its turn; idle effects do not count as motion.
3843
+ export function viewerIdle(canvas) {
3844
+ const st = canvas ? STATE.get(canvas) : null;
3845
+ return !!(st && st.plan && st.settled && !st.pending);
3846
+ }
3847
+
3848
+ // The topmost face (not a shadow) whose polygon holds the point, by the even-odd rule. Pure.
3849
+ export function hitOps(ops, x, y) {
3850
+ for (let i = ops.length - 1; i >= 0; i--) {
3851
+ const op = ops[i];
3852
+ const p = op.points;
3853
+ if (op.face === 'shadow' || !p || p.length < 3) continue;
3854
+ let inside = false;
3855
+ for (let a = 0, b = p.length - 1; a < p.length; b = a++) {
3856
+ 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;
3857
+ }
3858
+ if (inside) return op.txid;
3859
+ }
3860
+ return null;
3861
+ }
3862
+
3863
+ export function hitTest(canvas, clientX, clientY) {
3864
+ const st = STATE.get(canvas);
3865
+ if (!st || !st.lastFit || !st.restTiles || !st.settled) return null;
3866
+ const rect = canvas.getBoundingClientRect ? canvas.getBoundingClientRect() : { left: 0, top: 0, width: canvas.clientWidth, height: canvas.clientHeight };
3867
+ const dpr = (globalThis.window && window.devicePixelRatio) || 1;
3868
+ const px = (clientX - rect.left) * dpr;
3869
+ const py = (clientY - rect.top) * dpr;
3870
+ const f = st.lastFit;
3871
+ // WHAT IS DRAWN THERE, not whose footprint is under it: a market candle floats far above its
3872
+ // footprint, and a tall cube covers the floor behind it. The topmost face drawn at the pointer.
3873
+ if (st.lastOps) {
3874
+ const id = hitOps(st.lastOps, (px - (f.tx ?? 0)) / f.scaleX, (py - (f.ty ?? f.ph)) / f.scaleY);
3875
+ return id == null ? null : st.restTiles.find((t) => String(t.txid) === String(id)) ?? null;
3876
+ }
3877
+ const gx = ((px - (f.tx ?? 0)) / f.scaleX) / f.unit;
3878
+ const gy = -((py - (f.ty ?? f.ph)) / f.scaleY) / f.unit;
3879
+ for (const tile of st.restTiles) {
3880
+ if (gx >= tile.x && gx < tile.x + tile.s && gy >= tile.y && gy < tile.y + tile.s) return tile;
3881
+ }
3882
+ return null;
3883
+ }
3884
+
3885
+ function fmtVb(n) { return n >= 1000 ? `${(n / 1000).toFixed(1)} kvB` : `${Math.round(n)} vB`; }
3886
+
3887
+ // HOVER GLOW AND CLICK-THROUGH (operator, 2026-09-11: "when I mouse-over on a block in the block
3888
+ // space, I want it to glow and then fade out when I move the mouse off it. if I click a glowing
3889
+ // box, I want it to take me to the txid in the explorer view"). The tile under the pointer lights
3890
+ // up over GLOW_IN ms and, released, fades over GLOW_OUT ms -- a re-hover mid-fade resumes from
3891
+ // where it is. The loop runs only while a glow is changing; a steadily lit tile costs nothing.
3892
+ const GLOW_IN = 120, GLOW_OUT = 600;
3893
+ const TXID = /^[0-9a-f]{64}$/i;
3894
+ const perfNow = () => (globalThis.performance && performance.now()) || 0;
3895
+ export function glowLevel(g, t) {
3896
+ if (!g) return 0;
3897
+ const up = (on) => Math.min(1, (g.from ?? 0) + Math.max(0, on - g.on) / GLOW_IN);
3898
+ if (g.off == null) return up(t);
3899
+ return Math.max(0, up(g.off) * (1 - Math.max(0, t - g.off) / GLOW_OUT));
3900
+ }
3901
+ function setHover(st, id) {
3902
+ const now = perfNow();
3903
+ st.glow ??= new Map();
3904
+ if (st.hoverId) { const g = st.glow.get(st.hoverId); if (g && g.off == null) g.off = now; }
3905
+ st.hoverId = id;
3906
+ if (id) st.glow.set(id, { on: now, off: null, from: glowLevel(st.glow.get(id), now) });
3907
+ st.dirty = true;
3908
+ st.wake?.();
3909
+ }
3910
+ function glowMap(st, t) {
3911
+ if (!st.glow?.size) return null;
3912
+ const m = new Map();
3913
+ for (const [id, g] of st.glow) {
3914
+ const v = glowLevel(g, t);
3915
+ if (v > 0.005) m.set(id, v);
3916
+ else if (g.off != null) st.glow.delete(id);
3917
+ }
3918
+ return m.size ? m : null;
3919
+ }
3920
+ function glowAnimating(st, t) {
3921
+ for (const g of st.glow?.values() ?? []) {
3922
+ if (g.off != null ? glowLevel(g, t) > 0.005 : glowLevel(g, t) < 1) return true;
3923
+ }
3924
+ return false;
3925
+ }
3926
+
3927
+ function bindHover(canvas, st) {
3928
+ if (st.hoverBound || !canvas.addEventListener) return;
3929
+ st.hoverBound = true;
3930
+ const tip = canvas.parentElement?.querySelector?.('.goggles-tip') ?? null;
3931
+ const hide = () => tip?.classList?.add('hidden');
3932
+ canvas.addEventListener('pointermove', (e) => {
3933
+ // A BOARD CAN REFUSE HOVER (`hover: false`; operator, 2026-09-12: "In blockout, don't highlite
3934
+ // the paddle block controller when I mouse over it"). On a data board the pointer picks out a
3935
+ // transaction; on a playfield it is holding the bat, so lighting whatever is under it -- and
3936
+ // offering a tooltip and a pointer cursor for it -- is noise. The flag is read from the state
3937
+ // rather than captured here, so it follows the option rather than whichever draw bound first.
3938
+ if (st.noHover) return;
3939
+ const hit = hitTest(canvas, e.clientX ?? 0, e.clientY ?? 0);
3940
+ const hid = hit ? String(hit.txid) : null;
3941
+ if (hid !== (st.hoverId ?? null)) setHover(st, hid);
3942
+ if (canvas.style) canvas.style.cursor = hit && TXID.test(hid) ? 'pointer' : '';
3943
+ if (!tip) return;
3944
+ if (!hit) { hide(); return; }
3945
+ if (hit.label) { tip.textContent = hit.label; tip.classList.remove('hidden'); return; }
3946
+ const rate = hit.rate ?? 0;
3947
+ const id = String(hit.txid);
3948
+ const label = id.startsWith('aggregate@')
3949
+ ? 'aggregated small transactions'
3950
+ : (id.length > 20 ? id.slice(0, 12) + '…' + id.slice(-6) : id);
3951
+ tip.textContent = `${label} · ${fmtVb(hit.vsize ?? 0)} · ${rate.toFixed(rate < 10 ? 2 : 1)} sat/vB`;
3952
+ tip.classList.remove('hidden');
3953
+ });
3954
+ canvas.addEventListener('pointerleave', () => { hide(); setHover(st, null); if (canvas.style) canvas.style.cursor = ''; });
3955
+ // a transaction opens in the explorer; aggregate pieces and market candles only glow
3956
+ canvas.addEventListener('click', (e) => {
3957
+ if (st.noHover) return; // a click on a playfield serves the ball, it does not navigate
3958
+ const hit = hitTest(canvas, e.clientX ?? 0, e.clientY ?? 0);
3959
+ if (hit && TXID.test(String(hit.txid)) && globalThis.location) globalThis.location.hash = `#explorer/tx/${String(hit.txid).toLowerCase()}`;
3960
+ });
3961
+ canvas.addEventListener('pointercancel', hide);
3962
+ }
3963
+
3964
+ export function render3d(canvas, cells, options = {}) {
3965
+ if (!canvas || !canvas.getContext) return null;
3966
+ const opts = { ...DEFAULTS, ...options };
3967
+ const ctx = canvas.getContext('2d');
3968
+ if (!ctx) return null;
3969
+
3970
+ let st = STATE.get(canvas);
3971
+ if (!st) {
3972
+ st = { prev: [], raf: null, plan: null, dirty: false };
3973
+ STATE.set(canvas, st);
3974
+ }
3975
+ st.noHover = opts.hover === false; // see bindHover: playfields do not light up under the pointer
3976
+
3977
+ // FLUSH WITH THE VIEWPORT (operator, 2026-09-11: "Blocks are being shown
3978
+ // off the viewport. I want the grid bounds flush with the viewport"). One
3979
+ // block's worth packs to very nearly resolution x resolution, but square
3980
+ // sides round UP to whole units, so the packing can come out a row or two
3981
+ // taller than the grid, and the board transform (a constant, see below)
3982
+ // then draws the top of it past the edge. Dropping the overflow was tried
3983
+ // and cost over 10% of the block on a realistic pool, so the block is
3984
+ // re-packed at a slightly smaller scale instead -- every square shrinks
3985
+ // alike, so relative areas stay exact -- until it fits. The scale moves in
3986
+ // 5% steps and is remembered per canvas: similar data keeps the same scale
3987
+ // and does not set every tile moving on a refresh.
3988
+ // Stable where it can be (packStable keeps every surviving transaction in
3989
+ // its square), fresh where it must be -- and only at the scale the previous
3990
+ // layout was made at, since at another scale every square's side changes.
3991
+ const laid = Array.isArray(opts.laid) ? opts.laid : null; // board3d: tiles the caller laid out
3992
+ // THE EXACT FILL for the Simple board (operator, 2026-09-12: "Simple viewer mode is what I use
3993
+ // as default. It needs to be fucking perfect" ... "packer is still fucked up"). One block's
3994
+ // worth with a summed tail is packed to the last cell with the scale SOLVED (blockpack.js
3995
+ // packExact) instead of shrunk 5% at a time until it happened to fit -- which left the last row
3996
+ // partial by construction: measured, the top row 55% full and 92 cells stranded. Chosen by the
3997
+ // presence of the tail, which only the Simple board carries; the dithered per-transaction
3998
+ // Detailed board has no tail to fill with and keeps its own pack below, as does a board the
3999
+ // caller laid out (Markets).
4000
+ const agg = laid || opts.dither ? null : ((cells || []).find((c) => (Number(c?.aggregate) || 0) > 1) ?? null);
4001
+ const pack = (k) => {
4002
+ const txs = toTxs(cells, vbytesPerUnit(opts.blockVbytes * k, opts.resolution));
4003
+ const cfg = { resolution: opts.resolution, blockLimit: opts.blockVbytes * k, dither: !!opts.dither }; // dither: Detailed, area-true sides
4004
+ // Fresh every time. The stable packer (blockpack.js packStable) moved far
4005
+ // fewer blocks but left the resting board ragged -- columns half a cell
4006
+ // out of step, holes (operator: "I mean what even is this?"). Kept for
4007
+ // later; the oblique camera makes crossing paths read as depth instead.
4008
+ return packBlock(txs, cfg);
4009
+ };
4010
+ const tallest = (p) => p.tiles.reduce((m, t) => Math.max(m, t.y + t.s), 0);
4011
+ let fitK = st.fitK ?? 1;
4012
+ let packed;
4013
+ if (agg) {
4014
+ const plain = (cells || []).filter((c) => c !== agg).map((c, i) => ({
4015
+ txid: c.txid || `cell-${i}`, vsize: Math.max(1, Number(c.vbytes ?? c.vsize) || 0), rate: Math.max(0, Number(c.rate) || 0),
4016
+ }));
4017
+ // the tail's pieces take the feerate of the stratum they fall in, richest first -- the same
4018
+ // rule toTxs applies, so the colours are unchanged; only the layout is
4019
+ const tail = { vbytes: Math.max(1, Number(agg.vbytes ?? agg.vsize) || 0), rateAt: strataRates(agg.strata, Math.max(0, Number(agg.rate) || 0)) };
4020
+ packed = packExact(plain, tail, { resolution: opts.resolution, cap: 3 });
4021
+ fitK = 1; // nothing to shrink: the scale was solved
4022
+ } else {
4023
+ packed = laid ? { tiles: laid, vbytesPerUnit: 0, gridWidth: 1 } : pack(fitK);
4024
+ while (!laid && tallest(packed) > opts.resolution && fitK < 2) { fitK *= 1.05; packed = pack(fitK); }
4025
+ while (!laid && fitK > 1.0001) {
4026
+ const smaller = pack(fitK / 1.05);
4027
+ if (tallest(smaller) > opts.resolution) break;
4028
+ fitK /= 1.05; packed = smaller;
4029
+ }
4030
+ }
4031
+ st.fitK = fitK;
4032
+ const tiles0 = laid ?? packed.tiles.filter((t) => t.y + t.s <= opts.resolution);
4033
+ // SLABS (Viewer Mode 2): with every transaction on the board, a cube as tall as it is wide
4034
+ // hides its neighbours, so each stands no taller than opts.slab
4035
+ const tiles = opts.slab ? tiles0.map((t) => ({ ...t, tall: Math.min(t.s, opts.slab) })) : tiles0; // belt and braces past 2x
4036
+ // The grid must contain EVERY block and fill the space, so it spans the
4037
+ // packed extent rather than one block's worth. A tile outside its own grid
4038
+ // would be a transaction standing on nothing.
4039
+ // The tail pieces are interchangeable -- equal size, equal colour, and they
4040
+ // stand for transactions we were never sent individually. Numbering them by
4041
+ // ARRIVAL order meant one change early in the packing shifted every piece
4042
+ // after it, so the whole green field took flight on a round where nothing
4043
+ // about it had actually changed (operator: "if there is no movement
4044
+ // forecast for the larger green blocks, don't animate them"). Numbering
4045
+ // them by the SLOT they land in makes a piece in an unchanged cell the same
4046
+ // piece, so it holds still and only genuine churn moves.
4047
+ for (const t2 of tiles) if (String(t2.txid).startsWith('aggregate-')) t2.txid = `aggregate@${t2.x},${t2.y}`;
4048
+ // THE GRID IS A CONSTANT, AND THAT IS WHAT MAKES THE VIEW HOLD STILL.
4049
+ //
4050
+ // It used to be the EXACT packed extent -- gridH = packed.gridHeight. The
4051
+ // flush corner mapping below then maps grid row 0 to the canvas floor and
4052
+ // row gridH to the ceiling, so bottom-left is pinned... but the SCALE is
4053
+ // ph/(gridH*unit), and gridH changed on every refresh as the packing came
4054
+ // out a row taller or shorter. A taller packing shrinks scaleY, the whole
4055
+ // picture compresses toward the floor, and the top edge walks down the
4056
+ // panel. That is the "it shifts down on the first transition" the operator
4057
+ // kept seeing, and it survived both earlier attempts at the fit because
4058
+ // neither of them touched the thing that was actually varying.
4059
+ //
4060
+ // One block at this resolution packs to very nearly resolution x
4061
+ // resolution by construction (vbytesPerUnit is derived from exactly that),
4062
+ // so a fixed square grid is also the right grid: it is full, it is square
4063
+ // as asked, and it never moves. A packing that overflows it draws past the
4064
+ // top edge, the same licence blocks in flight already have.
4065
+ bindHover(canvas, st);
4066
+ st.restTiles = tiles; // resting footprints, for the pointer
4067
+ st.axes = opts.axes ?? null; // the price board's axes and line, so an agent's build() knows which board it is on
4068
+ st.gridW = Math.max(1, laid ? (opts.gridW ?? opts.resolution) : opts.resolution);
4069
+ st.gridH = laid ? Math.max(1, opts.gridH ?? st.gridW) : st.gridW;
4070
+ st.gridN = st.gridW;
4071
+ // where one block's worth of vbytes ends, as a row on this grid: the
4072
+ // template line, kept meaningful now that the grid is the whole pool
4073
+ st.blockRows = opts.blockVbytes > 0 && packed.vbytesPerUnit > 0
4074
+ ? Math.min(st.gridN, Math.round((1000000 / packed.vbytesPerUnit) / Math.max(1, packed.gridWidth)))
4075
+ : 0;
4076
+
4077
+ const now = (globalThis.performance && performance.now()) || 0;
4078
+ // STILL, by request as well as by preference (Tetrust, 2026-09-12: "Treat as groupings of blocks
4079
+ // making up a shape that move in unison. not drop at different rates. This is a different
4080
+ // application of our engine"). The choreography schedules every tile on its own -- a stagger of
4081
+ // seconds between one cube's drop and the next is the whole point of the block board's
4082
+ // reshuffle -- so a tetromino handed to it came down one cell at a time. A still board is drawn
4083
+ // AS LAID, every frame, with nothing planned: a piece moves as one shape because nothing moves
4084
+ // it, the frame simply changes.
4085
+ const still = reducedMotion() || opts.still === true;
4086
+
4087
+ // THE BUG THIS GUARDS (2026-09-10, operator: "redrawn instead of ...
4088
+ // smoothly moving"): mining.js calls this on every fast-tier paint, about
4089
+ // once a second. Re-planning on each call cancelled the running rAF and
4090
+ // started a fresh 5.4 s choreography from the CURRENT positions, so the
4091
+ // transition never got past its first second and the picture looked like a
4092
+ // redraw. A plan is only made when the layout actually differs; otherwise
4093
+ // the in-flight animation is left alone to finish.
4094
+ 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('|');
4095
+ // how it is DRAWN, not what is drawn: the display settings (settings.js). A change here has to
4096
+ // reach the board without waiting for the next poll, and without setting every block flying.
4097
+ // The sky belongs in here too. It was missing, so every star control was a latent dead control:
4098
+ // the field is cached on density and shape (drawStars), and nothing asked for a repaint when
4099
+ // either changed -- the galaxy toggle and both sliders would have sat there doing nothing until
4100
+ // some unrelated poll happened to replan the board, which reads exactly like a broken switch.
4101
+ const optSig = [opts.shadows !== false, opts.edges !== false, opts.grid !== false, !!opts.space,
4102
+ starsOn(opts),
4103
+ opts.seamAlpha, opts.facetPx, opts.crownPx, opts.dome, opts.idleFx !== false,
4104
+ opts.starDensity, opts.starBrightness, opts.galaxy === true, opts.galaxyAt,
4105
+ opts.nebulae !== false, opts.galaxies !== false, opts.dust !== false, opts.clusters !== false,
4106
+ opts.starColours !== false, opts.starGlints !== false,
4107
+ opts.neon === true, opts.sheen === true, opts.sheenStyle, opts.overheadLight === true, opts.light,
4108
+ opts.neonSource, opts.neonColour, opts.neonBrightness, opts.wireWidth,
4109
+ opts.transition ? `${opts.transition.rise}/${opts.transition.travel}/${opts.transition.drop}` : 'default'].join('|');
4110
+ const lookChanged = st.optSig !== undefined && st.optSig !== optSig;
4111
+ st.optSig = optSig;
4112
+ const unchanged = sig === st.sig && !lookChanged;
4113
+ // `still` governs the TILES, never the SKY (2026-09-12, operator: "why can't we get the galaxy
4114
+ // smoothly animating in the background for tetrust?" -- measured: zero repaints in three
4115
+ // seconds, frozen, not slow). A still board skipped this cheap path, and the park below
4116
+ // returned before requestAnimationFrame, so a board that asked for no choreography also got no
4117
+ // twinkle and no galaxy spin: it repainted only when its page happened to call board3d again.
4118
+ if (unchanged && st.plan && (!still || starsOn(opts))) {
4119
+ 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?.();
4120
+ return { tiles, settled: now >= st.plan.settleAt, yaw: st.yaw, replanned: false };
4121
+ }
4122
+
4123
+ // A NEW LAYOUT WAITS FOR THE RUNNING ONE TO LAND (2026-09-11, with the
4124
+ // transition at 40 s). Replanning mid-flight starts the new plan from the
4125
+ // previous TARGETS, so every block in the air would jump to where it was
4126
+ // going and set off again. The newest layout is parked and planned the
4127
+ // moment the current one settles; a still newer one simply replaces it.
4128
+ //
4129
+ // ...AND IT NOW WAITS FOR THE RUNNING EFFECT TOO (operator, 2026-09-13: "for the block space
4130
+ // panel, we need to defer a refresh until the active effect has finished its sequence").
4131
+ // Accepting a layout does `st.fx = null` below -- a transition takes the stage -- so a refresh
4132
+ // arriving mid-effect cut the effect off wherever it had got to. On this board that was most of
4133
+ // them: the pool refreshes on a timer, the signature changes, and effects run 3.2-7.4 s. The
4134
+ // scheduler's own comment already recorded the damage from the other side ("a 7 s pulse is
4135
+ // interrupted nearly every time it is chosen").
4136
+ //
4137
+ // TWO LIMITS, so this cannot become its own bug:
4138
+ // - only a TILE change waits. A look change (a switch flipped in settings) still applies at
4139
+ // once, because a control that appears dead for seven seconds is worse than an interrupted
4140
+ // effect. That is why this tests `sig !== st.sig` rather than `!unchanged`.
4141
+ // - it waits at most FX_DEFER_MAX. fxNow is bounded by the effect's own `ms`, so an effect
4142
+ // always ends on its own; the cap is there so that a bug in an effect cannot freeze the
4143
+ // data on screen indefinitely. Deferring is a courtesy to the animation, never a reason to
4144
+ // show stale figures for ever.
4145
+ //
4146
+ // Nothing can overtake the parked layout: scheduleFx treats a pending render as `busy` and
4147
+ // re-arms its timer instead of starting another effect, so an effect cannot chain ahead of a
4148
+ // refresh that is already waiting.
4149
+ // the cap runs from the later of the parking and the effect's own start: a layout parked
4150
+ // before an effect was forced (triggerIdle) must still wait that effect out, not lift off
4151
+ // under it because the clock started early
4152
+ const heldSince = st.pendingAt == null ? null : Math.max(st.pendingAt, st.fx?.t0 ?? 0);
4153
+ const fxHolding = (!!fxNow(st, now) || fxResting(st, now)) && sig !== st.sig
4154
+ && (heldSince == null || now - heldSince < FX_DEFER_MAX);
4155
+ if (!unchanged && st.plan && !still && st.raf != null && (now < st.plan.settleAt || fxHolding)) {
4156
+ st.pending = { cells, options };
4157
+ st.pendingAt ??= now;
4158
+ return { tiles: st.prev, settled: false, deferred: true };
4159
+ }
4160
+ st.pending = null;
4161
+ st.pendingAt = null;
4162
+ st.atRest = false; // a new layout: the next settle arms the next effect
4163
+
4164
+ // The FIRST paint never animates. With arrivals no longer drawn until they
4165
+ // fall, a first render treated as "everything is arriving" would leave the
4166
+ // board empty until the drop phase -- twenty-five seconds of blank canvas.
4167
+ // Nothing was there before, so nothing has moved.
4168
+ const firstPaint = !st.prev.length;
4169
+ // a look change on the same tiles is not a transition: land it where it already is
4170
+ const lookOnly = lookChanged && sig === st.sig;
4171
+ const plan = (still || firstPaint || lookOnly)
4172
+ // gridN matters even for a no-op plan: the camera constant is derived
4173
+ // from it, and a first paint on a different camera than every later
4174
+ // frame is exactly the load-time artefact this guards.
4175
+ ? planTransition(tiles, tiles, { now, gridN: st.gridN, maxGrowth: opts.edgeMargin, ...(opts.transition || {}) })
4176
+ : planTransition(st.prev, tiles, { now, gridN: st.gridN, maxGrowth: opts.edgeMargin, ...(opts.transition || {}) });
4177
+
4178
+ st.prev = tiles;
4179
+ st.sig = sig;
4180
+ st.plan = plan;
4181
+ st.fx = null; // a transition takes the stage; idle effects wait for rest
4182
+
4183
+ if (st.raf != null && globalThis.cancelAnimationFrame) cancelAnimationFrame(st.raf);
4184
+ st.raf = null;
4185
+
4186
+ const geom = sizeCanvas(canvas, Number.isFinite(opts.maxDpr) ? Math.max(0.5, opts.maxDpr) : Infinity);
4187
+ // Device pixels per grid unit, from the CONSTANT board transform (pw across
4188
+ // gridW units), so a stone's level of detail can never flicker mid-flight.
4189
+ const pxPerUnit = geom.pw / Math.max(1, st.gridW);
4190
+ const draw = (t) => {
4191
+ const view = {
4192
+ unit: opts.unit, zUnit: opts.zUnit, risePerUnit: st.plan?.cfg?.risePerUnit,
4193
+ // the camera: where the vanishing point sits, as fractions of the board
4194
+ // (0.5, 0.5 is straight overhead, as it always was), and how far the
4195
+ // surface domes toward the viewer (0 = flat). Both are options so the
4196
+ // placements can be compared on the same data before one is chosen.
4197
+ vanishX: st.gridW * opts.unit * opts.vanish.fx, vanishY: -st.gridH * opts.unit * (1 - opts.vanish.fy), persp: opts.persp,
4198
+ boardW: st.gridW * opts.unit, boardH: st.gridH * opts.unit, dome: opts.dome, gridW: st.gridW, gridH: st.gridH,
4199
+ // the camera, plus settings.js space.perspective folded in: `rise` is how much height
4200
+ // foreshortens, and 0 (the default) is the parallel camera this board has always drawn
4201
+ seamAlpha: opts.seamAlpha, fx: fxNow(st, t), now: t, light: opts.light, order: opts.order, hoverGlow: glowMap(st, t),
4202
+ axes: opts.axes ?? null, // the price board's axes: buildScene lights candle sides where these exist
4203
+ oblique: opts.obliqueRise ? { ...opts.oblique, rise: opts.obliqueRise } : opts.oblique,
4204
+ // the departure path (settings.js space.departures): it reaches the geometry AND the paint
4205
+ // order through the same view object, which is the only way those two can agree
4206
+ departures: opts.departures,
4207
+ facetMinUnits: opts.facetPx / pxPerUnit, crownMinUnits: opts.crownPx / pxPerUnit,
4208
+ shadows: opts.shadows !== false, // settings.js: the board can be drawn without them
4209
+ // the finishes (settings.js space.neon / space.sheen). They were in the look signature and
4210
+ // in buildScene from the first cut, but not HERE, so a flipped switch repainted the same
4211
+ // picture (2026-09-12: "I don't see neon blocks working, nor the metallic sheen")
4212
+ neon: opts.neon === true, sheen: opts.sheen === true, sheenStyle: opts.sheenStyle,
4213
+ overheadLight: opts.overheadLight === true, // the lamp straight above (Tetrust)
4214
+ light: opts.light, // or wherever settings.js space.light puts it
4215
+ neonSource: opts.neonSource, neonColour: opts.neonColour, neonBrightness: opts.neonBrightness,
4216
+ wireWidth: opts.wireWidth,
4217
+ // the paint order's memory across frames (blockscene3d obliqueOrder): a tangle keeps the
4218
+ // relative order it had last frame, so nothing flickers in and out of one
4219
+ orderMemo: (st.orderMemo ??= new Map()),
4220
+ };
4221
+ // the panel's extent in grid units, from the same constant fit paintFrame
4222
+ // uses: the textured sphere is laid over all of it (drawGrid), and an
4223
+ // arrival starts wholly outside it (offscreenLift)
4224
+ if (opts.oblique) view.viewRect = obliqueFit(geom.pw, geom.ph, st.gridW, st.gridH, opts).rect;
4225
+ // how far the PANEL reaches beyond the board, so a sweeping front enters and leaves out of
4226
+ // sight instead of appearing in the middle of the picture (fxFront)
4227
+ if (view.fx && view.viewRect) {
4228
+ const r = view.viewRect;
4229
+ view.fx.margin = Math.max(0 - r.x0, r.x1 - st.gridW, 0 - r.y0, r.y1 - st.gridH, 4) + 3;
4230
+ // the scan's beam: its half-width, so fxAt lights exactly the swath the cone covers, and
4231
+ // whether it PANS -- a searchlight sweeping out and back, which is the top-down board's
4232
+ // motion; the price chart keeps its single pass along the hours
4233
+ if (view.fx.kind === 'scan') {
4234
+ const g = scanBeamGeom(view);
4235
+ view.fx.beamR = g.spread;
4236
+ view.fx.pan = !g.price;
4237
+ }
4238
+ }
4239
+ const frame = frameAt(st.plan, t, view);
4240
+ paintFrame(ctx, geom, frame, opts, view, st.gridW, st.blockRows, st.gridH);
4241
+ st.lastFit = frame.__fit ?? st.lastFit;
4242
+ st.lastOps = frame.ops;
4243
+ st.settled = frame.settled;
4244
+ st.dirty = false;
4245
+ return frame;
4246
+ };
4247
+
4248
+ const step = () => {
4249
+ const t = (globalThis.performance && performance.now()) || 0;
4250
+ // THE SKY keeps the loop alive for the twinkle -- about 24 frames a second once the board is
4251
+ // still -- and stops it while the canvas is hidden (another tab of the app); the next
4252
+ // render3d call wakes it (see the unchanged-layout branch). It is the STARS that need the
4253
+ // repaint, not the board style, so a space-styled board with the sky off parks like any other.
4254
+ if (starsOn(opts)) {
4255
+ // ABSENT COUNTS AS HIDDEN, and this is the sky loop's only exit: 2218 re-arms when the
4256
+ // frame is throttled, 2242 re-arms otherwise, and the park at 2234 is gated behind
4257
+ // !starsOn -- so a starry board that cannot answer "am I visible?" never unwinds. The
4258
+ // strict `=== false` / `=== null` form could only fire where the properties exist, and
4259
+ // the day space.stars shipped ON by default that turned an unreachable branch into an
4260
+ // infinite one (renderMining: Maximum call stack size exceeded, wherever rAF runs inline
4261
+ // rather than deferring to a real frame). In a browser this is a no-op: isConnected is
4262
+ // always a boolean and offsetParent always an Element or null.
4263
+ if (!canvas.isConnected || !canvas.offsetParent) { st.raf = null; return; }
4264
+ if (st.settled && !st.dirty && !st.pending && !fxNow(st, t) && t - (st.lastPaint ?? 0) < 33) { st.raf = requestAnimationFrame(step); return; }
4265
+ st.lastPaint = t;
4266
+ }
4267
+ const frame = draw(t);
4268
+ // THE FLUSH WAITS FOR THE EFFECT AS WELL. `frame.settled` is about the TRANSITION, not the
4269
+ // effect, so without the fxNow test this let a parked refresh through the moment the board
4270
+ // landed -- cutting the effect off exactly as before. It is also the live path: stars ship on
4271
+ // by default, so this is the branch a real board takes, and guarding only the entry above
4272
+ // would have looked correct and done nothing.
4273
+ if (starsOn(opts) && frame.settled && st.pending && !fxNow(st, t) && !fxResting(st, t)) { const p = st.pending; st.pending = null; st.pendingAt = null; st.raf = null; render3d(canvas, p.cells, p.options); return; }
4274
+ // keep the loop alive while the choreography runs OR the camera is moving;
4275
+ // park otherwise, because repainting a still picture is a heater
4276
+ if (frame.settled && !st.dirty && !fxNow(st, t)) {
4277
+ // the board is at rest: arm the next effect whether or not the loop is about to park --
4278
+ // but ONCE, on the frame it arrives, or a running loop would re-arm the timer for ever
4279
+ if (!st.atRest) {
4280
+ const afterEffect = st.fx != null; // an effect just ended -- or it has only now settled
4281
+ if (afterEffect) st.fxEndedAt = t; // the rest before a held refresh runs from here
4282
+ st.fx = null;
4283
+ st.atRest = true;
4284
+ scheduleFx(canvas, st, opts, !afterEffect);
4285
+ }
4286
+ if (!starsOn(opts) && !glowAnimating(st, t)) {
4287
+ st.raf = null;
4288
+ if (st.pending) {
4289
+ // the loop parks here (no stars to keep it running), so the rest is kept by a timer
4290
+ if (fxResting(st, t)) { setTimeout(() => { if (st.pending && st.raf == null) { const p = st.pending; st.pending = null; st.pendingAt = null; render3d(canvas, p.cells, p.options); } }, FX_REST_MS - (t - st.fxEndedAt) + 20); return; }
4291
+ const p = st.pending; st.pending = null; st.pendingAt = null; render3d(canvas, p.cells, p.options); return;
4292
+ }
4293
+ return;
4294
+ }
4295
+ if (st.pending) { const p = st.pending; st.pending = null; st.pendingAt = null; st.raf = null; render3d(canvas, p.cells, p.options); return; }
4296
+ }
4297
+ else st.atRest = false; // something is moving again: the next rest re-arms
4298
+ st.raf = requestAnimationFrame(step);
4299
+ };
4300
+ st.wake = () => {
4301
+ if (st.raf == null && globalThis.requestAnimationFrame) st.raf = requestAnimationFrame(step);
4302
+ };
4303
+
4304
+ const first = draw(now);
4305
+ if (first.settled) { st.atRest = true; scheduleFx(canvas, st, opts, true); } // at rest already, stars or not
4306
+ // Park when nothing is moving: a settled board, or one that asked for no choreography at all.
4307
+ // Stars are motion in their own right, so a sky keeps the loop whatever the tiles are doing.
4308
+ if (!globalThis.requestAnimationFrame || (!starsOn(opts) && (still || first.settled))) {
4309
+ return { tiles, settled: true };
4310
+ }
4311
+ st.raf = requestAnimationFrame(step);
4312
+ return { tiles, settled: false };
4313
+ }
4314
+
4315
+ export function block3d(canvas, visual, economy, options = {}) {
4316
+ const weightLimit = options.weightLimit ?? 4000000;
4317
+ return render3d(canvas, visual?.cells ?? [], { ...options, blockVbytes: weightLimit / 4 });
4318
+ }
4319
+
4320
+ // The pool scales to the POOL, not to a block. Measured 2026-09-10: a 3.4 MB
4321
+ // pool drawn against a 1 MB block packs 44 wide by ~140 tall, which in
4322
+ // isometric is a long thin diamond. Scaling to the pool's own total makes the
4323
+ // grid square at any resolution. The cost, stated rather than hidden: a pool
4324
+ // tile is then not comparable in size with a block tile.
4325
+ export function mempool3d(canvas, dist, options = {}) {
4326
+ const block = options.blockVbytes ?? DEFAULTS.blockVbytes;
4327
+ const cells = takeOneBlock(dist?.cells ?? [], block);
4328
+ return render3d(canvas, cells, { ...options, blockVbytes: block });
4329
+ }
4330
+
4331
+ export { TRANSITION, SLAB_H };
4332
+
4333
+ // THE BOARD FOR OTHER DATA (operator, 2026-09-11: "leverage the 3D view we have established with
4334
+ // the block space panel ... If we're going to render a price chart, it would be good to see it on
4335
+ // a similar grid structure, with similar effects, just tasked to show market data ... We should be
4336
+ // flexible with how we can use that 3D view"). The same sphere, neon grid, cubes, shadows,
4337
+ // choreography and idle effects; the caller lays the tiles out itself on a gridW x gridH board:
4338
+ // { txid (a stable id -- the same id is the same tile, so it moves rather than re-arrives),
4339
+ // x, y, s (footprint side), tall (height; default s), color (#rrggbb), label (hover text) }
4340
+ export function board3d(canvas, tiles, options = {}) {
4341
+ return render3d(canvas, null, { ...options, laid: tiles ?? [] });
4342
+ }