@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/ui.js CHANGED
@@ -1,25 +1,30 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getHTML = getHTML;
4
+ const animations_1 = require("./animations");
4
5
  const scenes_1 = require("./scenes");
5
6
  function getHTML() {
6
7
  const sceneNames = Object.keys(scenes_1.scenes);
8
+ const animationNames = Object.keys(animations_1.animations);
7
9
  return `<!DOCTYPE html>
8
10
  <html>
9
11
  <head>
10
12
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
11
- <title>Illuminate · 7×7 Simulator</title>
13
+ <title>Wavegrid · Master Controller</title>
14
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
12
15
  <style>
13
16
  * { box-sizing: border-box; margin: 0; padding: 0; }
14
17
  body {
15
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
18
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Mono', 'Fira Code', monospace;
16
19
  background: #0a0a0a; color: #eee; padding: 1rem;
17
20
  min-height: 100vh;
18
21
  }
19
22
  h1 {
20
- font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem;
23
+ font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;
21
24
  color: #ccc; letter-spacing: 0.03em;
22
25
  }
26
+ .subtitle { font-size: 0.7rem; color: #555; margin-bottom: 1rem; }
27
+
23
28
  .master {
24
29
  background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
25
30
  padding: 1rem; margin-bottom: 1.25rem;
@@ -36,11 +41,22 @@ function getHTML() {
36
41
  }
37
42
  .scene-btn:hover { border-color: #555; color: #ddd; }
38
43
  .scene-btn.active { background: #1a3a6a; border-color: #3a6acc; color: #fff; }
44
+
45
+ .anim-btn {
46
+ padding: 6px 14px; border-radius: 20px; font-size: 0.75rem;
47
+ cursor: pointer; border: 1px solid #333; background: #1a1a1a;
48
+ color: #aaa; transition: all 0.2s;
49
+ }
50
+ .anim-btn:hover { border-color: #555; color: #ddd; }
51
+ .anim-btn.active { background: #1a4a2a; border-color: #3acc5a; color: #fff; }
52
+ .anim-btn.stop { border-color: #5a2222; color: #c44; }
53
+ .anim-btn.stop:hover { border-color: #8a3333; color: #e66; }
54
+
39
55
  .slider-row {
40
56
  display: flex; align-items: center; gap: 10px; margin-bottom: 0.6rem;
41
57
  }
42
58
  .slider-row label {
43
- font-size: 0.8rem; color: #777; min-width: 80px;
59
+ font-size: 0.75rem; color: #777; min-width: 90px;
44
60
  }
45
61
  .slider-row input[type=range] {
46
62
  flex: 1; height: 6px; -webkit-appearance: none; appearance: none;
@@ -51,7 +67,8 @@ function getHTML() {
51
67
  border-radius: 50%; background: #4a8cde; cursor: pointer;
52
68
  }
53
69
  .slider-row .val {
54
- font-size: 0.8rem; font-weight: 500; min-width: 38px; text-align: right; color: #888;
70
+ font-size: 0.75rem; font-weight: 500; min-width: 50px; text-align: right; color: #888;
71
+ font-family: 'SF Mono', 'Fira Code', monospace;
55
72
  }
56
73
  .all-off {
57
74
  width: 100%; padding: 10px; border-radius: 8px; margin-top: 0.5rem;
@@ -61,6 +78,10 @@ function getHTML() {
61
78
  }
62
79
  .all-off:hover { background: #2a1010; }
63
80
 
81
+ .columns { display: flex; gap: 1.25rem; flex-wrap: wrap; }
82
+ .col-left { flex: 1; min-width: 300px; }
83
+ .col-right { flex: 1; min-width: 300px; }
84
+
64
85
  .grid-section { margin-bottom: 1.25rem; }
65
86
  .sel-actions { display: flex; gap: 6px; margin-bottom: 0.5rem; }
66
87
  .sel-btn {
@@ -97,7 +118,7 @@ function getHTML() {
97
118
  .cell.selected { border-color: #4a8cde; }
98
119
  .cell-beam {
99
120
  width: 60%; height: 60%; border-radius: 50%;
100
- transition: none; /* driven by animation frame */
121
+ transition: none;
101
122
  }
102
123
  .cell-num {
103
124
  font-size: 7px; color: #444; margin-top: 2px;
@@ -120,6 +141,28 @@ function getHTML() {
120
141
  background: #1a1a1a; color: #aaa; cursor: pointer;
121
142
  }
122
143
 
144
+ .telemetry {
145
+ background: #0d0d0d; border: 1px solid #1a1a1a; border-radius: 8px;
146
+ padding: 0.75rem; font-size: 0.65rem; color: #555;
147
+ font-family: 'SF Mono', 'Fira Code', monospace;
148
+ line-height: 1.6;
149
+ }
150
+ .telemetry .val-live { color: #4a8cde; }
151
+ .telemetry .val-anim { color: #3acc5a; }
152
+
153
+ .ambient-section {
154
+ background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
155
+ padding: 1rem; margin-bottom: 1.25rem;
156
+ }
157
+ .preset-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 0.75rem; }
158
+ .preset-btn {
159
+ padding: 8px 16px; border-radius: 20px; font-size: 0.75rem;
160
+ cursor: pointer; border: 1px solid #2a3a2a; background: #0d1a0d;
161
+ color: #7aaa7a; transition: all 0.2s;
162
+ }
163
+ .preset-btn:hover { border-color: #3a5a3a; color: #aad; }
164
+ .preset-btn.active { background: #1a3a1a; border-color: #3acc5a; color: #fff; }
165
+
123
166
  .status {
124
167
  font-size: 0.65rem; color: #444; text-align: center; padding-top: 0.5rem;
125
168
  }
@@ -127,22 +170,64 @@ function getHTML() {
127
170
  </head>
128
171
  <body>
129
172
 
130
- <h1>Illuminate · 7×7 Civic Center</h1>
173
+ <h1>Wavegrid · Master Controller</h1>
174
+ <div class="subtitle">7×7 Civic Center Plaza · 49 cannons</div>
175
+
176
+ <div class="columns">
177
+ <div class="col-left">
131
178
 
132
179
  <div class="master">
133
180
  <div class="section-title">Scenes</div>
134
181
  <div class="scene-row" id="scene-row">
135
182
  ${sceneNames.map((name, i) => `<button class="scene-btn${i === 0 ? ' active' : ''}" data-scene="${name}">${name}</button>`).join('\n ')}
136
183
  </div>
137
- <div class="section-title" style="margin-top:0.75rem">Master</div>
184
+
185
+ <div class="section-title" style="margin-top:0.75rem">Animations</div>
186
+ <div class="scene-row" id="anim-row">
187
+ ${animationNames.map(name => `<button class="anim-btn" data-anim="${name}">${name}</button>`).join('\n ')}
188
+ <button class="anim-btn stop" id="anim-stop">stop</button>
189
+ </div>
190
+
191
+ <div class="section-title" style="margin-top:0.75rem">Envelope</div>
138
192
  <div class="slider-row">
139
193
  <label>Brightness</label>
140
194
  <input type="range" min="0" max="100" value="80" id="master-bright">
141
195
  <span class="val" id="master-bright-val">80%</span>
142
196
  </div>
197
+ <div class="slider-row">
198
+ <label>Smoothness</label>
199
+ <input type="range" min="0" max="100" value="50" id="master-smooth">
200
+ <span class="val" id="master-smooth-val">α 0.08</span>
201
+ </div>
202
+ <div class="slider-row">
203
+ <label>Attack</label>
204
+ <input type="range" min="0" max="100" value="100" id="master-attack">
205
+ <span class="val" id="master-attack-val">1.00</span>
206
+ </div>
207
+ <div class="slider-row">
208
+ <label>Idle timeout</label>
209
+ <input type="range" min="0" max="30" value="0" id="idle-timeout">
210
+ <span class="val" id="idle-timeout-val">off</span>
211
+ </div>
143
212
  <div class="all-off" id="all-off">All Off</div>
144
213
  </div>
145
214
 
215
+ <div class="ambient-section">
216
+ <div class="section-title">Ambient Presets — walk away mode</div>
217
+ <div class="preset-row" id="preset-row">
218
+ <button class="preset-btn" data-preset="civic-breathe">Civic Breathe</button>
219
+ <button class="preset-btn" data-preset="ocean-wave">Ocean Wave</button>
220
+ <button class="preset-btn" data-preset="sunset-spiral">Sunset Spiral</button>
221
+ <button class="preset-btn" data-preset="pride-rainbow">Pride Rainbow</button>
222
+ <button class="preset-btn" data-preset="night-rain">Night Rain</button>
223
+ <button class="preset-btn" data-preset="heartbeat">Heartbeat</button>
224
+ </div>
225
+ <div style="font-size:0.65rem;color:#555">Tap a preset → scene + animation + envelope are all set. Safe to walk away.</div>
226
+ </div>
227
+
228
+ </div>
229
+ <div class="col-right">
230
+
146
231
  <div class="grid-section">
147
232
  <div class="section-title">Cannon Grid — tap to select</div>
148
233
  <div class="sel-actions">
@@ -175,6 +260,18 @@ function getHTML() {
175
260
  </div>
176
261
  </div>
177
262
 
263
+ <div class="telemetry" id="telemetry">
264
+ <div>state: <span class="val-live" id="t-state">idle</span></div>
265
+ <div>animation: <span class="val-anim" id="t-anim">none</span></div>
266
+ <div>alpha (smooth): <span class="val-live" id="t-alpha">0.080</span></div>
267
+ <div>attack: <span class="val-live" id="t-attack">1.000</span></div>
268
+ <div>idle: <span class="val-live" id="t-idle">0s</span> / timeout: <span class="val-live" id="t-idle-max">off</span></div>
269
+ <div>clients: <span class="val-live" id="t-clients">1</span></div>
270
+ </div>
271
+
272
+ </div>
273
+ </div>
274
+
178
275
  <div class="status" id="status">Connecting...</div>
179
276
 
180
277
  <script>
@@ -183,10 +280,16 @@ const ws = new WebSocket('ws://' + location.host);
183
280
  const status = document.getElementById('status');
184
281
  const selected = new Set();
185
282
 
186
- // Local display state (smoothly updated from server)
187
283
  const display = Array.from({length: NUM}, () => ({ h: 220, s: 90, b: 80 }));
188
284
 
189
- ws.onopen = () => { status.textContent = 'Connected · 49 cannons · smooth mode'; };
285
+ let currentAlpha = 0.08;
286
+ let currentAttack = 1.0;
287
+ let currentAnim = null;
288
+ let idleTimeout = 0;
289
+ let idleSeconds = 0;
290
+ let lastInputTime = Date.now();
291
+
292
+ ws.onopen = () => { status.textContent = 'Connected · 49 cannons · master controller'; };
190
293
  ws.onclose = () => { status.textContent = 'Disconnected — reload page'; };
191
294
 
192
295
  ws.onmessage = (e) => {
@@ -202,6 +305,7 @@ ws.onmessage = (e) => {
202
305
 
203
306
  function send(data) {
204
307
  if (ws.readyState === 1) ws.send(JSON.stringify(data));
308
+ lastInputTime = Date.now();
205
309
  }
206
310
 
207
311
  function hslToHex(h, s, l) {
@@ -293,21 +397,136 @@ document.getElementById('done-btn').addEventListener('click', () => {
293
397
  updatePanel();
294
398
  });
295
399
 
296
- // Master brightness
400
+ // ═══════════════════════════════════════════════════
401
+ // Master controls
402
+ // ═══════════════════════════════════════════════════
297
403
  document.getElementById('master-bright').addEventListener('input', function() {
298
404
  const v = parseInt(this.value);
299
405
  document.getElementById('master-bright-val').textContent = v + '%';
300
406
  send({ type: 'master_brightness', value: v / 100 });
301
407
  });
302
408
 
409
+ document.getElementById('master-smooth').addEventListener('input', function() {
410
+ const pct = parseInt(this.value) / 100;
411
+ const alpha = Math.pow(10, -2.7 * pct);
412
+ currentAlpha = Math.max(0.002, Math.min(1.0, alpha));
413
+ document.getElementById('master-smooth-val').textContent = 'α ' + currentAlpha.toFixed(3);
414
+ send({ type: 'smoothness', value: currentAlpha });
415
+ });
416
+
417
+ document.getElementById('master-attack').addEventListener('input', function() {
418
+ const pct = parseInt(this.value) / 100;
419
+ currentAttack = 0.05 + pct * 0.95;
420
+ document.getElementById('master-attack-val').textContent = currentAttack.toFixed(2);
421
+ send({ type: 'attack', value: currentAttack });
422
+ });
423
+
424
+ document.getElementById('idle-timeout').addEventListener('input', function() {
425
+ idleTimeout = parseInt(this.value);
426
+ document.getElementById('idle-timeout-val').textContent = idleTimeout === 0 ? 'off' : idleTimeout + 'min';
427
+ document.getElementById('t-idle-max').textContent = idleTimeout === 0 ? 'off' : idleTimeout + 'min';
428
+ });
429
+
303
430
  // All off
304
431
  document.getElementById('all-off').addEventListener('click', () => {
305
432
  document.getElementById('master-bright').value = 0;
306
433
  document.getElementById('master-bright-val').textContent = '0%';
307
434
  send({ type: 'master_brightness', value: 0 });
435
+ currentAnim = null;
436
+ document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
437
+ send({ type: 'animation', name: 'stop' });
438
+ });
439
+
440
+ // ═══════════════════════════════════════════════════
441
+ // Scenes
442
+ // ═══════════════════════════════════════════════════
443
+ document.getElementById('scene-row').addEventListener('click', (e) => {
444
+ const btn = e.target.closest('.scene-btn');
445
+ if (!btn) return;
446
+ document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
447
+ btn.classList.add('active');
448
+ send({ type: 'scene', name: btn.dataset.scene });
449
+ });
450
+
451
+ // ═══════════════════════════════════════════════════
452
+ // Animations
453
+ // ═══════════════════════════════════════════════════
454
+ document.getElementById('anim-row').addEventListener('click', (e) => {
455
+ const btn = e.target.closest('.anim-btn');
456
+ if (!btn) return;
457
+
458
+ if (btn.id === 'anim-stop' || btn.dataset.anim === currentAnim) {
459
+ // Stop
460
+ document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
461
+ currentAnim = null;
462
+ send({ type: 'animation', name: 'stop' });
463
+ document.getElementById('t-anim').textContent = 'none';
464
+ document.getElementById('t-state').textContent = 'idle';
465
+ } else {
466
+ document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
467
+ btn.classList.add('active');
468
+ currentAnim = btn.dataset.anim;
469
+ send({ type: 'animation', name: currentAnim });
470
+ document.getElementById('t-anim').textContent = currentAnim;
471
+ document.getElementById('t-state').textContent = 'animating';
472
+ }
473
+ });
474
+
475
+ // ═══════════════════════════════════════════════════
476
+ // Ambient Presets
477
+ // ═══════════════════════════════════════════════════
478
+ const PRESETS = {
479
+ 'civic-breathe': { scene: 'civic', anim: 'breathe', smooth: 75, attack: 30 },
480
+ 'ocean-wave': { scene: 'ocean', anim: 'wave', smooth: 70, attack: 40 },
481
+ 'sunset-spiral': { scene: 'sunset', anim: 'spiral', smooth: 60, attack: 50 },
482
+ 'pride-rainbow': { scene: 'pride', anim: 'rainbow', smooth: 55, attack: 60 },
483
+ 'night-rain': { scene: 'ocean', anim: 'rain', smooth: 80, attack: 25 },
484
+ 'heartbeat': { scene: 'off', anim: 'heartbeat', smooth: 40, attack: 80 },
485
+ };
486
+
487
+ let activePreset = null;
488
+ document.getElementById('preset-row').addEventListener('click', (e) => {
489
+ const btn = e.target.closest('.preset-btn');
490
+ if (!btn) return;
491
+ const key = btn.dataset.preset;
492
+ const preset = PRESETS[key];
493
+ if (!preset) return;
494
+
495
+ document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
496
+ btn.classList.add('active');
497
+ activePreset = key;
498
+
499
+ // Apply scene
500
+ send({ type: 'scene', name: preset.scene });
501
+ document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
502
+ const sceneBtn = document.querySelector('[data-scene="' + preset.scene + '"]');
503
+ if (sceneBtn) sceneBtn.classList.add('active');
504
+
505
+ // Apply smoothness
506
+ const smoothSlider = document.getElementById('master-smooth');
507
+ smoothSlider.value = preset.smooth;
508
+ smoothSlider.dispatchEvent(new Event('input'));
509
+
510
+ // Apply attack
511
+ const attackSlider = document.getElementById('master-attack');
512
+ attackSlider.value = preset.attack;
513
+ attackSlider.dispatchEvent(new Event('input'));
514
+
515
+ // Start animation (slight delay so scene applies first)
516
+ setTimeout(() => {
517
+ send({ type: 'animation', name: preset.anim });
518
+ currentAnim = preset.anim;
519
+ document.querySelectorAll('.anim-btn').forEach(b => b.classList.remove('active'));
520
+ const animBtn = document.querySelector('[data-anim="' + preset.anim + '"]');
521
+ if (animBtn) animBtn.classList.add('active');
522
+ document.getElementById('t-anim').textContent = preset.anim;
523
+ document.getElementById('t-state').textContent = 'ambient: ' + key;
524
+ }, 100);
308
525
  });
309
526
 
527
+ // ═══════════════════════════════════════════════════
310
528
  // Per-cannon controls
529
+ // ═══════════════════════════════════════════════════
311
530
  document.getElementById('panel-bright').addEventListener('input', function() {
312
531
  const v = parseInt(this.value);
313
532
  document.getElementById('panel-bright-val').textContent = v + '%';
@@ -324,16 +543,28 @@ document.getElementById('panel-sat').addEventListener('input', function() {
324
543
  send({ type: 'selection', indices: [...selected], s: v });
325
544
  });
326
545
 
327
- // Scenes
328
- document.getElementById('scene-row').addEventListener('click', (e) => {
329
- const btn = e.target.closest('.scene-btn');
330
- if (!btn) return;
331
- document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
332
- btn.classList.add('active');
333
- send({ type: 'scene', name: btn.dataset.scene });
334
- });
546
+ // ═══════════════════════════════════════════════════
547
+ // Idle timeout — auto-switch to ambient
548
+ // ═══════════════════════════════════════════════════
549
+ setInterval(() => {
550
+ if (idleTimeout <= 0) {
551
+ idleSeconds = 0;
552
+ document.getElementById('t-idle').textContent = '0s';
553
+ return;
554
+ }
555
+ idleSeconds = Math.floor((Date.now() - lastInputTime) / 1000);
556
+ document.getElementById('t-idle').textContent = idleSeconds + 's';
557
+
558
+ if (idleSeconds >= idleTimeout * 60 && activePreset === null) {
559
+ // Auto-activate first preset on timeout
560
+ const firstBtn = document.querySelector('.preset-btn');
561
+ if (firstBtn) firstBtn.click();
562
+ }
563
+ }, 1000);
335
564
 
336
- // Render loop — updates beams from display state
565
+ // ═══════════════════════════════════════════════════
566
+ // Render loop
567
+ // ═══════════════════════════════════════════════════
337
568
  function render() {
338
569
  for (let i = 0; i < NUM; i++) {
339
570
  const c = display[i];