@wavegrid/canvas 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/ui.js ADDED
@@ -0,0 +1,1196 @@
1
+ export function getCanvasHTML() {
2
+ return `<!DOCTYPE html>
3
+ <html>
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <title>Illuminate</title>
10
+ <style>
11
+ :root {
12
+ --bg: #050508;
13
+ --surface: #0c0c12;
14
+ --surface2: #12121a;
15
+ --border: #1a1a25;
16
+ --text: #e8e8f0;
17
+ --text2: #888898;
18
+ --accent: #4a7cff;
19
+ --glow: rgba(74, 124, 255, 0.3);
20
+ }
21
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
22
+ html, body {
23
+ width: 100%; height: 100%; overflow: hidden;
24
+ background: var(--bg); color: var(--text);
25
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
26
+ touch-action: none; user-select: none; -webkit-user-select: none;
27
+ }
28
+
29
+ /* ─── Layout ─── */
30
+ .app {
31
+ display: flex; flex-direction: column;
32
+ height: 100%; width: 100%;
33
+ }
34
+ .top-bar {
35
+ display: flex; align-items: center; justify-content: space-between;
36
+ padding: 12px 20px; flex-shrink: 0;
37
+ background: var(--surface); border-bottom: 1px solid var(--border);
38
+ }
39
+ .scene-label {
40
+ font-size: 13px; font-weight: 500; color: var(--text2);
41
+ letter-spacing: 0.04em;
42
+ }
43
+ .energy-wrap {
44
+ display: flex; align-items: center; gap: 10px; flex: 1; max-width: 280px;
45
+ }
46
+ .energy-icon { font-size: 16px; opacity: 0.5; }
47
+ .energy-slider {
48
+ flex: 1; height: 4px; -webkit-appearance: none; appearance: none;
49
+ background: linear-gradient(to right, #1a1a2a, var(--accent));
50
+ border-radius: 2px; outline: none;
51
+ }
52
+ .energy-slider::-webkit-slider-thumb {
53
+ -webkit-appearance: none; width: 22px; height: 22px;
54
+ border-radius: 50%; background: var(--accent);
55
+ box-shadow: 0 0 12px var(--glow); cursor: pointer;
56
+ }
57
+ .energy-val { font-size: 12px; color: var(--text2); min-width: 32px; text-align: right; }
58
+
59
+ /* ─── Sculpture Canvas ─── */
60
+ .sculpture-wrap {
61
+ flex: 1; display: flex; align-items: center; justify-content: center;
62
+ padding: 16px; position: relative; overflow: hidden;
63
+ }
64
+ #sculpture {
65
+ display: block; touch-action: none;
66
+ border-radius: 16px;
67
+ }
68
+
69
+ /* ─── Tool Dock ─── */
70
+ .dock {
71
+ flex-shrink: 0; background: var(--surface);
72
+ border-top: 1px solid var(--border);
73
+ display: flex; flex-direction: column;
74
+ }
75
+ .mode-tabs {
76
+ display: flex; gap: 2px; padding: 8px 12px 4px;
77
+ overflow-x: auto; -webkit-overflow-scrolling: touch;
78
+ }
79
+ .mode-tab {
80
+ padding: 8px 16px; border-radius: 20px; font-size: 12px;
81
+ font-weight: 500; letter-spacing: 0.02em;
82
+ background: transparent; border: 1px solid transparent;
83
+ color: var(--text2); cursor: pointer; white-space: nowrap;
84
+ transition: all 0.2s;
85
+ }
86
+ .mode-tab:hover { color: var(--text); }
87
+ .mode-tab.active {
88
+ background: var(--surface2); border-color: var(--border);
89
+ color: var(--text);
90
+ }
91
+
92
+ .tool-area {
93
+ padding: 8px 16px 16px; min-height: 120px;
94
+ display: flex; align-items: center; gap: 16px;
95
+ }
96
+
97
+ /* ─── Color Wheel ─── */
98
+ .color-section {
99
+ display: flex; align-items: center; gap: 16px;
100
+ }
101
+ #color-wheel-wrap {
102
+ position: relative; width: 100px; height: 100px; flex-shrink: 0;
103
+ }
104
+ #color-wheel {
105
+ width: 100px; height: 100px; border-radius: 50%;
106
+ cursor: crosshair; touch-action: none;
107
+ }
108
+ #wheel-cursor {
109
+ position: absolute; width: 14px; height: 14px;
110
+ border: 2px solid #fff; border-radius: 50%;
111
+ pointer-events: none; transform: translate(-50%, -50%);
112
+ box-shadow: 0 0 6px rgba(0,0,0,0.5);
113
+ }
114
+ .color-preview {
115
+ width: 44px; height: 44px; border-radius: 12px;
116
+ border: 2px solid var(--border); flex-shrink: 0;
117
+ box-shadow: 0 0 20px rgba(0,0,0,0.3);
118
+ }
119
+ .brightness-vert {
120
+ width: 6px; height: 90px; border-radius: 3px;
121
+ background: linear-gradient(to top, #000, var(--accent));
122
+ position: relative; cursor: pointer; touch-action: none;
123
+ }
124
+ .brightness-thumb {
125
+ position: absolute; left: 50%; width: 16px; height: 16px;
126
+ border-radius: 50%; background: #fff; border: 2px solid var(--border);
127
+ transform: translate(-50%, -50%); pointer-events: none;
128
+ }
129
+
130
+ /* ─── Brush Controls ─── */
131
+ .brush-controls {
132
+ display: flex; align-items: center; gap: 12px;
133
+ }
134
+ .brush-size-wrap {
135
+ display: flex; flex-direction: column; align-items: center; gap: 4px;
136
+ }
137
+ .brush-preview {
138
+ width: 50px; height: 50px; border-radius: 50%;
139
+ border: 1px solid var(--border); display: flex;
140
+ align-items: center; justify-content: center;
141
+ }
142
+ .brush-dot {
143
+ border-radius: 50%; background: var(--accent); opacity: 0.7;
144
+ transition: width 0.15s, height 0.15s;
145
+ }
146
+ .brush-label { font-size: 10px; color: var(--text2); }
147
+ .brush-slider {
148
+ width: 100px; height: 4px; -webkit-appearance: none; appearance: none;
149
+ background: var(--border); border-radius: 2px;
150
+ }
151
+ .brush-slider::-webkit-slider-thumb {
152
+ -webkit-appearance: none; width: 16px; height: 16px;
153
+ border-radius: 50%; background: var(--text); cursor: pointer;
154
+ }
155
+ .toggle-pill {
156
+ display: flex; align-items: center; gap: 6px;
157
+ padding: 6px 12px; border-radius: 16px; font-size: 11px;
158
+ background: var(--surface2); border: 1px solid var(--border);
159
+ color: var(--text2); cursor: pointer; transition: all 0.2s;
160
+ }
161
+ .toggle-pill.active { border-color: var(--accent); color: var(--accent); }
162
+
163
+ /* ─── Scene Palette ─── */
164
+ .scene-palette {
165
+ display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start;
166
+ }
167
+ .scene-swatch {
168
+ width: 56px; height: 56px; border-radius: 14px; cursor: pointer;
169
+ border: 2px solid transparent; position: relative;
170
+ transition: transform 0.15s, border-color 0.2s;
171
+ overflow: hidden;
172
+ }
173
+ .scene-swatch:active { transform: scale(0.93); }
174
+ .scene-swatch.active { border-color: #fff; }
175
+ .scene-swatch-label {
176
+ position: absolute; bottom: 3px; left: 0; right: 0;
177
+ text-align: center; font-size: 8px; font-weight: 600;
178
+ color: rgba(255,255,255,0.85); text-shadow: 0 1px 3px rgba(0,0,0,0.7);
179
+ letter-spacing: 0.03em;
180
+ }
181
+
182
+ /* ─── Gradient Editor ─── */
183
+ .gradient-editor {
184
+ display: flex; align-items: center; gap: 12px;
185
+ }
186
+ .gradient-bar-wrap {
187
+ position: relative; width: 200px; height: 32px;
188
+ border-radius: 8px; overflow: hidden; cursor: pointer;
189
+ border: 1px solid var(--border);
190
+ }
191
+ .gradient-bar {
192
+ width: 100%; height: 100%;
193
+ }
194
+ .gradient-stop {
195
+ position: absolute; top: 50%; width: 14px; height: 14px;
196
+ border: 2px solid #fff; border-radius: 50%;
197
+ transform: translate(-50%, -50%); cursor: grab;
198
+ box-shadow: 0 1px 4px rgba(0,0,0,0.5);
199
+ }
200
+ .gradient-hint { font-size: 11px; color: var(--text2); max-width: 100px; }
201
+
202
+ /* ─── Motion Painter ─── */
203
+ .motion-controls {
204
+ display: flex; align-items: center; gap: 12px;
205
+ }
206
+ .motion-btn {
207
+ padding: 8px 16px; border-radius: 20px; font-size: 12px;
208
+ background: var(--surface2); border: 1px solid var(--border);
209
+ color: var(--text2); cursor: pointer; transition: all 0.2s;
210
+ }
211
+ .motion-btn:hover { border-color: var(--accent); color: var(--accent); }
212
+ .motion-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
213
+ .motion-speed {
214
+ display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2);
215
+ }
216
+
217
+ /* ─── Symmetry Tools ─── */
218
+ .symmetry-tools {
219
+ display: flex; gap: 6px;
220
+ }
221
+ .sym-btn {
222
+ width: 48px; height: 48px; border-radius: 12px; font-size: 18px;
223
+ display: flex; align-items: center; justify-content: center;
224
+ background: var(--surface2); border: 1px solid var(--border);
225
+ color: var(--text2); cursor: pointer; transition: all 0.2s;
226
+ }
227
+ .sym-btn:hover { border-color: var(--text2); }
228
+ .sym-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(74,124,255,0.1); }
229
+
230
+ /* ─── Drops Mode ─── */
231
+ .drops-controls {
232
+ display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
233
+ }
234
+ .spectrum-bar-wrap {
235
+ position: relative; width: 180px; height: 28px;
236
+ border-radius: 8px; overflow: hidden;
237
+ border: 1px solid var(--border); cursor: pointer;
238
+ }
239
+ .spectrum-bar {
240
+ width: 100%; height: 100%;
241
+ }
242
+ .spectrum-handle {
243
+ position: absolute; top: 0; bottom: 0; width: 3px;
244
+ background: #fff; pointer-events: none;
245
+ box-shadow: 0 0 4px rgba(0,0,0,0.8);
246
+ }
247
+ .drops-param {
248
+ display: flex; flex-direction: column; align-items: center; gap: 2px;
249
+ }
250
+ .drops-param label {
251
+ font-size: 10px; color: var(--text2); letter-spacing: 0.04em;
252
+ }
253
+ .drops-slider {
254
+ width: 80px; height: 4px; -webkit-appearance: none; appearance: none;
255
+ background: var(--border); border-radius: 2px;
256
+ }
257
+ .drops-slider::-webkit-slider-thumb {
258
+ -webkit-appearance: none; width: 14px; height: 14px;
259
+ border-radius: 50%; background: var(--text); cursor: pointer;
260
+ }
261
+
262
+ /* ─── Hidden tool panels ─── */
263
+ .tool-panel { display: none; }
264
+ .tool-panel.visible { display: flex; align-items: center; gap: 16px; }
265
+
266
+ /* ─── Status ─── */
267
+ .status-dot {
268
+ width: 6px; height: 6px; border-radius: 50%;
269
+ background: #333; flex-shrink: 0;
270
+ }
271
+ .status-dot.connected { background: #3a5; }
272
+ </style>
273
+ </head>
274
+ <body>
275
+ <div class="app">
276
+
277
+ <!-- ─── Top Bar ─── -->
278
+ <div class="top-bar">
279
+ <div style="display:flex;align-items:center;gap:8px">
280
+ <div class="status-dot" id="status-dot"></div>
281
+ <span class="scene-label" id="scene-label">Civic Blue</span>
282
+ </div>
283
+ <div class="energy-wrap">
284
+ <span class="energy-icon">◐</span>
285
+ <input type="range" class="energy-slider" id="energy" min="0" max="100" value="80">
286
+ <span class="energy-val" id="energy-val">80</span>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- ─── Sculpture Canvas ─── -->
291
+ <div class="sculpture-wrap">
292
+ <canvas id="sculpture" width="600" height="600"></canvas>
293
+ </div>
294
+
295
+ <!-- ─── Tool Dock ─── -->
296
+ <div class="dock">
297
+ <div class="mode-tabs" id="mode-tabs">
298
+ <div class="mode-tab active" data-mode="paint">Paint</div>
299
+ <div class="mode-tab" data-mode="gradient">Gradient</div>
300
+ <div class="mode-tab" data-mode="brush">Brush</div>
301
+ <div class="mode-tab" data-mode="energy">Energy</div>
302
+ <div class="mode-tab" data-mode="scenes">Scenes</div>
303
+ <div class="mode-tab" data-mode="motion">Motion</div>
304
+ <div class="mode-tab" data-mode="drops">Drops</div>
305
+ <div class="mode-tab" data-mode="symmetry">Symmetry</div>
306
+ </div>
307
+ <div class="tool-area">
308
+
309
+ <!-- Paint Mode -->
310
+ <div class="tool-panel visible" id="panel-paint">
311
+ <div class="color-section">
312
+ <div id="color-wheel-wrap">
313
+ <canvas id="color-wheel" width="100" height="100"></canvas>
314
+ <div id="wheel-cursor" style="left:50px;top:50px"></div>
315
+ </div>
316
+ <div class="brightness-vert" id="bright-bar">
317
+ <div class="brightness-thumb" id="bright-thumb" style="bottom:80%"></div>
318
+ </div>
319
+ <div class="color-preview" id="color-preview" style="background:#4a7cff"></div>
320
+ </div>
321
+ </div>
322
+
323
+ <!-- Gradient Mode -->
324
+ <div class="tool-panel" id="panel-gradient">
325
+ <div class="gradient-editor">
326
+ <div class="gradient-bar-wrap" id="gradient-bar-wrap">
327
+ <canvas class="gradient-bar" id="gradient-bar" width="200" height="32"></canvas>
328
+ </div>
329
+ <div class="gradient-hint">Tap bar to add stops. Drag across grid to apply.</div>
330
+ </div>
331
+ </div>
332
+
333
+ <!-- Brush Mode -->
334
+ <div class="tool-panel" id="panel-brush">
335
+ <div class="color-section">
336
+ <div id="brush-color-wheel-wrap" style="position:relative;width:80px;height:80px;flex-shrink:0">
337
+ <canvas id="brush-color-wheel" width="80" height="80" style="width:80px;height:80px;border-radius:50%;cursor:crosshair;touch-action:none"></canvas>
338
+ <div id="brush-wheel-cursor" style="position:absolute;width:12px;height:12px;border:2px solid #fff;border-radius:50%;pointer-events:none;transform:translate(-50%,-50%);box-shadow:0 0 6px rgba(0,0,0,0.5);left:40px;top:40px"></div>
339
+ </div>
340
+ <div class="brush-controls">
341
+ <div class="brush-size-wrap">
342
+ <div class="brush-preview">
343
+ <div class="brush-dot" id="brush-dot" style="width:20px;height:20px"></div>
344
+ </div>
345
+ <span class="brush-label">Size</span>
346
+ <input type="range" class="brush-slider" id="brush-size" min="1" max="5" value="1">
347
+ </div>
348
+ <div class="toggle-pill" id="brush-falloff">Soft edge</div>
349
+ </div>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- Energy Mode -->
354
+ <div class="tool-panel" id="panel-energy">
355
+ <div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:8px">
356
+ <div style="font-size:11px;color:var(--text2);letter-spacing:0.05em">INTENSITY</div>
357
+ <input type="range" class="energy-slider" id="energy-full" min="0" max="100" value="80"
358
+ style="width:100%;max-width:400px;height:8px">
359
+ <div style="font-size:24px;font-weight:300;color:var(--text)" id="energy-full-val">80</div>
360
+ </div>
361
+ </div>
362
+
363
+ <!-- Scenes -->
364
+ <div class="tool-panel" id="panel-scenes">
365
+ <div class="scene-palette" id="scene-palette"></div>
366
+ </div>
367
+
368
+ <!-- Motion -->
369
+ <div class="tool-panel" id="panel-motion">
370
+ <div class="motion-controls">
371
+ <div class="motion-btn" id="motion-record">Draw path</div>
372
+ <div class="motion-btn" id="motion-play">Play</div>
373
+ <div class="motion-btn" id="motion-clear">Clear</div>
374
+ <div class="motion-speed">
375
+ <span>Speed</span>
376
+ <input type="range" class="brush-slider" id="motion-speed" min="1" max="10" value="5" style="width:80px">
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ <!-- Drops -->
382
+ <div class="tool-panel" id="panel-drops">
383
+ <div class="drops-controls">
384
+ <div style="display:flex;flex-direction:column;gap:4px">
385
+ <label style="font-size:10px;color:var(--text2)">SPECTRUM</label>
386
+ <div class="spectrum-bar-wrap" id="spectrum-bar-wrap">
387
+ <canvas class="spectrum-bar" id="spectrum-bar" width="180" height="28"></canvas>
388
+ <div class="spectrum-handle" id="spectrum-start-handle" style="left:0%"></div>
389
+ <div class="spectrum-handle" id="spectrum-end-handle" style="left:50%"></div>
390
+ </div>
391
+ </div>
392
+ <div class="drops-param">
393
+ <label>Speed</label>
394
+ <input type="range" class="drops-slider" id="drops-speed" min="1" max="10" value="5">
395
+ </div>
396
+ <div class="drops-param">
397
+ <label>Decay</label>
398
+ <input type="range" class="drops-slider" id="drops-decay" min="1" max="10" value="5">
399
+ </div>
400
+ <div class="drops-param">
401
+ <label>Width</label>
402
+ <input type="range" class="drops-slider" id="drops-width" min="1" max="5" value="2">
403
+ </div>
404
+ </div>
405
+ </div>
406
+
407
+ <!-- Symmetry -->
408
+ <div class="tool-panel" id="panel-symmetry">
409
+ <div class="symmetry-tools">
410
+ <div class="sym-btn" data-sym="h" title="Mirror left/right">↔</div>
411
+ <div class="sym-btn" data-sym="v" title="Mirror top/bottom">↕</div>
412
+ <div class="sym-btn" data-sym="radial" title="Radial symmetry">✦</div>
413
+ <div class="sym-btn" data-sym="kaleidoscope" title="Kaleidoscope">❋</div>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ </div>
418
+ </div>
419
+
420
+ <script>
421
+ // ═══════════════════════════════════════════════════
422
+ // State
423
+ // ═══════════════════════════════════════════════════
424
+ const NUM = 49;
425
+ const GRID = 7;
426
+ const grid = Array.from({length: NUM}, () => ({ h: 220, s: 90, b: 80 }));
427
+
428
+ let currentMode = 'paint';
429
+ let currentHue = 220;
430
+ let currentSat = 90;
431
+ let currentBright = 80;
432
+ let brushSize = 1;
433
+ let brushFalloff = false;
434
+ let activeScene = 'civic';
435
+ let symmetry = { h: false, v: false, radial: false, kaleidoscope: false };
436
+
437
+ // Motion painter state
438
+ let motionPath = [];
439
+ let motionRecording = false;
440
+ let motionPlaying = false;
441
+ let motionFrame = 0;
442
+ let motionTimer = null;
443
+
444
+ // Drops/Ripple state
445
+ let drops = [];
446
+ let dropsSpectrumStart = 0;
447
+ let dropsSpectrumEnd = 180;
448
+ let dropsSpeed = 5;
449
+ let dropsDecay = 5;
450
+ let dropsWidth = 2;
451
+ let dropsTimer = null;
452
+
453
+ // Gradient state
454
+ let gradientStops = [
455
+ { pos: 0, h: 220, s: 90, b: 80 },
456
+ { pos: 1, h: 340, s: 80, b: 75 }
457
+ ];
458
+
459
+ // ═══════════════════════════════════════════════════
460
+ // WebSocket
461
+ // ═══════════════════════════════════════════════════
462
+ const ws = new WebSocket('ws://' + location.host);
463
+ const statusDot = document.getElementById('status-dot');
464
+
465
+ ws.onopen = () => { statusDot.classList.add('connected'); };
466
+ ws.onclose = () => { statusDot.classList.remove('connected'); };
467
+ ws.onmessage = (e) => {
468
+ const msg = JSON.parse(e.data);
469
+ if (msg.type === 'state') {
470
+ for (let i = 0; i < NUM; i++) {
471
+ grid[i].h = msg.grid[i].h;
472
+ grid[i].s = msg.grid[i].s;
473
+ grid[i].b = msg.grid[i].b;
474
+ }
475
+ }
476
+ };
477
+ function send(data) { if (ws.readyState === 1) ws.send(JSON.stringify(data)); }
478
+
479
+ // ═══════════════════════════════════════════════════
480
+ // Color utilities
481
+ // ═══════════════════════════════════════════════════
482
+ function hsl(h, s, l) {
483
+ return 'hsl(' + h + ',' + s + '%,' + l + '%)';
484
+ }
485
+ function hslRgb(h, s, l) {
486
+ s /= 100; l /= 100;
487
+ const a = s * Math.min(l, 1 - l);
488
+ const f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); };
489
+ return [f(0), f(8), f(4)];
490
+ }
491
+
492
+ // ═══════════════════════════════════════════════════
493
+ // Sculpture Canvas (2D rendered grid with glow)
494
+ // ═══════════════════════════════════════════════════
495
+ const sculptureCanvas = document.getElementById('sculpture');
496
+ const sctx = sculptureCanvas.getContext('2d');
497
+ let cellSize, gridOffset, canvasW, canvasH;
498
+
499
+ function resizeSculpture() {
500
+ const wrap = sculptureCanvas.parentElement;
501
+ const size = Math.min(wrap.clientWidth - 32, wrap.clientHeight - 32, 560);
502
+ canvasW = canvasH = size;
503
+ const dpr = window.devicePixelRatio || 1;
504
+ sculptureCanvas.width = size * dpr;
505
+ sculptureCanvas.height = size * dpr;
506
+ sculptureCanvas.style.width = size + 'px';
507
+ sculptureCanvas.style.height = size + 'px';
508
+ sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
509
+ cellSize = (size - 20) / GRID;
510
+ gridOffset = 10;
511
+ }
512
+
513
+ function drawSculpture() {
514
+ sctx.clearRect(0, 0, canvasW, canvasH);
515
+ const r = cellSize * 0.34;
516
+
517
+ for (let i = 0; i < NUM; i++) {
518
+ const row = Math.floor(i / GRID);
519
+ const col = i % GRID;
520
+ const cx = gridOffset + col * cellSize + cellSize / 2;
521
+ const cy = gridOffset + row * cellSize + cellSize / 2;
522
+ const c = grid[i];
523
+ const lightness = Math.max(5, c.b * 0.5);
524
+
525
+ // Outer glow
526
+ if (c.b > 5) {
527
+ const glowR = r * (1.2 + c.b * 0.012);
528
+ const grad = sctx.createRadialGradient(cx, cy, r * 0.3, cx, cy, glowR);
529
+ const [gr, gg, gb] = hslRgb(c.h, c.s, lightness);
530
+ grad.addColorStop(0, 'rgba(' + Math.round(gr*255) + ',' + Math.round(gg*255) + ',' + Math.round(gb*255) + ',0.5)');
531
+ grad.addColorStop(1, 'rgba(' + Math.round(gr*255) + ',' + Math.round(gg*255) + ',' + Math.round(gb*255) + ',0)');
532
+ sctx.beginPath();
533
+ sctx.arc(cx, cy, glowR, 0, Math.PI * 2);
534
+ sctx.fillStyle = grad;
535
+ sctx.fill();
536
+ }
537
+
538
+ // Core orb
539
+ const orbGrad = sctx.createRadialGradient(cx - r * 0.2, cy - r * 0.2, r * 0.1, cx, cy, r);
540
+ if (c.b < 2) {
541
+ orbGrad.addColorStop(0, '#181820');
542
+ orbGrad.addColorStop(1, '#0e0e14');
543
+ } else {
544
+ const bright = Math.min(lightness + 15, 95);
545
+ orbGrad.addColorStop(0, hsl(c.h, c.s, bright));
546
+ orbGrad.addColorStop(1, hsl(c.h, c.s, lightness * 0.6));
547
+ }
548
+ sctx.beginPath();
549
+ sctx.arc(cx, cy, r, 0, Math.PI * 2);
550
+ sctx.fillStyle = orbGrad;
551
+ sctx.fill();
552
+
553
+ // Specular highlight
554
+ if (c.b > 20) {
555
+ sctx.beginPath();
556
+ sctx.arc(cx - r * 0.25, cy - r * 0.25, r * 0.2, 0, Math.PI * 2);
557
+ sctx.fillStyle = 'rgba(255,255,255,' + (c.b * 0.002) + ')';
558
+ sctx.fill();
559
+ }
560
+ }
561
+
562
+ // Draw motion path if recording or has path
563
+ if (motionPath.length > 1) {
564
+ sctx.strokeStyle = 'rgba(255,255,255,0.15)';
565
+ sctx.lineWidth = 2;
566
+ sctx.setLineDash([4, 4]);
567
+ sctx.beginPath();
568
+ for (let i = 0; i < motionPath.length; i++) {
569
+ const idx = motionPath[i];
570
+ const row = Math.floor(idx / GRID);
571
+ const col = idx % GRID;
572
+ const cx = gridOffset + col * cellSize + cellSize / 2;
573
+ const cy = gridOffset + row * cellSize + cellSize / 2;
574
+ if (i === 0) sctx.moveTo(cx, cy);
575
+ else sctx.lineTo(cx, cy);
576
+ }
577
+ sctx.stroke();
578
+ sctx.setLineDash([]);
579
+ }
580
+
581
+ requestAnimationFrame(drawSculpture);
582
+ }
583
+
584
+ // ═══════════════════════════════════════════════════
585
+ // Touch → Grid mapping
586
+ // ═══════════════════════════════════════════════════
587
+ function cannonAtXY(x, y) {
588
+ const col = Math.floor((x - gridOffset) / cellSize);
589
+ const row = Math.floor((y - gridOffset) / cellSize);
590
+ if (col < 0 || col >= GRID || row < 0 || row >= GRID) return -1;
591
+ return row * GRID + col;
592
+ }
593
+
594
+ function getCanvasXY(e) {
595
+ const rect = sculptureCanvas.getBoundingClientRect();
596
+ const touch = e.touches ? e.touches[0] : e;
597
+ return {
598
+ x: (touch.clientX - rect.left) * (canvasW / rect.width),
599
+ y: (touch.clientY - rect.top) * (canvasH / rect.height)
600
+ };
601
+ }
602
+
603
+ function getAffectedCannons(centerIdx) {
604
+ if (centerIdx < 0) return [];
605
+ const cRow = Math.floor(centerIdx / GRID);
606
+ const cCol = centerIdx % GRID;
607
+ const result = [{ idx: centerIdx, falloff: 1 }];
608
+
609
+ // Brush size > 1: include neighbors
610
+ if (brushSize > 1 && (currentMode === 'brush' || currentMode === 'paint')) {
611
+ const reach = brushSize - 1;
612
+ for (let dr = -reach; dr <= reach; dr++) {
613
+ for (let dc = -reach; dc <= reach; dc++) {
614
+ if (dr === 0 && dc === 0) continue;
615
+ const nr = cRow + dr, nc = cCol + dc;
616
+ if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue;
617
+ const dist = Math.sqrt(dr * dr + dc * dc);
618
+ if (dist > reach + 0.5) continue;
619
+ const fo = brushFalloff ? Math.max(0, 1 - dist / (reach + 1)) : 1;
620
+ result.push({ idx: nr * GRID + nc, falloff: fo });
621
+ }
622
+ }
623
+ }
624
+
625
+ // Symmetry: mirror all affected cannons
626
+ const mirrored = [];
627
+ for (const { idx, falloff } of result) {
628
+ mirrored.push({ idx, falloff });
629
+ const r = Math.floor(idx / GRID), c = idx % GRID;
630
+ if (symmetry.h) mirrored.push({ idx: r * GRID + (GRID - 1 - c), falloff });
631
+ if (symmetry.v) mirrored.push({ idx: (GRID - 1 - r) * GRID + c, falloff });
632
+ if (symmetry.h && symmetry.v) mirrored.push({ idx: (GRID - 1 - r) * GRID + (GRID - 1 - c), falloff });
633
+ if (symmetry.radial) {
634
+ // 4-fold rotational
635
+ mirrored.push({ idx: c * GRID + (GRID - 1 - r), falloff });
636
+ mirrored.push({ idx: (GRID - 1 - c) * GRID + r, falloff });
637
+ }
638
+ if (symmetry.kaleidoscope) {
639
+ // 8-fold
640
+ mirrored.push({ idx: r * GRID + (GRID - 1 - c), falloff });
641
+ mirrored.push({ idx: (GRID - 1 - r) * GRID + c, falloff });
642
+ mirrored.push({ idx: (GRID - 1 - r) * GRID + (GRID - 1 - c), falloff });
643
+ mirrored.push({ idx: c * GRID + r, falloff });
644
+ mirrored.push({ idx: c * GRID + (GRID - 1 - r), falloff });
645
+ mirrored.push({ idx: (GRID - 1 - c) * GRID + r, falloff });
646
+ mirrored.push({ idx: (GRID - 1 - c) * GRID + (GRID - 1 - r), falloff });
647
+ }
648
+ }
649
+
650
+ // Deduplicate
651
+ const seen = new Set();
652
+ return mirrored.filter(m => {
653
+ if (m.idx < 0 || m.idx >= NUM || seen.has(m.idx)) return false;
654
+ seen.add(m.idx);
655
+ return true;
656
+ });
657
+ }
658
+
659
+ function paintCannon(idx, falloff) {
660
+ const h = currentHue;
661
+ const s = currentSat;
662
+ const b = currentBright * falloff;
663
+ send({ type: 'cannon', index: idx, h, s, b });
664
+ }
665
+
666
+ // Gradient helpers
667
+ function gradientColorAt(t) {
668
+ // Find the two surrounding stops
669
+ const sorted = [...gradientStops].sort((a, b) => a.pos - b.pos);
670
+ if (t <= sorted[0].pos) return sorted[0];
671
+ if (t >= sorted[sorted.length - 1].pos) return sorted[sorted.length - 1];
672
+ for (let i = 0; i < sorted.length - 1; i++) {
673
+ if (t >= sorted[i].pos && t <= sorted[i + 1].pos) {
674
+ const f = (t - sorted[i].pos) / (sorted[i + 1].pos - sorted[i].pos);
675
+ return {
676
+ h: sorted[i].h + (sorted[i + 1].h - sorted[i].h) * f,
677
+ s: sorted[i].s + (sorted[i + 1].s - sorted[i].s) * f,
678
+ b: sorted[i].b + (sorted[i + 1].b - sorted[i].b) * f
679
+ };
680
+ }
681
+ }
682
+ return sorted[0];
683
+ }
684
+
685
+ let gradientDragStart = -1;
686
+
687
+ // ═══════════════════════════════════════════════════
688
+ // Sculpture interaction
689
+ // ═══════════════════════════════════════════════════
690
+ let painting = false;
691
+ let lastPaintedIdx = -1;
692
+
693
+ function handleSculptureStart(e) {
694
+ e.preventDefault();
695
+ painting = true;
696
+ const { x, y } = getCanvasXY(e);
697
+ const idx = cannonAtXY(x, y);
698
+
699
+ if (currentMode === 'drops') {
700
+ if (idx >= 0) {
701
+ drops.push({ origin: idx, tick: 0 });
702
+ if (!dropsTimer) startDropsAnimation();
703
+ }
704
+ return;
705
+ }
706
+
707
+ if (currentMode === 'motion' && motionRecording) {
708
+ if (idx >= 0 && (motionPath.length === 0 || motionPath[motionPath.length - 1] !== idx)) {
709
+ motionPath.push(idx);
710
+ }
711
+ lastPaintedIdx = idx;
712
+ return;
713
+ }
714
+
715
+ if (currentMode === 'gradient') {
716
+ gradientDragStart = idx;
717
+ return;
718
+ }
719
+
720
+ if (idx >= 0 && (currentMode === 'paint' || currentMode === 'brush')) {
721
+ const affected = getAffectedCannons(idx);
722
+ affected.forEach(a => paintCannon(a.idx, a.falloff));
723
+ lastPaintedIdx = idx;
724
+ }
725
+ }
726
+
727
+ function handleSculptureMove(e) {
728
+ e.preventDefault();
729
+ if (!painting) return;
730
+ const { x, y } = getCanvasXY(e);
731
+ const idx = cannonAtXY(x, y);
732
+ if (idx < 0 || idx === lastPaintedIdx) return;
733
+
734
+ if (currentMode === 'drops') {
735
+ if (idx >= 0) {
736
+ drops.push({ origin: idx, tick: 0 });
737
+ }
738
+ lastPaintedIdx = idx;
739
+ return;
740
+ }
741
+
742
+ if (currentMode === 'motion' && motionRecording) {
743
+ if (motionPath.length === 0 || motionPath[motionPath.length - 1] !== idx) {
744
+ motionPath.push(idx);
745
+ }
746
+ lastPaintedIdx = idx;
747
+ return;
748
+ }
749
+
750
+ if (currentMode === 'gradient' && gradientDragStart >= 0) {
751
+ // Apply gradient from start to current position
752
+ const startRow = Math.floor(gradientDragStart / GRID);
753
+ const startCol = gradientDragStart % GRID;
754
+ const endRow = Math.floor(idx / GRID);
755
+ const endCol = idx % GRID;
756
+ const dist = Math.sqrt((endRow - startRow) ** 2 + (endCol - startCol) ** 2);
757
+ if (dist < 0.5) return;
758
+
759
+ for (let i = 0; i < NUM; i++) {
760
+ const r = Math.floor(i / GRID), c = i % GRID;
761
+ const proj = ((r - startRow) * (endRow - startRow) + (c - startCol) * (endCol - startCol)) / (dist * dist);
762
+ const t = Math.max(0, Math.min(1, proj));
763
+ const gc = gradientColorAt(t);
764
+ send({ type: 'cannon', index: i, h: gc.h, s: gc.s, b: gc.b });
765
+ }
766
+ lastPaintedIdx = idx;
767
+ return;
768
+ }
769
+
770
+ if (currentMode === 'paint' || currentMode === 'brush') {
771
+ const affected = getAffectedCannons(idx);
772
+ affected.forEach(a => paintCannon(a.idx, a.falloff));
773
+ lastPaintedIdx = idx;
774
+ }
775
+ }
776
+
777
+ function handleSculptureEnd(e) {
778
+ e.preventDefault();
779
+ painting = false;
780
+ lastPaintedIdx = -1;
781
+ gradientDragStart = -1;
782
+ }
783
+
784
+ sculptureCanvas.addEventListener('pointerdown', handleSculptureStart);
785
+ sculptureCanvas.addEventListener('pointermove', handleSculptureMove);
786
+ sculptureCanvas.addEventListener('pointerup', handleSculptureEnd);
787
+ sculptureCanvas.addEventListener('pointercancel', handleSculptureEnd);
788
+
789
+ // ═══════════════════════════════════════════════════
790
+ // Color Wheel
791
+ // ═══════════════════════════════════════════════════
792
+ function drawColorWheel(canvas, size) {
793
+ const ctx = canvas.getContext('2d');
794
+ const cx = size / 2, cy = size / 2, radius = size / 2 - 2;
795
+ const imgData = ctx.createImageData(size, size);
796
+ for (let y = 0; y < size; y++) {
797
+ for (let x = 0; x < size; x++) {
798
+ const dx = x - cx, dy = y - cy;
799
+ const dist = Math.sqrt(dx * dx + dy * dy);
800
+ if (dist > radius) continue;
801
+ const angle = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360;
802
+ const sat = (dist / radius) * 100;
803
+ const [r, g, b] = hslRgb(angle, sat, 50);
804
+ const idx = (y * size + x) * 4;
805
+ imgData.data[idx] = r * 255;
806
+ imgData.data[idx + 1] = g * 255;
807
+ imgData.data[idx + 2] = b * 255;
808
+ imgData.data[idx + 3] = 255;
809
+ }
810
+ }
811
+ ctx.putImageData(imgData, 0, 0);
812
+ }
813
+
814
+ function setupColorWheel(wheelId, cursorId, onPick) {
815
+ const wheel = document.getElementById(wheelId);
816
+ const cursor = document.getElementById(cursorId);
817
+ const size = parseInt(wheel.width);
818
+ drawColorWheel(wheel, size);
819
+
820
+ function pick(e) {
821
+ const rect = wheel.getBoundingClientRect();
822
+ const touch = e.touches ? e.touches[0] : e;
823
+ const x = touch.clientX - rect.left;
824
+ const y = touch.clientY - rect.top;
825
+ const scale = size / rect.width;
826
+ const px = x * scale, py = y * scale;
827
+ const cx = size / 2, cy = size / 2;
828
+ const dx = px - cx, dy = py - cy;
829
+ const dist = Math.sqrt(dx * dx + dy * dy);
830
+ const radius = size / 2 - 2;
831
+ if (dist > radius) return;
832
+ const hue = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360;
833
+ const sat = (dist / radius) * 100;
834
+ cursor.style.left = x + 'px';
835
+ cursor.style.top = y + 'px';
836
+ onPick(hue, sat);
837
+ }
838
+
839
+ let dragging = false;
840
+ wheel.addEventListener('pointerdown', (e) => { dragging = true; pick(e); });
841
+ window.addEventListener('pointermove', (e) => { if (dragging) pick(e); });
842
+ window.addEventListener('pointerup', () => { dragging = false; });
843
+ }
844
+
845
+ function updateColorPreview() {
846
+ const preview = document.getElementById('color-preview');
847
+ preview.style.background = hsl(currentHue, currentSat, Math.max(10, currentBright * 0.5));
848
+ preview.style.boxShadow = '0 0 20px ' + hsl(currentHue, currentSat, currentBright * 0.3);
849
+ }
850
+
851
+ setupColorWheel('color-wheel', 'wheel-cursor', (h, s) => {
852
+ currentHue = h;
853
+ currentSat = s;
854
+ updateColorPreview();
855
+ });
856
+
857
+ setupColorWheel('brush-color-wheel', 'brush-wheel-cursor', (h, s) => {
858
+ currentHue = h;
859
+ currentSat = s;
860
+ updateColorPreview();
861
+ const dot = document.getElementById('brush-dot');
862
+ dot.style.background = hsl(h, s, 50);
863
+ });
864
+
865
+ // Brightness bar
866
+ function setupBrightnessBar() {
867
+ const bar = document.getElementById('bright-bar');
868
+ const thumb = document.getElementById('bright-thumb');
869
+
870
+ function pick(e) {
871
+ const rect = bar.getBoundingClientRect();
872
+ const touch = e.touches ? e.touches[0] : e;
873
+ const y = touch.clientY - rect.top;
874
+ const pct = Math.max(0, Math.min(100, 100 - (y / rect.height) * 100));
875
+ currentBright = pct;
876
+ thumb.style.bottom = pct + '%';
877
+ updateColorPreview();
878
+ }
879
+
880
+ let dragging = false;
881
+ bar.addEventListener('pointerdown', (e) => { dragging = true; pick(e); });
882
+ window.addEventListener('pointermove', (e) => { if (dragging) pick(e); });
883
+ window.addEventListener('pointerup', () => { dragging = false; });
884
+ }
885
+ setupBrightnessBar();
886
+
887
+ // ═══════════════════════════════════════════════════
888
+ // Mode switching
889
+ // ═══════════════════════════════════════════════════
890
+ document.getElementById('mode-tabs').addEventListener('click', (e) => {
891
+ const tab = e.target.closest('.mode-tab');
892
+ if (!tab) return;
893
+ document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
894
+ tab.classList.add('active');
895
+ currentMode = tab.dataset.mode;
896
+ document.querySelectorAll('.tool-panel').forEach(p => p.classList.remove('visible'));
897
+ const panel = document.getElementById('panel-' + currentMode);
898
+ if (panel) panel.classList.add('visible');
899
+ });
900
+
901
+ // ═══════════════════════════════════════════════════
902
+ // Energy controls
903
+ // ═══════════════════════════════════════════════════
904
+ document.getElementById('energy').addEventListener('input', function() {
905
+ document.getElementById('energy-val').textContent = this.value;
906
+ send({ type: 'master_brightness', value: parseInt(this.value) / 100 });
907
+ });
908
+ document.getElementById('energy-full').addEventListener('input', function() {
909
+ document.getElementById('energy-full-val').textContent = this.value;
910
+ document.getElementById('energy').value = this.value;
911
+ document.getElementById('energy-val').textContent = this.value;
912
+ send({ type: 'master_brightness', value: parseInt(this.value) / 100 });
913
+ });
914
+
915
+ // ═══════════════════════════════════════════════════
916
+ // Scene Palette
917
+ // ═══════════════════════════════════════════════════
918
+ const SCENES = {
919
+ civic: { label: 'Civic Blue', colors: ['#1a3a7a', '#2a5aaa'] },
920
+ pride: { label: 'Pride', colors: ['#e33', '#f90', '#ee0', '#3a5', '#35e', '#a3e'] },
921
+ gold: { label: 'Golden Gate', colors: ['#b8860b', '#daa520'] },
922
+ white: { label: 'White', colors: ['#aaa', '#fff'] },
923
+ solstice: { label: 'Solstice', colors: ['#c63', '#da5', '#ac5'] },
924
+ ocean: { label: 'Ocean', colors: ['#0a5a6a', '#1aaabb'] },
925
+ sunset: { label: 'Sunset', colors: ['#c33', '#d85', '#da5'] },
926
+ off: { label: 'Blackout', colors: ['#111', '#000'] }
927
+ };
928
+
929
+ const scenePalette = document.getElementById('scene-palette');
930
+ for (const [key, scene] of Object.entries(SCENES)) {
931
+ const swatch = document.createElement('div');
932
+ swatch.className = 'scene-swatch' + (key === activeScene ? ' active' : '');
933
+ const gradient = scene.colors.length > 1
934
+ ? 'linear-gradient(135deg, ' + scene.colors.join(', ') + ')'
935
+ : scene.colors[0];
936
+ swatch.style.background = gradient;
937
+ swatch.innerHTML = '<div class="scene-swatch-label">' + scene.label + '</div>';
938
+ swatch.addEventListener('click', () => {
939
+ document.querySelectorAll('.scene-swatch').forEach(s => s.classList.remove('active'));
940
+ swatch.classList.add('active');
941
+ activeScene = key;
942
+ document.getElementById('scene-label').textContent = scene.label;
943
+ send({ type: 'scene', name: key });
944
+ });
945
+ scenePalette.appendChild(swatch);
946
+ }
947
+
948
+ // ═══════════════════════════════════════════════════
949
+ // Brush controls
950
+ // ═══════════════════════════════════════════════════
951
+ document.getElementById('brush-size').addEventListener('input', function() {
952
+ brushSize = parseInt(this.value);
953
+ const dot = document.getElementById('brush-dot');
954
+ const px = 10 + brushSize * 8;
955
+ dot.style.width = px + 'px';
956
+ dot.style.height = px + 'px';
957
+ });
958
+ document.getElementById('brush-falloff').addEventListener('click', function() {
959
+ brushFalloff = !brushFalloff;
960
+ this.classList.toggle('active', brushFalloff);
961
+ });
962
+
963
+ // ═══════════════════════════════════════════════════
964
+ // Motion Painter
965
+ // ═══════════════════════════════════════════════════
966
+ document.getElementById('motion-record').addEventListener('click', function() {
967
+ motionRecording = !motionRecording;
968
+ this.classList.toggle('active', motionRecording);
969
+ if (motionRecording) {
970
+ motionPath = [];
971
+ stopMotion();
972
+ }
973
+ });
974
+ document.getElementById('motion-play').addEventListener('click', function() {
975
+ if (motionPath.length < 2) return;
976
+ if (motionPlaying) { stopMotion(); return; }
977
+ motionPlaying = true;
978
+ this.classList.add('active');
979
+ motionRecording = false;
980
+ document.getElementById('motion-record').classList.remove('active');
981
+ motionFrame = 0;
982
+ playMotionStep();
983
+ });
984
+ document.getElementById('motion-clear').addEventListener('click', () => {
985
+ motionPath = [];
986
+ stopMotion();
987
+ });
988
+
989
+ function stopMotion() {
990
+ motionPlaying = false;
991
+ document.getElementById('motion-play').classList.remove('active');
992
+ if (motionTimer) { clearTimeout(motionTimer); motionTimer = null; }
993
+ }
994
+
995
+ function playMotionStep() {
996
+ if (!motionPlaying || motionPath.length < 2) { stopMotion(); return; }
997
+ const idx = motionPath[motionFrame % motionPath.length];
998
+ // Light up current, dim previous
999
+ for (let i = 0; i < motionPath.length; i++) {
1000
+ const dist = Math.min(
1001
+ Math.abs(i - (motionFrame % motionPath.length)),
1002
+ motionPath.length - Math.abs(i - (motionFrame % motionPath.length))
1003
+ );
1004
+ const falloff = Math.max(0, 1 - dist * 0.3);
1005
+ send({
1006
+ type: 'cannon', index: motionPath[i],
1007
+ h: currentHue, s: currentSat, b: currentBright * falloff
1008
+ });
1009
+ }
1010
+ motionFrame++;
1011
+ const speed = parseInt(document.getElementById('motion-speed').value);
1012
+ motionTimer = setTimeout(playMotionStep, 300 - speed * 25);
1013
+ }
1014
+
1015
+ // ═══════════════════════════════════════════════════
1016
+ // Symmetry
1017
+ // ═══════════════════════════════════════════════════
1018
+ document.querySelectorAll('.sym-btn').forEach(btn => {
1019
+ btn.addEventListener('click', function() {
1020
+ const key = this.dataset.sym;
1021
+ symmetry[key] = !symmetry[key];
1022
+ this.classList.toggle('active', symmetry[key]);
1023
+ });
1024
+ });
1025
+
1026
+ // ═══════════════════════════════════════════════════
1027
+ // Gradient bar rendering
1028
+ // ═══════════════════════════════════════════════════
1029
+ function drawGradientBar() {
1030
+ const canvas = document.getElementById('gradient-bar');
1031
+ const ctx = canvas.getContext('2d');
1032
+ const w = canvas.width, h = canvas.height;
1033
+ const sorted = [...gradientStops].sort((a, b) => a.pos - b.pos);
1034
+ const grad = ctx.createLinearGradient(0, 0, w, 0);
1035
+ for (const stop of sorted) {
1036
+ grad.addColorStop(stop.pos, hsl(stop.h, stop.s, Math.max(10, stop.b * 0.5)));
1037
+ }
1038
+ ctx.fillStyle = grad;
1039
+ ctx.fillRect(0, 0, w, h);
1040
+
1041
+ // Draw stop markers
1042
+ const wrap = document.getElementById('gradient-bar-wrap');
1043
+ wrap.querySelectorAll('.gradient-stop').forEach(el => el.remove());
1044
+ for (const stop of sorted) {
1045
+ const marker = document.createElement('div');
1046
+ marker.className = 'gradient-stop';
1047
+ marker.style.left = (stop.pos * 100) + '%';
1048
+ marker.style.background = hsl(stop.h, stop.s, Math.max(10, stop.b * 0.5));
1049
+ wrap.appendChild(marker);
1050
+ }
1051
+ }
1052
+
1053
+ document.getElementById('gradient-bar-wrap').addEventListener('click', (e) => {
1054
+ const rect = e.currentTarget.getBoundingClientRect();
1055
+ const pos = (e.clientX - rect.left) / rect.width;
1056
+ gradientStops.push({ pos, h: currentHue, s: currentSat, b: currentBright });
1057
+ drawGradientBar();
1058
+ });
1059
+
1060
+ // ═══════════════════════════════════════════════════
1061
+ // Drops / Ripple Engine
1062
+ // ═══════════════════════════════════════════════════
1063
+ function drawSpectrumBar() {
1064
+ const canvas = document.getElementById('spectrum-bar');
1065
+ const ctx = canvas.getContext('2d');
1066
+ const w = canvas.width, h = canvas.height;
1067
+ for (let x = 0; x < w; x++) {
1068
+ const hue = (x / w) * 360;
1069
+ ctx.fillStyle = hsl(hue, 90, 50);
1070
+ ctx.fillRect(x, 0, 1, h);
1071
+ }
1072
+ // Update handle positions
1073
+ document.getElementById('spectrum-start-handle').style.left = (dropsSpectrumStart / 360 * 100) + '%';
1074
+ document.getElementById('spectrum-end-handle').style.left = (dropsSpectrumEnd / 360 * 100) + '%';
1075
+ }
1076
+
1077
+ (function setupSpectrumBar() {
1078
+ const wrap = document.getElementById('spectrum-bar-wrap');
1079
+ let dragging = null; // 'start' or 'end'
1080
+ wrap.addEventListener('pointerdown', (e) => {
1081
+ const rect = wrap.getBoundingClientRect();
1082
+ const pos = (e.clientX - rect.left) / rect.width;
1083
+ const hue = pos * 360;
1084
+ // Determine which handle is closer
1085
+ const dStart = Math.abs(hue - dropsSpectrumStart);
1086
+ const dEnd = Math.abs(hue - dropsSpectrumEnd);
1087
+ dragging = dStart < dEnd ? 'start' : 'end';
1088
+ if (dragging === 'start') dropsSpectrumStart = Math.max(0, Math.min(360, hue));
1089
+ else dropsSpectrumEnd = Math.max(0, Math.min(360, hue));
1090
+ drawSpectrumBar();
1091
+ });
1092
+ window.addEventListener('pointermove', (e) => {
1093
+ if (!dragging) return;
1094
+ const rect = wrap.getBoundingClientRect();
1095
+ const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
1096
+ const hue = pos * 360;
1097
+ if (dragging === 'start') dropsSpectrumStart = hue;
1098
+ else dropsSpectrumEnd = hue;
1099
+ drawSpectrumBar();
1100
+ });
1101
+ window.addEventListener('pointerup', () => { dragging = null; });
1102
+ })();
1103
+
1104
+ document.getElementById('drops-speed').addEventListener('input', function() { dropsSpeed = parseInt(this.value); });
1105
+ document.getElementById('drops-decay').addEventListener('input', function() { dropsDecay = parseInt(this.value); });
1106
+ document.getElementById('drops-width').addEventListener('input', function() { dropsWidth = parseInt(this.value); });
1107
+
1108
+ function startDropsAnimation() {
1109
+ if (dropsTimer) return;
1110
+ dropsTimer = setInterval(tickDrops, 80);
1111
+ }
1112
+
1113
+ function stopDropsAnimation() {
1114
+ if (dropsTimer) { clearInterval(dropsTimer); dropsTimer = null; }
1115
+ }
1116
+
1117
+ function tickDrops() {
1118
+ if (drops.length === 0) { stopDropsAnimation(); return; }
1119
+
1120
+ // Accumulate brightness contributions per cannon
1121
+ const contrib = new Float32Array(NUM);
1122
+ const hues = new Float32Array(NUM);
1123
+ const sats = new Float32Array(NUM);
1124
+ const counts = new Float32Array(NUM);
1125
+
1126
+ const maxRadius = GRID * 1.5; // max distance before drop dies
1127
+ const decayRate = 0.6 + (10 - dropsDecay) * 0.06; // higher decay slider = slower decay
1128
+ const speedMult = 0.3 + dropsSpeed * 0.15;
1129
+ const ringWidth = dropsWidth;
1130
+
1131
+ for (let d = drops.length - 1; d >= 0; d--) {
1132
+ const drop = drops[d];
1133
+ const radius = drop.tick * speedMult;
1134
+ if (radius > maxRadius + ringWidth) {
1135
+ drops.splice(d, 1);
1136
+ continue;
1137
+ }
1138
+
1139
+ const oRow = Math.floor(drop.origin / GRID);
1140
+ const oCol = drop.origin % GRID;
1141
+
1142
+ for (let i = 0; i < NUM; i++) {
1143
+ const r = Math.floor(i / GRID);
1144
+ const c = i % GRID;
1145
+ const dist = Math.sqrt((r - oRow) * (r - oRow) + (c - oCol) * (c - oCol));
1146
+
1147
+ // How close is this cannon to the current wavefront?
1148
+ const delta = Math.abs(dist - radius);
1149
+ if (delta > ringWidth) continue;
1150
+
1151
+ // Intensity: peaks at wavefront center, fades with ring width and age
1152
+ const ringFalloff = 1 - (delta / ringWidth);
1153
+ const ageFalloff = Math.pow(decayRate, drop.tick * 0.3);
1154
+ const intensity = ringFalloff * ageFalloff * currentBright;
1155
+
1156
+ if (intensity < 1) continue;
1157
+
1158
+ // Hue: cycles through spectrum based on distance from origin
1159
+ const specRange = dropsSpectrumEnd - dropsSpectrumStart;
1160
+ const hue = (dropsSpectrumStart + (dist / maxRadius) * specRange + 360) % 360;
1161
+
1162
+ contrib[i] += intensity;
1163
+ hues[i] += hue * intensity;
1164
+ sats[i] += 90 * intensity;
1165
+ counts[i] += intensity;
1166
+ }
1167
+
1168
+ drop.tick++;
1169
+ }
1170
+
1171
+ // Send updates for affected cannons
1172
+ for (let i = 0; i < NUM; i++) {
1173
+ if (counts[i] > 0) {
1174
+ const h = (hues[i] / counts[i] + 360) % 360;
1175
+ const s = sats[i] / counts[i];
1176
+ const b = Math.min(100, contrib[i]);
1177
+ send({ type: 'cannon', index: i, h, s, b });
1178
+ }
1179
+ }
1180
+
1181
+ if (drops.length === 0) stopDropsAnimation();
1182
+ }
1183
+
1184
+ // ═══════════════════════════════════════════════════
1185
+ // Init
1186
+ // ═══════════════════════════════════════════════════
1187
+ resizeSculpture();
1188
+ updateColorPreview();
1189
+ drawGradientBar();
1190
+ drawSpectrumBar();
1191
+ drawSculpture();
1192
+ window.addEventListener('resize', resizeSculpture);
1193
+ </script>
1194
+ </body>
1195
+ </html>`;
1196
+ }