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