@wavegrid/simulator 0.1.1 → 0.2.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 +18 -5
- package/animations.d.ts +4 -0
- package/animations.js +105 -0
- package/esm/animations.js +101 -0
- package/esm/grid.js +27 -9
- package/esm/index.js +2 -1
- package/esm/server.js +46 -8
- package/esm/ui.js +251 -20
- package/grid.d.ts +8 -2
- package/grid.js +27 -9
- package/index.d.ts +4 -2
- package/index.js +9 -6
- package/package.json +2 -2
- package/server.d.ts +2 -3
- package/server.js +44 -6
- package/ui.js +251 -20
package/esm/ui.js
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
|
+
import { animations } from './animations';
|
|
1
2
|
import { scenes } from './scenes';
|
|
2
3
|
export function getHTML() {
|
|
3
4
|
const sceneNames = Object.keys(scenes);
|
|
5
|
+
const animationNames = Object.keys(animations);
|
|
4
6
|
return `<!DOCTYPE html>
|
|
5
7
|
<html>
|
|
6
8
|
<head>
|
|
7
9
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
|
8
|
-
<title>
|
|
10
|
+
<title>Wavegrid · Master Controller</title>
|
|
11
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
9
12
|
<style>
|
|
10
13
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
11
14
|
body {
|
|
12
|
-
font-family: -apple-system, BlinkMacSystemFont, '
|
|
15
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Mono', 'Fira Code', monospace;
|
|
13
16
|
background: #0a0a0a; color: #eee; padding: 1rem;
|
|
14
17
|
min-height: 100vh;
|
|
15
18
|
}
|
|
16
19
|
h1 {
|
|
17
|
-
font-size: 1.1rem; font-weight: 600; margin-bottom:
|
|
20
|
+
font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;
|
|
18
21
|
color: #ccc; letter-spacing: 0.03em;
|
|
19
22
|
}
|
|
23
|
+
.subtitle { font-size: 0.7rem; color: #555; margin-bottom: 1rem; }
|
|
24
|
+
|
|
20
25
|
.master {
|
|
21
26
|
background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
|
|
22
27
|
padding: 1rem; margin-bottom: 1.25rem;
|
|
@@ -33,11 +38,22 @@ export function getHTML() {
|
|
|
33
38
|
}
|
|
34
39
|
.scene-btn:hover { border-color: #555; color: #ddd; }
|
|
35
40
|
.scene-btn.active { background: #1a3a6a; border-color: #3a6acc; color: #fff; }
|
|
41
|
+
|
|
42
|
+
.anim-btn {
|
|
43
|
+
padding: 6px 14px; border-radius: 20px; font-size: 0.75rem;
|
|
44
|
+
cursor: pointer; border: 1px solid #333; background: #1a1a1a;
|
|
45
|
+
color: #aaa; transition: all 0.2s;
|
|
46
|
+
}
|
|
47
|
+
.anim-btn:hover { border-color: #555; color: #ddd; }
|
|
48
|
+
.anim-btn.active { background: #1a4a2a; border-color: #3acc5a; color: #fff; }
|
|
49
|
+
.anim-btn.stop { border-color: #5a2222; color: #c44; }
|
|
50
|
+
.anim-btn.stop:hover { border-color: #8a3333; color: #e66; }
|
|
51
|
+
|
|
36
52
|
.slider-row {
|
|
37
53
|
display: flex; align-items: center; gap: 10px; margin-bottom: 0.6rem;
|
|
38
54
|
}
|
|
39
55
|
.slider-row label {
|
|
40
|
-
font-size: 0.
|
|
56
|
+
font-size: 0.75rem; color: #777; min-width: 90px;
|
|
41
57
|
}
|
|
42
58
|
.slider-row input[type=range] {
|
|
43
59
|
flex: 1; height: 6px; -webkit-appearance: none; appearance: none;
|
|
@@ -48,7 +64,8 @@ export function getHTML() {
|
|
|
48
64
|
border-radius: 50%; background: #4a8cde; cursor: pointer;
|
|
49
65
|
}
|
|
50
66
|
.slider-row .val {
|
|
51
|
-
font-size: 0.
|
|
67
|
+
font-size: 0.75rem; font-weight: 500; min-width: 50px; text-align: right; color: #888;
|
|
68
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
52
69
|
}
|
|
53
70
|
.all-off {
|
|
54
71
|
width: 100%; padding: 10px; border-radius: 8px; margin-top: 0.5rem;
|
|
@@ -58,6 +75,10 @@ export function getHTML() {
|
|
|
58
75
|
}
|
|
59
76
|
.all-off:hover { background: #2a1010; }
|
|
60
77
|
|
|
78
|
+
.columns { display: flex; gap: 1.25rem; flex-wrap: wrap; }
|
|
79
|
+
.col-left { flex: 1; min-width: 300px; }
|
|
80
|
+
.col-right { flex: 1; min-width: 300px; }
|
|
81
|
+
|
|
61
82
|
.grid-section { margin-bottom: 1.25rem; }
|
|
62
83
|
.sel-actions { display: flex; gap: 6px; margin-bottom: 0.5rem; }
|
|
63
84
|
.sel-btn {
|
|
@@ -94,7 +115,7 @@ export function getHTML() {
|
|
|
94
115
|
.cell.selected { border-color: #4a8cde; }
|
|
95
116
|
.cell-beam {
|
|
96
117
|
width: 60%; height: 60%; border-radius: 50%;
|
|
97
|
-
transition: none;
|
|
118
|
+
transition: none;
|
|
98
119
|
}
|
|
99
120
|
.cell-num {
|
|
100
121
|
font-size: 7px; color: #444; margin-top: 2px;
|
|
@@ -117,6 +138,28 @@ export function getHTML() {
|
|
|
117
138
|
background: #1a1a1a; color: #aaa; cursor: pointer;
|
|
118
139
|
}
|
|
119
140
|
|
|
141
|
+
.telemetry {
|
|
142
|
+
background: #0d0d0d; border: 1px solid #1a1a1a; border-radius: 8px;
|
|
143
|
+
padding: 0.75rem; font-size: 0.65rem; color: #555;
|
|
144
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
145
|
+
line-height: 1.6;
|
|
146
|
+
}
|
|
147
|
+
.telemetry .val-live { color: #4a8cde; }
|
|
148
|
+
.telemetry .val-anim { color: #3acc5a; }
|
|
149
|
+
|
|
150
|
+
.ambient-section {
|
|
151
|
+
background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
|
|
152
|
+
padding: 1rem; margin-bottom: 1.25rem;
|
|
153
|
+
}
|
|
154
|
+
.preset-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
|
155
|
+
.preset-btn {
|
|
156
|
+
padding: 8px 16px; border-radius: 20px; font-size: 0.75rem;
|
|
157
|
+
cursor: pointer; border: 1px solid #2a3a2a; background: #0d1a0d;
|
|
158
|
+
color: #7aaa7a; transition: all 0.2s;
|
|
159
|
+
}
|
|
160
|
+
.preset-btn:hover { border-color: #3a5a3a; color: #aad; }
|
|
161
|
+
.preset-btn.active { background: #1a3a1a; border-color: #3acc5a; color: #fff; }
|
|
162
|
+
|
|
120
163
|
.status {
|
|
121
164
|
font-size: 0.65rem; color: #444; text-align: center; padding-top: 0.5rem;
|
|
122
165
|
}
|
|
@@ -124,22 +167,64 @@ export function getHTML() {
|
|
|
124
167
|
</head>
|
|
125
168
|
<body>
|
|
126
169
|
|
|
127
|
-
<h1>
|
|
170
|
+
<h1>Wavegrid · Master Controller</h1>
|
|
171
|
+
<div class="subtitle">7×7 Civic Center Plaza · 49 cannons</div>
|
|
172
|
+
|
|
173
|
+
<div class="columns">
|
|
174
|
+
<div class="col-left">
|
|
128
175
|
|
|
129
176
|
<div class="master">
|
|
130
177
|
<div class="section-title">Scenes</div>
|
|
131
178
|
<div class="scene-row" id="scene-row">
|
|
132
179
|
${sceneNames.map((name, i) => `<button class="scene-btn${i === 0 ? ' active' : ''}" data-scene="${name}">${name}</button>`).join('\n ')}
|
|
133
180
|
</div>
|
|
134
|
-
|
|
181
|
+
|
|
182
|
+
<div class="section-title" style="margin-top:0.75rem">Animations</div>
|
|
183
|
+
<div class="scene-row" id="anim-row">
|
|
184
|
+
${animationNames.map(name => `<button class="anim-btn" data-anim="${name}">${name}</button>`).join('\n ')}
|
|
185
|
+
<button class="anim-btn stop" id="anim-stop">stop</button>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="section-title" style="margin-top:0.75rem">Envelope</div>
|
|
135
189
|
<div class="slider-row">
|
|
136
190
|
<label>Brightness</label>
|
|
137
191
|
<input type="range" min="0" max="100" value="80" id="master-bright">
|
|
138
192
|
<span class="val" id="master-bright-val">80%</span>
|
|
139
193
|
</div>
|
|
194
|
+
<div class="slider-row">
|
|
195
|
+
<label>Smoothness</label>
|
|
196
|
+
<input type="range" min="0" max="100" value="50" id="master-smooth">
|
|
197
|
+
<span class="val" id="master-smooth-val">α 0.08</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="slider-row">
|
|
200
|
+
<label>Attack</label>
|
|
201
|
+
<input type="range" min="0" max="100" value="100" id="master-attack">
|
|
202
|
+
<span class="val" id="master-attack-val">1.00</span>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="slider-row">
|
|
205
|
+
<label>Idle timeout</label>
|
|
206
|
+
<input type="range" min="0" max="30" value="0" id="idle-timeout">
|
|
207
|
+
<span class="val" id="idle-timeout-val">off</span>
|
|
208
|
+
</div>
|
|
140
209
|
<div class="all-off" id="all-off">All Off</div>
|
|
141
210
|
</div>
|
|
142
211
|
|
|
212
|
+
<div class="ambient-section">
|
|
213
|
+
<div class="section-title">Ambient Presets — walk away mode</div>
|
|
214
|
+
<div class="preset-row" id="preset-row">
|
|
215
|
+
<button class="preset-btn" data-preset="civic-breathe">Civic Breathe</button>
|
|
216
|
+
<button class="preset-btn" data-preset="ocean-wave">Ocean Wave</button>
|
|
217
|
+
<button class="preset-btn" data-preset="sunset-spiral">Sunset Spiral</button>
|
|
218
|
+
<button class="preset-btn" data-preset="pride-rainbow">Pride Rainbow</button>
|
|
219
|
+
<button class="preset-btn" data-preset="night-rain">Night Rain</button>
|
|
220
|
+
<button class="preset-btn" data-preset="heartbeat">Heartbeat</button>
|
|
221
|
+
</div>
|
|
222
|
+
<div style="font-size:0.65rem;color:#555">Tap a preset → scene + animation + envelope are all set. Safe to walk away.</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
</div>
|
|
226
|
+
<div class="col-right">
|
|
227
|
+
|
|
143
228
|
<div class="grid-section">
|
|
144
229
|
<div class="section-title">Cannon Grid — tap to select</div>
|
|
145
230
|
<div class="sel-actions">
|
|
@@ -172,6 +257,18 @@ export function getHTML() {
|
|
|
172
257
|
</div>
|
|
173
258
|
</div>
|
|
174
259
|
|
|
260
|
+
<div class="telemetry" id="telemetry">
|
|
261
|
+
<div>state: <span class="val-live" id="t-state">idle</span></div>
|
|
262
|
+
<div>animation: <span class="val-anim" id="t-anim">none</span></div>
|
|
263
|
+
<div>alpha (smooth): <span class="val-live" id="t-alpha">0.080</span></div>
|
|
264
|
+
<div>attack: <span class="val-live" id="t-attack">1.000</span></div>
|
|
265
|
+
<div>idle: <span class="val-live" id="t-idle">0s</span> / timeout: <span class="val-live" id="t-idle-max">off</span></div>
|
|
266
|
+
<div>clients: <span class="val-live" id="t-clients">1</span></div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
175
272
|
<div class="status" id="status">Connecting...</div>
|
|
176
273
|
|
|
177
274
|
<script>
|
|
@@ -180,10 +277,16 @@ const ws = new WebSocket('ws://' + location.host);
|
|
|
180
277
|
const status = document.getElementById('status');
|
|
181
278
|
const selected = new Set();
|
|
182
279
|
|
|
183
|
-
// Local display state (smoothly updated from server)
|
|
184
280
|
const display = Array.from({length: NUM}, () => ({ h: 220, s: 90, b: 80 }));
|
|
185
281
|
|
|
186
|
-
|
|
282
|
+
let currentAlpha = 0.08;
|
|
283
|
+
let currentAttack = 1.0;
|
|
284
|
+
let currentAnim = null;
|
|
285
|
+
let idleTimeout = 0;
|
|
286
|
+
let idleSeconds = 0;
|
|
287
|
+
let lastInputTime = Date.now();
|
|
288
|
+
|
|
289
|
+
ws.onopen = () => { status.textContent = 'Connected · 49 cannons · master controller'; };
|
|
187
290
|
ws.onclose = () => { status.textContent = 'Disconnected — reload page'; };
|
|
188
291
|
|
|
189
292
|
ws.onmessage = (e) => {
|
|
@@ -199,6 +302,7 @@ ws.onmessage = (e) => {
|
|
|
199
302
|
|
|
200
303
|
function send(data) {
|
|
201
304
|
if (ws.readyState === 1) ws.send(JSON.stringify(data));
|
|
305
|
+
lastInputTime = Date.now();
|
|
202
306
|
}
|
|
203
307
|
|
|
204
308
|
function hslToHex(h, s, l) {
|
|
@@ -290,21 +394,136 @@ document.getElementById('done-btn').addEventListener('click', () => {
|
|
|
290
394
|
updatePanel();
|
|
291
395
|
});
|
|
292
396
|
|
|
293
|
-
//
|
|
397
|
+
// ═══════════════════════════════════════════════════
|
|
398
|
+
// Master controls
|
|
399
|
+
// ═══════════════════════════════════════════════════
|
|
294
400
|
document.getElementById('master-bright').addEventListener('input', function() {
|
|
295
401
|
const v = parseInt(this.value);
|
|
296
402
|
document.getElementById('master-bright-val').textContent = v + '%';
|
|
297
403
|
send({ type: 'master_brightness', value: v / 100 });
|
|
298
404
|
});
|
|
299
405
|
|
|
406
|
+
document.getElementById('master-smooth').addEventListener('input', function() {
|
|
407
|
+
const pct = parseInt(this.value) / 100;
|
|
408
|
+
const alpha = Math.pow(10, -2.7 * pct);
|
|
409
|
+
currentAlpha = Math.max(0.002, Math.min(1.0, alpha));
|
|
410
|
+
document.getElementById('master-smooth-val').textContent = 'α ' + currentAlpha.toFixed(3);
|
|
411
|
+
send({ type: 'smoothness', value: currentAlpha });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
document.getElementById('master-attack').addEventListener('input', function() {
|
|
415
|
+
const pct = parseInt(this.value) / 100;
|
|
416
|
+
currentAttack = 0.05 + pct * 0.95;
|
|
417
|
+
document.getElementById('master-attack-val').textContent = currentAttack.toFixed(2);
|
|
418
|
+
send({ type: 'attack', value: currentAttack });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
document.getElementById('idle-timeout').addEventListener('input', function() {
|
|
422
|
+
idleTimeout = parseInt(this.value);
|
|
423
|
+
document.getElementById('idle-timeout-val').textContent = idleTimeout === 0 ? 'off' : idleTimeout + 'min';
|
|
424
|
+
document.getElementById('t-idle-max').textContent = idleTimeout === 0 ? 'off' : idleTimeout + 'min';
|
|
425
|
+
});
|
|
426
|
+
|
|
300
427
|
// All off
|
|
301
428
|
document.getElementById('all-off').addEventListener('click', () => {
|
|
302
429
|
document.getElementById('master-bright').value = 0;
|
|
303
430
|
document.getElementById('master-bright-val').textContent = '0%';
|
|
304
431
|
send({ type: 'master_brightness', value: 0 });
|
|
432
|
+
currentAnim = null;
|
|
433
|
+
document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
|
|
434
|
+
send({ type: 'animation', name: 'stop' });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// ═══════════════════════════════════════════════════
|
|
438
|
+
// Scenes
|
|
439
|
+
// ═══════════════════════════════════════════════════
|
|
440
|
+
document.getElementById('scene-row').addEventListener('click', (e) => {
|
|
441
|
+
const btn = e.target.closest('.scene-btn');
|
|
442
|
+
if (!btn) return;
|
|
443
|
+
document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
|
|
444
|
+
btn.classList.add('active');
|
|
445
|
+
send({ type: 'scene', name: btn.dataset.scene });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ═══════════════════════════════════════════════════
|
|
449
|
+
// Animations
|
|
450
|
+
// ═══════════════════════════════════════════════════
|
|
451
|
+
document.getElementById('anim-row').addEventListener('click', (e) => {
|
|
452
|
+
const btn = e.target.closest('.anim-btn');
|
|
453
|
+
if (!btn) return;
|
|
454
|
+
|
|
455
|
+
if (btn.id === 'anim-stop' || btn.dataset.anim === currentAnim) {
|
|
456
|
+
// Stop
|
|
457
|
+
document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
|
|
458
|
+
currentAnim = null;
|
|
459
|
+
send({ type: 'animation', name: 'stop' });
|
|
460
|
+
document.getElementById('t-anim').textContent = 'none';
|
|
461
|
+
document.getElementById('t-state').textContent = 'idle';
|
|
462
|
+
} else {
|
|
463
|
+
document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
|
|
464
|
+
btn.classList.add('active');
|
|
465
|
+
currentAnim = btn.dataset.anim;
|
|
466
|
+
send({ type: 'animation', name: currentAnim });
|
|
467
|
+
document.getElementById('t-anim').textContent = currentAnim;
|
|
468
|
+
document.getElementById('t-state').textContent = 'animating';
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ═══════════════════════════════════════════════════
|
|
473
|
+
// Ambient Presets
|
|
474
|
+
// ═══════════════════════════════════════════════════
|
|
475
|
+
const PRESETS = {
|
|
476
|
+
'civic-breathe': { scene: 'civic', anim: 'breathe', smooth: 75, attack: 30 },
|
|
477
|
+
'ocean-wave': { scene: 'ocean', anim: 'wave', smooth: 70, attack: 40 },
|
|
478
|
+
'sunset-spiral': { scene: 'sunset', anim: 'spiral', smooth: 60, attack: 50 },
|
|
479
|
+
'pride-rainbow': { scene: 'pride', anim: 'rainbow', smooth: 55, attack: 60 },
|
|
480
|
+
'night-rain': { scene: 'ocean', anim: 'rain', smooth: 80, attack: 25 },
|
|
481
|
+
'heartbeat': { scene: 'off', anim: 'heartbeat', smooth: 40, attack: 80 },
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
let activePreset = null;
|
|
485
|
+
document.getElementById('preset-row').addEventListener('click', (e) => {
|
|
486
|
+
const btn = e.target.closest('.preset-btn');
|
|
487
|
+
if (!btn) return;
|
|
488
|
+
const key = btn.dataset.preset;
|
|
489
|
+
const preset = PRESETS[key];
|
|
490
|
+
if (!preset) return;
|
|
491
|
+
|
|
492
|
+
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
|
493
|
+
btn.classList.add('active');
|
|
494
|
+
activePreset = key;
|
|
495
|
+
|
|
496
|
+
// Apply scene
|
|
497
|
+
send({ type: 'scene', name: preset.scene });
|
|
498
|
+
document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
|
|
499
|
+
const sceneBtn = document.querySelector('[data-scene="' + preset.scene + '"]');
|
|
500
|
+
if (sceneBtn) sceneBtn.classList.add('active');
|
|
501
|
+
|
|
502
|
+
// Apply smoothness
|
|
503
|
+
const smoothSlider = document.getElementById('master-smooth');
|
|
504
|
+
smoothSlider.value = preset.smooth;
|
|
505
|
+
smoothSlider.dispatchEvent(new Event('input'));
|
|
506
|
+
|
|
507
|
+
// Apply attack
|
|
508
|
+
const attackSlider = document.getElementById('master-attack');
|
|
509
|
+
attackSlider.value = preset.attack;
|
|
510
|
+
attackSlider.dispatchEvent(new Event('input'));
|
|
511
|
+
|
|
512
|
+
// Start animation (slight delay so scene applies first)
|
|
513
|
+
setTimeout(() => {
|
|
514
|
+
send({ type: 'animation', name: preset.anim });
|
|
515
|
+
currentAnim = preset.anim;
|
|
516
|
+
document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
|
|
517
|
+
const animBtn = document.querySelector('[data-anim="' + preset.anim + '"]');
|
|
518
|
+
if (animBtn) animBtn.classList.add('active');
|
|
519
|
+
document.getElementById('t-anim').textContent = preset.anim;
|
|
520
|
+
document.getElementById('t-state').textContent = 'ambient: ' + key;
|
|
521
|
+
}, 100);
|
|
305
522
|
});
|
|
306
523
|
|
|
524
|
+
// ═══════════════════════════════════════════════════
|
|
307
525
|
// Per-cannon controls
|
|
526
|
+
// ═══════════════════════════════════════════════════
|
|
308
527
|
document.getElementById('panel-bright').addEventListener('input', function() {
|
|
309
528
|
const v = parseInt(this.value);
|
|
310
529
|
document.getElementById('panel-bright-val').textContent = v + '%';
|
|
@@ -321,16 +540,28 @@ document.getElementById('panel-sat').addEventListener('input', function() {
|
|
|
321
540
|
send({ type: 'selection', indices: [...selected], s: v });
|
|
322
541
|
});
|
|
323
542
|
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
543
|
+
// ═══════════════════════════════════════════════════
|
|
544
|
+
// Idle timeout — auto-switch to ambient
|
|
545
|
+
// ═══════════════════════════════════════════════════
|
|
546
|
+
setInterval(() => {
|
|
547
|
+
if (idleTimeout <= 0) {
|
|
548
|
+
idleSeconds = 0;
|
|
549
|
+
document.getElementById('t-idle').textContent = '0s';
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
idleSeconds = Math.floor((Date.now() - lastInputTime) / 1000);
|
|
553
|
+
document.getElementById('t-idle').textContent = idleSeconds + 's';
|
|
554
|
+
|
|
555
|
+
if (idleSeconds >= idleTimeout * 60 && activePreset === null) {
|
|
556
|
+
// Auto-activate first preset on timeout
|
|
557
|
+
const firstBtn = document.querySelector('.preset-btn');
|
|
558
|
+
if (firstBtn) firstBtn.click();
|
|
559
|
+
}
|
|
560
|
+
}, 1000);
|
|
332
561
|
|
|
333
|
-
//
|
|
562
|
+
// ═══════════════════════════════════════════════════
|
|
563
|
+
// Render loop
|
|
564
|
+
// ═══════════════════════════════════════════════════
|
|
334
565
|
function render() {
|
|
335
566
|
for (let i = 0; i < NUM; i++) {
|
|
336
567
|
const c = display[i];
|
package/grid.d.ts
CHANGED
|
@@ -22,5 +22,11 @@ export declare function createGrid(): CannonTarget[];
|
|
|
22
22
|
* Called once per animation frame to smoothly converge current → target.
|
|
23
23
|
*/
|
|
24
24
|
export declare function tickGrid(grid: CannonTarget[], alpha?: number): boolean;
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Set target for a single cannon.
|
|
27
|
+
* attack (0–1): how much of the new value to apply.
|
|
28
|
+
* 1.0 = full (instant snap to new target)
|
|
29
|
+
* 0.1 = soft (target blends 10% toward new value)
|
|
30
|
+
*/
|
|
31
|
+
export declare function setCannonTarget(grid: CannonTarget[], index: number, h?: number, s?: number, b?: number, attack?: number): void;
|
|
32
|
+
export declare function setAllTargets(grid: CannonTarget[], h?: number, s?: number, b?: number, attack?: number): void;
|
package/grid.js
CHANGED
|
@@ -55,17 +55,35 @@ function angleDelta(from, to) {
|
|
|
55
55
|
let d = ((to - from + 540) % 360) - 180;
|
|
56
56
|
return d;
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Set target for a single cannon.
|
|
60
|
+
* attack (0–1): how much of the new value to apply.
|
|
61
|
+
* 1.0 = full (instant snap to new target)
|
|
62
|
+
* 0.1 = soft (target blends 10% toward new value)
|
|
63
|
+
*/
|
|
64
|
+
function setCannonTarget(grid, index, h, s, b, attack = 1.0) {
|
|
59
65
|
const c = grid[index];
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
if (attack >= 1.0) {
|
|
67
|
+
if (h !== undefined)
|
|
68
|
+
c.targetH = h;
|
|
69
|
+
if (s !== undefined)
|
|
70
|
+
c.targetS = s;
|
|
71
|
+
if (b !== undefined)
|
|
72
|
+
c.targetB = b;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
if (h !== undefined) {
|
|
76
|
+
const dh = angleDelta(c.targetH, h);
|
|
77
|
+
c.targetH = (c.targetH + dh * attack + 360) % 360;
|
|
78
|
+
}
|
|
79
|
+
if (s !== undefined)
|
|
80
|
+
c.targetS = c.targetS + (s - c.targetS) * attack;
|
|
81
|
+
if (b !== undefined)
|
|
82
|
+
c.targetB = c.targetB + (b - c.targetB) * attack;
|
|
83
|
+
}
|
|
66
84
|
}
|
|
67
|
-
function setAllTargets(grid, h, s, b) {
|
|
85
|
+
function setAllTargets(grid, h, s, b, attack = 1.0) {
|
|
68
86
|
for (let i = 0; i < grid.length; i++) {
|
|
69
|
-
setCannonTarget(grid, i, h, s, b);
|
|
87
|
+
setCannonTarget(grid, i, h, s, b, attack);
|
|
70
88
|
}
|
|
71
89
|
}
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
1
|
+
export type { AnimationFn } from './animations';
|
|
2
|
+
export { animations, getAnimationNames } from './animations';
|
|
3
3
|
export type { CannonState, CannonTarget } from './grid';
|
|
4
|
+
export { createGrid, DEFAULT_ALPHA, GRID_SIZE, NUM_CANNONS, setAllTargets, setCannonTarget, tickGrid } from './grid';
|
|
4
5
|
export type { SceneColor, SceneGenerator } from './scenes';
|
|
6
|
+
export { applyScene, scenes } from './scenes';
|
package/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.scenes = exports.applyScene = exports.
|
|
3
|
+
exports.scenes = exports.applyScene = exports.tickGrid = exports.setCannonTarget = exports.setAllTargets = exports.NUM_CANNONS = exports.GRID_SIZE = exports.DEFAULT_ALPHA = exports.createGrid = exports.getAnimationNames = exports.animations = void 0;
|
|
4
|
+
var animations_1 = require("./animations");
|
|
5
|
+
Object.defineProperty(exports, "animations", { enumerable: true, get: function () { return animations_1.animations; } });
|
|
6
|
+
Object.defineProperty(exports, "getAnimationNames", { enumerable: true, get: function () { return animations_1.getAnimationNames; } });
|
|
4
7
|
var grid_1 = require("./grid");
|
|
5
8
|
Object.defineProperty(exports, "createGrid", { enumerable: true, get: function () { return grid_1.createGrid; } });
|
|
6
|
-
Object.defineProperty(exports, "tickGrid", { enumerable: true, get: function () { return grid_1.tickGrid; } });
|
|
7
|
-
Object.defineProperty(exports, "setCannonTarget", { enumerable: true, get: function () { return grid_1.setCannonTarget; } });
|
|
8
|
-
Object.defineProperty(exports, "setAllTargets", { enumerable: true, get: function () { return grid_1.setAllTargets; } });
|
|
9
|
-
Object.defineProperty(exports, "NUM_CANNONS", { enumerable: true, get: function () { return grid_1.NUM_CANNONS; } });
|
|
10
|
-
Object.defineProperty(exports, "GRID_SIZE", { enumerable: true, get: function () { return grid_1.GRID_SIZE; } });
|
|
11
9
|
Object.defineProperty(exports, "DEFAULT_ALPHA", { enumerable: true, get: function () { return grid_1.DEFAULT_ALPHA; } });
|
|
10
|
+
Object.defineProperty(exports, "GRID_SIZE", { enumerable: true, get: function () { return grid_1.GRID_SIZE; } });
|
|
11
|
+
Object.defineProperty(exports, "NUM_CANNONS", { enumerable: true, get: function () { return grid_1.NUM_CANNONS; } });
|
|
12
|
+
Object.defineProperty(exports, "setAllTargets", { enumerable: true, get: function () { return grid_1.setAllTargets; } });
|
|
13
|
+
Object.defineProperty(exports, "setCannonTarget", { enumerable: true, get: function () { return grid_1.setCannonTarget; } });
|
|
14
|
+
Object.defineProperty(exports, "tickGrid", { enumerable: true, get: function () { return grid_1.tickGrid; } });
|
|
12
15
|
var scenes_1 = require("./scenes");
|
|
13
16
|
Object.defineProperty(exports, "applyScene", { enumerable: true, get: function () { return scenes_1.applyScene; } });
|
|
14
17
|
Object.defineProperty(exports, "scenes", { enumerable: true, get: function () { return scenes_1.scenes; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavegrid/simulator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"author": "Dan Lynch <pyramation@gmail.com>",
|
|
5
5
|
"description": "7×7 RGB grid simulator with smooth transitions",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
"@types/ws": "^8.5.13",
|
|
44
44
|
"makage": "^0.3.0"
|
|
45
45
|
},
|
|
46
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "60ed9683c2b2003a811d0298a086ee481cd310b1"
|
|
47
47
|
}
|
package/server.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
|
-
|
|
3
|
-
declare const grid: CannonTarget[];
|
|
2
|
+
declare const grid: import("./grid").CannonTarget[];
|
|
4
3
|
declare const server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
5
|
-
export {
|
|
4
|
+
export { grid, server };
|
package/server.js
CHANGED
|
@@ -3,9 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.server = exports.grid = void 0;
|
|
7
7
|
const http_1 = __importDefault(require("http"));
|
|
8
8
|
const ws_1 = require("ws");
|
|
9
|
+
const animations_1 = require("./animations");
|
|
9
10
|
const grid_1 = require("./grid");
|
|
10
11
|
const scenes_1 = require("./scenes");
|
|
11
12
|
const ui_1 = require("./ui");
|
|
@@ -13,7 +14,20 @@ const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
|
13
14
|
const TICK_MS = 1000 / 60; // 60fps interpolation
|
|
14
15
|
const grid = (0, grid_1.createGrid)();
|
|
15
16
|
exports.grid = grid;
|
|
16
|
-
|
|
17
|
+
let currentAlpha = grid_1.DEFAULT_ALPHA;
|
|
18
|
+
let currentAttack = 1.0;
|
|
19
|
+
let currentAnimation = null;
|
|
20
|
+
let animationTick = 0;
|
|
21
|
+
// constructive.io brand mark — served as the favicon
|
|
22
|
+
const FAVICON_SVG = `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
23
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3315 21.7046V28.9348L26.2354 27.2232L29.8206 25.1157L36.9909 29.3307V37.761L30.1657 41.7731V41.785L22.9955 46L19.4102 43.8925L15.8343 41.7848V41.7749L12.5759 39.8595L9 37.7518V21.2924L12.5759 19.1847L15.8343 17.2694V9.25722L19.4102 7.14956L22.6685 5.23418V5.21521L26.2445 3.10755L29.8297 1L37 5.21499V13.6453L30.1657 17.6628V17.6873L23.3315 21.7046ZM16.16 17.8789L12.9168 19.7854L10.0443 21.4784L16.0542 25.0113L22.2948 21.4903L19.4101 19.7945L16.16 17.8789ZM23.6598 5.43249L29.7813 9.0309L35.955 5.40169L33.0743 3.70829L29.8297 1.80095L26.5853 3.70818L23.6598 5.43249ZM22.5139 38.2327L16.8333 41.5721L19.7511 43.2918L22.5185 44.9187L22.5196 38.2427L22.5139 38.2327ZM29.0399 33.6349L29.0153 33.5916L29.1105 33.5357L26.24 31.8482L23.3405 30.1438V33.546V36.9854L29.0399 33.6349ZM29.0998 9.43154L26.24 7.75041L22.9953 5.84307L19.7509 7.7503L16.8486 9.461L22.97 13.0595L29.0348 9.49437L29.0244 9.47595L29.0998 9.43154ZM16.5153 10.0661V13.4722V17.2854L22.5224 20.8167L22.5236 13.598L16.5153 10.0661ZM35.9458 29.5176L33.0651 27.8242L29.8205 25.9168L26.5761 27.8241L23.6705 29.5367L29.7919 33.1352L35.9458 29.5176ZM15.794 33.7218L12.5758 31.8299L9.68105 30.1237V33.5369V37.3517L12.9167 39.2589L15.7928 40.9496L15.794 33.7218ZM15.7954 25.7332L9.68116 22.1389V25.5074V29.3222L12.9168 31.2293L15.7943 32.9208L15.7954 25.7332Z" fill="#01A1FF"/>
|
|
24
|
+
</svg>`;
|
|
25
|
+
const server = http_1.default.createServer((req, res) => {
|
|
26
|
+
if (req.url === '/favicon.svg' || req.url === '/favicon.ico') {
|
|
27
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' });
|
|
28
|
+
res.end(FAVICON_SVG);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
17
31
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
18
32
|
res.end((0, ui_1.getHTML)());
|
|
19
33
|
});
|
|
@@ -49,30 +63,54 @@ wss.on('connection', (ws) => {
|
|
|
49
63
|
function handleMessage(msg) {
|
|
50
64
|
switch (msg.type) {
|
|
51
65
|
case 'cannon':
|
|
52
|
-
(0, grid_1.setCannonTarget)(grid, msg.index, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
|
|
66
|
+
(0, grid_1.setCannonTarget)(grid, msg.index, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined, currentAttack);
|
|
53
67
|
break;
|
|
54
68
|
case 'master_brightness':
|
|
55
|
-
(0, grid_1.setAllTargets)(grid, undefined, undefined, msg.value * 100);
|
|
69
|
+
(0, grid_1.setAllTargets)(grid, undefined, undefined, msg.value * 100, currentAttack);
|
|
56
70
|
break;
|
|
57
71
|
case 'scene':
|
|
58
72
|
if (msg.name && scenes_1.scenes[msg.name]) {
|
|
73
|
+
currentAnimation = null;
|
|
59
74
|
(0, scenes_1.applyScene)(grid, msg.name);
|
|
60
75
|
}
|
|
61
76
|
break;
|
|
77
|
+
case 'animation':
|
|
78
|
+
if (msg.name && animations_1.animations[msg.name]) {
|
|
79
|
+
currentAnimation = msg.name;
|
|
80
|
+
animationTick = 0;
|
|
81
|
+
}
|
|
82
|
+
else if (msg.name === 'stop') {
|
|
83
|
+
currentAnimation = null;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
62
86
|
case 'selection':
|
|
63
87
|
if (Array.isArray(msg.indices)) {
|
|
64
88
|
for (const idx of msg.indices) {
|
|
65
89
|
if (idx >= 0 && idx < grid.length) {
|
|
66
|
-
(0, grid_1.setCannonTarget)(grid, idx, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
|
|
90
|
+
(0, grid_1.setCannonTarget)(grid, idx, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined, currentAttack);
|
|
67
91
|
}
|
|
68
92
|
}
|
|
69
93
|
}
|
|
70
94
|
break;
|
|
95
|
+
case 'smoothness':
|
|
96
|
+
if (typeof msg.value === 'number') {
|
|
97
|
+
currentAlpha = msg.value;
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
case 'attack':
|
|
101
|
+
if (typeof msg.value === 'number') {
|
|
102
|
+
currentAttack = msg.value;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
71
105
|
}
|
|
72
106
|
}
|
|
73
107
|
// Animation loop: tick interpolation and broadcast
|
|
74
108
|
setInterval(() => {
|
|
75
|
-
|
|
109
|
+
if (currentAnimation && animations_1.animations[currentAnimation]) {
|
|
110
|
+
animations_1.animations[currentAnimation](grid, animationTick, currentAttack);
|
|
111
|
+
animationTick++;
|
|
112
|
+
}
|
|
113
|
+
const changed = (0, grid_1.tickGrid)(grid, currentAlpha);
|
|
76
114
|
if (changed) {
|
|
77
115
|
broadcastState();
|
|
78
116
|
}
|