@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 +7 -0
- package/README.md +37 -0
- package/esm/grid.js +64 -0
- package/esm/index.js +2 -0
- package/esm/scenes.js +31 -0
- package/esm/server.js +82 -0
- package/esm/ui.js +354 -0
- package/grid.d.ts +26 -0
- package/grid.js +71 -0
- package/index.d.ts +4 -0
- package/index.js +14 -0
- package/package.json +47 -0
- package/scenes.d.ts +9 -0
- package/scenes.js +35 -0
- package/server.d.ts +5 -0
- package/server.js +89 -0
- package/ui.d.ts +1 -0
- package/ui.js +357 -0
package/LICENSE
ADDED
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
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
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
|
+
}
|