@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/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>
|
|
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, '
|
|
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:
|
|
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.
|
|
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.
|
|
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;
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
//
|
|
565
|
+
// ═══════════════════════════════════════════════════
|
|
566
|
+
// Render loop
|
|
567
|
+
// ═══════════════════════════════════════════════════
|
|
337
568
|
function render() {
|
|
338
569
|
for (let i = 0; i < NUM; i++) {
|
|
339
570
|
const c = display[i];
|