@ciphore/radiocli 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 (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CODE_OF_CONDUCT.md +13 -0
  3. package/CONTRIBUTING.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +364 -0
  6. package/SECURITY.md +31 -0
  7. package/dist/activity/stats.js +122 -0
  8. package/dist/cli.js +143 -0
  9. package/dist/player/backend-install.js +122 -0
  10. package/dist/player/command.js +8 -0
  11. package/dist/player/player-controller.js +494 -0
  12. package/dist/playlists/playlist.js +169 -0
  13. package/dist/providers/cache.js +92 -0
  14. package/dist/providers/provider-manager.js +50 -0
  15. package/dist/providers/radio-browser.js +412 -0
  16. package/dist/providers/radio-garden.js +87 -0
  17. package/dist/storage/store.js +369 -0
  18. package/dist/types.js +55 -0
  19. package/dist/ui/App.js +770 -0
  20. package/dist/ui/AppContent.js +45 -0
  21. package/dist/ui/app-state.js +250 -0
  22. package/dist/ui/components/Logo.js +9 -0
  23. package/dist/ui/components/Menu.js +23 -0
  24. package/dist/ui/components/ScreenHeader.js +15 -0
  25. package/dist/ui/components/StationList.js +23 -0
  26. package/dist/ui/components/TopTabs.js +82 -0
  27. package/dist/ui/cosmo-land-data.js +4 -0
  28. package/dist/ui/cosmo-world-map.js +156 -0
  29. package/dist/ui/explore-map-layout.js +24 -0
  30. package/dist/ui/format.js +22 -0
  31. package/dist/ui/layout.js +27 -0
  32. package/dist/ui/list-window.js +8 -0
  33. package/dist/ui/page-footer.js +45 -0
  34. package/dist/ui/playback-footer.js +48 -0
  35. package/dist/ui/screen-items.js +26 -0
  36. package/dist/ui/screens/CountriesScreen.js +10 -0
  37. package/dist/ui/screens/ExploreScreen.js +44 -0
  38. package/dist/ui/screens/HomeScreen.js +9 -0
  39. package/dist/ui/screens/MapScreen.js +59 -0
  40. package/dist/ui/screens/NowPlayingScreen.js +79 -0
  41. package/dist/ui/screens/SearchScreen.js +12 -0
  42. package/dist/ui/screens/SettingsScreen.js +38 -0
  43. package/dist/ui/screens/StationScreen.js +7 -0
  44. package/dist/ui/screens/StatsScreen.js +90 -0
  45. package/dist/ui/terminal-mouse.js +38 -0
  46. package/dist/ui/theme.js +105 -0
  47. package/dist/ui/use-app-input.js +387 -0
  48. package/dist/ui/use-command-executor.js +156 -0
  49. package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
  50. package/dist/ui/world-map.js +274 -0
  51. package/docs/THIRD_PARTY_NOTICES.md +33 -0
  52. package/package.json +83 -0
@@ -0,0 +1,2188 @@
1
+ import { themeAccent, themeContributionColors } from '../theme.js';
2
+ export function buildVisualizer(style, pulse, width, height, _station, playback, theme) {
3
+ if (!playbackHasSignal(playback)) {
4
+ return buildZeroSignalVisualizer(width, height, theme);
5
+ }
6
+ if (style === 'equalizer') {
7
+ return buildEqualizer(pulse, width, height, theme);
8
+ }
9
+ if (style === 'motion-blob') {
10
+ return buildMotionBlob(pulse, width, height, theme);
11
+ }
12
+ if (style === 'motion-area') {
13
+ return buildMotionArea(pulse, width, height, theme);
14
+ }
15
+ if (style === 'motion-contour') {
16
+ return buildMotionContour(pulse, width, height, theme);
17
+ }
18
+ if (style === 'leds') {
19
+ return buildLeds(pulse, width, height, theme);
20
+ }
21
+ if (style === 'matrix') {
22
+ return buildMatrix(pulse, width, height, theme);
23
+ }
24
+ if (style === 'hologram') {
25
+ return buildHologram(pulse, width, height, theme);
26
+ }
27
+ if (style === 'cube') {
28
+ return buildAsciiCube(pulse, width, height, theme);
29
+ }
30
+ if (style === 'fire') {
31
+ return buildAsciiFire(pulse, width, height, theme);
32
+ }
33
+ if (style === 'fireworks') {
34
+ return buildAsciiFireworks(pulse, width, height, theme);
35
+ }
36
+ if (style === 'plasma') {
37
+ return buildAsciiPlasma(pulse, width, height, theme);
38
+ }
39
+ if (style === 'spinning-donut') {
40
+ return buildAsciiDonut(pulse, width, height, theme);
41
+ }
42
+ if (style === 'starfield') {
43
+ return buildAsciiStarfield(pulse, width, height, theme);
44
+ }
45
+ if (style === 'mesh') {
46
+ return buildMesh(pulse, width, height, theme);
47
+ }
48
+ if (style === 'ribbon') {
49
+ return buildRibbon(pulse, width, height, theme);
50
+ }
51
+ if (style === 'orbits') {
52
+ return buildOrbits(pulse, width, height, theme);
53
+ }
54
+ if (style === 'mirror') {
55
+ return buildMirror(pulse, width, height, theme);
56
+ }
57
+ if (style === 'tunnel') {
58
+ return buildTunnel(pulse, width, height, theme);
59
+ }
60
+ if (style === 'kaleidoscope') {
61
+ return buildKaleidoscope(pulse, width, height, theme);
62
+ }
63
+ if (style === 'constellation') {
64
+ return buildConstellation(pulse, width, height, theme);
65
+ }
66
+ if (style === 'pulse-grid') {
67
+ return buildPulseGrid(pulse, width, height, theme);
68
+ }
69
+ if (style === 'lissajous') {
70
+ return buildLissajous(pulse, width, height, theme);
71
+ }
72
+ if (style === 'braille-wave') {
73
+ return buildBrailleWave(pulse, width, height, theme);
74
+ }
75
+ if (style === 'radial-eq') {
76
+ return buildRadialEq(pulse, width, height, theme);
77
+ }
78
+ if (style === 'spectrogram') {
79
+ return buildSpectrogram(pulse, width, height, theme);
80
+ }
81
+ if (style === 'nebula') {
82
+ return buildNebula(pulse, width, height, theme);
83
+ }
84
+ if (style === 'silk') {
85
+ return buildSilk(pulse, width, height, theme);
86
+ }
87
+ if (style === 'ripple-tank') {
88
+ return buildRippleTank(pulse, width, height, theme);
89
+ }
90
+ if (style === 'phyllotaxis') {
91
+ return buildPhyllotaxis(pulse, width, height, theme);
92
+ }
93
+ if (style === 'harmonograph') {
94
+ return buildHarmonograph(pulse, width, height, theme);
95
+ }
96
+ if (style === 'bloom-bars') {
97
+ return buildBloomBars(pulse, width, height, theme);
98
+ }
99
+ if (style === 'moire') {
100
+ return buildMoire(pulse, width, height, theme);
101
+ }
102
+ if (style === 'galaxy') {
103
+ return buildGalaxy(pulse, width, height, theme);
104
+ }
105
+ if (style === 'caustics') {
106
+ return buildCaustics(pulse, width, height, theme);
107
+ }
108
+ if (style === 'lorenz') {
109
+ return buildLorenz(pulse, width, height, theme);
110
+ }
111
+ if (style === 'fern') {
112
+ return buildFern(pulse, width, height, theme);
113
+ }
114
+ if (style === 'chladni') {
115
+ return buildChladni(pulse, width, height, theme);
116
+ }
117
+ if (style === 'tesseract') {
118
+ return buildTesseract(pulse, width, height, theme);
119
+ }
120
+ if (style === 'torus-knot') {
121
+ return buildTorusKnot(pulse, width, height, theme);
122
+ }
123
+ if (style === 'rotozoomer') {
124
+ return buildRotozoomer(pulse, width, height, theme);
125
+ }
126
+ if (style === 'fractal-tree') {
127
+ return buildFractalTree(pulse, width, height, theme);
128
+ }
129
+ if (style === 'julia') {
130
+ return buildJulia(pulse, width, height, theme);
131
+ }
132
+ if (style === 'clifford') {
133
+ return buildClifford(pulse, width, height, theme);
134
+ }
135
+ if (style === 'goniometer') {
136
+ return buildGoniometer(pulse, width, height, theme);
137
+ }
138
+ if (style === 'copper-bars') {
139
+ return buildCopperBars(pulse, width, height, theme);
140
+ }
141
+ if (style === 'twister') {
142
+ return buildTwister(pulse, width, height, theme);
143
+ }
144
+ if (style === 'coral') {
145
+ return buildCoral(pulse, width, height, theme);
146
+ }
147
+ if (style === 'cyclone') {
148
+ return buildCyclone(pulse, width, height, theme);
149
+ }
150
+ if (style === 'lava-lamp') {
151
+ return buildLavaLamp(pulse, width, height, theme);
152
+ }
153
+ if (style === 'newton') {
154
+ return buildNewton(pulse, width, height, theme);
155
+ }
156
+ return buildEqualizer(pulse, width, height, theme);
157
+ }
158
+ function playbackHasSignal(playback) {
159
+ return playback.state === 'playing' && playback.ready;
160
+ }
161
+ function buildZeroSignalVisualizer(width, height, theme) {
162
+ return buildFlatZeroSignal(width, height, theme);
163
+ }
164
+ function buildFlatZeroSignal(width, requestedHeight, theme) {
165
+ const lineWidth = Math.max(0, width);
166
+ const height = Math.max(1, requestedHeight);
167
+ const blank = ''.padEnd(lineWidth, ' ');
168
+ const baseline = '▁'.repeat(lineWidth);
169
+ const accent = themeAccent(theme);
170
+ return Array.from({ length: height }, (_, rowIndex) => ({
171
+ text: rowIndex === height - 1 ? baseline : blank,
172
+ color: rowIndex === height - 1 ? accent : '#767676'
173
+ }));
174
+ }
175
+ function buildEqualizer(pulse, width, height, theme) {
176
+ const accent = themeAccent(theme);
177
+ const bandWidth = 3;
178
+ const bandCount = Math.floor((width - 2) / bandWidth);
179
+ const levels = Array.from({ length: bandCount }, (_, i) => {
180
+ const low = Math.sin(i * 0.18 + pulse * 0.46);
181
+ const mid = Math.cos(i * 0.32 - pulse * 0.28);
182
+ const high = Math.sin(i * 0.45 + pulse * 0.68);
183
+ const normalized = (low * 0.4 + mid * 0.35 + high * 0.25 + 1) / 2;
184
+ const eased = Math.pow(Math.max(0, Math.min(1, normalized)), 1.4);
185
+ return Math.round(eased * height);
186
+ });
187
+ const peaks = Array.from({ length: bandCount }, (_, i) => {
188
+ let maxLvl = 0;
189
+ for (let k = 0; k < 8; k++) {
190
+ const p = (pulse - k + 240) % 240;
191
+ const low = Math.sin(i * 0.18 + p * 0.46);
192
+ const mid = Math.cos(i * 0.32 - p * 0.28);
193
+ const high = Math.sin(i * 0.45 + p * 0.68);
194
+ const normalized = (low * 0.4 + mid * 0.35 + high * 0.25 + 1) / 2;
195
+ const eased = Math.pow(Math.max(0, Math.min(1, normalized)), 1.4);
196
+ const lvl = Math.round(eased * height);
197
+ if (lvl > maxLvl) {
198
+ maxLvl = lvl;
199
+ }
200
+ }
201
+ return maxLvl;
202
+ });
203
+ const rows = [];
204
+ const colors = themeContributionColors(theme);
205
+ for (let rowIndex = 0; rowIndex < height; rowIndex++) {
206
+ const threshold = height - rowIndex;
207
+ let rowText = ' ';
208
+ for (let i = 0; i < bandCount; i++) {
209
+ const lvl = levels[i];
210
+ const peak = peaks[i];
211
+ let bandChar = ' ';
212
+ if (lvl >= threshold) {
213
+ bandChar = '██';
214
+ }
215
+ else if (peak === threshold) {
216
+ bandChar = '◆◆';
217
+ }
218
+ else {
219
+ bandChar = '··';
220
+ }
221
+ rowText += bandChar + ' ';
222
+ }
223
+ let color = colors[2] ?? accent;
224
+ if (rowIndex < Math.max(1, height * 0.3)) {
225
+ color = '#ff5f87';
226
+ }
227
+ else if (rowIndex < Math.max(2, height * 0.6)) {
228
+ color = colors[4] ?? accent;
229
+ }
230
+ else {
231
+ color = colors[3] ?? accent;
232
+ }
233
+ rows.push({
234
+ text: rowText.padEnd(width).slice(0, width),
235
+ color
236
+ });
237
+ }
238
+ return rows;
239
+ }
240
+ function buildMotionBlob(pulse, width, height, theme) {
241
+ const h = Math.max(7, height);
242
+ const mid = (h - 1) / 2;
243
+ const half = Math.max(1, h / 2 - 1);
244
+ const accent = themeAccent(theme);
245
+ return Array.from({ length: h }, (_, y) => {
246
+ const normalizedY = Math.abs((y - mid) / half);
247
+ const cells = Array.from({ length: width }, (_, x) => {
248
+ const position = x / Math.max(1, width - 1);
249
+ const fade = 1 - Math.pow(position, 1.35) * 0.56;
250
+ const outer = clampNumber(motionEnvelope(position, pulse, 1.1) * fade, 0, 1);
251
+ const inner = clampNumber(outer * (0.42 + 0.16 * Math.sin(position * 17 - pulse * 0.3)), 0, 1);
252
+ if (normalizedY <= inner) {
253
+ return { text: '█', color: position > 0.56 ? '#5ab7ff' : '#a36bff' };
254
+ }
255
+ if (normalizedY <= outer) {
256
+ return { text: '▓', color: position > 0.68 ? '#4bc9d9' : '#ff3f8e' };
257
+ }
258
+ if (normalizedY <= outer + 0.08 && x % 2 === 0) {
259
+ return { text: '░', color: dimMotionColorAt(position, theme) };
260
+ }
261
+ if (Math.round(y) === Math.round(mid) && x % 7 === 0) {
262
+ return { text: '·', color: accent };
263
+ }
264
+ return { text: ' ', color: accent };
265
+ });
266
+ return lineFromCells(cells, accent);
267
+ }).slice(0, height);
268
+ }
269
+ function buildMotionArea(pulse, width, height, theme) {
270
+ const h = Math.max(8, height);
271
+ const mid = Math.floor(h / 2);
272
+ const accent = themeAccent(theme);
273
+ const topLevels = Array.from({ length: width }, (_, x) => {
274
+ const position = x / Math.max(1, width - 1);
275
+ return clampNumber(0.2 + motionEnvelope(position, pulse, 2.0) * 0.74, 0.1, 1);
276
+ });
277
+ const bottomLevels = Array.from({ length: width }, (_, x) => {
278
+ const position = x / Math.max(1, width - 1);
279
+ return clampNumber(0.14 + motionEnvelope(1 - position, pulse + 37, 0.8) * 0.7, 0.08, 0.92);
280
+ });
281
+ return Array.from({ length: h }, (_, y) => {
282
+ const cells = Array.from({ length: width }, (_, x) => {
283
+ const position = x / Math.max(1, width - 1);
284
+ const top = mid - Math.round((topLevels[x] ?? 0) * Math.max(1, mid - 1));
285
+ const bottom = mid + Math.round((bottomLevels[x] ?? 0) * Math.max(1, h - mid - 2));
286
+ if (y === mid) {
287
+ return { text: x % 6 === 0 ? '━' : '─', color: accent };
288
+ }
289
+ if (y < mid && y >= top) {
290
+ return {
291
+ text: y === top ? '▄' : '█',
292
+ color: position < 0.58 ? '#5fe6e0' : '#314c68'
293
+ };
294
+ }
295
+ if (y > mid && y <= bottom) {
296
+ return {
297
+ text: y === bottom ? '▀' : '█',
298
+ color: position < 0.55 ? '#ff3f8e' : '#a6346f'
299
+ };
300
+ }
301
+ if ((y === top - 1 || y === bottom + 1) && x % 4 === 0) {
302
+ return { text: '·', color: dimMotionColorAt(position, theme) };
303
+ }
304
+ return { text: ' ', color: accent };
305
+ });
306
+ return lineFromCells(cells, accent);
307
+ }).slice(0, height);
308
+ }
309
+ function buildMotionContour(pulse, width, height, theme) {
310
+ const h = Math.max(9, height);
311
+ const cx = (width - 1) / 2;
312
+ const cy = (h - 1) / 2;
313
+ const xScale = Math.max(1, width * 0.36);
314
+ const yScale = Math.max(1, h * 0.58);
315
+ const rings = 8;
316
+ const accent = themeAccent(theme);
317
+ return Array.from({ length: h }, (_, y) => {
318
+ const cells = Array.from({ length: width }, (_, x) => {
319
+ const nx = (x - cx) / xScale;
320
+ const ny = (y - cy) / yScale;
321
+ const radius = Math.sqrt(nx * nx + ny * ny);
322
+ const theta = Math.atan2(ny, nx);
323
+ const boundary = 0.62 +
324
+ 0.08 * Math.sin(theta * 5 + pulse * 0.18) +
325
+ 0.07 * Math.cos(theta * 8 - pulse * 0.13) +
326
+ 0.04 * Math.sin(theta * 13 + pulse * 0.09);
327
+ for (let ring = rings; ring >= 1; ring -= 1) {
328
+ const target = boundary * (ring / rings);
329
+ const distance = Math.abs(radius - target);
330
+ if (distance < 0.017 + ring * 0.0015) {
331
+ const position = (theta + Math.PI) / (Math.PI * 2);
332
+ return {
333
+ text: ring % 3 === 0 ? '∙' : ring % 2 === 0 ? '•' : '·',
334
+ color: motionColorAt((position + ring * 0.08) % 1, theme)
335
+ };
336
+ }
337
+ }
338
+ return { text: ' ', color: accent };
339
+ });
340
+ return lineFromCells(cells, accent);
341
+ }).slice(0, height);
342
+ }
343
+ function buildLeds(pulse, width, height, theme) {
344
+ const accent = themeAccent(theme);
345
+ const rows = [];
346
+ const freqStrings = [' 60 Hz', ' 150 Hz', ' 400 Hz', ' 1 kHz', '2.5 kHz', ' 6 kHz', ' 15 kHz', ' 20 kHz'];
347
+ for (let y = 0; y < height; y++) {
348
+ const label = freqStrings[y] ?? `Band ${y + 1}`.padStart(7);
349
+ const meterWidth = Math.max(10, width - 13);
350
+ const t = pulse * 0.15;
351
+ const rawVal = 0.4 * Math.sin(y * 0.6 + t * 2.1) + 0.35 * Math.cos(y * 0.23 - t * 1.3) + 0.25 * Math.sin(pulse * 0.08);
352
+ const lvl = Math.max(0.02, Math.min(0.98, (rawVal + 1.0) / 2.0));
353
+ const filledCount = Math.round(lvl * meterWidth);
354
+ const emptyCount = Math.max(0, meterWidth - filledCount);
355
+ const filledBar = '■'.repeat(filledCount);
356
+ const emptyBar = '□'.repeat(emptyCount);
357
+ const text = `${label} │ [${filledBar}${emptyBar}]`.padEnd(width).slice(0, width);
358
+ const colors = themeContributionColors(theme);
359
+ let color = colors[2] ?? accent;
360
+ if (y === 0 || y === 1) {
361
+ color = '#ff5f87';
362
+ }
363
+ else if (y < 4) {
364
+ color = colors[4] ?? accent;
365
+ }
366
+ else {
367
+ color = colors[3] ?? accent;
368
+ }
369
+ rows.push({ text, color });
370
+ }
371
+ return rows;
372
+ }
373
+ function buildMatrix(pulse, width, height, theme) {
374
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%^&*()';
375
+ const rows = [];
376
+ const colors = themeContributionColors(theme);
377
+ const primaryColor = colors[3] ?? '#00ff00';
378
+ const headColor = '#ffffff';
379
+ for (let y = 0; y < height; y++) {
380
+ let rowText = '';
381
+ for (let x = 0; x < width; x++) {
382
+ if (x % 2 === 1) {
383
+ rowText += ' ';
384
+ continue;
385
+ }
386
+ const speed = 0.5 + Math.abs(Math.sin(x * 123.45)) * 1.5;
387
+ const offset = Math.abs(Math.cos(x * 321.65)) * 100;
388
+ const dropY = (pulse * speed + offset) % (height * 2) - height;
389
+ const dist = y - dropY;
390
+ const tailLen = 4 + Math.abs(Math.sin(x * 11.11)) * 6;
391
+ if (dist === 0) {
392
+ const charIdx = Math.floor(Math.abs(Math.sin(x * y * pulse)) * chars.length) % chars.length;
393
+ rowText += chars[charIdx];
394
+ }
395
+ else if (dist > 0 && dist < tailLen) {
396
+ const charIdx = Math.floor(Math.abs(Math.cos(x * y + pulse)) * chars.length) % chars.length;
397
+ rowText += chars[charIdx];
398
+ }
399
+ else {
400
+ rowText += ' ';
401
+ }
402
+ }
403
+ rows.push({
404
+ text: rowText.slice(0, width),
405
+ color: y === Math.floor(pulse) % height ? headColor : primaryColor
406
+ });
407
+ }
408
+ for (let y = 0; y < height; y++) {
409
+ const rowText = rows[y].text;
410
+ let isHead = false;
411
+ for (let x = 0; x < width; x++) {
412
+ if (rowText[x] !== ' ') {
413
+ const speed = 0.5 + Math.abs(Math.sin(x * 123.45)) * 1.5;
414
+ const offset = Math.abs(Math.cos(x * 321.65)) * 100;
415
+ const dropY = Math.round((pulse * speed + offset) % (height * 2) - height);
416
+ if (y === dropY) {
417
+ isHead = true;
418
+ }
419
+ }
420
+ }
421
+ if (isHead) {
422
+ rows[y].color = headColor;
423
+ }
424
+ }
425
+ return rows;
426
+ }
427
+ function buildHologram(pulse, width, height, theme) {
428
+ const accent = themeAccent(theme);
429
+ const rows = [];
430
+ const cx = width / 2;
431
+ const cy = height / 2;
432
+ for (let y = 0; y < height; y++) {
433
+ let rowText = '';
434
+ const ny = (y - cy) / cy;
435
+ for (let x = 0; x < width; x++) {
436
+ const nx = (x - cx) / cx;
437
+ const r = Math.sqrt(nx * nx + ny * ny);
438
+ const theta = Math.atan2(ny, nx);
439
+ const wave1 = Math.sin(r * 10 - pulse * 0.5);
440
+ const wave2 = Math.cos(theta * 4 + pulse * 0.3);
441
+ const interference = (wave1 + wave2) * 0.5;
442
+ const glitch = Math.random() > 0.98 ? 1 : 0;
443
+ const scanline = Math.abs(y - ((pulse * 2) % height)) < 1 ? 1 : 0;
444
+ const intensity = Math.max(0, interference) + scanline * 0.5 + glitch;
445
+ if (intensity > 1.2)
446
+ rowText += '█';
447
+ else if (intensity > 0.8)
448
+ rowText += '▓';
449
+ else if (intensity > 0.4)
450
+ rowText += '▒';
451
+ else if (intensity > 0.1)
452
+ rowText += '░';
453
+ else
454
+ rowText += ' ';
455
+ }
456
+ let color = accent;
457
+ if (Math.abs(y - ((pulse * 2) % height)) < 1) {
458
+ color = '#ffffff';
459
+ }
460
+ rows.push({
461
+ text: rowText.slice(0, width),
462
+ color
463
+ });
464
+ }
465
+ return rows;
466
+ }
467
+ function buildAsciiCube(pulse, width, height, theme) {
468
+ const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
469
+ const zBuffer = Array.from({ length: height }, () => Array.from({ length: width }, () => Number.NEGATIVE_INFINITY));
470
+ const angleY = pulse * 0.13;
471
+ const angleX = 0.62 + Math.sin(pulse * 0.045) * 0.28;
472
+ const angleZ = Math.sin(pulse * 0.035) * 0.16;
473
+ const scaleY = Math.max(2, Math.min((height - 2) / 3.0, width / 12));
474
+ const scaleX = scaleY * 2.35;
475
+ drawCubeSurfaces(grid, zBuffer, angleX, angleY, angleZ, scaleX, scaleY, width, height);
476
+ const vertices = cubeVertices().map(point => projectRotatedCubePoint(rotateCubePoint(point, angleX, angleY, angleZ), scaleX, scaleY, width, height));
477
+ const edges = [
478
+ [0, 1],
479
+ [1, 3],
480
+ [3, 2],
481
+ [2, 0],
482
+ [4, 5],
483
+ [5, 7],
484
+ [7, 6],
485
+ [6, 4],
486
+ [0, 4],
487
+ [1, 5],
488
+ [2, 6],
489
+ [3, 7]
490
+ ];
491
+ for (const [from, to] of edges) {
492
+ const a = vertices[from];
493
+ const b = vertices[to];
494
+ if (!a || !b) {
495
+ continue;
496
+ }
497
+ drawCubeEdgeZ(grid, zBuffer, a, b);
498
+ }
499
+ for (const point of vertices) {
500
+ writeCubeSurfaceGlyph(grid, zBuffer, point.x, point.y, point.z + 0.14, cubeCornerGlyph(point.z));
501
+ }
502
+ const accent = themeAccent(theme);
503
+ return grid.map((cells, rowIndex) => {
504
+ const text = cells.join('').slice(0, width);
505
+ const hasCube = /[.:;=+*#%@/\\|_\-]/.test(text);
506
+ let color = '#ff5f87';
507
+ if (!hasCube) {
508
+ color = '#767676';
509
+ }
510
+ else if (rowIndex < height * 0.25) {
511
+ color = '#ff9ab3';
512
+ }
513
+ else if (rowIndex > height * 0.76) {
514
+ color = '#c06cff';
515
+ }
516
+ else if (theme === 'mono') {
517
+ color = accent;
518
+ }
519
+ return { text, color };
520
+ });
521
+ }
522
+ function buildAsciiFire(pulse, width, height, theme) {
523
+ const chars = [' ', '.', ':', '^', '~', '*', 'x', 'X', '#', '@'];
524
+ const colors = ['#2d1218', '#743127', '#b9482d', '#f26f35', '#ffc857', '#fff1a8'];
525
+ const rows = [];
526
+ for (let y = 0; y < height; y += 1) {
527
+ const positionY = y / Math.max(1, height - 1);
528
+ const heatBase = Math.pow(positionY, 1.12);
529
+ const cells = [];
530
+ for (let x = 0; x < width; x += 1) {
531
+ const centerPull = 1 - Math.abs((x / Math.max(1, width - 1)) - 0.5) * 0.38;
532
+ const upwardTime = pulse * 0.58 + (height - y) * 0.62;
533
+ const lick = Math.sin(x * 0.18 + upwardTime - y * 0.32) * 0.2 +
534
+ Math.sin(x * 0.07 - upwardTime * 0.7 + y * 0.46) * 0.15 +
535
+ Math.cos(x * 0.31 + upwardTime * 0.42) * 0.08;
536
+ const sparks = hashNoise(x, y, pulse * 2) > 0.968 && positionY < 0.46 ? 0.42 : 0;
537
+ const heat = clampNumber(heatBase * centerPull + lick + sparks - (1 - positionY) * 0.28, 0, 1);
538
+ const index = Math.min(chars.length - 1, Math.floor(heat * chars.length));
539
+ const colorIndex = Math.min(colors.length - 1, Math.floor(heat * colors.length));
540
+ cells.push({ text: chars[index] ?? ' ', color: colors[colorIndex] ?? themeAccent(theme) });
541
+ }
542
+ rows.push(lineFromCells(cells, themeAccent(theme)));
543
+ }
544
+ return rows;
545
+ }
546
+ function buildAsciiFireworks(pulse, width, height, theme) {
547
+ const grid = emptyMotionGrid(width, height, '#767676');
548
+ const palette = ['#ff5f87', '#ffd166', '#53a8ff', '#8df084', '#c06cff', '#ffffff'];
549
+ const bursts = Math.max(3, Math.min(7, Math.floor(width / 26)));
550
+ for (let burst = 0; burst < bursts; burst += 1) {
551
+ const seed = burst * 97 + 19;
552
+ const cycle = (pulse * 1.55 + burst * 17) % 44;
553
+ const radius = cycle * 0.22;
554
+ const centerX = Math.round(width * (0.18 + ((seed * 37) % 64) / 100));
555
+ const centerY = Math.round(height * (0.18 + ((seed * 23) % 42) / 100));
556
+ const color = palette[burst % palette.length] ?? themeAccent(theme);
557
+ const rays = 14 + (burst % 5) * 4;
558
+ if (cycle < 6) {
559
+ const launchY = height - 1 - Math.round(cycle * 1.25);
560
+ setMotionCell(grid, centerX, launchY, '|', color);
561
+ setMotionCell(grid, centerX, launchY - 1, '.', color);
562
+ continue;
563
+ }
564
+ for (let ray = 0; ray < rays; ray += 1) {
565
+ const angle = (ray / rays) * Math.PI * 2 + burst * 0.33;
566
+ const x = Math.round(centerX + Math.cos(angle) * radius * (1.7 + (ray % 3) * 0.34));
567
+ const y = Math.round(centerY + Math.sin(angle) * radius * 0.72);
568
+ const tailX = Math.round(centerX + Math.cos(angle) * Math.max(0, radius - 1.2) * 1.5);
569
+ const tailY = Math.round(centerY + Math.sin(angle) * Math.max(0, radius - 1.2) * 0.66);
570
+ const glyph = cycle < 16 ? ['*', '+', 'x', 'o'][ray % 4] : ['.', ':', "'", '`'][ray % 4];
571
+ setMotionCell(grid, tailX, tailY, '.', '#767676');
572
+ setMotionCell(grid, x, y, glyph, color);
573
+ }
574
+ }
575
+ return grid.map(row => lineFromCells(row, themeAccent(theme)));
576
+ }
577
+ function buildAsciiPlasma(pulse, width, height, theme) {
578
+ const chars = [' ', '.', ':', ';', '-', '=', '+', '*', '#', '%', '@'];
579
+ const palette = ['#53a8ff', '#6ee7f2', '#8df084', '#ffd166', '#ff5f87', '#c06cff'];
580
+ const rows = [];
581
+ for (let y = 0; y < height; y += 1) {
582
+ const cells = [];
583
+ for (let x = 0; x < width; x += 1) {
584
+ const nx = x / Math.max(1, width - 1);
585
+ const ny = y / Math.max(1, height - 1);
586
+ const value = Math.sin(nx * 14 + pulse * 0.16) +
587
+ Math.sin((nx * 7 + ny * 9) + pulse * 0.11) +
588
+ Math.cos(Math.sqrt(Math.pow(nx - 0.5, 2) + Math.pow(ny - 0.5, 2)) * 18 - pulse * 0.22);
589
+ const normalized = clampNumber((value + 3) / 6, 0, 1);
590
+ const char = chars[Math.min(chars.length - 1, Math.floor(normalized * chars.length))] ?? ' ';
591
+ const color = palette[Math.min(palette.length - 1, Math.floor(((normalized + pulse * 0.01) % 1) * palette.length))] ?? themeAccent(theme);
592
+ cells.push({ text: char, color });
593
+ }
594
+ rows.push(lineFromCells(cells, themeAccent(theme)));
595
+ }
596
+ return rows;
597
+ }
598
+ function buildAsciiDonut(pulse, width, height, theme) {
599
+ const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
600
+ const zBuffer = Array.from({ length: height }, () => Array.from({ length: width }, () => Number.NEGATIVE_INFINITY));
601
+ const chars = ['.', ',', '-', '~', ':', ';', '=', '!', '*', '#', '$', '@'];
602
+ const angleA = pulse * 0.07;
603
+ const angleB = pulse * 0.043;
604
+ const cx = width / 2;
605
+ const cy = height / 2;
606
+ const scale = Math.min(width * 0.22, height * 1.15);
607
+ for (let theta = 0; theta < Math.PI * 2; theta += 0.11) {
608
+ for (let phi = 0; phi < Math.PI * 2; phi += 0.18) {
609
+ const circleX = 2 + Math.cos(theta);
610
+ const circleY = Math.sin(theta);
611
+ const x = circleX * (Math.cos(angleB) * Math.cos(phi) + Math.sin(angleA) * Math.sin(angleB) * Math.sin(phi)) -
612
+ circleY * Math.cos(angleA) * Math.sin(angleB);
613
+ const y = circleX * (Math.sin(angleB) * Math.cos(phi) - Math.sin(angleA) * Math.cos(angleB) * Math.sin(phi)) +
614
+ circleY * Math.cos(angleA) * Math.cos(angleB);
615
+ const z = Math.cos(angleA) * circleX * Math.sin(phi) + circleY * Math.sin(angleA) + 5;
616
+ const inverseZ = 1 / z;
617
+ const screenX = Math.round(cx + scale * inverseZ * x * 2.1);
618
+ const screenY = Math.round(cy + scale * inverseZ * y * 0.86);
619
+ const luminance = Math.cos(phi) * Math.cos(theta) * Math.sin(angleB) -
620
+ Math.cos(angleA) * Math.cos(theta) * Math.sin(phi) -
621
+ Math.sin(angleA) * Math.sin(theta) +
622
+ Math.cos(angleB) * (Math.cos(angleA) * Math.sin(theta) - Math.cos(theta) * Math.sin(angleA) * Math.sin(phi));
623
+ if (screenX < 0 || screenX >= width || screenY < 0 || screenY >= height || inverseZ <= zBuffer[screenY][screenX]) {
624
+ continue;
625
+ }
626
+ const charIndex = Math.max(0, Math.min(chars.length - 1, Math.round((luminance + 1) * 0.5 * (chars.length - 1))));
627
+ grid[screenY][screenX] = chars[charIndex];
628
+ zBuffer[screenY][screenX] = inverseZ;
629
+ }
630
+ }
631
+ const colors = themeContributionColors(theme);
632
+ return grid.map((cells, rowIndex) => ({
633
+ text: cells.join('').slice(0, width),
634
+ color: colors[Math.min(colors.length - 1, Math.floor((rowIndex / Math.max(1, height - 1)) * colors.length))] ?? themeAccent(theme)
635
+ }));
636
+ }
637
+ function buildAsciiStarfield(pulse, width, height, theme) {
638
+ const grid = emptyMotionGrid(width, height, '#767676');
639
+ const count = Math.max(45, Math.min(180, Math.floor((width * height) / 10)));
640
+ const cx = width / 2;
641
+ const cy = height / 2;
642
+ for (let star = 0; star < count; star += 1) {
643
+ const seed = star * 9283 + 17;
644
+ const angle = (hashNoise(seed, 0, 0) * Math.PI * 2);
645
+ const lane = 0.08 + hashNoise(seed, 9, 0) * 0.92;
646
+ const speed = 0.2 + hashNoise(seed, 17, 0) * 0.9;
647
+ const depth = ((pulse * speed + seed) % 90) / 90;
648
+ const radius = Math.pow(depth, 1.75) * Math.max(width * 0.56, height * 2.6) * lane;
649
+ const x = Math.round(cx + Math.cos(angle) * radius);
650
+ const y = Math.round(cy + Math.sin(angle) * radius * 0.34);
651
+ const glyph = depth > 0.82 ? '@' : depth > 0.64 ? '*' : depth > 0.42 ? '+' : '.';
652
+ const color = depth > 0.75 ? '#ffffff' : depth > 0.48 ? themeAccent(theme) : '#767676';
653
+ setMotionCell(grid, x, y, glyph, color);
654
+ }
655
+ return grid.map(row => lineFromCells(row, themeAccent(theme)));
656
+ }
657
+ function buildMesh(pulse, width, height, theme) {
658
+ const grid = emptyMotionGrid(width, height, '#0a0e16');
659
+ const colors = themeContributionColors(theme);
660
+ const accent = themeAccent(theme);
661
+ const rows = Math.max(4, Math.min(Math.round(height * 0.56), height - 1));
662
+ for (let depthRow = rows; depthRow >= 0; depthRow -= 1) {
663
+ const depth = depthRow / rows;
664
+ const nearness = 1 - depth;
665
+ const baseY = (height - 1) * (0.28 + 0.66 * nearness);
666
+ const amp = height * 0.16 * (0.35 + 0.65 * nearness);
667
+ let previousY = -1;
668
+ for (let x = 0; x < width; x += 1) {
669
+ const nx = x / Math.max(1, width - 1);
670
+ const wobble = Math.sin(nx * 7 - pulse * 0.18 + depth * 3.1) * amp +
671
+ Math.sin(nx * 13 + pulse * 0.09) * amp * 0.4;
672
+ const y = baseY - wobble;
673
+ const glyph = previousY < 0 ? '─' : y < previousY - 0.4 ? '╱' : y > previousY + 0.4 ? '╲' : '─';
674
+ const colorIndex = Math.min(colors.length - 1, 1 + Math.round(nearness * (colors.length - 2)));
675
+ paintCell(grid, x, y, glyph, colors[colorIndex] ?? accent);
676
+ previousY = y;
677
+ }
678
+ }
679
+ return grid.map(row => lineFromCells(row, accent));
680
+ }
681
+ function buildRibbon(pulse, width, height, theme) {
682
+ const grid = emptyMotionGrid(width, height, '#0a0e16');
683
+ const accent = themeAccent(theme);
684
+ const palette = themeContributionColors(theme);
685
+ const mid = (height - 1) / 2;
686
+ const baseThickness = Math.max(1.5, height * 0.3);
687
+ for (let x = 0; x < width; x += 1) {
688
+ const nx = x / Math.max(1, width - 1);
689
+ const center = mid +
690
+ Math.sin(nx * 5 - pulse * 0.16) * (height * 0.26) +
691
+ Math.sin(nx * 11 + pulse * 0.07) * (height * 0.1);
692
+ const thickness = baseThickness * (0.55 + 0.45 * Math.sin(nx * 9 - pulse * 0.12));
693
+ for (let y = 0; y < height; y += 1) {
694
+ const distance = Math.abs(y - center);
695
+ if (distance > thickness) {
696
+ continue;
697
+ }
698
+ const shade = 1 - distance / Math.max(0.5, thickness);
699
+ const glyph = shade > 0.66 ? '█' : shade > 0.36 ? '▓' : '▒';
700
+ const colorIndex = Math.min(palette.length - 1, Math.max(1, Math.round(shade * (palette.length - 1))));
701
+ paintCell(grid, x, y, glyph, palette[colorIndex] ?? accent);
702
+ }
703
+ }
704
+ return grid.map(row => lineFromCells(row, accent));
705
+ }
706
+ function buildOrbits(pulse, width, height, theme) {
707
+ const grid = emptyMotionGrid(width, height, '#080b12');
708
+ const accent = themeAccent(theme);
709
+ const cx = (width - 1) / 2;
710
+ const cy = (height - 1) / 2;
711
+ const rx = width * 0.42;
712
+ const ry = height * 0.42;
713
+ const palette = ['#6ee7f2', '#8df084', '#ffd166', '#ff5f87', '#c06cff'];
714
+ const particles = 5;
715
+ for (let particle = 0; particle < particles; particle += 1) {
716
+ const a = 2 + particle * 0.5;
717
+ const b = 3 + particle * 0.4;
718
+ const phase = particle * 1.3;
719
+ const headColor = theme === 'mono' ? accent : palette[particle % palette.length] ?? accent;
720
+ for (let trail = 11; trail >= 0; trail -= 1) {
721
+ const t = pulse * 0.08 - trail * 0.085;
722
+ const x = cx + Math.sin(a * t + phase) * rx;
723
+ const y = cy + Math.sin(b * t) * ry;
724
+ const glyph = trail === 0 ? '◉' : trail < 3 ? '●' : trail < 6 ? '•' : '·';
725
+ const color = trail < 3 ? headColor : dimMotionColorAt(particle / particles, theme);
726
+ paintCell(grid, x, y, glyph, color);
727
+ }
728
+ }
729
+ return grid.map(row => lineFromCells(row, accent));
730
+ }
731
+ function buildMirror(pulse, width, height, theme) {
732
+ const grid = emptyMotionGrid(width, height, '#0a0e16');
733
+ const colors = themeContributionColors(theme);
734
+ const accent = themeAccent(theme);
735
+ const baseY = Math.round((height - 1) * 0.66);
736
+ const bandWidth = 2;
737
+ const bands = Math.ceil((width + 1) / bandWidth);
738
+ const sample = (band, p) => clampNumber((Math.sin(band * 0.4 + p * 0.3) * 0.42 +
739
+ Math.sin(band * 0.13 - p * 0.18) * 0.34 +
740
+ Math.sin(band * 0.66 + p * 0.52) * 0.24 +
741
+ 1) /
742
+ 2, 0, 1);
743
+ for (let band = 0; band < bands; band += 1) {
744
+ const x = band * bandWidth;
745
+ const up = Math.round(sample(band, pulse) * baseY);
746
+ let peak = 0;
747
+ for (let k = 0; k < 10; k += 1) {
748
+ peak = Math.max(peak, Math.round(sample(band, pulse - k) * baseY));
749
+ }
750
+ for (let d = 0; d <= up; d += 1) {
751
+ const frac = d / Math.max(1, baseY);
752
+ const colorIndex = Math.min(colors.length - 1, 1 + Math.round(frac * (colors.length - 2)));
753
+ paintCell(grid, x, baseY - d, '█', colors[colorIndex] ?? accent);
754
+ }
755
+ const reflection = Math.round(up * 0.45);
756
+ for (let d = 1; d <= reflection; d += 1) {
757
+ paintCell(grid, x, baseY + d, d > reflection - 1 ? '░' : '▒', '#27313d');
758
+ }
759
+ if (peak > 0) {
760
+ paintCell(grid, x, baseY - peak, '▀', '#ffffff');
761
+ }
762
+ }
763
+ const baseline = grid[baseY];
764
+ if (baseline) {
765
+ for (let x = 0; x < width; x += 1) {
766
+ if (baseline[x]?.text === ' ') {
767
+ paintCell(grid, x, baseY, '─', '#1d2530');
768
+ }
769
+ }
770
+ }
771
+ return grid.map(row => lineFromCells(row, accent));
772
+ }
773
+ function buildTunnel(pulse, width, height, theme) {
774
+ const grid = emptyMotionGrid(width, height, '#04060a');
775
+ const colors = themeContributionColors(theme);
776
+ const accent = themeAccent(theme);
777
+ const cx = (width - 1) / 2;
778
+ const cy = (height - 1) / 2;
779
+ for (let y = 0; y < height; y += 1) {
780
+ for (let x = 0; x < width; x += 1) {
781
+ const dx = (x - cx) / (width * 0.5);
782
+ const dy = (y - cy) / (height * 0.5);
783
+ const r = Math.sqrt(dx * dx + dy * dy) + 1e-3;
784
+ const angle = Math.atan2(dy, dx);
785
+ const depth = 1 / r;
786
+ const ring = Math.sin(depth * 5 - pulse * 0.4);
787
+ const twist = Math.sin(angle * 5 + depth * 2 + pulse * 0.08);
788
+ const shade = (ring * 0.7 + twist * 0.3 + 1) / 2;
789
+ const glyph = shade > 0.78 ? '█' : shade > 0.58 ? '▓' : shade > 0.38 ? '▒' : shade > 0.2 ? '░' : ' ';
790
+ if (glyph === ' ') {
791
+ continue;
792
+ }
793
+ const colorIndex = Math.min(colors.length - 1, Math.max(1, Math.round(Math.min(1, depth * 0.7) * (colors.length - 1))));
794
+ paintCell(grid, x, y, glyph, r < 0.12 ? '#ffffff' : colors[colorIndex] ?? accent);
795
+ }
796
+ }
797
+ return grid.map(row => lineFromCells(row, accent));
798
+ }
799
+ function buildKaleidoscope(pulse, width, height, theme) {
800
+ const grid = emptyMotionGrid(width, height, '#070a12');
801
+ const accent = themeAccent(theme);
802
+ const palette = ['#6ee7f2', '#8df084', '#ffd166', '#ff5f87', '#c06cff', '#66a3ff'];
803
+ const cx = (width - 1) / 2;
804
+ const cy = (height - 1) / 2;
805
+ const segment = Math.PI / 3;
806
+ const rotation = pulse * 0.05;
807
+ const glyphs = [' ', '·', '∘', '*', '✦', '◆', '█'];
808
+ for (let y = 0; y < height; y += 1) {
809
+ for (let x = 0; x < width; x += 1) {
810
+ const dx = (x - cx) / 2.0;
811
+ const dy = y - cy;
812
+ const r = Math.sqrt(dx * dx + dy * dy);
813
+ let folded = (Math.atan2(dy, dx) + rotation) % segment;
814
+ folded = Math.abs(((folded + segment) % segment) - segment / 2);
815
+ const value = Math.sin(r * 0.9 - pulse * 0.2) + Math.cos(folded * 6 + r * 0.5 - pulse * 0.12);
816
+ const normalized = clampNumber((value + 2) / 4, 0, 1);
817
+ const glyphIndex = Math.min(glyphs.length - 1, Math.floor(normalized * glyphs.length));
818
+ if (glyphIndex === 0) {
819
+ continue;
820
+ }
821
+ const color = theme === 'mono'
822
+ ? motionColorAt(normalized, theme)
823
+ : palette[Math.floor((normalized + rotation * 0.1) * palette.length) % palette.length] ?? accent;
824
+ paintCell(grid, x, y, glyphs[glyphIndex] ?? '·', color);
825
+ }
826
+ }
827
+ return grid.map(row => lineFromCells(row, accent));
828
+ }
829
+ function buildConstellation(pulse, width, height, theme) {
830
+ const grid = emptyMotionGrid(width, height, '#06090f');
831
+ const accent = themeAccent(theme);
832
+ const count = Math.max(8, Math.min(18, Math.floor(width / 6)));
833
+ const nodes = [];
834
+ for (let i = 0; i < count; i += 1) {
835
+ const baseX = hashNoise(i * 13 + 1, 1, 0);
836
+ const baseY = hashNoise(i * 7 + 3, 2, 0);
837
+ const speedX = 0.6 + hashNoise(i, 5, 0) * 0.8;
838
+ const speedY = 0.4 + hashNoise(i, 9, 0) * 0.7;
839
+ const x = baseX * (width - 1) + Math.sin(pulse * 0.03 * speedX + i) * width * 0.16;
840
+ const y = baseY * (height - 1) + Math.cos(pulse * 0.025 * speedY + i * 1.3) * height * 0.2;
841
+ nodes.push({
842
+ x: ((x % (width - 1)) + (width - 1)) % (width - 1),
843
+ y: ((y % (height - 1)) + (height - 1)) % (height - 1)
844
+ });
845
+ }
846
+ const linkDistance = width * 0.22;
847
+ for (let i = 0; i < nodes.length; i += 1) {
848
+ for (let j = i + 1; j < nodes.length; j += 1) {
849
+ const a = nodes[i];
850
+ const b = nodes[j];
851
+ const dx = a.x - b.x;
852
+ const dy = (a.y - b.y) * 2;
853
+ if (Math.sqrt(dx * dx + dy * dy) < linkDistance) {
854
+ drawLineCells(grid, a.x, a.y, b.x, b.y, '·', '#2c3340');
855
+ }
856
+ }
857
+ }
858
+ for (const node of nodes) {
859
+ paintCell(grid, node.x, node.y, '✦', accent);
860
+ }
861
+ return grid.map(row => lineFromCells(row, accent));
862
+ }
863
+ function buildPulseGrid(pulse, width, height, theme) {
864
+ const grid = emptyMotionGrid(width, height, '#0a0e16');
865
+ const accent = themeAccent(theme);
866
+ const colors = themeContributionColors(theme);
867
+ const cx = (width - 1) / 2;
868
+ const cy = (height - 1) / 2;
869
+ const levels = ['·', '∘', '•', '●', '◉'];
870
+ for (let y = 0; y < height; y += 1) {
871
+ for (let x = 0; x < width; x += 1) {
872
+ if (x % 2 === 1) {
873
+ continue;
874
+ }
875
+ const dx = (x - cx) / 2.0;
876
+ const dy = y - cy;
877
+ const r = Math.sqrt(dx * dx + dy * dy);
878
+ const wave = Math.sin(r * 0.7 - pulse * 0.3) * 0.5 + 0.5;
879
+ const sway = Math.sin(x * 0.2 + y * 0.3 + pulse * 0.1) * 0.2;
880
+ const value = clampNumber(wave + sway, 0, 1);
881
+ const levelIndex = Math.min(levels.length - 1, Math.floor(value * levels.length));
882
+ const colorIndex = Math.min(colors.length - 1, Math.max(1, Math.round(value * (colors.length - 1))));
883
+ paintCell(grid, x, y, levels[levelIndex] ?? '·', value > 0.16 ? colors[colorIndex] ?? accent : '#1a2029');
884
+ }
885
+ }
886
+ return grid.map(row => lineFromCells(row, accent));
887
+ }
888
+ function buildLissajous(pulse, width, height, theme) {
889
+ const grid = emptyMotionGrid(width, height, '#04070d');
890
+ const accent = themeAccent(theme);
891
+ const ramp = themeContributionColors(theme);
892
+ const cx = (width - 1) / 2;
893
+ const cy = (height - 1) / 2;
894
+ const rx = width * 0.42;
895
+ const ry = height * 0.42;
896
+ const a = 3;
897
+ const b = 2;
898
+ const delta = pulse * 0.03;
899
+ const samples = 620;
900
+ for (let s = samples; s >= 0; s -= 1) {
901
+ const t = pulse * 0.05 + s * 0.0102;
902
+ const x = cx + Math.sin(a * t + delta) * rx;
903
+ const y = cy + Math.sin(b * t) * ry;
904
+ const brightness = 1 - s / samples;
905
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.floor(brightness * ramp.length)));
906
+ const glyph = s === 0 ? '◉' : brightness > 0.6 ? '●' : brightness > 0.3 ? '•' : '·';
907
+ const color = theme === 'mono' ? motionColorAt(brightness, theme) : ramp[colorIndex] ?? accent;
908
+ paintCell(grid, x, y, glyph, color);
909
+ }
910
+ return grid.map(row => lineFromCells(row, accent));
911
+ }
912
+ function paintCell(grid, x, y, text, color) {
913
+ const ix = Math.round(x);
914
+ const iy = Math.round(y);
915
+ const row = grid[iy];
916
+ if (!row || !row[ix]) {
917
+ return;
918
+ }
919
+ row[ix] = { text, color };
920
+ }
921
+ function drawLineCells(grid, x0, y0, x1, y1, text, color) {
922
+ const dx = x1 - x0;
923
+ const dy = y1 - y0;
924
+ const steps = Math.max(1, Math.round(Math.max(Math.abs(dx), Math.abs(dy) * 2)));
925
+ for (let step = 1; step < steps; step += 1) {
926
+ const t = step / steps;
927
+ const x = Math.round(x0 + dx * t);
928
+ const y = Math.round(y0 + dy * t);
929
+ const row = grid[y];
930
+ if (row?.[x]?.text === ' ') {
931
+ row[x] = { text, color };
932
+ }
933
+ }
934
+ }
935
+ const BRAILLE_BITS = [
936
+ [0x01, 0x08],
937
+ [0x02, 0x10],
938
+ [0x04, 0x20],
939
+ [0x40, 0x80]
940
+ ];
941
+ function createBraille(width, height) {
942
+ const size = Math.max(0, width * height);
943
+ return { width, height, dots: new Uint8Array(size), colors: new Array(size).fill('') };
944
+ }
945
+ function brailleSet(canvas, fx, fy, color) {
946
+ const px = Math.round(fx);
947
+ const py = Math.round(fy);
948
+ if (px < 0 || py < 0 || px >= canvas.width * 2 || py >= canvas.height * 4) {
949
+ return;
950
+ }
951
+ const cx = px >> 1;
952
+ const cy = py >> 2;
953
+ const index = cy * canvas.width + cx;
954
+ canvas.dots[index] |= BRAILLE_BITS[py & 3][px & 1];
955
+ if (color) {
956
+ canvas.colors[index] = color;
957
+ }
958
+ }
959
+ function brailleToLines(canvas, fallbackColor) {
960
+ const rows = [];
961
+ for (let cy = 0; cy < canvas.height; cy += 1) {
962
+ const cells = [];
963
+ for (let cx = 0; cx < canvas.width; cx += 1) {
964
+ const index = cy * canvas.width + cx;
965
+ const bits = canvas.dots[index] ?? 0;
966
+ cells.push({
967
+ text: bits === 0 ? ' ' : String.fromCharCode(0x2800 + bits),
968
+ color: canvas.colors[index] || fallbackColor
969
+ });
970
+ }
971
+ rows.push(lineFromCells(cells, fallbackColor));
972
+ }
973
+ return rows;
974
+ }
975
+ function drawBrailleLine(canvas, x0, y0, x1, y1, color) {
976
+ const dx = x1 - x0;
977
+ const dy = y1 - y0;
978
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy))));
979
+ for (let step = 0; step <= steps; step += 1) {
980
+ const t = step / steps;
981
+ brailleSet(canvas, x0 + dx * t, y0 + dy * t, color);
982
+ }
983
+ }
984
+ function spectrumSample(band, p) {
985
+ return clampNumber((Math.sin(band * 0.35 + p * 0.3) * 0.4 +
986
+ Math.sin(band * 0.12 - p * 0.17) * 0.35 +
987
+ Math.sin(band * 0.62 + p * 0.52) * 0.25 +
988
+ 1) /
989
+ 2, 0, 1);
990
+ }
991
+ function buildBrailleWave(pulse, width, height, theme) {
992
+ const canvas = createBraille(width, height);
993
+ const colors = themeContributionColors(theme);
994
+ const accent = themeAccent(theme);
995
+ const dotsWide = width * 2;
996
+ const dotsHigh = height * 4;
997
+ const mid = (dotsHigh - 1) / 2;
998
+ let previousY = -1;
999
+ for (let x = 0; x < dotsWide; x += 1) {
1000
+ const nx = x / Math.max(1, dotsWide - 1);
1001
+ const wave = Math.sin(nx * Math.PI * 4 - pulse * 0.22) * 0.5 +
1002
+ Math.sin(nx * Math.PI * 9 + pulse * 0.13) * 0.28 +
1003
+ Math.sin(nx * Math.PI * 2 - pulse * 0.07) * 0.2;
1004
+ const envelope = 0.35 + 0.65 * Math.sin(nx * Math.PI);
1005
+ const amplitude = wave * envelope;
1006
+ const y = mid - amplitude * mid * 0.82;
1007
+ const colorIndex = Math.min(colors.length - 1, 1 + Math.round(Math.abs(amplitude) * (colors.length - 2)));
1008
+ const color = colors[colorIndex] ?? accent;
1009
+ const target = Math.round(y);
1010
+ if (previousY < 0) {
1011
+ previousY = target;
1012
+ }
1013
+ const low = Math.min(previousY, target);
1014
+ const high = Math.max(previousY, target);
1015
+ for (let yy = low; yy <= high; yy += 1) {
1016
+ brailleSet(canvas, x, yy, color);
1017
+ }
1018
+ previousY = target;
1019
+ }
1020
+ return brailleToLines(canvas, accent);
1021
+ }
1022
+ function buildRadialEq(pulse, width, height, theme) {
1023
+ const canvas = createBraille(width, height);
1024
+ const colors = themeContributionColors(theme);
1025
+ const accent = themeAccent(theme);
1026
+ const dotsWide = width * 2;
1027
+ const dotsHigh = height * 4;
1028
+ const cx = (dotsWide - 1) / 2;
1029
+ const cy = (dotsHigh - 1) / 2;
1030
+ const inner = Math.min(dotsWide, dotsHigh) * 0.13;
1031
+ const maxLength = Math.max(2, Math.min(dotsWide, dotsHigh) / 2 - inner - 1);
1032
+ const bands = 80;
1033
+ for (let band = 0; band < bands; band += 1) {
1034
+ const angle = (band / bands) * Math.PI * 2;
1035
+ const level = spectrumSample(band * 1.4, pulse);
1036
+ const length = inner + level * maxLength;
1037
+ for (let r = inner; r <= length; r += 0.6) {
1038
+ const frac = (r - inner) / Math.max(1, maxLength);
1039
+ const colorIndex = Math.min(colors.length - 1, 1 + Math.round(frac * (colors.length - 2)));
1040
+ brailleSet(canvas, cx + Math.cos(angle) * r, cy + Math.sin(angle) * r, colors[colorIndex] ?? accent);
1041
+ }
1042
+ }
1043
+ for (let angle = 0; angle < Math.PI * 2; angle += 0.05) {
1044
+ brailleSet(canvas, cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner, accent);
1045
+ }
1046
+ return brailleToLines(canvas, accent);
1047
+ }
1048
+ function buildSpectrogram(pulse, width, height, theme) {
1049
+ const grid = emptyMotionGrid(width, height, '#05070d');
1050
+ const ramp = themeContributionColors(theme);
1051
+ const accent = themeAccent(theme);
1052
+ const shades = [' ', '░', '▒', '▓', '█'];
1053
+ for (let x = 0; x < width; x += 1) {
1054
+ const t = pulse * 0.9 - (width - 1 - x);
1055
+ for (let y = 0; y < height; y += 1) {
1056
+ const freq = (height - 1 - y) / Math.max(1, height - 1);
1057
+ const formantA = Math.exp(-Math.pow((freq - (0.3 + 0.18 * Math.sin(t * 0.06))) / 0.12, 2));
1058
+ const formantB = 0.7 * Math.exp(-Math.pow((freq - (0.62 + 0.12 * Math.sin(t * 0.04 + 2))) / 0.09, 2));
1059
+ const base = (1 - freq) * 0.5;
1060
+ const shimmer = 0.2 * Math.sin(freq * 30 + t * 0.5) * Math.cos(freq * 11 - t * 0.3);
1061
+ const value = clampNumber(base + (formantA + formantB) * 0.7 + shimmer, 0, 1);
1062
+ const shadeIndex = Math.min(shades.length - 1, Math.floor(value * shades.length));
1063
+ if (shadeIndex === 0) {
1064
+ continue;
1065
+ }
1066
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(value * (ramp.length - 1))));
1067
+ paintCell(grid, x, y, shades[shadeIndex] ?? '░', ramp[colorIndex] ?? accent);
1068
+ }
1069
+ }
1070
+ return grid.map(row => lineFromCells(row, accent));
1071
+ }
1072
+ function buildNebula(pulse, width, height, theme) {
1073
+ const grid = emptyMotionGrid(width, height, '#04060e');
1074
+ const ramp = themeContributionColors(theme);
1075
+ const accent = themeAccent(theme);
1076
+ const shades = [' ', '·', '░', '▒', '▓'];
1077
+ for (let y = 0; y < height; y += 1) {
1078
+ for (let x = 0; x < width; x += 1) {
1079
+ const nx = x / width;
1080
+ const ny = y / height;
1081
+ const cloudA = Math.sin(nx * 5 + pulse * 0.03) * 0.5 + 0.5;
1082
+ const cloudB = Math.sin(nx * 9 - ny * 7 + pulse * 0.02) * 0.5 + 0.5;
1083
+ const cloudC = Math.sin((nx + ny) * 6 + pulse * 0.025) * 0.5 + 0.5;
1084
+ const density = clampNumber(cloudA * 0.4 + cloudB * 0.35 + cloudC * 0.35 - 0.4, 0, 1);
1085
+ const shadeIndex = Math.min(shades.length - 1, Math.floor(density * shades.length));
1086
+ if (shadeIndex > 0) {
1087
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(density * (ramp.length - 1))));
1088
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', ramp[colorIndex] ?? accent);
1089
+ }
1090
+ const star = hashNoise(x * 3 + 1, y * 7 + 3, 0);
1091
+ if (star > 0.992) {
1092
+ const twinkle = 0.5 + 0.5 * Math.sin(pulse * 0.3 + star * 40);
1093
+ if (twinkle > 0.5) {
1094
+ paintCell(grid, x, y, twinkle > 0.85 ? '✦' : '·', twinkle > 0.85 ? '#ffffff' : '#9aa4b2');
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+ return grid.map(row => lineFromCells(row, accent));
1100
+ }
1101
+ function buildSilk(pulse, width, height, theme) {
1102
+ const canvas = createBraille(width, height);
1103
+ const ramp = themeContributionColors(theme);
1104
+ const accent = themeAccent(theme);
1105
+ const dotsWide = width * 2;
1106
+ const dotsHigh = height * 4;
1107
+ const lines = Math.max(6, Math.floor(dotsHigh / 4));
1108
+ for (let line = 0; line < lines; line += 1) {
1109
+ const base = ((line + 0.5) / lines) * dotsHigh;
1110
+ const phase = line * 0.5;
1111
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round((line / lines) * (ramp.length - 2)));
1112
+ const color = ramp[colorIndex] ?? accent;
1113
+ let previous = -1;
1114
+ for (let x = 0; x < dotsWide; x += 1) {
1115
+ const nx = x / Math.max(1, dotsWide - 1);
1116
+ const warp = Math.sin(nx * Math.PI * 3 - pulse * 0.12 + phase) * dotsHigh * 0.06 +
1117
+ Math.sin(nx * Math.PI * 7 + pulse * 0.06 - phase) * dotsHigh * 0.03 +
1118
+ Math.sin(pulse * 0.04 + phase) * dotsHigh * 0.02;
1119
+ const target = Math.round(base + warp);
1120
+ if (previous < 0) {
1121
+ previous = target;
1122
+ }
1123
+ const low = Math.min(previous, target);
1124
+ const high = Math.max(previous, target);
1125
+ for (let yy = low; yy <= high; yy += 1) {
1126
+ brailleSet(canvas, x, yy, color);
1127
+ }
1128
+ previous = target;
1129
+ }
1130
+ }
1131
+ return brailleToLines(canvas, accent);
1132
+ }
1133
+ function buildRippleTank(pulse, width, height, theme) {
1134
+ const canvas = createBraille(width, height);
1135
+ const ramp = themeContributionColors(theme);
1136
+ const accent = themeAccent(theme);
1137
+ const dotsWide = width * 2;
1138
+ const dotsHigh = height * 4;
1139
+ const sources = [
1140
+ { x: dotsWide * 0.32 + Math.sin(pulse * 0.03) * dotsWide * 0.05, y: dotsHigh * 0.4 },
1141
+ { x: dotsWide * 0.68 + Math.cos(pulse * 0.025) * dotsWide * 0.05, y: dotsHigh * 0.62 }
1142
+ ];
1143
+ for (let y = 0; y < dotsHigh; y += 1) {
1144
+ for (let x = 0; x < dotsWide; x += 1) {
1145
+ let value = 0;
1146
+ for (const source of sources) {
1147
+ const distance = Math.sqrt((x - source.x) ** 2 + (y - source.y) ** 2);
1148
+ value += Math.cos(distance * 0.5 - pulse * 0.35);
1149
+ }
1150
+ const normalized = (value + 2) / 4;
1151
+ if (normalized > 0.85) {
1152
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round(normalized * (ramp.length - 2)));
1153
+ brailleSet(canvas, x, y, ramp[colorIndex] ?? accent);
1154
+ }
1155
+ }
1156
+ }
1157
+ return brailleToLines(canvas, accent);
1158
+ }
1159
+ function buildPhyllotaxis(pulse, width, height, theme) {
1160
+ const canvas = createBraille(width, height);
1161
+ const accent = themeAccent(theme);
1162
+ const palette = ['#6ee7f2', '#8df084', '#ffd166', '#ff5f87', '#c06cff', '#66a3ff'];
1163
+ const dotsWide = width * 2;
1164
+ const dotsHigh = height * 4;
1165
+ const cx = (dotsWide - 1) / 2;
1166
+ const cy = (dotsHigh - 1) / 2;
1167
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
1168
+ const count = Math.min(540, Math.floor((dotsWide * dotsHigh) / 6));
1169
+ const scale = (Math.min(dotsWide, dotsHigh) * 0.46) / Math.sqrt(Math.max(1, count));
1170
+ const breath = 0.88 + 0.12 * Math.sin(pulse * 0.12);
1171
+ for (let n = 0; n < count; n += 1) {
1172
+ const angle = n * goldenAngle + pulse * 0.03;
1173
+ const radius = scale * Math.sqrt(n) * breath;
1174
+ const frac = n / count;
1175
+ const color = theme === 'mono' ? motionColorAt(frac, theme) : palette[Math.floor(frac * palette.length) % palette.length] ?? accent;
1176
+ brailleSet(canvas, cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius, color);
1177
+ }
1178
+ return brailleToLines(canvas, accent);
1179
+ }
1180
+ function buildHarmonograph(pulse, width, height, theme) {
1181
+ const canvas = createBraille(width, height);
1182
+ const ramp = themeContributionColors(theme);
1183
+ const accent = themeAccent(theme);
1184
+ const dotsWide = width * 2;
1185
+ const dotsHigh = height * 4;
1186
+ const cx = (dotsWide - 1) / 2;
1187
+ const cy = (dotsHigh - 1) / 2;
1188
+ const ax = dotsWide * 0.44;
1189
+ const ay = dotsHigh * 0.44;
1190
+ const f1 = 2 + 0.3 * Math.sin(pulse * 0.01);
1191
+ const f2 = 3 + 0.3 * Math.cos(pulse * 0.012);
1192
+ const phase = pulse * 0.02;
1193
+ const samples = 900;
1194
+ for (let s = 0; s < samples; s += 1) {
1195
+ const t = s * 0.05;
1196
+ const decay = Math.exp(-t * 0.022);
1197
+ const x = cx + ax * decay * Math.sin(f1 * t + phase);
1198
+ const y = cy + ay * decay * Math.sin(f2 * t + phase * 1.3);
1199
+ const frac = s / samples;
1200
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round((1 - frac) * (ramp.length - 2)));
1201
+ brailleSet(canvas, x, y, ramp[colorIndex] ?? accent);
1202
+ }
1203
+ return brailleToLines(canvas, accent);
1204
+ }
1205
+ function buildBloomBars(pulse, width, height, theme) {
1206
+ const grid = emptyMotionGrid(width, height, '#070b12');
1207
+ const ramp = themeContributionColors(theme);
1208
+ const accent = themeAccent(theme);
1209
+ const eighths = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
1210
+ const bandWidth = 2;
1211
+ const bands = Math.ceil(width / bandWidth);
1212
+ for (let band = 0; band < bands; band += 1) {
1213
+ const x = band * bandWidth;
1214
+ const level = spectrumSample(band, pulse);
1215
+ const filled = level * height;
1216
+ const fullCells = Math.floor(filled);
1217
+ const remainder = filled - fullCells;
1218
+ let peak = 0;
1219
+ for (let k = 0; k < 14; k += 1) {
1220
+ peak = Math.max(peak, spectrumSample(band, pulse - k));
1221
+ }
1222
+ for (let i = 0; i < fullCells; i += 1) {
1223
+ const y = height - 1 - i;
1224
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round((i / Math.max(1, height - 1)) * (ramp.length - 2)));
1225
+ paintCell(grid, x, y, '█', ramp[colorIndex] ?? accent);
1226
+ }
1227
+ if (remainder > 0.08 && fullCells < height) {
1228
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round((fullCells / Math.max(1, height - 1)) * (ramp.length - 2)));
1229
+ paintCell(grid, x, height - 1 - fullCells, eighths[Math.min(7, Math.floor(remainder * 8))] ?? '▁', ramp[colorIndex] ?? accent);
1230
+ }
1231
+ const tipY = height - 1 - fullCells;
1232
+ if (tipY - 1 >= 0) {
1233
+ paintCell(grid, x, tipY - 1, '░', '#2b3340');
1234
+ }
1235
+ const peakY = height - 1 - Math.round(peak * (height - 1));
1236
+ if (peakY >= 0 && peakY < height) {
1237
+ paintCell(grid, x, peakY, '▔', '#ffffff');
1238
+ }
1239
+ }
1240
+ return grid.map(row => lineFromCells(row, accent));
1241
+ }
1242
+ function buildMoire(pulse, width, height, theme) {
1243
+ const grid = emptyMotionGrid(width, height, '#05060c');
1244
+ const ramp = themeContributionColors(theme);
1245
+ const accent = themeAccent(theme);
1246
+ const centerA = { x: width * 0.4 + Math.sin(pulse * 0.02) * width * 0.1, y: height * 0.5 };
1247
+ const centerB = { x: width * 0.6 + Math.cos(pulse * 0.018) * width * 0.1, y: height * 0.5 };
1248
+ const shades = [' ', '·', '░', '▒', '▓', '█'];
1249
+ for (let y = 0; y < height; y += 1) {
1250
+ for (let x = 0; x < width; x += 1) {
1251
+ const da = Math.sqrt(((x - centerA.x) / 2) ** 2 + (y - centerA.y) ** 2);
1252
+ const db = Math.sqrt(((x - centerB.x) / 2) ** 2 + (y - centerB.y) ** 2);
1253
+ const value = Math.sin(da * 1.1 - pulse * 0.1) * Math.sin(db * 1.1 + pulse * 0.08);
1254
+ const normalized = (value + 1) / 2;
1255
+ const shadeIndex = Math.min(shades.length - 1, Math.floor(normalized * shades.length));
1256
+ if (shadeIndex > 0) {
1257
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(normalized * (ramp.length - 1))));
1258
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', ramp[colorIndex] ?? accent);
1259
+ }
1260
+ }
1261
+ }
1262
+ return grid.map(row => lineFromCells(row, accent));
1263
+ }
1264
+ function buildGalaxy(pulse, width, height, theme) {
1265
+ const canvas = createBraille(width, height);
1266
+ const accent = themeAccent(theme);
1267
+ const palette = ['#bcd7ff', '#9ec2ff', '#ffffff', '#ffd9a8', '#ffb38a'];
1268
+ const dotsWide = width * 2;
1269
+ const dotsHigh = height * 4;
1270
+ const cx = (dotsWide - 1) / 2;
1271
+ const cy = (dotsHigh - 1) / 2;
1272
+ const arms = 2;
1273
+ const rotation = pulse * 0.03;
1274
+ const span = Math.min(dotsWide, dotsHigh) * 0.62;
1275
+ const count = Math.min(1100, Math.floor((dotsWide * dotsHigh) / 3));
1276
+ for (let i = 0; i < count; i += 1) {
1277
+ const fraction = i / count;
1278
+ const radius = fraction * span;
1279
+ const arm = i % arms;
1280
+ const jitter = (hashNoise(i, 1, 0) - 0.5) * 0.55;
1281
+ const angle = arm * ((Math.PI * 2) / arms) + radius * 0.12 + rotation + jitter;
1282
+ const brightness = fraction < 0.12 ? 1 : 1 - fraction;
1283
+ if (hashNoise(i, 7, 0) < 0.6 + brightness * 0.4) {
1284
+ const color = fraction < 0.1
1285
+ ? '#ffffff'
1286
+ : theme === 'mono'
1287
+ ? motionColorAt(1 - fraction, theme)
1288
+ : palette[Math.floor(hashNoise(i, 3, 0) * palette.length) % palette.length] ?? accent;
1289
+ brailleSet(canvas, cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius, color);
1290
+ }
1291
+ }
1292
+ const coreRadius = Math.min(dotsWide, dotsHigh) * 0.05;
1293
+ for (let angle = 0; angle < Math.PI * 2; angle += 0.18) {
1294
+ for (let r = 0; r < coreRadius; r += 0.7) {
1295
+ brailleSet(canvas, cx + Math.cos(angle) * r, cy + Math.sin(angle) * r, '#ffffff');
1296
+ }
1297
+ }
1298
+ return brailleToLines(canvas, accent);
1299
+ }
1300
+ function buildCaustics(pulse, width, height, theme) {
1301
+ const grid = emptyMotionGrid(width, height, '#04141a');
1302
+ const ramp = themeContributionColors(theme);
1303
+ const accent = themeAccent(theme);
1304
+ for (let y = 0; y < height; y += 1) {
1305
+ for (let x = 0; x < width; x += 1) {
1306
+ const nx = (x / width) * 6;
1307
+ const ny = (y / height) * 6;
1308
+ const field = Math.sin(nx + pulse * 0.06) +
1309
+ Math.sin(ny * 1.3 - pulse * 0.05) +
1310
+ Math.sin((nx + ny) * 0.9 + pulse * 0.04) +
1311
+ Math.sin((nx - ny) * 1.1 - pulse * 0.045);
1312
+ const ridged = 1 - Math.abs(field) / 4;
1313
+ const value = Math.pow(clampNumber(ridged, 0, 1), 3);
1314
+ let glyph = ' ';
1315
+ if (value > 0.86) {
1316
+ glyph = '█';
1317
+ }
1318
+ else if (value > 0.72) {
1319
+ glyph = '▓';
1320
+ }
1321
+ else if (value > 0.58) {
1322
+ glyph = '▒';
1323
+ }
1324
+ else if (value > 0.45) {
1325
+ glyph = '░';
1326
+ }
1327
+ else if (value > 0.33) {
1328
+ glyph = '·';
1329
+ }
1330
+ if (glyph !== ' ') {
1331
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(value * (ramp.length - 1))));
1332
+ paintCell(grid, x, y, glyph, value > 0.86 ? '#dffaff' : ramp[colorIndex] ?? accent);
1333
+ }
1334
+ }
1335
+ }
1336
+ return grid.map(row => lineFromCells(row, accent));
1337
+ }
1338
+ function buildLorenz(pulse, width, height, theme) {
1339
+ const canvas = createBraille(width, height);
1340
+ const ramp = themeContributionColors(theme);
1341
+ const accent = themeAccent(theme);
1342
+ const dotsWide = width * 2;
1343
+ const dotsHigh = height * 4;
1344
+ const cx = (dotsWide - 1) / 2;
1345
+ const cy = (dotsHigh - 1) / 2;
1346
+ const steps = 1500;
1347
+ const dt = 0.009;
1348
+ const sigma = 10;
1349
+ const rho = 28;
1350
+ const beta = 8 / 3;
1351
+ const points = [];
1352
+ let x = 0.1;
1353
+ let y = 0;
1354
+ let z = 0;
1355
+ for (let i = 0; i < steps; i += 1) {
1356
+ x += sigma * (y - x) * dt;
1357
+ y += (x * (rho - z) - y) * dt;
1358
+ z += (x * y - beta * z) * dt;
1359
+ points.push([x, y, z]);
1360
+ }
1361
+ const angle = pulse * 0.02;
1362
+ const cos = Math.cos(angle);
1363
+ const sin = Math.sin(angle);
1364
+ const scaleX = (dotsWide * 0.42) / 26;
1365
+ const scaleZ = (dotsHigh * 0.46) / 26;
1366
+ const head = Math.floor(pulse * 6) % steps;
1367
+ for (let i = 0; i < steps; i += 1) {
1368
+ const point = points[i];
1369
+ const rx = point[0] * cos - point[1] * sin;
1370
+ const sx = cx + rx * scaleX;
1371
+ const sy = cy - (point[2] - 25) * scaleZ;
1372
+ const distance = (i - head + steps) % steps;
1373
+ const depth = clampNumber((point[2] - 5) / 40, 0, 1);
1374
+ const color = distance < 45 ? '#ffffff' : ramp[Math.min(ramp.length - 1, 1 + Math.round(depth * (ramp.length - 2)))] ?? accent;
1375
+ brailleSet(canvas, sx, sy, color);
1376
+ }
1377
+ return brailleToLines(canvas, accent);
1378
+ }
1379
+ function buildFern(pulse, width, height, theme) {
1380
+ const canvas = createBraille(width, height);
1381
+ const ramp = themeContributionColors(theme);
1382
+ const accent = themeAccent(theme);
1383
+ const dotsWide = width * 2;
1384
+ const dotsHigh = height * 4;
1385
+ const total = 5000;
1386
+ const grow = Math.min(total, 600 + Math.floor((pulse * 30) % (total + 1200)));
1387
+ const sway = Math.sin(pulse * 0.05) * 0.04;
1388
+ const maxY = 9.9983;
1389
+ const midX = 0.2369;
1390
+ const scale = (dotsHigh * 0.97) / maxY;
1391
+ const cx = (dotsWide - 1) / 2;
1392
+ let seed = 987654321 >>> 0;
1393
+ let x = 0;
1394
+ let y = 0;
1395
+ for (let i = 0; i < grow; i += 1) {
1396
+ seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
1397
+ const r = (seed & 0x7fffffff) / 0x7fffffff;
1398
+ let nx;
1399
+ let ny;
1400
+ if (r < 0.01) {
1401
+ nx = 0;
1402
+ ny = 0.16 * y;
1403
+ }
1404
+ else if (r < 0.86) {
1405
+ nx = 0.85 * x + 0.04 * y;
1406
+ ny = -0.04 * x + 0.85 * y + 1.6;
1407
+ }
1408
+ else if (r < 0.93) {
1409
+ nx = 0.2 * x - 0.26 * y;
1410
+ ny = 0.23 * x + 0.22 * y + 1.6;
1411
+ }
1412
+ else {
1413
+ nx = -0.15 * x + 0.28 * y;
1414
+ ny = 0.26 * x + 0.24 * y + 0.44;
1415
+ }
1416
+ x = nx;
1417
+ y = ny;
1418
+ if (i < 30) {
1419
+ continue;
1420
+ }
1421
+ const swayed = x + sway * y;
1422
+ const px = cx + (swayed - midX) * scale;
1423
+ const py = dotsHigh - y * scale;
1424
+ const color = ramp[Math.min(ramp.length - 1, 1 + Math.round((y / maxY) * (ramp.length - 2)))] ?? accent;
1425
+ brailleSet(canvas, px, py, color);
1426
+ }
1427
+ return brailleToLines(canvas, accent);
1428
+ }
1429
+ function buildChladni(pulse, width, height, theme) {
1430
+ const canvas = createBraille(width, height);
1431
+ const ramp = themeContributionColors(theme);
1432
+ const accent = themeAccent(theme);
1433
+ const dotsWide = width * 2;
1434
+ const dotsHigh = height * 4;
1435
+ const n = 3 + 2 * Math.sin(pulse * 0.02);
1436
+ const m = 4 + 2 * Math.cos(pulse * 0.017);
1437
+ for (let yy = 0; yy < dotsHigh; yy += 1) {
1438
+ const v = yy / Math.max(1, dotsHigh - 1);
1439
+ for (let xx = 0; xx < dotsWide; xx += 1) {
1440
+ const u = xx / Math.max(1, dotsWide - 1);
1441
+ const z = Math.cos(n * Math.PI * u) * Math.cos(m * Math.PI * v) -
1442
+ Math.cos(m * Math.PI * u) * Math.cos(n * Math.PI * v);
1443
+ if (Math.abs(z) < 0.05) {
1444
+ brailleSet(canvas, xx, yy, ramp[Math.min(ramp.length - 1, 2 + Math.floor(v * (ramp.length - 2)))] ?? accent);
1445
+ }
1446
+ }
1447
+ }
1448
+ return brailleToLines(canvas, accent);
1449
+ }
1450
+ function buildTesseract(pulse, width, height, theme) {
1451
+ const canvas = createBraille(width, height);
1452
+ const ramp = themeContributionColors(theme);
1453
+ const accent = themeAccent(theme);
1454
+ const dotsWide = width * 2;
1455
+ const dotsHigh = height * 4;
1456
+ const cx = (dotsWide - 1) / 2;
1457
+ const cy = (dotsHigh - 1) / 2;
1458
+ const angleA = pulse * 0.025;
1459
+ const angleB = pulse * 0.017;
1460
+ const scale = Math.min(dotsWide, dotsHigh) * 0.26;
1461
+ const projected = Array.from({ length: 16 }, (_, i) => {
1462
+ let x = i & 1 ? 1 : -1;
1463
+ let y = i & 2 ? 1 : -1;
1464
+ let z = i & 4 ? 1 : -1;
1465
+ let w = i & 8 ? 1 : -1;
1466
+ const xw = x * Math.cos(angleA) - w * Math.sin(angleA);
1467
+ w = x * Math.sin(angleA) + w * Math.cos(angleA);
1468
+ x = xw;
1469
+ const yz = y * Math.cos(angleB) - z * Math.sin(angleB);
1470
+ z = y * Math.sin(angleB) + z * Math.cos(angleB);
1471
+ y = yz;
1472
+ const w4 = 2.2 / (2.2 - w);
1473
+ const x3 = x * w4;
1474
+ const y3 = y * w4;
1475
+ const z3 = z * w4;
1476
+ const persp = 2.6 / (2.6 - z3);
1477
+ return { x: x3 * persp, y: y3 * persp, z: z3 };
1478
+ });
1479
+ for (let i = 0; i < 16; i += 1) {
1480
+ for (let j = i + 1; j < 16; j += 1) {
1481
+ const diff = i ^ j;
1482
+ if ((diff & (diff - 1)) === 0) {
1483
+ const a = projected[i];
1484
+ const b = projected[j];
1485
+ const depth = clampNumber((a.z + 1.6) / 3.2, 0, 1);
1486
+ const color = ramp[Math.min(ramp.length - 1, 1 + Math.round(depth * (ramp.length - 2)))] ?? accent;
1487
+ drawBrailleLine(canvas, cx + a.x * scale, cy + a.y * scale, cx + b.x * scale, cy + b.y * scale, color);
1488
+ }
1489
+ }
1490
+ }
1491
+ return brailleToLines(canvas, accent);
1492
+ }
1493
+ function buildTorusKnot(pulse, width, height, theme) {
1494
+ const canvas = createBraille(width, height);
1495
+ const ramp = themeContributionColors(theme);
1496
+ const accent = themeAccent(theme);
1497
+ const dotsWide = width * 2;
1498
+ const dotsHigh = height * 4;
1499
+ const cx = (dotsWide - 1) / 2;
1500
+ const cy = (dotsHigh - 1) / 2;
1501
+ const p = 2;
1502
+ const q = 3;
1503
+ const tilt = 0.6 + 0.3 * Math.sin(pulse * 0.01);
1504
+ const spin = pulse * 0.02;
1505
+ const scale = Math.min(dotsWide, dotsHigh) * 0.17;
1506
+ const samples = 1400;
1507
+ let previous = null;
1508
+ for (let i = 0; i <= samples; i += 1) {
1509
+ const theta = (i / samples) * Math.PI * 2;
1510
+ const r = Math.cos(q * theta) + 2;
1511
+ let x = r * Math.cos(p * theta);
1512
+ let y = r * Math.sin(p * theta);
1513
+ let z = -Math.sin(q * theta);
1514
+ const xs = x * Math.cos(spin) + z * Math.sin(spin);
1515
+ z = -x * Math.sin(spin) + z * Math.cos(spin);
1516
+ x = xs;
1517
+ const ys = y * Math.cos(tilt) - z * Math.sin(tilt);
1518
+ z = y * Math.sin(tilt) + z * Math.cos(tilt);
1519
+ y = ys;
1520
+ const persp = 3.2 / (3.2 - z);
1521
+ const sx = cx + x * scale * persp;
1522
+ const sy = cy + y * scale * persp;
1523
+ const color = ramp[Math.min(ramp.length - 1, 1 + Math.round(clampNumber((z + 3) / 6, 0, 1) * (ramp.length - 2)))] ?? accent;
1524
+ if (previous) {
1525
+ drawBrailleLine(canvas, previous.x, previous.y, sx, sy, color);
1526
+ }
1527
+ previous = { x: sx, y: sy };
1528
+ }
1529
+ return brailleToLines(canvas, accent);
1530
+ }
1531
+ function buildRotozoomer(pulse, width, height, theme) {
1532
+ const grid = emptyMotionGrid(width, height, '#05060c');
1533
+ const ramp = themeContributionColors(theme);
1534
+ const accent = themeAccent(theme);
1535
+ const cx = (width - 1) / 2;
1536
+ const cy = (height - 1) / 2;
1537
+ const angle = pulse * 0.03;
1538
+ const zoom = 0.5 + 0.35 * Math.sin(pulse * 0.02);
1539
+ const cos = Math.cos(angle) * zoom;
1540
+ const sin = Math.sin(angle) * zoom;
1541
+ const shades = ['·', '░', '▒', '▓', '█'];
1542
+ for (let y = 0; y < height; y += 1) {
1543
+ for (let x = 0; x < width; x += 1) {
1544
+ const dx = x - cx;
1545
+ const dy = (y - cy) * 2;
1546
+ const u = dx * cos - dy * sin;
1547
+ const v = dx * sin + dy * cos;
1548
+ const texture = Math.sin(u * 0.4) * Math.sin(v * 0.4);
1549
+ const normalized = (texture + 1) / 2;
1550
+ const shadeIndex = Math.min(shades.length - 1, Math.floor(normalized * shades.length));
1551
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(normalized * (ramp.length - 1))));
1552
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', ramp[colorIndex] ?? accent);
1553
+ }
1554
+ }
1555
+ return grid.map(row => lineFromCells(row, accent));
1556
+ }
1557
+ function buildFractalTree(pulse, width, height, theme) {
1558
+ const canvas = createBraille(width, height);
1559
+ const ramp = themeContributionColors(theme);
1560
+ const accent = themeAccent(theme);
1561
+ const dotsWide = width * 2;
1562
+ const dotsHigh = height * 4;
1563
+ const wind = Math.sin(pulse * 0.05) * 0.12;
1564
+ const maxDepth = 9;
1565
+ const branch = (x, y, angle, length, depth) => {
1566
+ if (depth > maxDepth || length < 1) {
1567
+ return;
1568
+ }
1569
+ const nx = x + Math.cos(angle) * length;
1570
+ const ny = y - Math.sin(angle) * length;
1571
+ const color = ramp[Math.min(ramp.length - 1, 1 + Math.round((depth / maxDepth) * (ramp.length - 2)))] ?? accent;
1572
+ drawBrailleLine(canvas, x, y, nx, ny, color);
1573
+ const spread = 0.4 + 0.08 * Math.sin(pulse * 0.04 + depth);
1574
+ branch(nx, ny, angle + spread + wind, length * 0.72, depth + 1);
1575
+ branch(nx, ny, angle - spread + wind * 0.6, length * 0.72, depth + 1);
1576
+ if (depth % 2 === 0) {
1577
+ branch(nx, ny, angle + wind * 1.4, length * 0.6, depth + 2);
1578
+ }
1579
+ };
1580
+ branch(dotsWide / 2, dotsHigh - 1, Math.PI / 2, dotsHigh * 0.26, 0);
1581
+ return brailleToLines(canvas, accent);
1582
+ }
1583
+ function buildJulia(pulse, width, height, theme) {
1584
+ const grid = emptyMotionGrid(width, height, '#05060c');
1585
+ const ramp = themeContributionColors(theme);
1586
+ const accent = themeAccent(theme);
1587
+ const shades = [' ', '·', '░', '▒', '▓', '█'];
1588
+ const cRe = 0.7885 * Math.cos(pulse * 0.02);
1589
+ const cIm = 0.7885 * Math.sin(pulse * 0.02);
1590
+ const maxIter = 42;
1591
+ for (let y = 0; y < height; y += 1) {
1592
+ for (let x = 0; x < width; x += 1) {
1593
+ let zx = (x / Math.max(1, width - 1) - 0.5) * 3.0;
1594
+ let zy = (y / Math.max(1, height - 1) - 0.5) * 3.0;
1595
+ let iter = 0;
1596
+ for (; iter < maxIter; iter += 1) {
1597
+ const xt = zx * zx - zy * zy + cRe;
1598
+ zy = 2 * zx * zy + cIm;
1599
+ zx = xt;
1600
+ if (zx * zx + zy * zy > 4) {
1601
+ break;
1602
+ }
1603
+ }
1604
+ if (iter === maxIter) {
1605
+ paintCell(grid, x, y, '█', accent);
1606
+ continue;
1607
+ }
1608
+ const t = Math.pow(iter / maxIter, 0.5);
1609
+ const shadeIndex = Math.min(shades.length - 1, Math.floor(t * shades.length));
1610
+ if (shadeIndex === 0) {
1611
+ continue;
1612
+ }
1613
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(t * (ramp.length - 1))));
1614
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', ramp[colorIndex] ?? accent);
1615
+ }
1616
+ }
1617
+ return grid.map(row => lineFromCells(row, accent));
1618
+ }
1619
+ function buildClifford(pulse, width, height, theme) {
1620
+ const canvas = createBraille(width, height);
1621
+ const ramp = themeContributionColors(theme);
1622
+ const accent = themeAccent(theme);
1623
+ const dotsWide = width * 2;
1624
+ const dotsHigh = height * 4;
1625
+ const cx = (dotsWide - 1) / 2;
1626
+ const cy = (dotsHigh - 1) / 2;
1627
+ const a = -1.4 + 0.28 * Math.sin(pulse * 0.011);
1628
+ const b = 1.6 + 0.2 * Math.cos(pulse * 0.008);
1629
+ const c = 1.0;
1630
+ const d = 0.7;
1631
+ const scaleX = dotsWide * 0.4 / 2.1;
1632
+ const scaleY = dotsHigh * 0.4 / 1.9;
1633
+ const count = Math.min(4200, Math.floor((dotsWide * dotsHigh) / 2));
1634
+ let x = 0.12;
1635
+ let y = 0.08;
1636
+ for (let i = 0; i < count; i += 1) {
1637
+ const nx = Math.sin(a * y) + c * Math.cos(a * x);
1638
+ const ny = Math.sin(b * x) + d * Math.cos(b * y);
1639
+ x = nx;
1640
+ y = ny;
1641
+ if (i < 20) {
1642
+ continue;
1643
+ }
1644
+ const color = ramp[Math.min(ramp.length - 1, 1 + Math.round(((y + 1.9) / 3.8) * (ramp.length - 2)))] ?? accent;
1645
+ brailleSet(canvas, cx + x * scaleX, cy + y * scaleY, color);
1646
+ }
1647
+ return brailleToLines(canvas, accent);
1648
+ }
1649
+ function buildGoniometer(pulse, width, height, theme) {
1650
+ const canvas = createBraille(width, height);
1651
+ const ramp = themeContributionColors(theme);
1652
+ const accent = themeAccent(theme);
1653
+ const dotsWide = width * 2;
1654
+ const dotsHigh = height * 4;
1655
+ const cx = (dotsWide - 1) / 2;
1656
+ const cy = (dotsHigh - 1) / 2;
1657
+ const scale = Math.min(dotsWide, dotsHigh) * 0.44;
1658
+ const reach = scale * 0.9;
1659
+ for (let s = -reach; s <= reach; s += 1) {
1660
+ brailleSet(canvas, cx, cy + s, '#2a313c');
1661
+ brailleSet(canvas, cx + s, cy, '#2a313c');
1662
+ brailleSet(canvas, cx + s, cy + s, '#1f2630');
1663
+ brailleSet(canvas, cx + s, cy - s, '#1f2630');
1664
+ }
1665
+ const widthMod = 0.35 + 0.5 * (0.5 + 0.5 * Math.sin(pulse * 0.04));
1666
+ const samples = 320;
1667
+ for (let i = samples; i >= 0; i -= 1) {
1668
+ const t = pulse * 0.12 + i * 0.07;
1669
+ const env = 0.6 + 0.4 * Math.sin(t * 0.5);
1670
+ const leftCh = Math.sin(t * 3) * env;
1671
+ const rightCh = Math.sin(t * 3 + Math.sin(pulse * 0.03) * widthMod * Math.PI) * env;
1672
+ const px = ((leftCh - rightCh) / Math.SQRT2) * scale;
1673
+ const py = -((leftCh + rightCh) / Math.SQRT2) * scale;
1674
+ const bright = 1 - i / samples;
1675
+ const color = i < 6 ? '#ffffff' : ramp[Math.min(ramp.length - 1, 1 + Math.round(bright * (ramp.length - 2)))] ?? accent;
1676
+ brailleSet(canvas, cx + px, cy + py, color);
1677
+ }
1678
+ return brailleToLines(canvas, accent);
1679
+ }
1680
+ function buildCopperBars(pulse, width, height, theme) {
1681
+ const grid = emptyMotionGrid(width, height, '#05060c');
1682
+ const accent = themeAccent(theme);
1683
+ const palette = ['#ff5f87', '#ffd166', '#53a8ff', '#8df084', '#c06cff', '#5eead4'];
1684
+ const bars = 6;
1685
+ for (let b = 0; b < bars; b += 1) {
1686
+ const center = (0.5 + 0.42 * Math.sin(pulse * 0.05 + b * 1.05)) * (height - 1);
1687
+ const halfThickness = 1.7;
1688
+ const color = theme === 'mono' ? motionColorAt(b / bars, theme) : palette[b % palette.length] ?? accent;
1689
+ for (let y = 0; y < height; y += 1) {
1690
+ const distance = Math.abs(y - center);
1691
+ if (distance <= halfThickness) {
1692
+ const shade = 1 - distance / halfThickness;
1693
+ const glyph = shade > 0.66 ? '█' : shade > 0.33 ? '▓' : '▒';
1694
+ for (let x = 0; x < width; x += 1) {
1695
+ paintCell(grid, x, y, glyph, color);
1696
+ }
1697
+ }
1698
+ }
1699
+ }
1700
+ return grid.map(row => lineFromCells(row, accent));
1701
+ }
1702
+ function buildTwister(pulse, width, height, theme) {
1703
+ const grid = emptyMotionGrid(width, height, '#070a12');
1704
+ const ramp = themeContributionColors(theme);
1705
+ const accent = themeAccent(theme);
1706
+ const cx = (width - 1) / 2;
1707
+ const halfWidth = Math.min(width * 0.4, 20);
1708
+ const ribbon = 1.5;
1709
+ for (let y = 0; y < height; y += 1) {
1710
+ const angle = y * 0.4 - pulse * 0.12;
1711
+ const x1 = cx + Math.cos(angle) * halfWidth;
1712
+ const x2 = cx + Math.cos(angle + ribbon) * halfWidth;
1713
+ const z1 = Math.sin(angle);
1714
+ const z2 = Math.sin(angle + ribbon);
1715
+ const lo = Math.round(Math.min(x1, x2));
1716
+ const hi = Math.round(Math.max(x1, x2));
1717
+ for (let x = lo; x <= hi; x += 1) {
1718
+ const tt = (x - x1) / ((x2 - x1) || 1);
1719
+ const z = z1 + (z2 - z1) * tt;
1720
+ const brightness = (z + 1) / 2;
1721
+ const glyph = brightness > 0.66 ? '█' : brightness > 0.33 ? '▓' : '▒';
1722
+ const colorIndex = Math.min(ramp.length - 1, 1 + Math.round(brightness * (ramp.length - 2)));
1723
+ paintCell(grid, x, y, glyph, ramp[colorIndex] ?? accent);
1724
+ }
1725
+ }
1726
+ return grid.map(row => lineFromCells(row, accent));
1727
+ }
1728
+ function buildCoral(pulse, width, height, theme) {
1729
+ const canvas = createBraille(width, height);
1730
+ const ramp = themeContributionColors(theme);
1731
+ const accent = themeAccent(theme);
1732
+ const dotsWide = width * 2;
1733
+ const dotsHigh = height * 4;
1734
+ const cx = (dotsWide - 1) / 2;
1735
+ const cy = (dotsHigh - 1) / 2;
1736
+ const growFrac = clampNumber(((pulse * 1.3) % 200) / 150, 0, 1);
1737
+ const effDepth = Math.min(8, 2 + Math.floor(growFrac * 6));
1738
+ const seedLength = Math.min(dotsWide, dotsHigh) * 0.17;
1739
+ const branch = (x, y, angle, length, depth) => {
1740
+ if (depth > effDepth || length < 1.2) {
1741
+ return;
1742
+ }
1743
+ const nx = x + Math.cos(angle) * length;
1744
+ const ny = y + Math.sin(angle) * length;
1745
+ const color = ramp[Math.min(ramp.length - 1, 1 + Math.round((depth / 7) * (ramp.length - 2)))] ?? accent;
1746
+ drawBrailleLine(canvas, x, y, nx, ny, color);
1747
+ const jitter = (hashNoise(Math.round(x), Math.round(y), depth) - 0.5) * 0.3;
1748
+ branch(nx, ny, angle + Math.PI / 3 + jitter, length * 0.74, depth + 1);
1749
+ branch(nx, ny, angle - Math.PI / 3 + jitter, length * 0.74, depth + 1);
1750
+ branch(nx, ny, angle + jitter * 0.5, length * 0.6, depth + 2);
1751
+ };
1752
+ for (let seed = 0; seed < 6; seed += 1) {
1753
+ branch(cx, cy, (seed / 6) * Math.PI * 2 + pulse * 0.01, seedLength, 0);
1754
+ }
1755
+ return brailleToLines(canvas, accent);
1756
+ }
1757
+ function buildCyclone(pulse, width, height, theme) {
1758
+ const canvas = createBraille(width, height);
1759
+ const ramp = themeContributionColors(theme);
1760
+ const accent = themeAccent(theme);
1761
+ const dotsWide = width * 2;
1762
+ const dotsHigh = height * 4;
1763
+ const cx = (dotsWide - 1) / 2;
1764
+ const cy = (dotsHigh - 1) / 2;
1765
+ const maxR = Math.min(dotsWide, dotsHigh) * 0.52;
1766
+ const count = Math.min(260, Math.floor((dotsWide * dotsHigh) / 9));
1767
+ for (let i = 0; i < count; i += 1) {
1768
+ const phase = hashNoise(i, 1, 0) * Math.PI * 2;
1769
+ const life = ((pulse * 0.9 + i * 7.3) % 130) / 130;
1770
+ const r = maxR * (1 - life);
1771
+ const angle = phase + pulse * 0.05 + (maxR / (r + 5)) * 0.9;
1772
+ for (let trail = 0; trail < 4; trail += 1) {
1773
+ const rr = r + trail * 1.4;
1774
+ const aa = angle - trail * 0.12;
1775
+ const x = cx + Math.cos(aa) * rr;
1776
+ const y = cy + Math.sin(aa) * rr;
1777
+ const color = trail === 0 ? (r < maxR * 0.18 ? '#ffffff' : ramp[ramp.length - 1] ?? accent) : ramp[Math.min(ramp.length - 1, 1 + Math.round((1 - rr / maxR) * (ramp.length - 2)))] ?? accent;
1778
+ brailleSet(canvas, x, y, color);
1779
+ }
1780
+ }
1781
+ brailleSet(canvas, cx, cy, '#ffffff');
1782
+ return brailleToLines(canvas, accent);
1783
+ }
1784
+ function buildLavaLamp(pulse, width, height, theme) {
1785
+ const grid = emptyMotionGrid(width, height, '#0a0610');
1786
+ const ramp = themeContributionColors(theme);
1787
+ const accent = themeAccent(theme);
1788
+ const blobs = Array.from({ length: 5 }, (_, b) => {
1789
+ const speed = 0.14 + ((b * 7) % 5) / 20;
1790
+ return {
1791
+ x: width * (0.5 + 0.32 * Math.sin(pulse * 0.015 * (1 + b * 0.2) + b)),
1792
+ y: height + (b * height) / 5 - ((pulse * speed) % (height + 6)),
1793
+ r: 2.2 + 1.3 * Math.sin(pulse * 0.02 + b * 1.7) + (b % 2 ? 0.8 : 0)
1794
+ };
1795
+ });
1796
+ for (let y = 0; y < height; y += 1) {
1797
+ for (let x = 0; x < width; x += 1) {
1798
+ let field = 0;
1799
+ for (const blob of blobs) {
1800
+ const dx = (x - blob.x) * 0.5;
1801
+ const dy = y - blob.y;
1802
+ field += (blob.r * blob.r) / (dx * dx + dy * dy + 0.6);
1803
+ }
1804
+ const glyph = field > 2 ? '█' : field > 1.4 ? '▓' : field > 1 ? '▒' : field > 0.7 ? '░' : ' ';
1805
+ if (glyph !== ' ') {
1806
+ const colorIndex = Math.min(ramp.length - 1, Math.max(1, Math.round(clampNumber(field / 2.4, 0, 1) * (ramp.length - 1))));
1807
+ paintCell(grid, x, y, glyph, ramp[colorIndex] ?? accent);
1808
+ }
1809
+ }
1810
+ }
1811
+ return grid.map(row => lineFromCells(row, accent));
1812
+ }
1813
+ function buildNewton(pulse, width, height, theme) {
1814
+ const grid = emptyMotionGrid(width, height, '#05060c');
1815
+ const ramp = themeContributionColors(theme);
1816
+ const accent = themeAccent(theme);
1817
+ const shades = [' ', '·', '░', '▒', '▓', '█'];
1818
+ const roots = [
1819
+ { x: 1, y: 0, color: ramp[2] ?? accent },
1820
+ { x: -0.5, y: Math.sqrt(3) / 2, color: ramp[4] ?? accent },
1821
+ { x: -0.5, y: -Math.sqrt(3) / 2, color: accent }
1822
+ ];
1823
+ const zoom = 1.7 + 0.4 * Math.sin(pulse * 0.02);
1824
+ const rot = pulse * 0.012;
1825
+ const cosR = Math.cos(rot);
1826
+ const sinR = Math.sin(rot);
1827
+ const maxIter = 22;
1828
+ for (let y = 0; y < height; y += 1) {
1829
+ for (let x = 0; x < width; x += 1) {
1830
+ const px = (x / Math.max(1, width - 1) - 0.5) * 2 * zoom;
1831
+ const py = (y / Math.max(1, height - 1) - 0.5) * 2 * zoom;
1832
+ let zx = px * cosR - py * sinR;
1833
+ let zy = px * sinR + py * cosR;
1834
+ let iter = 0;
1835
+ for (; iter < maxIter; iter += 1) {
1836
+ const r2 = zx * zx + zy * zy;
1837
+ if (r2 < 1e-6) {
1838
+ break;
1839
+ }
1840
+ const denom = 3 * ((zx * zx - zy * zy) * (zx * zx - zy * zy) + (2 * zx * zy) * (2 * zx * zy));
1841
+ const ax = zx * zx - zy * zy;
1842
+ const ay = 2 * zx * zy;
1843
+ const numX = (ax * zx - ay * zy) - 1;
1844
+ const numY = ax * zy + ay * zx;
1845
+ zx = zx - (numX * ax + numY * ay) / (denom || 1e-6);
1846
+ zy = zy - (numY * ax - numX * ay) / (denom || 1e-6);
1847
+ let done = false;
1848
+ for (const root of roots) {
1849
+ if ((zx - root.x) ** 2 + (zy - root.y) ** 2 < 0.01) {
1850
+ done = true;
1851
+ break;
1852
+ }
1853
+ }
1854
+ if (done) {
1855
+ break;
1856
+ }
1857
+ }
1858
+ let chosen = roots[0];
1859
+ let best = Infinity;
1860
+ for (const root of roots) {
1861
+ const dist = (zx - root.x) ** 2 + (zy - root.y) ** 2;
1862
+ if (dist < best) {
1863
+ best = dist;
1864
+ chosen = root;
1865
+ }
1866
+ }
1867
+ const t = 1 - iter / maxIter;
1868
+ const shadeIndex = Math.min(shades.length - 1, 1 + Math.floor(t * (shades.length - 1)));
1869
+ paintCell(grid, x, y, shades[shadeIndex] ?? '·', chosen.color);
1870
+ }
1871
+ }
1872
+ return grid.map(row => lineFromCells(row, accent));
1873
+ }
1874
+ function lineFromCells(cells, fallbackColor) {
1875
+ const text = cells.map(cell => cell.text).join('');
1876
+ const segments = [];
1877
+ for (const cell of cells) {
1878
+ const previous = segments[segments.length - 1];
1879
+ if (previous && previous.color === cell.color) {
1880
+ previous.text += cell.text;
1881
+ }
1882
+ else {
1883
+ segments.push({ text: cell.text, color: cell.color });
1884
+ }
1885
+ }
1886
+ return { text, color: fallbackColor, segments };
1887
+ }
1888
+ function emptyMotionGrid(width, height, color) {
1889
+ return Array.from({ length: height }, () => Array.from({ length: width }, () => ({ text: ' ', color })));
1890
+ }
1891
+ function setMotionCell(grid, x, y, text, color) {
1892
+ const row = grid[y];
1893
+ const cell = row?.[x];
1894
+ if (!row || !cell) {
1895
+ return;
1896
+ }
1897
+ row[x] = cell.text === ' ' ? { text, color } : { text: '█', color: '#ffe45c' };
1898
+ }
1899
+ function motionEnvelope(position, pulse, variant) {
1900
+ const t = pulse * 0.13 + variant;
1901
+ const centers = [
1902
+ 0.12 + 0.035 * Math.sin(t * 0.9),
1903
+ 0.34 + 0.045 * Math.cos(t * 0.55 + variant),
1904
+ 0.58 + 0.05 * Math.sin(t * 0.72 + 1.4),
1905
+ 0.80 + 0.032 * Math.cos(t * 1.1)
1906
+ ];
1907
+ const widths = [0.065, 0.095, 0.08, 0.055];
1908
+ const weights = [0.95, 0.7, 1.0, 0.42];
1909
+ let value = 0.06;
1910
+ for (let index = 0; index < centers.length; index += 1) {
1911
+ const center = centers[index] ?? 0.5;
1912
+ const width = widths[index] ?? 0.08;
1913
+ const weight = weights[index] ?? 0.5;
1914
+ value += weight * Math.exp(-Math.pow((position - center) / width, 2));
1915
+ }
1916
+ const ripple = 0.1 * Math.sin(position * 30 + t * 2.4) +
1917
+ 0.06 * Math.cos(position * 61 - t * 1.6) +
1918
+ 0.04 * Math.sin(position * 97 + variant * 3.1);
1919
+ return clampNumber(value + ripple, 0.03, 1);
1920
+ }
1921
+ function motionColorAt(position, theme) {
1922
+ if (theme === 'mono') {
1923
+ const mono = ['#767676', '#9a9a9a', '#b0b0b0', '#d0d0d0'];
1924
+ const index = Math.min(mono.length - 1, Math.max(0, Math.floor(position * mono.length)));
1925
+ return mono[index] ?? '#d0d0d0';
1926
+ }
1927
+ const palette = ['#6ee7f2', '#8df084', '#e7f75c', '#ffd24f', '#ff9345', '#ff3f8e', '#b56cff', '#66a3ff'];
1928
+ const index = Math.min(palette.length - 1, Math.max(0, Math.floor(position * palette.length)));
1929
+ return palette[index] ?? '#6ee7f2';
1930
+ }
1931
+ function dimMotionColorAt(position, theme) {
1932
+ if (theme === 'mono') {
1933
+ return '#767676';
1934
+ }
1935
+ const palette = ['#245760', '#315f49', '#626629', '#6b5523', '#6e3728', '#5b2444', '#3f2c5c', '#2f4368'];
1936
+ const index = Math.min(palette.length - 1, Math.max(0, Math.floor(position * palette.length)));
1937
+ return palette[index] ?? '#245760';
1938
+ }
1939
+ export function visualizerHeight(style, availableRows, width = 80) {
1940
+ const maxRowsByStyle = {
1941
+ equalizer: 12,
1942
+ 'motion-blob': 12,
1943
+ 'motion-area': 11,
1944
+ 'motion-contour': 14,
1945
+ leds: 10,
1946
+ matrix: 14,
1947
+ hologram: 12,
1948
+ cube: 14,
1949
+ fire: 14,
1950
+ fireworks: 14,
1951
+ plasma: 14,
1952
+ 'spinning-donut': 14,
1953
+ starfield: 14,
1954
+ mesh: 14,
1955
+ ribbon: 12,
1956
+ orbits: 12,
1957
+ mirror: 12,
1958
+ tunnel: 14,
1959
+ kaleidoscope: 14,
1960
+ constellation: 12,
1961
+ 'pulse-grid': 12,
1962
+ lissajous: 14,
1963
+ 'braille-wave': 12,
1964
+ 'radial-eq': 14,
1965
+ spectrogram: 12,
1966
+ nebula: 14,
1967
+ silk: 14,
1968
+ 'ripple-tank': 14,
1969
+ phyllotaxis: 14,
1970
+ harmonograph: 14,
1971
+ 'bloom-bars': 12,
1972
+ moire: 14,
1973
+ galaxy: 14,
1974
+ caustics: 14,
1975
+ lorenz: 14,
1976
+ fern: 14,
1977
+ chladni: 14,
1978
+ tesseract: 14,
1979
+ 'torus-knot': 14,
1980
+ rotozoomer: 14,
1981
+ 'fractal-tree': 14,
1982
+ julia: 14,
1983
+ clifford: 14,
1984
+ goniometer: 14,
1985
+ 'copper-bars': 14,
1986
+ twister: 14,
1987
+ coral: 14,
1988
+ cyclone: 14,
1989
+ 'lava-lamp': 14,
1990
+ newton: 14
1991
+ };
1992
+ const spaciousStyles = new Set([
1993
+ 'equalizer',
1994
+ 'motion-blob',
1995
+ 'motion-area',
1996
+ 'motion-contour',
1997
+ 'leds',
1998
+ 'matrix',
1999
+ 'hologram',
2000
+ 'cube',
2001
+ 'fire',
2002
+ 'fireworks',
2003
+ 'plasma',
2004
+ 'spinning-donut',
2005
+ 'starfield',
2006
+ 'mesh',
2007
+ 'ribbon',
2008
+ 'orbits',
2009
+ 'mirror',
2010
+ 'tunnel',
2011
+ 'kaleidoscope',
2012
+ 'constellation',
2013
+ 'pulse-grid',
2014
+ 'lissajous',
2015
+ 'braille-wave',
2016
+ 'radial-eq',
2017
+ 'spectrogram',
2018
+ 'nebula',
2019
+ 'silk',
2020
+ 'ripple-tank',
2021
+ 'phyllotaxis',
2022
+ 'harmonograph',
2023
+ 'bloom-bars',
2024
+ 'moire',
2025
+ 'galaxy',
2026
+ 'caustics',
2027
+ 'lorenz',
2028
+ 'fern',
2029
+ 'chladni',
2030
+ 'tesseract',
2031
+ 'torus-knot',
2032
+ 'rotozoomer',
2033
+ 'fractal-tree',
2034
+ 'julia',
2035
+ 'clifford',
2036
+ 'goniometer',
2037
+ 'copper-bars',
2038
+ 'twister',
2039
+ 'coral',
2040
+ 'cyclone',
2041
+ 'lava-lamp',
2042
+ 'newton'
2043
+ ]);
2044
+ const minRows = style === 'cube' || style === 'motion-contour' || style === 'spinning-donut'
2045
+ ? 8
2046
+ : spaciousStyles.has(style)
2047
+ ? 7
2048
+ : style.startsWith('motion-')
2049
+ ? 6
2050
+ : 3;
2051
+ const baseMaxRows = maxRowsByStyle[style] ?? 8;
2052
+ const maxRows = responsiveVisualizerMaxRows(style, availableRows, width, baseMaxRows, spaciousStyles);
2053
+ return Math.max(minRows, Math.min(maxRows, availableRows));
2054
+ }
2055
+ function responsiveVisualizerMaxRows(style, availableRows, width, baseMaxRows, spaciousStyles) {
2056
+ if (!spaciousStyles.has(style)) {
2057
+ return baseMaxRows;
2058
+ }
2059
+ const landscapeSafeRows = Math.max(baseMaxRows, Math.floor(Math.max(1, width) / 3));
2060
+ return Math.min(availableRows, landscapeSafeRows);
2061
+ }
2062
+ function cubeVertices() {
2063
+ return [
2064
+ { x: -1, y: -1, z: -1 },
2065
+ { x: 1, y: -1, z: -1 },
2066
+ { x: -1, y: 1, z: -1 },
2067
+ { x: 1, y: 1, z: -1 },
2068
+ { x: -1, y: -1, z: 1 },
2069
+ { x: 1, y: -1, z: 1 },
2070
+ { x: -1, y: 1, z: 1 },
2071
+ { x: 1, y: 1, z: 1 }
2072
+ ];
2073
+ }
2074
+ function cubeFaces() {
2075
+ return [
2076
+ { normal: { x: 0, y: 0, z: 1 }, pointAt: (u, v) => ({ x: u, y: v, z: 1 }) },
2077
+ { normal: { x: 0, y: 0, z: -1 }, pointAt: (u, v) => ({ x: -u, y: v, z: -1 }) },
2078
+ { normal: { x: 1, y: 0, z: 0 }, pointAt: (u, v) => ({ x: 1, y: v, z: -u }) },
2079
+ { normal: { x: -1, y: 0, z: 0 }, pointAt: (u, v) => ({ x: -1, y: v, z: u }) },
2080
+ { normal: { x: 0, y: 1, z: 0 }, pointAt: (u, v) => ({ x: u, y: 1, z: -v }) },
2081
+ { normal: { x: 0, y: -1, z: 0 }, pointAt: (u, v) => ({ x: u, y: -1, z: v }) }
2082
+ ];
2083
+ }
2084
+ function rotateCubePoint(point, angleX, angleY, angleZ) {
2085
+ const cosX = Math.cos(angleX);
2086
+ const sinX = Math.sin(angleX);
2087
+ const cosY = Math.cos(angleY);
2088
+ const sinY = Math.sin(angleY);
2089
+ const cosZ = Math.cos(angleZ);
2090
+ const sinZ = Math.sin(angleZ);
2091
+ const yRotatedX = point.y * cosX - point.z * sinX;
2092
+ const zRotatedX = point.y * sinX + point.z * cosX;
2093
+ const xRotatedY = point.x * cosY + zRotatedX * sinY;
2094
+ const zRotatedY = -point.x * sinY + zRotatedX * cosY;
2095
+ return {
2096
+ x: xRotatedY * cosZ - yRotatedX * sinZ,
2097
+ y: xRotatedY * sinZ + yRotatedX * cosZ,
2098
+ z: zRotatedY
2099
+ };
2100
+ }
2101
+ function projectRotatedCubePoint(rotated, scaleX, scaleY, width, height) {
2102
+ const perspective = 4.4 / (4.4 - rotated.z * 0.68);
2103
+ return {
2104
+ x: Math.round((width - 1) / 2 + rotated.x * scaleX * perspective),
2105
+ y: Math.round((height - 1) / 2 - rotated.y * scaleY * perspective),
2106
+ z: rotated.z
2107
+ };
2108
+ }
2109
+ function drawCubeSurfaces(grid, zBuffer, angleX, angleY, angleZ, scaleX, scaleY, width, height) {
2110
+ const light = normalizeCubePoint({ x: -0.5, y: 0.72, z: 0.9 });
2111
+ for (const face of cubeFaces()) {
2112
+ const normal = rotateCubePoint(face.normal, angleX, angleY, angleZ);
2113
+ if (normal.z <= 0.04) {
2114
+ continue;
2115
+ }
2116
+ const glyph = cubeSurfaceGlyph(clampNumber(dotCubePoint(normal, light) * 0.6 + 0.42, 0.08, 1));
2117
+ for (let u = -1; u <= 1.001; u += 0.07) {
2118
+ for (let v = -1; v <= 1.001; v += 0.07) {
2119
+ const rotated = rotateCubePoint(face.pointAt(u, v), angleX, angleY, angleZ);
2120
+ const projected = projectRotatedCubePoint(rotated, scaleX, scaleY, width, height);
2121
+ writeCubeSurfaceGlyph(grid, zBuffer, projected.x, projected.y, projected.z, glyph);
2122
+ }
2123
+ }
2124
+ }
2125
+ }
2126
+ function normalizeCubePoint(point) {
2127
+ const length = Math.sqrt(point.x * point.x + point.y * point.y + point.z * point.z) || 1;
2128
+ return {
2129
+ x: point.x / length,
2130
+ y: point.y / length,
2131
+ z: point.z / length
2132
+ };
2133
+ }
2134
+ function dotCubePoint(left, right) {
2135
+ return left.x * right.x + left.y * right.y + left.z * right.z;
2136
+ }
2137
+ function cubeSurfaceGlyph(value) {
2138
+ const chars = ['.', ':', '-', '=', '+', '*', '#', '%', '@'];
2139
+ return chars[Math.max(0, Math.min(chars.length - 1, Math.round(value * (chars.length - 1))))] ?? '+';
2140
+ }
2141
+ function writeCubeSurfaceGlyph(grid, zBuffer, x, y, z, glyph) {
2142
+ const width = grid[0]?.length ?? 0;
2143
+ const height = grid.length;
2144
+ if (x < 0 || x >= width || y < 0 || y >= height || z < (zBuffer[y]?.[x] ?? Number.NEGATIVE_INFINITY)) {
2145
+ return;
2146
+ }
2147
+ grid[y][x] = glyph;
2148
+ zBuffer[y][x] = z;
2149
+ }
2150
+ function drawCubeEdgeZ(grid, zBuffer, from, to) {
2151
+ const dx = to.x - from.x;
2152
+ const dy = to.y - from.y;
2153
+ const steps = Math.max(Math.abs(dx), Math.abs(dy), 1);
2154
+ for (let step = 0; step <= steps; step += 1) {
2155
+ const t = step / steps;
2156
+ const z = from.z + (to.z - from.z) * t + 0.08;
2157
+ writeCubeSurfaceGlyph(grid, zBuffer, Math.round(from.x + dx * t), Math.round(from.y + dy * t), z, cubeEdgeGlyph(dx, dy, z, step));
2158
+ }
2159
+ }
2160
+ function cubeEdgeGlyph(dx, dy, depth, step) {
2161
+ if (Math.abs(dx) > Math.abs(dy) * 2.3) {
2162
+ return depth > 0.62 ? '=' : '-';
2163
+ }
2164
+ if (Math.abs(dy) > Math.abs(dx) * 1.45) {
2165
+ return '|';
2166
+ }
2167
+ const diagonal = dx * dy > 0 ? '\\' : '/';
2168
+ return depth < -0.7 && step % 4 === 2 ? '.' : diagonal;
2169
+ }
2170
+ function cubeCornerGlyph(depth) {
2171
+ if (depth > 0.78) {
2172
+ return '@';
2173
+ }
2174
+ if (depth > 0.28) {
2175
+ return '#';
2176
+ }
2177
+ if (depth < -0.78) {
2178
+ return '.';
2179
+ }
2180
+ return '+';
2181
+ }
2182
+ function hashNoise(x, y, pulse) {
2183
+ const value = Math.sin(x * 12.9898 + y * 78.233 + pulse * 0.037) * 43758.5453;
2184
+ return value - Math.floor(value);
2185
+ }
2186
+ function clampNumber(value, min, max) {
2187
+ return Math.min(max, Math.max(min, value));
2188
+ }