@wavegrid/simulator 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 ADDED
@@ -0,0 +1,7 @@
1
+ All Rights Reserved.
2
+
3
+ Copyright (c) 2024 Interweb, Inc.
4
+
5
+ This software and associated documentation files (the "Software") may not be
6
+ reproduced, distributed, or used without express written permission from
7
+ Interweb, Inc.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @wavegrid/simulator
2
+
3
+ <p align="center" width="100%">
4
+ <img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5
+ </p>
6
+
7
+ <p align="center" width="100%">
8
+ <a href="https://github.com/constructive-io/Illuminate/actions/workflows/ci.yml">
9
+ <img height="20" src="https://github.com/constructive-io/Illuminate/actions/workflows/ci.yml/badge.svg" />
10
+ </a>
11
+ </p>
12
+
13
+ 7×7 RGB grid simulator for the Illuminate project. Renders 49 virtual laser cannons with smooth interpolated transitions (low-pass filtered) — no abrupt color or brightness changes.
14
+
15
+ ## Usage
16
+
17
+ ```sh
18
+ # Start the dev server
19
+ pnpm dev
20
+
21
+ # Run tests
22
+ pnpm test
23
+
24
+ # Build
25
+ pnpm build
26
+ ```
27
+
28
+ ## Architecture
29
+
30
+ - **`grid.ts`** — Core state engine with exponential interpolation (lerp). Each tick smoothly converges current values toward targets.
31
+ - **`scenes.ts`** — Predefined color/brightness patterns (civic blue, pride, gold, sunset, etc.)
32
+ - **`server.ts`** — HTTP + WebSocket server. Ticks at 60fps, broadcasts only when state changes.
33
+ - **`ui.ts`** — Embedded HTML/CSS/JS client with touch-friendly grid controls.
34
+
35
+ ## Smooth Transitions
36
+
37
+ All state changes go through a low-pass filter. When you change a scene or adjust a slider, the grid **flows** to the new state over ~1.5 seconds rather than jumping instantly. This ensures the physical laser array will never produce jarring transitions.
package/esm/grid.js ADDED
@@ -0,0 +1,64 @@
1
+ export const NUM_CANNONS = 49;
2
+ export const GRID_SIZE = 7;
3
+ /**
4
+ * Smoothing factor per tick (0–1).
5
+ * Lower = smoother/slower transitions (more low-pass filtering).
6
+ * At 60fps with alpha=0.08, a full transition takes ~1.5s to settle.
7
+ */
8
+ export const DEFAULT_ALPHA = 0.08;
9
+ export function createGrid() {
10
+ return Array.from({ length: NUM_CANNONS }, () => ({
11
+ h: 220,
12
+ s: 90,
13
+ b: 80,
14
+ targetH: 220,
15
+ targetS: 90,
16
+ targetB: 80
17
+ }));
18
+ }
19
+ /**
20
+ * Exponential low-pass filter (lerp toward target).
21
+ * Called once per animation frame to smoothly converge current → target.
22
+ */
23
+ export function tickGrid(grid, alpha = DEFAULT_ALPHA) {
24
+ let changed = false;
25
+ for (let i = 0; i < grid.length; i++) {
26
+ const c = grid[i];
27
+ const dh = angleDelta(c.h, c.targetH);
28
+ const ds = c.targetS - c.s;
29
+ const db = c.targetB - c.b;
30
+ if (Math.abs(dh) > 0.5 || Math.abs(ds) > 0.5 || Math.abs(db) > 0.5) {
31
+ c.h = (c.h + dh * alpha + 360) % 360;
32
+ c.s = c.s + ds * alpha;
33
+ c.b = c.b + db * alpha;
34
+ changed = true;
35
+ }
36
+ else {
37
+ c.h = c.targetH;
38
+ c.s = c.targetS;
39
+ c.b = c.targetB;
40
+ }
41
+ }
42
+ return changed;
43
+ }
44
+ /**
45
+ * Shortest angular distance on the hue circle (handles wrap-around).
46
+ */
47
+ function angleDelta(from, to) {
48
+ let d = ((to - from + 540) % 360) - 180;
49
+ return d;
50
+ }
51
+ export function setCannonTarget(grid, index, h, s, b) {
52
+ const c = grid[index];
53
+ if (h !== undefined)
54
+ c.targetH = h;
55
+ if (s !== undefined)
56
+ c.targetS = s;
57
+ if (b !== undefined)
58
+ c.targetB = b;
59
+ }
60
+ export function setAllTargets(grid, h, s, b) {
61
+ for (let i = 0; i < grid.length; i++) {
62
+ setCannonTarget(grid, i, h, s, b);
63
+ }
64
+ }
package/esm/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createGrid, tickGrid, setCannonTarget, setAllTargets, NUM_CANNONS, GRID_SIZE, DEFAULT_ALPHA } from './grid';
2
+ export { applyScene, scenes } from './scenes';
package/esm/scenes.js ADDED
@@ -0,0 +1,31 @@
1
+ import { NUM_CANNONS, setCannonTarget } from './grid';
2
+ export const scenes = {
3
+ civic: () => ({ h: 220, s: 90, b: 80 }),
4
+ pride: (i, total) => ({ h: Math.round((i / total) * 360), s: 90, b: 80 }),
5
+ gold: () => ({ h: 45, s: 95, b: 80 }),
6
+ white: () => ({ h: 0, s: 0, b: 80 }),
7
+ solstice: (i) => {
8
+ const row = Math.floor(i / 7);
9
+ const col = i % 7;
10
+ return { h: 40 + row * 5 + col * 4, s: 85, b: 80 };
11
+ },
12
+ ocean: (i) => {
13
+ const row = Math.floor(i / 7);
14
+ const col = i % 7;
15
+ return { h: 180 + row * 8 + col * 3, s: 75, b: 70 };
16
+ },
17
+ sunset: (i) => {
18
+ const row = Math.floor(i / 7);
19
+ return { h: 10 + row * 5, s: 90, b: 85 - row * 5 };
20
+ },
21
+ off: () => ({ h: 0, s: 0, b: 0 })
22
+ };
23
+ export function applyScene(grid, sceneName) {
24
+ const generator = scenes[sceneName];
25
+ if (!generator)
26
+ return;
27
+ for (let i = 0; i < NUM_CANNONS; i++) {
28
+ const { h, s, b } = generator(i, NUM_CANNONS);
29
+ setCannonTarget(grid, i, h, s, b);
30
+ }
31
+ }
package/esm/server.js ADDED
@@ -0,0 +1,82 @@
1
+ import http from 'http';
2
+ import { WebSocketServer, WebSocket } from 'ws';
3
+ import { createGrid, tickGrid, setCannonTarget, setAllTargets } from './grid';
4
+ import { applyScene, scenes } from './scenes';
5
+ import { getHTML } from './ui';
6
+ const PORT = parseInt(process.env.PORT || '3000', 10);
7
+ const TICK_MS = 1000 / 60; // 60fps interpolation
8
+ const grid = createGrid();
9
+ const server = http.createServer((_req, res) => {
10
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11
+ res.end(getHTML());
12
+ });
13
+ const wss = new WebSocketServer({ server });
14
+ function broadcastState() {
15
+ const payload = JSON.stringify({
16
+ type: 'state',
17
+ grid: grid.map(c => ({ h: c.h, s: c.s, b: c.b }))
18
+ });
19
+ wss.clients.forEach(client => {
20
+ if (client.readyState === WebSocket.OPEN) {
21
+ client.send(payload);
22
+ }
23
+ });
24
+ }
25
+ wss.on('connection', (ws) => {
26
+ // Send initial state
27
+ ws.send(JSON.stringify({
28
+ type: 'state',
29
+ grid: grid.map(c => ({ h: c.h, s: c.s, b: c.b }))
30
+ }));
31
+ ws.on('message', (raw) => {
32
+ try {
33
+ const msg = JSON.parse(raw.toString());
34
+ handleMessage(msg);
35
+ }
36
+ catch (_e) {
37
+ // ignore malformed messages
38
+ }
39
+ });
40
+ });
41
+ function handleMessage(msg) {
42
+ switch (msg.type) {
43
+ case 'cannon':
44
+ setCannonTarget(grid, msg.index, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
45
+ break;
46
+ case 'master_brightness':
47
+ setAllTargets(grid, undefined, undefined, msg.value * 100);
48
+ break;
49
+ case 'scene':
50
+ if (msg.name && scenes[msg.name]) {
51
+ applyScene(grid, msg.name);
52
+ }
53
+ break;
54
+ case 'selection':
55
+ if (Array.isArray(msg.indices)) {
56
+ for (const idx of msg.indices) {
57
+ if (idx >= 0 && idx < grid.length) {
58
+ setCannonTarget(grid, idx, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
59
+ }
60
+ }
61
+ }
62
+ break;
63
+ }
64
+ }
65
+ // Animation loop: tick interpolation and broadcast
66
+ setInterval(() => {
67
+ const changed = tickGrid(grid);
68
+ if (changed) {
69
+ broadcastState();
70
+ }
71
+ }, TICK_MS);
72
+ server.listen(PORT, '0.0.0.0', () => {
73
+ console.log('');
74
+ console.log('╔══════════════════════════════════════════╗');
75
+ console.log('║ Illuminate · 7×7 Grid Simulator ║');
76
+ console.log('║ 49 virtual cannons ready ║');
77
+ console.log('╚══════════════════════════════════════════╝');
78
+ console.log('');
79
+ console.log(` → http://localhost:${PORT}`);
80
+ console.log('');
81
+ });
82
+ export { server, grid };
package/esm/ui.js ADDED
@@ -0,0 +1,354 @@
1
+ import { scenes } from './scenes';
2
+ export function getHTML() {
3
+ const sceneNames = Object.keys(scenes);
4
+ return `<!DOCTYPE html>
5
+ <html>
6
+ <head>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
8
+ <title>Illuminate · 7×7 Simulator</title>
9
+ <style>
10
+ * { box-sizing: border-box; margin: 0; padding: 0; }
11
+ body {
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
13
+ background: #0a0a0a; color: #eee; padding: 1rem;
14
+ min-height: 100vh;
15
+ }
16
+ h1 {
17
+ font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem;
18
+ color: #ccc; letter-spacing: 0.03em;
19
+ }
20
+ .master {
21
+ background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
22
+ padding: 1rem; margin-bottom: 1.25rem;
23
+ }
24
+ .section-title {
25
+ font-size: 0.7rem; color: #666; text-transform: uppercase;
26
+ letter-spacing: 0.08em; margin-bottom: 0.75rem;
27
+ }
28
+ .scene-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 0.75rem; }
29
+ .scene-btn {
30
+ padding: 6px 14px; border-radius: 20px; font-size: 0.75rem;
31
+ cursor: pointer; border: 1px solid #333; background: #1a1a1a;
32
+ color: #aaa; transition: all 0.2s;
33
+ }
34
+ .scene-btn:hover { border-color: #555; color: #ddd; }
35
+ .scene-btn.active { background: #1a3a6a; border-color: #3a6acc; color: #fff; }
36
+ .slider-row {
37
+ display: flex; align-items: center; gap: 10px; margin-bottom: 0.6rem;
38
+ }
39
+ .slider-row label {
40
+ font-size: 0.8rem; color: #777; min-width: 80px;
41
+ }
42
+ .slider-row input[type=range] {
43
+ flex: 1; height: 6px; -webkit-appearance: none; appearance: none;
44
+ background: #333; border-radius: 3px; outline: none;
45
+ }
46
+ .slider-row input[type=range]::-webkit-slider-thumb {
47
+ -webkit-appearance: none; width: 18px; height: 18px;
48
+ border-radius: 50%; background: #4a8cde; cursor: pointer;
49
+ }
50
+ .slider-row .val {
51
+ font-size: 0.8rem; font-weight: 500; min-width: 38px; text-align: right; color: #888;
52
+ }
53
+ .all-off {
54
+ width: 100%; padding: 10px; border-radius: 8px; margin-top: 0.5rem;
55
+ background: #1a0808; border: 1px solid #3a1515; color: #c44;
56
+ font-size: 0.85rem; font-weight: 500; cursor: pointer; text-align: center;
57
+ transition: background 0.2s;
58
+ }
59
+ .all-off:hover { background: #2a1010; }
60
+
61
+ .grid-section { margin-bottom: 1.25rem; }
62
+ .sel-actions { display: flex; gap: 6px; margin-bottom: 0.5rem; }
63
+ .sel-btn {
64
+ font-size: 0.7rem; padding: 5px 10px;
65
+ border: 1px solid #333; border-radius: 6px;
66
+ background: #1a1a1a; color: #aaa; cursor: pointer;
67
+ transition: all 0.15s;
68
+ }
69
+ .sel-btn:hover { border-color: #555; color: #ddd; }
70
+ .rc-btns { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 0.6rem; }
71
+ .rc-btn {
72
+ font-size: 0.65rem; padding: 4px 8px;
73
+ border: 1px solid #1a2a4a; border-radius: 5px;
74
+ background: #0e1520; color: #5a8abb; cursor: pointer;
75
+ transition: all 0.15s;
76
+ }
77
+ .rc-btn:hover { background: #152030; border-color: #2a4a7a; }
78
+
79
+ .grid {
80
+ display: grid;
81
+ grid-template-columns: repeat(7, 1fr);
82
+ gap: 4px;
83
+ max-width: 420px;
84
+ }
85
+ .cell {
86
+ aspect-ratio: 1; border-radius: 8px;
87
+ border: 1.5px solid #222; background: #111;
88
+ display: flex; flex-direction: column;
89
+ align-items: center; justify-content: center;
90
+ cursor: pointer; transition: border-color 0.15s, transform 0.1s;
91
+ position: relative;
92
+ }
93
+ .cell:active { transform: scale(0.95); }
94
+ .cell.selected { border-color: #4a8cde; }
95
+ .cell-beam {
96
+ width: 60%; height: 60%; border-radius: 50%;
97
+ transition: none; /* driven by animation frame */
98
+ }
99
+ .cell-num {
100
+ font-size: 7px; color: #444; margin-top: 2px;
101
+ position: absolute; bottom: 3px;
102
+ }
103
+
104
+ .panel {
105
+ background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
106
+ padding: 1rem; display: none; margin-bottom: 1rem;
107
+ }
108
+ .panel.visible { display: block; }
109
+ .panel-header {
110
+ display: flex; align-items: center; justify-content: space-between;
111
+ margin-bottom: 0.75rem;
112
+ }
113
+ .panel-title { font-size: 0.9rem; font-weight: 500; }
114
+ .done-btn {
115
+ font-size: 0.7rem; padding: 4px 10px;
116
+ border: 1px solid #333; border-radius: 6px;
117
+ background: #1a1a1a; color: #aaa; cursor: pointer;
118
+ }
119
+
120
+ .status {
121
+ font-size: 0.65rem; color: #444; text-align: center; padding-top: 0.5rem;
122
+ }
123
+ </style>
124
+ </head>
125
+ <body>
126
+
127
+ <h1>Illuminate · 7×7 Civic Center</h1>
128
+
129
+ <div class="master">
130
+ <div class="section-title">Scenes</div>
131
+ <div class="scene-row" id="scene-row">
132
+ ${sceneNames.map((name, i) => `<button class="scene-btn${i === 0 ? ' active' : ''}" data-scene="${name}">${name}</button>`).join('\n ')}
133
+ </div>
134
+ <div class="section-title" style="margin-top:0.75rem">Master</div>
135
+ <div class="slider-row">
136
+ <label>Brightness</label>
137
+ <input type="range" min="0" max="100" value="80" id="master-bright">
138
+ <span class="val" id="master-bright-val">80%</span>
139
+ </div>
140
+ <div class="all-off" id="all-off">All Off</div>
141
+ </div>
142
+
143
+ <div class="grid-section">
144
+ <div class="section-title">Cannon Grid — tap to select</div>
145
+ <div class="sel-actions">
146
+ <button class="sel-btn" id="sel-all">Select All</button>
147
+ <button class="sel-btn" id="sel-none">Clear</button>
148
+ </div>
149
+ <div class="rc-btns" id="rc-btns"></div>
150
+ <div class="grid" id="cannon-grid"></div>
151
+ </div>
152
+
153
+ <div class="panel" id="panel">
154
+ <div class="panel-header">
155
+ <span class="panel-title" id="panel-title">Cannon 1</span>
156
+ <button class="done-btn" id="done-btn">Done</button>
157
+ </div>
158
+ <div class="slider-row">
159
+ <label>Brightness</label>
160
+ <input type="range" min="0" max="100" value="80" id="panel-bright">
161
+ <span class="val" id="panel-bright-val">80%</span>
162
+ </div>
163
+ <div class="slider-row">
164
+ <label>Hue</label>
165
+ <input type="range" min="0" max="360" value="220" id="panel-hue">
166
+ <span class="val" id="panel-hue-val">220°</span>
167
+ </div>
168
+ <div class="slider-row">
169
+ <label>Saturation</label>
170
+ <input type="range" min="0" max="100" value="90" id="panel-sat">
171
+ <span class="val" id="panel-sat-val">90%</span>
172
+ </div>
173
+ </div>
174
+
175
+ <div class="status" id="status">Connecting...</div>
176
+
177
+ <script>
178
+ const NUM = 49;
179
+ const ws = new WebSocket('ws://' + location.host);
180
+ const status = document.getElementById('status');
181
+ const selected = new Set();
182
+
183
+ // Local display state (smoothly updated from server)
184
+ const display = Array.from({length: NUM}, () => ({ h: 220, s: 90, b: 80 }));
185
+
186
+ ws.onopen = () => { status.textContent = 'Connected · 49 cannons · smooth mode'; };
187
+ ws.onclose = () => { status.textContent = 'Disconnected — reload page'; };
188
+
189
+ ws.onmessage = (e) => {
190
+ const msg = JSON.parse(e.data);
191
+ if (msg.type === 'state') {
192
+ for (let i = 0; i < NUM; i++) {
193
+ display[i].h = msg.grid[i].h;
194
+ display[i].s = msg.grid[i].s;
195
+ display[i].b = msg.grid[i].b;
196
+ }
197
+ }
198
+ };
199
+
200
+ function send(data) {
201
+ if (ws.readyState === 1) ws.send(JSON.stringify(data));
202
+ }
203
+
204
+ function hslToHex(h, s, l) {
205
+ s /= 100; l /= 100;
206
+ const a = s * Math.min(l, 1 - l);
207
+ const f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); };
208
+ const r = Math.round(255 * f(0));
209
+ const g = Math.round(255 * f(8));
210
+ const b = Math.round(255 * f(4));
211
+ return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
212
+ }
213
+
214
+ // Build grid
215
+ const gridEl = document.getElementById('cannon-grid');
216
+ for (let i = 0; i < NUM; i++) {
217
+ const cell = document.createElement('div');
218
+ cell.className = 'cell';
219
+ cell.id = 'cell-' + i;
220
+ cell.innerHTML = '<div class="cell-beam" id="beam-' + i + '"></div><span class="cell-num">' + (i + 1) + '</span>';
221
+ cell.addEventListener('click', () => toggleSelect(i));
222
+ gridEl.appendChild(cell);
223
+ }
224
+
225
+ // Build row/col buttons
226
+ const rcEl = document.getElementById('rc-btns');
227
+ for (let r = 0; r < 7; r++) {
228
+ const btn = document.createElement('button');
229
+ btn.className = 'rc-btn';
230
+ btn.textContent = 'R' + (r + 1);
231
+ btn.addEventListener('click', () => {
232
+ for (let c = 0; c < 7; c++) selectOn(r * 7 + c);
233
+ updatePanel();
234
+ });
235
+ rcEl.appendChild(btn);
236
+ }
237
+ for (let c = 0; c < 7; c++) {
238
+ const btn = document.createElement('button');
239
+ btn.className = 'rc-btn';
240
+ btn.textContent = 'C' + (c + 1);
241
+ btn.addEventListener('click', () => {
242
+ for (let r = 0; r < 7; r++) selectOn(r * 7 + c);
243
+ updatePanel();
244
+ });
245
+ rcEl.appendChild(btn);
246
+ }
247
+
248
+ function selectOn(idx) {
249
+ selected.add(idx);
250
+ document.getElementById('cell-' + idx).classList.add('selected');
251
+ }
252
+
253
+ function toggleSelect(idx) {
254
+ if (selected.has(idx)) {
255
+ selected.delete(idx);
256
+ document.getElementById('cell-' + idx).classList.remove('selected');
257
+ } else {
258
+ selected.add(idx);
259
+ document.getElementById('cell-' + idx).classList.add('selected');
260
+ }
261
+ updatePanel();
262
+ }
263
+
264
+ function updatePanel() {
265
+ const panel = document.getElementById('panel');
266
+ const title = document.getElementById('panel-title');
267
+ if (selected.size === 0) {
268
+ panel.classList.remove('visible');
269
+ } else {
270
+ panel.classList.add('visible');
271
+ title.textContent = selected.size === 1
272
+ ? 'Cannon ' + ([...selected][0] + 1)
273
+ : selected.size + ' cannons selected';
274
+ }
275
+ }
276
+
277
+ // Selection controls
278
+ document.getElementById('sel-all').addEventListener('click', () => {
279
+ for (let i = 0; i < NUM; i++) selectOn(i);
280
+ updatePanel();
281
+ });
282
+ document.getElementById('sel-none').addEventListener('click', () => {
283
+ selected.forEach(idx => document.getElementById('cell-' + idx).classList.remove('selected'));
284
+ selected.clear();
285
+ updatePanel();
286
+ });
287
+ document.getElementById('done-btn').addEventListener('click', () => {
288
+ selected.forEach(idx => document.getElementById('cell-' + idx).classList.remove('selected'));
289
+ selected.clear();
290
+ updatePanel();
291
+ });
292
+
293
+ // Master brightness
294
+ document.getElementById('master-bright').addEventListener('input', function() {
295
+ const v = parseInt(this.value);
296
+ document.getElementById('master-bright-val').textContent = v + '%';
297
+ send({ type: 'master_brightness', value: v / 100 });
298
+ });
299
+
300
+ // All off
301
+ document.getElementById('all-off').addEventListener('click', () => {
302
+ document.getElementById('master-bright').value = 0;
303
+ document.getElementById('master-bright-val').textContent = '0%';
304
+ send({ type: 'master_brightness', value: 0 });
305
+ });
306
+
307
+ // Per-cannon controls
308
+ document.getElementById('panel-bright').addEventListener('input', function() {
309
+ const v = parseInt(this.value);
310
+ document.getElementById('panel-bright-val').textContent = v + '%';
311
+ send({ type: 'selection', indices: [...selected], b: v });
312
+ });
313
+ document.getElementById('panel-hue').addEventListener('input', function() {
314
+ const v = parseInt(this.value);
315
+ document.getElementById('panel-hue-val').textContent = v + '°';
316
+ send({ type: 'selection', indices: [...selected], h: v });
317
+ });
318
+ document.getElementById('panel-sat').addEventListener('input', function() {
319
+ const v = parseInt(this.value);
320
+ document.getElementById('panel-sat-val').textContent = v + '%';
321
+ send({ type: 'selection', indices: [...selected], s: v });
322
+ });
323
+
324
+ // Scenes
325
+ document.getElementById('scene-row').addEventListener('click', (e) => {
326
+ const btn = e.target.closest('.scene-btn');
327
+ if (!btn) return;
328
+ document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
329
+ btn.classList.add('active');
330
+ send({ type: 'scene', name: btn.dataset.scene });
331
+ });
332
+
333
+ // Render loop — updates beams from display state
334
+ function render() {
335
+ for (let i = 0; i < NUM; i++) {
336
+ const c = display[i];
337
+ const beam = document.getElementById('beam-' + i);
338
+ if (beam) {
339
+ const lightness = Math.max(5, c.b * 0.5);
340
+ beam.style.background = c.b < 1
341
+ ? '#111'
342
+ : hslToHex(c.h, c.s, lightness);
343
+ beam.style.boxShadow = c.b > 20
344
+ ? '0 0 ' + (c.b * 0.15) + 'px ' + hslToHex(c.h, c.s, lightness)
345
+ : 'none';
346
+ }
347
+ }
348
+ requestAnimationFrame(render);
349
+ }
350
+ requestAnimationFrame(render);
351
+ </script>
352
+ </body>
353
+ </html>`;
354
+ }
package/grid.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export interface CannonState {
2
+ h: number;
3
+ s: number;
4
+ b: number;
5
+ }
6
+ export interface CannonTarget extends CannonState {
7
+ targetH: number;
8
+ targetS: number;
9
+ targetB: number;
10
+ }
11
+ export declare const NUM_CANNONS = 49;
12
+ export declare const GRID_SIZE = 7;
13
+ /**
14
+ * Smoothing factor per tick (0–1).
15
+ * Lower = smoother/slower transitions (more low-pass filtering).
16
+ * At 60fps with alpha=0.08, a full transition takes ~1.5s to settle.
17
+ */
18
+ export declare const DEFAULT_ALPHA = 0.08;
19
+ export declare function createGrid(): CannonTarget[];
20
+ /**
21
+ * Exponential low-pass filter (lerp toward target).
22
+ * Called once per animation frame to smoothly converge current → target.
23
+ */
24
+ export declare function tickGrid(grid: CannonTarget[], alpha?: number): boolean;
25
+ export declare function setCannonTarget(grid: CannonTarget[], index: number, h?: number, s?: number, b?: number): void;
26
+ export declare function setAllTargets(grid: CannonTarget[], h?: number, s?: number, b?: number): void;
package/grid.js ADDED
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_ALPHA = exports.GRID_SIZE = exports.NUM_CANNONS = void 0;
4
+ exports.createGrid = createGrid;
5
+ exports.tickGrid = tickGrid;
6
+ exports.setCannonTarget = setCannonTarget;
7
+ exports.setAllTargets = setAllTargets;
8
+ exports.NUM_CANNONS = 49;
9
+ exports.GRID_SIZE = 7;
10
+ /**
11
+ * Smoothing factor per tick (0–1).
12
+ * Lower = smoother/slower transitions (more low-pass filtering).
13
+ * At 60fps with alpha=0.08, a full transition takes ~1.5s to settle.
14
+ */
15
+ exports.DEFAULT_ALPHA = 0.08;
16
+ function createGrid() {
17
+ return Array.from({ length: exports.NUM_CANNONS }, () => ({
18
+ h: 220,
19
+ s: 90,
20
+ b: 80,
21
+ targetH: 220,
22
+ targetS: 90,
23
+ targetB: 80
24
+ }));
25
+ }
26
+ /**
27
+ * Exponential low-pass filter (lerp toward target).
28
+ * Called once per animation frame to smoothly converge current → target.
29
+ */
30
+ function tickGrid(grid, alpha = exports.DEFAULT_ALPHA) {
31
+ let changed = false;
32
+ for (let i = 0; i < grid.length; i++) {
33
+ const c = grid[i];
34
+ const dh = angleDelta(c.h, c.targetH);
35
+ const ds = c.targetS - c.s;
36
+ const db = c.targetB - c.b;
37
+ if (Math.abs(dh) > 0.5 || Math.abs(ds) > 0.5 || Math.abs(db) > 0.5) {
38
+ c.h = (c.h + dh * alpha + 360) % 360;
39
+ c.s = c.s + ds * alpha;
40
+ c.b = c.b + db * alpha;
41
+ changed = true;
42
+ }
43
+ else {
44
+ c.h = c.targetH;
45
+ c.s = c.targetS;
46
+ c.b = c.targetB;
47
+ }
48
+ }
49
+ return changed;
50
+ }
51
+ /**
52
+ * Shortest angular distance on the hue circle (handles wrap-around).
53
+ */
54
+ function angleDelta(from, to) {
55
+ let d = ((to - from + 540) % 360) - 180;
56
+ return d;
57
+ }
58
+ function setCannonTarget(grid, index, h, s, b) {
59
+ const c = grid[index];
60
+ if (h !== undefined)
61
+ c.targetH = h;
62
+ if (s !== undefined)
63
+ c.targetS = s;
64
+ if (b !== undefined)
65
+ c.targetB = b;
66
+ }
67
+ function setAllTargets(grid, h, s, b) {
68
+ for (let i = 0; i < grid.length; i++) {
69
+ setCannonTarget(grid, i, h, s, b);
70
+ }
71
+ }
package/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { createGrid, tickGrid, setCannonTarget, setAllTargets, NUM_CANNONS, GRID_SIZE, DEFAULT_ALPHA } from './grid';
2
+ export { applyScene, scenes } from './scenes';
3
+ export type { CannonState, CannonTarget } from './grid';
4
+ export type { SceneColor, SceneGenerator } from './scenes';
package/index.js ADDED
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scenes = exports.applyScene = exports.DEFAULT_ALPHA = exports.GRID_SIZE = exports.NUM_CANNONS = exports.setAllTargets = exports.setCannonTarget = exports.tickGrid = exports.createGrid = void 0;
4
+ var grid_1 = require("./grid");
5
+ 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
+ Object.defineProperty(exports, "DEFAULT_ALPHA", { enumerable: true, get: function () { return grid_1.DEFAULT_ALPHA; } });
12
+ var scenes_1 = require("./scenes");
13
+ Object.defineProperty(exports, "applyScene", { enumerable: true, get: function () { return scenes_1.applyScene; } });
14
+ Object.defineProperty(exports, "scenes", { enumerable: true, get: function () { return scenes_1.scenes; } });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@wavegrid/simulator",
3
+ "version": "0.1.1",
4
+ "author": "Dan Lynch <pyramation@gmail.com>",
5
+ "description": "7×7 RGB grid simulator with smooth transitions",
6
+ "main": "index.js",
7
+ "module": "esm/index.js",
8
+ "types": "index.d.ts",
9
+ "homepage": "https://github.com/constructive-io/Illuminate",
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "directory": "dist"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/constructive-io/Illuminate"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/constructive-io/Illuminate/issues"
21
+ },
22
+ "scripts": {
23
+ "clean": "makage clean",
24
+ "prepack": "npm run build",
25
+ "build": "makage build",
26
+ "build:dev": "makage build --dev",
27
+ "lint": "ESLINT_USE_FLAT_CONFIG=false eslint src --fix",
28
+ "test": "jest --passWithNoTests",
29
+ "test:watch": "jest --watch",
30
+ "dev": "ts-node src/server.ts"
31
+ },
32
+ "keywords": [
33
+ "laser",
34
+ "osc",
35
+ "lighting",
36
+ "simulator",
37
+ "7x7"
38
+ ],
39
+ "dependencies": {
40
+ "ws": "^8.18.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/ws": "^8.5.13",
44
+ "makage": "^0.3.0"
45
+ },
46
+ "gitHead": "6f651b8d0dfbb6538c667dd3905ce6989ae18e46"
47
+ }
package/scenes.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { CannonTarget } from './grid';
2
+ export interface SceneColor {
3
+ h: number;
4
+ s: number;
5
+ b: number;
6
+ }
7
+ export type SceneGenerator = (index: number, total: number) => SceneColor;
8
+ export declare const scenes: Record<string, SceneGenerator>;
9
+ export declare function applyScene(grid: CannonTarget[], sceneName: string): void;
package/scenes.js ADDED
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scenes = void 0;
4
+ exports.applyScene = applyScene;
5
+ const grid_1 = require("./grid");
6
+ exports.scenes = {
7
+ civic: () => ({ h: 220, s: 90, b: 80 }),
8
+ pride: (i, total) => ({ h: Math.round((i / total) * 360), s: 90, b: 80 }),
9
+ gold: () => ({ h: 45, s: 95, b: 80 }),
10
+ white: () => ({ h: 0, s: 0, b: 80 }),
11
+ solstice: (i) => {
12
+ const row = Math.floor(i / 7);
13
+ const col = i % 7;
14
+ return { h: 40 + row * 5 + col * 4, s: 85, b: 80 };
15
+ },
16
+ ocean: (i) => {
17
+ const row = Math.floor(i / 7);
18
+ const col = i % 7;
19
+ return { h: 180 + row * 8 + col * 3, s: 75, b: 70 };
20
+ },
21
+ sunset: (i) => {
22
+ const row = Math.floor(i / 7);
23
+ return { h: 10 + row * 5, s: 90, b: 85 - row * 5 };
24
+ },
25
+ off: () => ({ h: 0, s: 0, b: 0 })
26
+ };
27
+ function applyScene(grid, sceneName) {
28
+ const generator = exports.scenes[sceneName];
29
+ if (!generator)
30
+ return;
31
+ for (let i = 0; i < grid_1.NUM_CANNONS; i++) {
32
+ const { h, s, b } = generator(i, grid_1.NUM_CANNONS);
33
+ (0, grid_1.setCannonTarget)(grid, i, h, s, b);
34
+ }
35
+ }
package/server.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import http from 'http';
2
+ import { CannonTarget } from './grid';
3
+ declare const grid: CannonTarget[];
4
+ declare const server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
5
+ export { server, grid };
package/server.js ADDED
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.grid = exports.server = void 0;
7
+ const http_1 = __importDefault(require("http"));
8
+ const ws_1 = require("ws");
9
+ const grid_1 = require("./grid");
10
+ const scenes_1 = require("./scenes");
11
+ const ui_1 = require("./ui");
12
+ const PORT = parseInt(process.env.PORT || '3000', 10);
13
+ const TICK_MS = 1000 / 60; // 60fps interpolation
14
+ const grid = (0, grid_1.createGrid)();
15
+ exports.grid = grid;
16
+ const server = http_1.default.createServer((_req, res) => {
17
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
18
+ res.end((0, ui_1.getHTML)());
19
+ });
20
+ exports.server = server;
21
+ const wss = new ws_1.WebSocketServer({ server });
22
+ function broadcastState() {
23
+ const payload = JSON.stringify({
24
+ type: 'state',
25
+ grid: grid.map(c => ({ h: c.h, s: c.s, b: c.b }))
26
+ });
27
+ wss.clients.forEach(client => {
28
+ if (client.readyState === ws_1.WebSocket.OPEN) {
29
+ client.send(payload);
30
+ }
31
+ });
32
+ }
33
+ wss.on('connection', (ws) => {
34
+ // Send initial state
35
+ ws.send(JSON.stringify({
36
+ type: 'state',
37
+ grid: grid.map(c => ({ h: c.h, s: c.s, b: c.b }))
38
+ }));
39
+ ws.on('message', (raw) => {
40
+ try {
41
+ const msg = JSON.parse(raw.toString());
42
+ handleMessage(msg);
43
+ }
44
+ catch (_e) {
45
+ // ignore malformed messages
46
+ }
47
+ });
48
+ });
49
+ function handleMessage(msg) {
50
+ switch (msg.type) {
51
+ case 'cannon':
52
+ (0, grid_1.setCannonTarget)(grid, msg.index, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
53
+ break;
54
+ case 'master_brightness':
55
+ (0, grid_1.setAllTargets)(grid, undefined, undefined, msg.value * 100);
56
+ break;
57
+ case 'scene':
58
+ if (msg.name && scenes_1.scenes[msg.name]) {
59
+ (0, scenes_1.applyScene)(grid, msg.name);
60
+ }
61
+ break;
62
+ case 'selection':
63
+ if (Array.isArray(msg.indices)) {
64
+ for (const idx of msg.indices) {
65
+ if (idx >= 0 && idx < grid.length) {
66
+ (0, grid_1.setCannonTarget)(grid, idx, msg.h ?? undefined, msg.s ?? undefined, msg.b ?? undefined);
67
+ }
68
+ }
69
+ }
70
+ break;
71
+ }
72
+ }
73
+ // Animation loop: tick interpolation and broadcast
74
+ setInterval(() => {
75
+ const changed = (0, grid_1.tickGrid)(grid);
76
+ if (changed) {
77
+ broadcastState();
78
+ }
79
+ }, TICK_MS);
80
+ server.listen(PORT, '0.0.0.0', () => {
81
+ console.log('');
82
+ console.log('╔══════════════════════════════════════════╗');
83
+ console.log('║ Illuminate · 7×7 Grid Simulator ║');
84
+ console.log('║ 49 virtual cannons ready ║');
85
+ console.log('╚══════════════════════════════════════════╝');
86
+ console.log('');
87
+ console.log(` → http://localhost:${PORT}`);
88
+ console.log('');
89
+ });
package/ui.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function getHTML(): string;
package/ui.js ADDED
@@ -0,0 +1,357 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getHTML = getHTML;
4
+ const scenes_1 = require("./scenes");
5
+ function getHTML() {
6
+ const sceneNames = Object.keys(scenes_1.scenes);
7
+ return `<!DOCTYPE html>
8
+ <html>
9
+ <head>
10
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
11
+ <title>Illuminate · 7×7 Simulator</title>
12
+ <style>
13
+ * { box-sizing: border-box; margin: 0; padding: 0; }
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
16
+ background: #0a0a0a; color: #eee; padding: 1rem;
17
+ min-height: 100vh;
18
+ }
19
+ h1 {
20
+ font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem;
21
+ color: #ccc; letter-spacing: 0.03em;
22
+ }
23
+ .master {
24
+ background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
25
+ padding: 1rem; margin-bottom: 1.25rem;
26
+ }
27
+ .section-title {
28
+ font-size: 0.7rem; color: #666; text-transform: uppercase;
29
+ letter-spacing: 0.08em; margin-bottom: 0.75rem;
30
+ }
31
+ .scene-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 0.75rem; }
32
+ .scene-btn {
33
+ padding: 6px 14px; border-radius: 20px; font-size: 0.75rem;
34
+ cursor: pointer; border: 1px solid #333; background: #1a1a1a;
35
+ color: #aaa; transition: all 0.2s;
36
+ }
37
+ .scene-btn:hover { border-color: #555; color: #ddd; }
38
+ .scene-btn.active { background: #1a3a6a; border-color: #3a6acc; color: #fff; }
39
+ .slider-row {
40
+ display: flex; align-items: center; gap: 10px; margin-bottom: 0.6rem;
41
+ }
42
+ .slider-row label {
43
+ font-size: 0.8rem; color: #777; min-width: 80px;
44
+ }
45
+ .slider-row input[type=range] {
46
+ flex: 1; height: 6px; -webkit-appearance: none; appearance: none;
47
+ background: #333; border-radius: 3px; outline: none;
48
+ }
49
+ .slider-row input[type=range]::-webkit-slider-thumb {
50
+ -webkit-appearance: none; width: 18px; height: 18px;
51
+ border-radius: 50%; background: #4a8cde; cursor: pointer;
52
+ }
53
+ .slider-row .val {
54
+ font-size: 0.8rem; font-weight: 500; min-width: 38px; text-align: right; color: #888;
55
+ }
56
+ .all-off {
57
+ width: 100%; padding: 10px; border-radius: 8px; margin-top: 0.5rem;
58
+ background: #1a0808; border: 1px solid #3a1515; color: #c44;
59
+ font-size: 0.85rem; font-weight: 500; cursor: pointer; text-align: center;
60
+ transition: background 0.2s;
61
+ }
62
+ .all-off:hover { background: #2a1010; }
63
+
64
+ .grid-section { margin-bottom: 1.25rem; }
65
+ .sel-actions { display: flex; gap: 6px; margin-bottom: 0.5rem; }
66
+ .sel-btn {
67
+ font-size: 0.7rem; padding: 5px 10px;
68
+ border: 1px solid #333; border-radius: 6px;
69
+ background: #1a1a1a; color: #aaa; cursor: pointer;
70
+ transition: all 0.15s;
71
+ }
72
+ .sel-btn:hover { border-color: #555; color: #ddd; }
73
+ .rc-btns { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 0.6rem; }
74
+ .rc-btn {
75
+ font-size: 0.65rem; padding: 4px 8px;
76
+ border: 1px solid #1a2a4a; border-radius: 5px;
77
+ background: #0e1520; color: #5a8abb; cursor: pointer;
78
+ transition: all 0.15s;
79
+ }
80
+ .rc-btn:hover { background: #152030; border-color: #2a4a7a; }
81
+
82
+ .grid {
83
+ display: grid;
84
+ grid-template-columns: repeat(7, 1fr);
85
+ gap: 4px;
86
+ max-width: 420px;
87
+ }
88
+ .cell {
89
+ aspect-ratio: 1; border-radius: 8px;
90
+ border: 1.5px solid #222; background: #111;
91
+ display: flex; flex-direction: column;
92
+ align-items: center; justify-content: center;
93
+ cursor: pointer; transition: border-color 0.15s, transform 0.1s;
94
+ position: relative;
95
+ }
96
+ .cell:active { transform: scale(0.95); }
97
+ .cell.selected { border-color: #4a8cde; }
98
+ .cell-beam {
99
+ width: 60%; height: 60%; border-radius: 50%;
100
+ transition: none; /* driven by animation frame */
101
+ }
102
+ .cell-num {
103
+ font-size: 7px; color: #444; margin-top: 2px;
104
+ position: absolute; bottom: 3px;
105
+ }
106
+
107
+ .panel {
108
+ background: #141414; border: 1px solid #2a2a2a; border-radius: 12px;
109
+ padding: 1rem; display: none; margin-bottom: 1rem;
110
+ }
111
+ .panel.visible { display: block; }
112
+ .panel-header {
113
+ display: flex; align-items: center; justify-content: space-between;
114
+ margin-bottom: 0.75rem;
115
+ }
116
+ .panel-title { font-size: 0.9rem; font-weight: 500; }
117
+ .done-btn {
118
+ font-size: 0.7rem; padding: 4px 10px;
119
+ border: 1px solid #333; border-radius: 6px;
120
+ background: #1a1a1a; color: #aaa; cursor: pointer;
121
+ }
122
+
123
+ .status {
124
+ font-size: 0.65rem; color: #444; text-align: center; padding-top: 0.5rem;
125
+ }
126
+ </style>
127
+ </head>
128
+ <body>
129
+
130
+ <h1>Illuminate · 7×7 Civic Center</h1>
131
+
132
+ <div class="master">
133
+ <div class="section-title">Scenes</div>
134
+ <div class="scene-row" id="scene-row">
135
+ ${sceneNames.map((name, i) => `<button class="scene-btn${i === 0 ? ' active' : ''}" data-scene="${name}">${name}</button>`).join('\n ')}
136
+ </div>
137
+ <div class="section-title" style="margin-top:0.75rem">Master</div>
138
+ <div class="slider-row">
139
+ <label>Brightness</label>
140
+ <input type="range" min="0" max="100" value="80" id="master-bright">
141
+ <span class="val" id="master-bright-val">80%</span>
142
+ </div>
143
+ <div class="all-off" id="all-off">All Off</div>
144
+ </div>
145
+
146
+ <div class="grid-section">
147
+ <div class="section-title">Cannon Grid — tap to select</div>
148
+ <div class="sel-actions">
149
+ <button class="sel-btn" id="sel-all">Select All</button>
150
+ <button class="sel-btn" id="sel-none">Clear</button>
151
+ </div>
152
+ <div class="rc-btns" id="rc-btns"></div>
153
+ <div class="grid" id="cannon-grid"></div>
154
+ </div>
155
+
156
+ <div class="panel" id="panel">
157
+ <div class="panel-header">
158
+ <span class="panel-title" id="panel-title">Cannon 1</span>
159
+ <button class="done-btn" id="done-btn">Done</button>
160
+ </div>
161
+ <div class="slider-row">
162
+ <label>Brightness</label>
163
+ <input type="range" min="0" max="100" value="80" id="panel-bright">
164
+ <span class="val" id="panel-bright-val">80%</span>
165
+ </div>
166
+ <div class="slider-row">
167
+ <label>Hue</label>
168
+ <input type="range" min="0" max="360" value="220" id="panel-hue">
169
+ <span class="val" id="panel-hue-val">220°</span>
170
+ </div>
171
+ <div class="slider-row">
172
+ <label>Saturation</label>
173
+ <input type="range" min="0" max="100" value="90" id="panel-sat">
174
+ <span class="val" id="panel-sat-val">90%</span>
175
+ </div>
176
+ </div>
177
+
178
+ <div class="status" id="status">Connecting...</div>
179
+
180
+ <script>
181
+ const NUM = 49;
182
+ const ws = new WebSocket('ws://' + location.host);
183
+ const status = document.getElementById('status');
184
+ const selected = new Set();
185
+
186
+ // Local display state (smoothly updated from server)
187
+ const display = Array.from({length: NUM}, () => ({ h: 220, s: 90, b: 80 }));
188
+
189
+ ws.onopen = () => { status.textContent = 'Connected · 49 cannons · smooth mode'; };
190
+ ws.onclose = () => { status.textContent = 'Disconnected — reload page'; };
191
+
192
+ ws.onmessage = (e) => {
193
+ const msg = JSON.parse(e.data);
194
+ if (msg.type === 'state') {
195
+ for (let i = 0; i < NUM; i++) {
196
+ display[i].h = msg.grid[i].h;
197
+ display[i].s = msg.grid[i].s;
198
+ display[i].b = msg.grid[i].b;
199
+ }
200
+ }
201
+ };
202
+
203
+ function send(data) {
204
+ if (ws.readyState === 1) ws.send(JSON.stringify(data));
205
+ }
206
+
207
+ function hslToHex(h, s, l) {
208
+ s /= 100; l /= 100;
209
+ const a = s * Math.min(l, 1 - l);
210
+ const f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); };
211
+ const r = Math.round(255 * f(0));
212
+ const g = Math.round(255 * f(8));
213
+ const b = Math.round(255 * f(4));
214
+ return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
215
+ }
216
+
217
+ // Build grid
218
+ const gridEl = document.getElementById('cannon-grid');
219
+ for (let i = 0; i < NUM; i++) {
220
+ const cell = document.createElement('div');
221
+ cell.className = 'cell';
222
+ cell.id = 'cell-' + i;
223
+ cell.innerHTML = '<div class="cell-beam" id="beam-' + i + '"></div><span class="cell-num">' + (i + 1) + '</span>';
224
+ cell.addEventListener('click', () => toggleSelect(i));
225
+ gridEl.appendChild(cell);
226
+ }
227
+
228
+ // Build row/col buttons
229
+ const rcEl = document.getElementById('rc-btns');
230
+ for (let r = 0; r < 7; r++) {
231
+ const btn = document.createElement('button');
232
+ btn.className = 'rc-btn';
233
+ btn.textContent = 'R' + (r + 1);
234
+ btn.addEventListener('click', () => {
235
+ for (let c = 0; c < 7; c++) selectOn(r * 7 + c);
236
+ updatePanel();
237
+ });
238
+ rcEl.appendChild(btn);
239
+ }
240
+ for (let c = 0; c < 7; c++) {
241
+ const btn = document.createElement('button');
242
+ btn.className = 'rc-btn';
243
+ btn.textContent = 'C' + (c + 1);
244
+ btn.addEventListener('click', () => {
245
+ for (let r = 0; r < 7; r++) selectOn(r * 7 + c);
246
+ updatePanel();
247
+ });
248
+ rcEl.appendChild(btn);
249
+ }
250
+
251
+ function selectOn(idx) {
252
+ selected.add(idx);
253
+ document.getElementById('cell-' + idx).classList.add('selected');
254
+ }
255
+
256
+ function toggleSelect(idx) {
257
+ if (selected.has(idx)) {
258
+ selected.delete(idx);
259
+ document.getElementById('cell-' + idx).classList.remove('selected');
260
+ } else {
261
+ selected.add(idx);
262
+ document.getElementById('cell-' + idx).classList.add('selected');
263
+ }
264
+ updatePanel();
265
+ }
266
+
267
+ function updatePanel() {
268
+ const panel = document.getElementById('panel');
269
+ const title = document.getElementById('panel-title');
270
+ if (selected.size === 0) {
271
+ panel.classList.remove('visible');
272
+ } else {
273
+ panel.classList.add('visible');
274
+ title.textContent = selected.size === 1
275
+ ? 'Cannon ' + ([...selected][0] + 1)
276
+ : selected.size + ' cannons selected';
277
+ }
278
+ }
279
+
280
+ // Selection controls
281
+ document.getElementById('sel-all').addEventListener('click', () => {
282
+ for (let i = 0; i < NUM; i++) selectOn(i);
283
+ updatePanel();
284
+ });
285
+ document.getElementById('sel-none').addEventListener('click', () => {
286
+ selected.forEach(idx => document.getElementById('cell-' + idx).classList.remove('selected'));
287
+ selected.clear();
288
+ updatePanel();
289
+ });
290
+ document.getElementById('done-btn').addEventListener('click', () => {
291
+ selected.forEach(idx => document.getElementById('cell-' + idx).classList.remove('selected'));
292
+ selected.clear();
293
+ updatePanel();
294
+ });
295
+
296
+ // Master brightness
297
+ document.getElementById('master-bright').addEventListener('input', function() {
298
+ const v = parseInt(this.value);
299
+ document.getElementById('master-bright-val').textContent = v + '%';
300
+ send({ type: 'master_brightness', value: v / 100 });
301
+ });
302
+
303
+ // All off
304
+ document.getElementById('all-off').addEventListener('click', () => {
305
+ document.getElementById('master-bright').value = 0;
306
+ document.getElementById('master-bright-val').textContent = '0%';
307
+ send({ type: 'master_brightness', value: 0 });
308
+ });
309
+
310
+ // Per-cannon controls
311
+ document.getElementById('panel-bright').addEventListener('input', function() {
312
+ const v = parseInt(this.value);
313
+ document.getElementById('panel-bright-val').textContent = v + '%';
314
+ send({ type: 'selection', indices: [...selected], b: v });
315
+ });
316
+ document.getElementById('panel-hue').addEventListener('input', function() {
317
+ const v = parseInt(this.value);
318
+ document.getElementById('panel-hue-val').textContent = v + '°';
319
+ send({ type: 'selection', indices: [...selected], h: v });
320
+ });
321
+ document.getElementById('panel-sat').addEventListener('input', function() {
322
+ const v = parseInt(this.value);
323
+ document.getElementById('panel-sat-val').textContent = v + '%';
324
+ send({ type: 'selection', indices: [...selected], s: v });
325
+ });
326
+
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
+ });
335
+
336
+ // Render loop — updates beams from display state
337
+ function render() {
338
+ for (let i = 0; i < NUM; i++) {
339
+ const c = display[i];
340
+ const beam = document.getElementById('beam-' + i);
341
+ if (beam) {
342
+ const lightness = Math.max(5, c.b * 0.5);
343
+ beam.style.background = c.b < 1
344
+ ? '#111'
345
+ : hslToHex(c.h, c.s, lightness);
346
+ beam.style.boxShadow = c.b > 20
347
+ ? '0 0 ' + (c.b * 0.15) + 'px ' + hslToHex(c.h, c.s, lightness)
348
+ : 'none';
349
+ }
350
+ }
351
+ requestAnimationFrame(render);
352
+ }
353
+ requestAnimationFrame(render);
354
+ </script>
355
+ </body>
356
+ </html>`;
357
+ }