From 6eb5846b0cb1f30e15b3fd5299a3df564c0fba22 Mon Sep 17 00:00:00 2001 From: Sam Mellor Date: Fri, 5 Sep 2025 16:27:25 -0700 Subject: [PATCH 1/9] init effectsim --- effectsim/SPEC.md | 230 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 effectsim/SPEC.md diff --git a/effectsim/SPEC.md b/effectsim/SPEC.md new file mode 100644 index 0000000..3fe515b --- /dev/null +++ b/effectsim/SPEC.md @@ -0,0 +1,230 @@ +# LED Matrix Simulator — MVP Implementation Spec + +**Goal:** A browser-based MVP that simulates a configurable LED matrix at high frame rates (target: 120+ FPS at 150×200) using a Web Component that renders into a ``. No frameworks (no React/Tailwind). Plain TypeScript and HTML. The MVP consumes pixel frames from a WebSocket connection. + +--- + +## 1. Scope & Non-Goals + +### In Scope (MVP) + +* A custom element `` implemented as a Web Component. +* Renders to an internal `` using Canvas 2D API (with an upgrade path to WebGL/WebGPU later). +* Configurable matrix geometry: total columns/rows, panel grid (horizontal/vertical panel count), per-panel size, wiring pattern. +* **Single data source:** WebSocket client. Receives binary RGB frames; drop-frame strategy for backpressure. +* Performance instrumentation: FPS overlay, dropped frames, render time. +* Deterministic coordinate mapping from logical LED indices → canvas pixels. +* Resize-aware rendering (component expands/contracts with layout). + +### Out of Scope (MVP) + +* Procedural/built-in effects (none included). +* Server-side WebSocket implementation (assumed external). +* Persistent settings, exporting video, advanced color management. +* Multi-tab sync, mobile optimizations. + +--- + +## 2. User Experience + +### Custom Element + +```html + +``` + +#### Attributes / Properties + +* `panels-x`, `panels-y` (number): panel grid dimensions. +* `panel-cols`, `panel-rows` (number): per-panel resolution. +* `wiring` ("row-major" | "column-major" | "serpentine"): logical wiring traversal within each panel. +* `pixel-size` (number | "auto"): logical LED square size in CSS pixels. `auto` scales to component size. +* `gap` (number): gap between LEDs in CSS pixels (visual only). +* `fps-cap` (number): 0 = uncapped (follows display refresh). Otherwise, caps update rate (e.g., 120). +* `ws-url` (string): URL to connect. + +All attributes reflect to properties with matching camelCase names (e.g., `panelsX`). + +#### CSS Custom Properties (visual tune) + +* `--led-radius`: border-radius of each LED (e.g., `35%`). +* `--led-off-bg`: background color behind LEDs. + +#### Events (CustomEvent) + +* `ready` → `{ detail: { cols, rows } }` +* `stats` → `{ detail: { fps: number, dropped: number, renderMs: number } }` (emitted \~once/second). +* `socketopen` / `socketclose` / `socketerror` (WebSocket lifecycle). + +#### Public Methods (Element instance) + +* `pushFrame(rgb: Uint8Array, opts?: { cols?: number; rows?: number; })`: Submit one RGB888 frame (length must be `cols*rows*3`). Drops previous queued frame and replaces it. +* `resize()` → Recalculate internal layout (called automatically by `ResizeObserver`). + +--- + +## 3. Rendering Model + +### Canvas Strategy (Canvas 2D) + +* Maintain a single `ImageData` buffer sized to `cols × rows`. +* Maintain a backing `Uint8ClampedArray` (`img.data`) for RGB, set `A=255` once upfront for all pixels. +* Each frame: + + 1. Fill/replace RGB bytes in the backing buffer from the last received socket frame. + 2. `putImageData(img, 0, 0)` onto an **offscreen canvas** sized to logical resolution. + 3. Scale the offscreen canvas to the visible canvas via `drawImage` with `imageSmoothingEnabled=false` to keep crisp LED squares. +* Optional LED gaps/rounded corners are simulated by drawing onto an intermediate mask if `gap>0` (MVP shortcut: draw a scaled bitmap with nearest-neighbor and draw a grid overlay to suggest gaps; true per‑pixel masking can be a post‑MVP enhancement). + +### Coordinate Mapping + +* Precompute a **LUT** from logical index `(r, c)` → linear buffer offset `i = (r*cols + c)*4` to minimize branching per frame. +* If panelization is provided, compute `(panelR, panelC, inR, inC)` and apply wiring: + + * `row-major`: `i = (r*cols + c)` + * `column-major`: `i = (c*rows + r)` + * `serpentine`: rows (or columns) alternate direction within each panel. +* The LUT is rebuilt when geometry/wiring changes. + +### Timing & Loop + +* Use `requestAnimationFrame` (RAF). Maintain `simTime` using `performance.now()`; apply `fps-cap` by skipping frames when needed. +* Track `frameCount`, compute FPS every \~1000 ms and dispatch `stats`. +* **Backpressure policy:** keep a single-slot incoming frame buffer (atomic swap); renderer consumes latest available; older frames are dropped. + +--- + +## 4. Data Source — WebSocket Client + +* Create a single `WebSocket` connection using `ws-url`. +* **Binary frame protocol (MVP simple):** + + * Dimensions are fixed by component props. + * Each message is exactly `cols*rows*3` bytes of RGB888. + * Any other message length is ignored (increment `dropped`). +* **Optional text control messages:** JSON for control (future use). +* Auto-reconnect every 2s on close (with cap 10s). Expose events. + +--- + +## 5. Performance Budget & Techniques + +* Target: **120 FPS** at **200×150** (30k pixels) on a recent laptop. +* Avoid allocations per frame; reuse buffers. +* Pre-fill alpha channel once. +* Use a single offscreen `ImageBitmap` or offscreen canvas for nearest-neighbor scaling. +* Set `ctx.imageSmoothingEnabled = false` on the visible canvas. +* Recompute LUTs only on geometry/wiring change. + +--- + +## 6. Public API Details + +### Observed Attributes → Reaction + +* Changing geometry (`panels-x`, `panels-y`, `panel-cols`, `panel-rows`, `wiring`) triggers LUT rebuild, buffer reallocation. +* Changing `ws-url` connects/disconnects WS. +* `gap`, `pixel-size`, `fps-cap` are applied next frame. + +### Methods + +* `pushFrame(rgb)` + + * Accept `Uint8Array`/`Uint8ClampedArray` length `cols*rows*3`. + * Copies into a single-slot buffer (no queue growth). Copy cost is amortized; consider `set` on a pre-sized buffer. + +### Errors + +* Throw `RangeError` on invalid geometry or frame size. +* Dispatch `socketerror` on WS errors. + +--- + +## 7. File Layout + +``` +/ (repo root) +├─ index.html # demo page +├─ src/ +│ ├─ led-matrix.ts # Web Component +│ ├─ util/ +│ │ ├─ lut.ts # coordinate mapping + wiring +│ │ ├─ fps.ts # stats helpers +│ └─ types.d.ts # shared interfaces +├─ styles/ +│ └─ component.css # shadow DOM styles (minimal) +├─ tsconfig.json +├─ package.json +└─ README.md # quick start +``` + +--- + +## 8. Application Page (`index.html`) + +* Includes the compiled `led-matrix.js` and registers the element. +* Hosts a small control bar (plain HTML) to tweak geometry and WebSocket URL. +* Shows a live stats overlay: FPS and render ms. + +--- + +## 9. Build & Tooling + +* **TypeScript only** (no bundler required for MVP). Output ES2024 modules to `dist/`. +* `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2024", + "module": "ES2024", + "moduleResolution": "Bundler", + "lib": ["DOM", "ES2024"], + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src/**/*"] +} +``` + +* `package.json` scripts: + +```json +{ + "scripts": { + "build": "tsc -p .", + "dev": "tsc -w" + } +} +``` + +* Serve `index.html` via any static server (e.g., `pnpx http-server`). + +--- + +## 10. Testing & Acceptance Criteria + +### Manual Tests + +* Default demo loads and animates smoothly on a modern desktop. +* Changing `panels-x`/`panels-y`/`panel-cols`/`panel-rows` live rebuilds buffers without leaks. +* `ws-url` connects to a local WS and renders frames; incorrect lengths are ignored. +* FPS overlay reports ≥120 fps at 200×150 on a recent laptop. + +### Edge Cases + +* WebSocket disconnect → auto-reconnect; UI events emitted. +* Resize container smaller than logical aspect → maintain pixel aspect (letterbox inside component). From a157859dd508399d4b101b5c5beb36689181d2b6 Mon Sep 17 00:00:00 2001 From: Sam Mellor Date: Fri, 5 Sep 2025 16:49:31 -0700 Subject: [PATCH 2/9] mvp --- effectsim/.gitignore | 1 + effectsim/README.md | 118 ++++++++ effectsim/index.html | 342 +++++++++++++++++++++++ effectsim/package.json | 20 ++ effectsim/pnpm-lock.yaml | 57 ++++ effectsim/server/test-server.js | 150 ++++++++++ effectsim/src/led-matrix.ts | 478 ++++++++++++++++++++++++++++++++ effectsim/src/types.d.ts | 61 ++++ effectsim/src/util/fps.ts | 142 ++++++++++ effectsim/src/util/lut.ts | 190 +++++++++++++ effectsim/styles/component.css | 26 ++ effectsim/tsconfig.json | 17 ++ 12 files changed, 1602 insertions(+) create mode 100644 effectsim/.gitignore create mode 100644 effectsim/README.md create mode 100644 effectsim/index.html create mode 100644 effectsim/package.json create mode 100644 effectsim/pnpm-lock.yaml create mode 100644 effectsim/server/test-server.js create mode 100644 effectsim/src/led-matrix.ts create mode 100644 effectsim/src/types.d.ts create mode 100644 effectsim/src/util/fps.ts create mode 100644 effectsim/src/util/lut.ts create mode 100644 effectsim/styles/component.css create mode 100644 effectsim/tsconfig.json diff --git a/effectsim/.gitignore b/effectsim/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/effectsim/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/effectsim/README.md b/effectsim/README.md new file mode 100644 index 0000000..65c7939 --- /dev/null +++ b/effectsim/README.md @@ -0,0 +1,118 @@ +# LED Matrix Simulator + +High-performance LED matrix simulator Web Component targeting 120+ FPS at 150×200 resolution. + +## Quick Start + +1. **Install dependencies:** + ```bash + pnpm install + ``` + +2. **Build the project:** + ```bash + pnpm run build + ``` + +3. **Start the WebSocket test server:** + ```bash + pnpm run server + ``` + +4. **Start the HTTP server:** + ```bash + pnpm run serve + ``` + +5. **Open in browser:** + - Navigate to http://localhost:8080 + - The demo should automatically connect to the WebSocket server and display a moving rainbow pattern + +## Usage + +### Basic HTML Integration + +```html + + +``` + +### Configuration Attributes + +- `panels-x`, `panels-y`: Panel grid dimensions +- `panel-cols`, `panel-rows`: Resolution per panel +- `wiring`: "serpentine" | "row-major" | "column-major" +- `pixel-size`: Size in CSS pixels or "auto" +- `gap`: Gap between LEDs in pixels +- `fps-cap`: FPS limit (0 = uncapped) +- `ws-url`: WebSocket server URL + +### JavaScript API + +```javascript +const matrix = document.querySelector('led-matrix'); + +// Push a frame manually +const rgbData = new Uint8Array(cols * rows * 3); +// ... fill with RGB888 data +matrix.pushFrame(rgbData); + +// Listen to events +matrix.addEventListener('ready', (e) => { + console.log(`Matrix ready: ${e.detail.cols}×${e.detail.rows}`); +}); + +matrix.addEventListener('stats', (e) => { + console.log(`FPS: ${e.detail.fps}, Render: ${e.detail.renderMs}ms`); +}); +``` + +## Architecture + +- **Web Component**: Framework-agnostic custom element +- **Canvas 2D**: High-performance rendering with offscreen buffer +- **WebSocket Client**: Real-time frame streaming with drop-frame backpressure +- **Coordinate Mapping**: Pre-computed LUT for panel/wiring configurations +- **Performance Monitoring**: Built-in FPS counter and render time tracking + +## WebSocket Protocol + +Send binary messages containing exactly `cols × rows × 3` bytes of RGB888 data: + +```javascript +// Example: 84×112 matrix = 28,224 bytes +const frame = new Uint8Array(84 * 112 * 3); +// Fill with RGB data... +websocket.send(frame); +``` + +## Performance + +- **Target**: 120+ FPS at 200×150 (30k pixels) +- **Optimizations**: Pre-computed coordinate mapping, buffer reuse, atomic frame swapping +- **Monitoring**: Real-time FPS, render time, and dropped frame statistics + +## Files + +``` +├── src/ +│ ├── led-matrix.ts # Main Web Component +│ ├── util/ +│ │ ├── lut.ts # Coordinate mapping +│ │ └── fps.ts # Performance monitoring +│ └── types.d.ts # Type definitions +├── server/ +│ └── test-server.js # WebSocket test server +├── index.html # Demo page +└── dist/ # Compiled JavaScript +``` + +## License + +MIT \ No newline at end of file diff --git a/effectsim/index.html b/effectsim/index.html new file mode 100644 index 0000000..acf58b4 --- /dev/null +++ b/effectsim/index.html @@ -0,0 +1,342 @@ + + + + + + LED Matrix Simulator + + + +
+

LED Matrix Simulator

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+

Performance

+
+ FPS: + 0.0 +
+
+ Render: + 0.00ms +
+
+ Dropped: + 0 +
+
+ Resolution: + 0×0 +
+
+ Connection: +
+
+ Disconnected +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/effectsim/package.json b/effectsim/package.json new file mode 100644 index 0000000..5af2fd3 --- /dev/null +++ b/effectsim/package.json @@ -0,0 +1,20 @@ +{ + "name": "led-matrix-simulator", + "version": "1.0.0", + "description": "High-performance LED matrix simulator Web Component", + "type": "module", + "main": "dist/led-matrix.js", + "scripts": { + "build": "tsc -p .", + "dev": "tsc -w", + "serve": "pnpx http-server -c-1 -p 8080", + "server": "node server/test-server.js" + }, + "devDependencies": { + "typescript": "^5.9.0", + "@types/node": "^24.0.0" + }, + "dependencies": { + "ws": "^8.18.3" + } +} \ No newline at end of file diff --git a/effectsim/pnpm-lock.yaml b/effectsim/pnpm-lock.yaml new file mode 100644 index 0000000..1e196ec --- /dev/null +++ b/effectsim/pnpm-lock.yaml @@ -0,0 +1,57 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ws: + specifier: ^8.18.3 + version: 8.18.3 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.3.1 + typescript: + specifier: ^5.9.0 + version: 5.9.2 + +packages: + + '@types/node@24.3.1': + resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@types/node@24.3.1': + dependencies: + undici-types: 7.10.0 + + typescript@5.9.2: {} + + undici-types@7.10.0: {} + + ws@8.18.3: {} diff --git a/effectsim/server/test-server.js b/effectsim/server/test-server.js new file mode 100644 index 0000000..d0872e1 --- /dev/null +++ b/effectsim/server/test-server.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import { WebSocketServer } from 'ws'; + +const PORT = 9002; +const DEFAULT_COLS = 84; // 3 panels × 28 cols +const DEFAULT_ROWS = 112; // 4 panels × 28 rows + +class TestServer { + constructor(port = PORT) { + this.port = port; + this.wss = null; + this.clients = new Set(); + this.animationId = null; + this.frameCount = 0; + this.startTime = Date.now(); + } + + start() { + this.wss = new WebSocketServer({ + port: this.port, + perMessageDeflate: false // Disable compression for better performance + }); + + this.wss.on('connection', (ws, req) => { + console.log(`Client connected from ${req.socket.remoteAddress}`); + this.clients.add(ws); + + ws.on('close', () => { + console.log('Client disconnected'); + this.clients.delete(ws); + + // Stop animation if no clients + if (this.clients.size === 0 && this.animationId) { + clearInterval(this.animationId); + this.animationId = null; + console.log('Animation stopped - no clients'); + } + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + this.clients.delete(ws); + }); + + // Start animation if first client + if (this.clients.size === 1 && !this.animationId) { + this.startAnimation(); + } + }); + + console.log(`LED Matrix Test Server running on ws://localhost:${this.port}`); + console.log(`Default matrix: ${DEFAULT_COLS}×${DEFAULT_ROWS} (${DEFAULT_COLS * DEFAULT_ROWS} pixels)`); + } + + startAnimation() { + console.log('Starting animation...'); + this.frameCount = 0; + this.startTime = Date.now(); + + // Target ~60 FPS for test data + this.animationId = setInterval(() => { + this.broadcastFrame(); + }, 1000 / 60); + } + + broadcastFrame() { + if (this.clients.size === 0) return; + + const cols = DEFAULT_COLS; + const rows = DEFAULT_ROWS; + const frame = this.generateTestPattern(cols, rows, this.frameCount); + + this.clients.forEach(client => { + if (client.readyState === 1) { // WebSocket.OPEN + client.send(frame); + } + }); + + this.frameCount++; + + // Log stats every 5 seconds + if (this.frameCount % 300 === 0) { + const elapsed = (Date.now() - this.startTime) / 1000; + const fps = this.frameCount / elapsed; + console.log(`Sent ${this.frameCount} frames (${fps.toFixed(1)} FPS avg) to ${this.clients.size} client(s)`); + } + } + + generateTestPattern(cols, rows, frameNum) { + const buffer = new Uint8Array(cols * rows * 3); + const time = frameNum * 0.1; + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const idx = (y * cols + x) * 3; + + // Create moving rainbow pattern + const hue = (x / cols + y / rows + time) % 1; + const [r, g, b] = this.hsvToRgb(hue, 1, 0.8); + + buffer[idx] = Math.round(r * 255); + buffer[idx + 1] = Math.round(g * 255); + buffer[idx + 2] = Math.round(b * 255); + } + } + + return buffer; + } + + // HSV to RGB conversion for rainbow effects + hsvToRgb(h, s, v) { + const c = v * s; + const x = c * (1 - Math.abs((h * 6) % 2 - 1)); + const m = v - c; + + let r, g, b; + if (h < 1/6) [r, g, b] = [c, x, 0]; + else if (h < 2/6) [r, g, b] = [x, c, 0]; + else if (h < 3/6) [r, g, b] = [0, c, x]; + else if (h < 4/6) [r, g, b] = [0, x, c]; + else if (h < 5/6) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + + return [r + m, g + m, b + m]; + } + + stop() { + if (this.animationId) { + clearInterval(this.animationId); + this.animationId = null; + } + + if (this.wss) { + this.wss.close(); + } + + console.log('Test server stopped'); + } +} + +// Handle graceful shutdown +const server = new TestServer(); +process.on('SIGINT', () => { + console.log('\nShutting down gracefully...'); + server.stop(); + process.exit(0); +}); + +server.start(); \ No newline at end of file diff --git a/effectsim/src/led-matrix.ts b/effectsim/src/led-matrix.ts new file mode 100644 index 0000000..10bcc06 --- /dev/null +++ b/effectsim/src/led-matrix.ts @@ -0,0 +1,478 @@ +// LED Matrix Simulator Web Component + +import { CoordinateLUT } from './util/lut.js'; +import { FPSCounter, FPSLimiter } from './util/fps.js'; +import type { + MatrixConfig, + MatrixDimensions, + FrameBuffer, + WiringPattern, + ReadyEventDetail, + StatsEventDetail, + ComponentState +} from './types.d.ts'; + +export class LEDMatrix extends HTMLElement { + // Configuration + private config: MatrixConfig; + private lut: CoordinateLUT; + + // Canvas and rendering + private canvas!: HTMLCanvasElement; + private ctx!: CanvasRenderingContext2D; + private offscreenCanvas!: OffscreenCanvas; + private offscreenCtx!: OffscreenCanvasRenderingContext2D; + private imageData!: ImageData; + private backBuffer!: Uint8ClampedArray; + + // Frame handling + private currentFrame: FrameBuffer | null = null; + private pendingFrame: FrameBuffer | null = null; + + // Performance monitoring + private fpsCounter: FPSCounter; + private fpsLimiter: FPSLimiter; + private animationId: number = 0; + + // WebSocket + private ws: WebSocket | null = null; + private wsUrl: string = ''; + private reconnectTimer: number = 0; + private reconnectDelay: number = 2000; + private maxReconnectDelay: number = 10000; + + // Component state + private state: ComponentState; + private resizeObserver: ResizeObserver; + + // Observed attributes + static get observedAttributes() { + return [ + 'panels-x', 'panels-y', + 'panel-cols', 'panel-rows', + 'wiring', 'pixel-size', 'gap', + 'fps-cap', 'ws-url' + ]; + } + + constructor() { + super(); + + // Initialize state + this.state = { + initialized: false, + connected: false, + rendering: false + }; + + // Default configuration + this.config = { + panelsX: 3, + panelsY: 4, + panelCols: 28, + panelRows: 28, + wiring: 'serpentine', + pixelSize: 'auto', + gap: 1, + fpsCap: 0 + }; + + // Initialize utilities + this.lut = new CoordinateLUT(this.config); + this.fpsCounter = new FPSCounter(); + this.fpsLimiter = new FPSLimiter(this.config.fpsCap); + + // Create shadow DOM + this.attachShadow({ mode: 'open' }); + + // Initialize resize observer + this.resizeObserver = new ResizeObserver(() => { + this.resize(); + }); + + this.initializeDOM(); + } + + connectedCallback() { + console.log('LED Matrix component connected'); + + // Start observing resize + this.resizeObserver.observe(this); + + // Initialize from attributes + this.updateConfigFromAttributes(); + this.initialize(); + + // Connect WebSocket if URL provided + if (this.wsUrl) { + this.connectWebSocket(); + } + } + + disconnectedCallback() { + console.log('LED Matrix component disconnected'); + + this.cleanup(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (oldValue === newValue) return; + + console.log(`Attribute ${name} changed: ${oldValue} → ${newValue}`); + + // Update configuration + this.updateConfigFromAttributes(); + + // Handle specific changes + if (name === 'ws-url') { + if (this.ws) { + this.disconnectWebSocket(); + } + if (newValue) { + this.connectWebSocket(); + } + } else if (['panels-x', 'panels-y', 'panel-cols', 'panel-rows', 'wiring'].includes(name)) { + // Geometry or wiring changed - reinitialize + this.initialize(); + } else if (name === 'fps-cap') { + this.fpsLimiter.setTargetFPS(this.config.fpsCap); + } + } + + // Public API methods + + /** + * Push a frame for rendering + */ + pushFrame(rgb: Uint8Array, opts?: { cols?: number; rows?: number }): void { + const dims = this.lut.getDimensions(); + const expectedLength = dims.totalPixels * 3; + + if (rgb.length !== expectedLength) { + console.error(`Frame size mismatch: expected ${expectedLength}, got ${rgb.length}`); + this.fpsCounter.dropFrame(); + return; + } + + // Create frame buffer + const frame: FrameBuffer = { + data: new Uint8ClampedArray(rgb.buffer.slice(rgb.byteOffset, rgb.byteOffset + rgb.byteLength)), + cols: dims.cols, + rows: dims.rows, + timestamp: performance.now() + }; + + // Atomic swap - replace any pending frame + this.pendingFrame = frame; + } + + /** + * Manually trigger resize recalculation + */ + resize(): void { + if (!this.canvas || !this.state.initialized) return; + + const rect = this.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + // Update canvas display size + this.canvas.style.width = '100%'; + this.canvas.style.height = '100%'; + + // Calculate actual pixel dimensions + const devicePixelRatio = window.devicePixelRatio || 1; + const displayWidth = rect.width * devicePixelRatio; + const displayHeight = rect.height * devicePixelRatio; + + // Set canvas resolution + this.canvas.width = displayWidth; + this.canvas.height = displayHeight; + + // Scale context back to logical pixels + this.ctx.scale(devicePixelRatio, devicePixelRatio); + + // Disable image smoothing for crisp pixels + this.ctx.imageSmoothingEnabled = false; + + console.log(`Canvas resized: ${displayWidth}×${displayHeight} (ratio: ${devicePixelRatio})`); + } + + // Private methods + + private initializeDOM(): void { + if (!this.shadowRoot) return; + + // Create canvas + this.canvas = document.createElement('canvas'); + this.canvas.style.display = 'block'; + this.canvas.style.width = '100%'; + this.canvas.style.height = '100%'; + this.canvas.style.imageRendering = 'pixelated'; + + // Get context + this.ctx = this.canvas.getContext('2d')!; + if (!this.ctx) { + throw new Error('Failed to get 2D canvas context'); + } + + // Add to shadow DOM + this.shadowRoot.appendChild(this.canvas); + + // Load CSS + const style = document.createElement('style'); + style.textContent = ` + :host { + display: block; + width: 100%; + height: 100%; + background: var(--led-off-bg, #111); + } + canvas { + width: 100%; + height: 100%; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + } + `; + this.shadowRoot.appendChild(style); + } + + private updateConfigFromAttributes(): void { + this.config.panelsX = parseInt(this.getAttribute('panels-x') || '3'); + this.config.panelsY = parseInt(this.getAttribute('panels-y') || '4'); + this.config.panelCols = parseInt(this.getAttribute('panel-cols') || '28'); + this.config.panelRows = parseInt(this.getAttribute('panel-rows') || '28'); + this.config.wiring = (this.getAttribute('wiring') || 'serpentine') as WiringPattern; + + const pixelSize = this.getAttribute('pixel-size'); + this.config.pixelSize = pixelSize === 'auto' ? 'auto' : parseFloat(pixelSize || 'auto') || 'auto'; + + this.config.gap = parseFloat(this.getAttribute('gap') || '1'); + this.config.fpsCap = parseInt(this.getAttribute('fps-cap') || '0'); + this.wsUrl = this.getAttribute('ws-url') || ''; + } + + private initialize(): void { + console.log('Initializing LED Matrix...', this.config); + + // Update coordinate mapping + this.lut.updateConfig(this.config); + const dims = this.lut.getDimensions(); + + // Create offscreen canvas for logical resolution + this.offscreenCanvas = new OffscreenCanvas(dims.cols, dims.rows); + this.offscreenCtx = this.offscreenCanvas.getContext('2d')!; + + // Create ImageData buffer + this.imageData = this.offscreenCtx.createImageData(dims.cols, dims.rows); + this.backBuffer = this.imageData.data; + + // Pre-fill alpha channel to 255 (opaque) + for (let i = 3; i < this.backBuffer.length; i += 4) { + this.backBuffer[i] = 255; + } + + // Reset performance counters + this.fpsCounter.reset(); + this.fpsLimiter.setTargetFPS(this.config.fpsCap); + + // Start render loop + this.startRenderLoop(); + + // Update state + this.state.initialized = true; + + // Dispatch ready event + const readyEvent = new CustomEvent('ready', { + detail: { cols: dims.cols, rows: dims.rows } + }); + this.dispatchEvent(readyEvent); + + console.log(`LED Matrix initialized: ${dims.cols}×${dims.rows} (${dims.totalPixels} pixels)`); + } + + private startRenderLoop(): void { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + this.state.rendering = true; + + const render = (timestamp: number) => { + if (!this.state.rendering) return; + + // Check FPS limiting + if (this.fpsLimiter.shouldRender(timestamp)) { + this.renderFrame(); + } + + // Update stats periodically + if (this.fpsCounter.shouldUpdateStats()) { + const stats = this.fpsCounter.getStats(); + const statsEvent = new CustomEvent('stats', { + detail: stats + }); + this.dispatchEvent(statsEvent); + } + + this.animationId = requestAnimationFrame(render); + }; + + this.animationId = requestAnimationFrame(render); + } + + private renderFrame(): void { + const renderStart = this.fpsCounter.startFrame(); + + try { + // Swap frame buffers atomically + if (this.pendingFrame) { + this.currentFrame = this.pendingFrame; + this.pendingFrame = null; + } + + // Render current frame + if (this.currentFrame) { + this.updateImageData(this.currentFrame); + this.drawToCanvas(); + } + + this.fpsCounter.endFrame(renderStart); + + } catch (error) { + console.error('Render error:', error); + this.fpsCounter.dropFrame(); + } + } + + private updateImageData(frame: FrameBuffer): void { + const dims = this.lut.getDimensions(); + + // Copy RGB data to ImageData buffer using coordinate mapping + for (let row = 0; row < dims.rows; row++) { + for (let col = 0; col < dims.cols; col++) { + const bufferOffset = this.lut.getBufferOffset(row, col); + if (bufferOffset === -1) continue; + + const srcIndex = (row * dims.cols + col) * 3; + const dstIndex = bufferOffset; + + // Copy RGB (alpha already set to 255) + this.backBuffer[dstIndex] = frame.data[srcIndex]; // R + this.backBuffer[dstIndex + 1] = frame.data[srcIndex + 1]; // G + this.backBuffer[dstIndex + 2] = frame.data[srcIndex + 2]; // B + } + } + } + + private drawToCanvas(): void { + if (!this.ctx || !this.canvas) return; + + // Put image data to offscreen canvas + this.offscreenCtx.putImageData(this.imageData, 0, 0); + + // Scale and draw to main canvas + const rect = this.getBoundingClientRect(); + this.ctx.clearRect(0, 0, rect.width, rect.height); + this.ctx.drawImage(this.offscreenCanvas, 0, 0, rect.width, rect.height); + } + + // WebSocket management + + private connectWebSocket(): void { + if (!this.wsUrl) return; + + console.log(`Connecting to WebSocket: ${this.wsUrl}`); + + try { + this.ws = new WebSocket(this.wsUrl); + this.ws.binaryType = 'arraybuffer'; + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.state.connected = true; + this.reconnectDelay = 2000; // Reset delay + + const event = new CustomEvent('socketopen'); + this.dispatchEvent(event); + }; + + this.ws.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + const rgb = new Uint8Array(event.data); + this.pushFrame(rgb); + } + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + this.state.connected = false; + this.ws = null; + + const event = new CustomEvent('socketclose'); + this.dispatchEvent(event); + + // Auto-reconnect + this.scheduleReconnect(); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + const event = new CustomEvent('socketerror', { detail: error }); + this.dispatchEvent(event); + }; + + } catch (error) { + console.error('Failed to create WebSocket:', error); + } + } + + private disconnectWebSocket(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = 0; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.state.connected = false; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + + console.log(`Reconnecting in ${this.reconnectDelay}ms...`); + + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = 0; + this.connectWebSocket(); + + // Exponential backoff + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); + }, this.reconnectDelay); + } + + private cleanup(): void { + // Stop render loop + this.state.rendering = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = 0; + } + + // Disconnect WebSocket + this.disconnectWebSocket(); + + // Stop resize observation + this.resizeObserver.disconnect(); + + console.log('LED Matrix component cleaned up'); + } +} + +// Register the custom element +customElements.define('led-matrix', LEDMatrix); \ No newline at end of file diff --git a/effectsim/src/types.d.ts b/effectsim/src/types.d.ts new file mode 100644 index 0000000..adfe67c --- /dev/null +++ b/effectsim/src/types.d.ts @@ -0,0 +1,61 @@ +// Core type definitions for LED Matrix Simulator + +export type WiringPattern = 'row-major' | 'column-major' | 'serpentine'; + +export interface MatrixConfig { + panelsX: number; + panelsY: number; + panelCols: number; + panelRows: number; + wiring: WiringPattern; + pixelSize: number | 'auto'; + gap: number; + fpsCap: number; +} + +export interface MatrixDimensions { + cols: number; + rows: number; + totalPixels: number; +} + +export interface PerformanceStats { + fps: number; + dropped: number; + renderMs: number; +} + +export interface CoordinateMapping { + logicalIndex: number; + bufferOffset: number; + panelX: number; + panelY: number; + inPanelX: number; + inPanelY: number; +} + +export interface FrameBuffer { + data: Uint8ClampedArray; + cols: number; + rows: number; + timestamp: number; +} + +// Custom events +export interface ReadyEventDetail { + cols: number; + rows: number; +} + +export interface StatsEventDetail { + fps: number; + dropped: number; + renderMs: number; +} + +// Component lifecycle +export interface ComponentState { + initialized: boolean; + connected: boolean; + rendering: boolean; +} \ No newline at end of file diff --git a/effectsim/src/util/fps.ts b/effectsim/src/util/fps.ts new file mode 100644 index 0000000..698a65f --- /dev/null +++ b/effectsim/src/util/fps.ts @@ -0,0 +1,142 @@ +// Performance monitoring utilities + +import type { PerformanceStats } from '../types.d.ts'; + +export class FPSCounter { + private frameCount: number = 0; + private lastTime: number = 0; + private startTime: number = 0; + private renderTimes: number[] = []; + private droppedFrames: number = 0; + private maxRenderSamples: number = 60; // Keep last 60 render times + + constructor() { + this.reset(); + } + + /** + * Reset all counters + */ + reset(): void { + this.frameCount = 0; + this.lastTime = performance.now(); + this.startTime = this.lastTime; + this.renderTimes = []; + this.droppedFrames = 0; + } + + /** + * Record the start of a frame render + */ + startFrame(): number { + return performance.now(); + } + + /** + * Record the end of a frame render + */ + endFrame(startTime: number): void { + const renderTime = performance.now() - startTime; + + // Store render time (keep only recent samples) + this.renderTimes.push(renderTime); + if (this.renderTimes.length > this.maxRenderSamples) { + this.renderTimes.shift(); + } + + this.frameCount++; + } + + /** + * Record a dropped frame + */ + dropFrame(): void { + this.droppedFrames++; + } + + /** + * Get current performance statistics + */ + getStats(): PerformanceStats { + const now = performance.now(); + const elapsed = (now - this.startTime) / 1000; // seconds + + // Calculate FPS over total elapsed time + const fps = elapsed > 0 ? this.frameCount / elapsed : 0; + + // Calculate average render time + const renderMs = this.renderTimes.length > 0 + ? this.renderTimes.reduce((a, b) => a + b, 0) / this.renderTimes.length + : 0; + + return { + fps: Math.round(fps * 10) / 10, // Round to 1 decimal + dropped: this.droppedFrames, + renderMs: Math.round(renderMs * 100) / 100 // Round to 2 decimals + }; + } + + /** + * Check if enough time has passed for stats update (typically ~1 second) + */ + shouldUpdateStats(intervalMs: number = 1000): boolean { + const now = performance.now(); + const elapsed = now - this.lastTime; + + if (elapsed >= intervalMs) { + this.lastTime = now; + return true; + } + + return false; + } +} + +/** + * Frame rate limiter utility + */ +export class FPSLimiter { + private targetFPS!: number; + private targetInterval!: number; + private lastFrameTime: number = 0; + + constructor(targetFPS: number = 0) { + this.setTargetFPS(targetFPS); + } + + /** + * Set target FPS (0 = uncapped) + */ + setTargetFPS(fps: number): void { + this.targetFPS = fps; + this.targetInterval = fps > 0 ? 1000 / fps : 0; + } + + /** + * Check if enough time has passed to render next frame + */ + shouldRender(currentTime: number): boolean { + if (this.targetFPS <= 0) { + // Uncapped - always render + return true; + } + + const elapsed = currentTime - this.lastFrameTime; + if (elapsed >= this.targetInterval) { + this.lastFrameTime = currentTime; + return true; + } + + return false; + } + + /** + * Get time until next frame should be rendered (in ms) + */ + getTimeToNextFrame(currentTime: number): number { + if (this.targetFPS <= 0) return 0; + + const elapsed = currentTime - this.lastFrameTime; + return Math.max(0, this.targetInterval - elapsed); + } +} \ No newline at end of file diff --git a/effectsim/src/util/lut.ts b/effectsim/src/util/lut.ts new file mode 100644 index 0000000..ea16849 --- /dev/null +++ b/effectsim/src/util/lut.ts @@ -0,0 +1,190 @@ +// Coordinate mapping and lookup table utilities + +import type { WiringPattern, MatrixConfig, MatrixDimensions, CoordinateMapping } from '../types.d.ts'; + +export class CoordinateLUT { + private config: MatrixConfig; + private dimensions: MatrixDimensions; + private lut: Int32Array; // Pre-computed buffer offsets for each logical position + private mappings: CoordinateMapping[]; // Detailed mapping info for debugging + + constructor(config: MatrixConfig) { + this.config = { ...config }; + this.dimensions = this.calculateDimensions(config); + this.lut = new Int32Array(0); + this.mappings = []; + this.rebuild(); + } + + /** + * Update configuration and rebuild LUT + */ + updateConfig(config: MatrixConfig): void { + this.config = { ...config }; + const newDimensions = this.calculateDimensions(config); + + // Only rebuild if dimensions or wiring changed + if (!this.dimensionsEqual(newDimensions, this.dimensions) || + this.config.wiring !== config.wiring) { + this.dimensions = newDimensions; + this.rebuild(); + } + } + + /** + * Get buffer offset for logical coordinate (row, col) + * Returns -1 if coordinates are out of bounds + */ + getBufferOffset(row: number, col: number): number { + if (row < 0 || row >= this.dimensions.rows || + col < 0 || col >= this.dimensions.cols) { + return -1; + } + + const logicalIndex = row * this.dimensions.cols + col; + return this.lut[logicalIndex]; + } + + /** + * Get detailed mapping info for debugging + */ + getMapping(row: number, col: number): CoordinateMapping | null { + const logicalIndex = row * this.dimensions.cols + col; + return this.mappings[logicalIndex] || null; + } + + /** + * Get matrix dimensions + */ + getDimensions(): MatrixDimensions { + return { ...this.dimensions }; + } + + /** + * Calculate total matrix dimensions from panel configuration + */ + private calculateDimensions(config: MatrixConfig): MatrixDimensions { + const cols = config.panelsX * config.panelCols; + const rows = config.panelsY * config.panelRows; + return { + cols, + rows, + totalPixels: cols * rows + }; + } + + /** + * Check if two dimensions are equal + */ + private dimensionsEqual(a: MatrixDimensions, b: MatrixDimensions): boolean { + return a.cols === b.cols && a.rows === b.rows; + } + + /** + * Rebuild the lookup table + */ + private rebuild(): void { + const { totalPixels } = this.dimensions; + + // Allocate arrays + this.lut = new Int32Array(totalPixels); + this.mappings = new Array(totalPixels); + + // Build mapping for each logical position + for (let row = 0; row < this.dimensions.rows; row++) { + for (let col = 0; col < this.dimensions.cols; col++) { + const logicalIndex = row * this.dimensions.cols + col; + const mapping = this.calculateMapping(row, col); + + this.lut[logicalIndex] = mapping.bufferOffset; + this.mappings[logicalIndex] = mapping; + } + } + + console.log(`LUT rebuilt: ${this.dimensions.cols}×${this.dimensions.rows} (${totalPixels} pixels), wiring: ${this.config.wiring}`); + } + + /** + * Calculate coordinate mapping for a single logical position + */ + private calculateMapping(logicalRow: number, logicalCol: number): CoordinateMapping { + // Determine which panel this pixel belongs to + const panelX = Math.floor(logicalCol / this.config.panelCols); + const panelY = Math.floor(logicalRow / this.config.panelRows); + + // Position within the panel + const inPanelX = logicalCol % this.config.panelCols; + const inPanelY = logicalRow % this.config.panelRows; + + // Apply wiring pattern within the panel + const physicalIndex = this.applyWiring( + panelX, panelY, + inPanelX, inPanelY, + this.config.wiring + ); + + // Convert to buffer offset (RGB888 = 4 bytes per pixel in ImageData) + const bufferOffset = physicalIndex * 4; + + return { + logicalIndex: logicalRow * this.dimensions.cols + logicalCol, + bufferOffset, + panelX, + panelY, + inPanelX, + inPanelY + }; + } + + /** + * Apply wiring pattern to convert logical panel coordinates to physical index + */ + private applyWiring( + panelX: number, + panelY: number, + inPanelX: number, + inPanelY: number, + wiring: WiringPattern + ): number { + // Calculate base offset for this panel + const panelIndex = panelY * this.config.panelsX + panelX; + const pixelsPerPanel = this.config.panelCols * this.config.panelRows; + const panelOffset = panelIndex * pixelsPerPanel; + + // Apply wiring within panel + let pixelInPanel: number; + + switch (wiring) { + case 'row-major': + pixelInPanel = inPanelY * this.config.panelCols + inPanelX; + break; + + case 'column-major': + pixelInPanel = inPanelX * this.config.panelRows + inPanelY; + break; + + case 'serpentine': + // Alternate row direction for serpentine wiring + if (inPanelY % 2 === 0) { + // Even rows: left to right + pixelInPanel = inPanelY * this.config.panelCols + inPanelX; + } else { + // Odd rows: right to left + pixelInPanel = inPanelY * this.config.panelCols + (this.config.panelCols - 1 - inPanelX); + } + break; + + default: + throw new Error(`Unknown wiring pattern: ${wiring}`); + } + + return panelOffset + pixelInPanel; + } +} + +/** + * Utility function to create a LUT from configuration + */ +export function createCoordinateLUT(config: MatrixConfig): CoordinateLUT { + return new CoordinateLUT(config); +} \ No newline at end of file diff --git a/effectsim/styles/component.css b/effectsim/styles/component.css new file mode 100644 index 0000000..fd2abb7 --- /dev/null +++ b/effectsim/styles/component.css @@ -0,0 +1,26 @@ +/* LED Matrix component styles */ + +:host { + display: block; + width: 100%; + height: 100%; + background: var(--led-off-bg, #111111); + border-radius: var(--led-border-radius, 4px); +} + +canvas { + width: 100%; + height: 100%; + display: block; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + border-radius: var(--led-radius, 0); +} + +/* Default CSS custom properties */ +:host { + --led-radius: 0px; + --led-off-bg: #0a0a0a; +} \ No newline at end of file diff --git a/effectsim/tsconfig.json b/effectsim/tsconfig.json new file mode 100644 index 0000000..2705786 --- /dev/null +++ b/effectsim/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ES2022", + "moduleResolution": "node", + "lib": ["DOM", "ES2024"], + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "server"] +} \ No newline at end of file From 1f198b5c7d4814986ce751f5393a2555365b4284 Mon Sep 17 00:00:00 2001 From: Sam Mellor Date: Fri, 5 Sep 2025 18:23:54 -0700 Subject: [PATCH 3/9] fixed mapping; server-driven geometry --- effectsim/README.md | 4 +- effectsim/index.html | 50 +--- effectsim/server/test-server.js | 106 ++++++++- effectsim/src/led-matrix.ts | 398 ++++++++++++++++++++------------ effectsim/src/types.d.ts | 3 - effectsim/src/util/lut.ts | 148 +----------- 6 files changed, 365 insertions(+), 344 deletions(-) diff --git a/effectsim/README.md b/effectsim/README.md index 65c7939..8a68b74 100644 --- a/effectsim/README.md +++ b/effectsim/README.md @@ -38,7 +38,6 @@ High-performance LED matrix simulator Web Component targeting 120+ FPS at 150×2 panels-y="4" panel-cols="28" panel-rows="28" - wiring="serpentine" ws-url="ws://localhost:9002/ws"> ``` @@ -47,7 +46,6 @@ High-performance LED matrix simulator Web Component targeting 120+ FPS at 150×2 - `panels-x`, `panels-y`: Panel grid dimensions - `panel-cols`, `panel-rows`: Resolution per panel -- `wiring`: "serpentine" | "row-major" | "column-major" - `pixel-size`: Size in CSS pixels or "auto" - `gap`: Gap between LEDs in pixels - `fps-cap`: FPS limit (0 = uncapped) @@ -78,7 +76,7 @@ matrix.addEventListener('stats', (e) => { - **Web Component**: Framework-agnostic custom element - **Canvas 2D**: High-performance rendering with offscreen buffer - **WebSocket Client**: Real-time frame streaming with drop-frame backpressure -- **Coordinate Mapping**: Pre-computed LUT for panel/wiring configurations +- **Coordinate Mapping**: Pre-computed LUT with serpentine wiring (rows alternate L→R, R→L) - **Performance Monitoring**: Built-in FPS counter and render time tracking ## WebSocket Protocol diff --git a/effectsim/index.html b/effectsim/index.html index acf58b4..be2227e 100644 --- a/effectsim/index.html +++ b/effectsim/index.html @@ -82,14 +82,15 @@ display: flex; align-items: center; justify-content: center; + overflow: auto; } led-matrix { - width: min(80vw, 80vh); - height: min(80vw, 80vh); border: 2px solid #333; border-radius: 8px; background: #0a0a0a; + max-width: 90vw; + max-height: 80vh; } .stats { @@ -175,28 +176,8 @@

LED Matrix Simulator

- - -
-
- - -
-
- - -
-
- - -
-
- - + +
@@ -208,7 +189,7 @@

LED Matrix Simulator

- +
@@ -216,15 +197,10 @@

LED Matrix Simulator

+ ws-url="ws://localhost:9002">
@@ -266,11 +242,7 @@

Performance

const matrix = document.getElementById('matrix'); const controls = { - panelsX: document.getElementById('panels-x'), - panelsY: document.getElementById('panels-y'), - panelCols: document.getElementById('panel-cols'), - panelRows: document.getElementById('panel-rows'), - wiring: document.getElementById('wiring'), + pixelSize: document.getElementById('pixel-size'), gap: document.getElementById('gap'), fpsCap: document.getElementById('fps-cap'), wsUrl: document.getElementById('ws-url') @@ -287,11 +259,7 @@

Performance

// Update matrix attributes when controls change function updateMatrix() { - matrix.setAttribute('panels-x', controls.panelsX.value); - matrix.setAttribute('panels-y', controls.panelsY.value); - matrix.setAttribute('panel-cols', controls.panelCols.value); - matrix.setAttribute('panel-rows', controls.panelRows.value); - matrix.setAttribute('wiring', controls.wiring.value); + matrix.setAttribute('pixel-size', controls.pixelSize.value); matrix.setAttribute('gap', controls.gap.value); matrix.setAttribute('fps-cap', controls.fpsCap.value); matrix.setAttribute('ws-url', controls.wsUrl.value); diff --git a/effectsim/server/test-server.js b/effectsim/server/test-server.js index d0872e1..a25c133 100644 --- a/effectsim/server/test-server.js +++ b/effectsim/server/test-server.js @@ -2,9 +2,48 @@ import { WebSocketServer } from 'ws'; +// Parse CLI arguments +const args = process.argv.slice(2); +let panelsX = 3; // Default 3 panels wide +let panelsY = 4; // Default 4 panels tall + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--panels-x' && i + 1 < args.length) { + panelsX = parseInt(args[i + 1]); + i++; // Skip next argument + } else if (args[i] === '--panels-y' && i + 1 < args.length) { + panelsY = parseInt(args[i + 1]); + i++; // Skip next argument + } else if (args[i] === '--help' || args[i] === '-h') { + console.log('Usage: node test-server.js [--panels-x ] [--panels-y ]'); + console.log(' --panels-x Number of panels horizontally (default: 3)'); + console.log(' --panels-y Number of panels vertically (default: 4)'); + console.log(' --help, -h Show this help message'); + process.exit(0); + } +} + +// Validate arguments +if (isNaN(panelsX) || panelsX < 1 || panelsX > 20) { + console.error('Error: --panels-x must be a number between 1 and 20'); + process.exit(1); +} +if (isNaN(panelsY) || panelsY < 1 || panelsY > 20) { + console.error('Error: --panels-y must be a number between 1 and 20'); + process.exit(1); +} + +// Server configuration const PORT = 9002; -const DEFAULT_COLS = 84; // 3 panels × 28 cols -const DEFAULT_ROWS = 112; // 4 panels × 28 rows +const PANEL_SIZE = 28; // Fixed 28×28 panels +const PANELS_X = panelsX; +const PANELS_Y = panelsY; +const DEFAULT_COLS = PANELS_X * PANEL_SIZE; +const DEFAULT_ROWS = PANELS_Y * PANEL_SIZE; + +// Frame protocol constants +const FRAME_MAGIC = 0x4D44454C; // "LEDM" in little-endian +const FRAME_HEADER_SIZE = 8; // bytes class TestServer { constructor(port = PORT) { @@ -50,7 +89,7 @@ class TestServer { }); console.log(`LED Matrix Test Server running on ws://localhost:${this.port}`); - console.log(`Default matrix: ${DEFAULT_COLS}×${DEFAULT_ROWS} (${DEFAULT_COLS * DEFAULT_ROWS} pixels)`); + console.log(`Matrix: ${PANELS_X}×${PANELS_Y} panels of ${PANEL_SIZE}×${PANEL_SIZE} = ${DEFAULT_COLS}×${DEFAULT_ROWS} pixels`); } startAnimation() { @@ -69,7 +108,8 @@ class TestServer { const cols = DEFAULT_COLS; const rows = DEFAULT_ROWS; - const frame = this.generateTestPattern(cols, rows, this.frameCount); + const rgbData = this.generateTestPattern(cols, rows, this.frameCount); + const frame = this.createFrameWithHeader(PANELS_X, PANELS_Y, rgbData); this.clients.forEach(client => { if (client.readyState === 1) { // WebSocket.OPEN @@ -87,17 +127,65 @@ class TestServer { } } + createFrameWithHeader(panelsX, panelsY, rgbData) { + // Frame format: + // Header (FRAME_HEADER_SIZE bytes): + // - Magic: "LEDM" (4 bytes) + // - Panels X: uint16 little-endian (2 bytes) + // - Panels Y: uint16 little-endian (2 bytes) + // Data: RGB888 column-major (panelsX * panelsY * PANEL_SIZE * PANEL_SIZE * 3 bytes) + + const dataSize = rgbData.length; + const frame = new ArrayBuffer(FRAME_HEADER_SIZE + dataSize); + const headerView = new DataView(frame, 0, FRAME_HEADER_SIZE); + const dataView = new Uint8Array(frame, FRAME_HEADER_SIZE); + + // Write header + headerView.setUint32(0, FRAME_MAGIC, true); + headerView.setUint16(4, panelsX, true); + headerView.setUint16(6, panelsY, true); + + // Copy RGB data + dataView.set(rgbData); + + return frame; + } + generateTestPattern(cols, rows, frameNum) { const buffer = new Uint8Array(cols * rows * 3); - const time = frameNum * 0.1; + + // Rainbow spiral parameters + const centerX = cols / 2; + const centerY = rows / 2; + const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY); + + // Rotate at 1/10 Hz = 0.1 rotations per second + // At 60 FPS, each frame is 1/60 second + const rotationSpeed = 0.1; // Hz + const timeSeconds = frameNum / 60; // Convert frame to seconds + const rotationOffset = timeSeconds * rotationSpeed * 2 * Math.PI; + // Generate in row-major order (to match Canvas ImageData) for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { - const idx = (y * cols + x) * 3; + const idx = (y * cols + x) * 3; // Row-major indexing + + // Calculate distance and angle from center + const dx = x - centerX; + const dy = y - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + + // Create spiral: combine angle and distance for hue + // Add rotation offset for animation + const spiralTurns = 3; // Number of complete color cycles in the spiral + const hue = ((angle + rotationOffset) / (2 * Math.PI) + + (distance / maxRadius) * spiralTurns) % 1; + + // Fade out at edges for better visual effect + const brightness = Math.max(0, 1 - (distance / maxRadius) * 0.3); - // Create moving rainbow pattern - const hue = (x / cols + y / rows + time) % 1; - const [r, g, b] = this.hsvToRgb(hue, 1, 0.8); + const [r, g, b] = this.hsvToRgb(hue, 1, brightness); buffer[idx] = Math.round(r * 255); buffer[idx + 1] = Math.round(g * 255); diff --git a/effectsim/src/led-matrix.ts b/effectsim/src/led-matrix.ts index 10bcc06..0496df5 100644 --- a/effectsim/src/led-matrix.ts +++ b/effectsim/src/led-matrix.ts @@ -2,21 +2,25 @@ import { CoordinateLUT } from './util/lut.js'; import { FPSCounter, FPSLimiter } from './util/fps.js'; -import type { - MatrixConfig, - MatrixDimensions, - FrameBuffer, - WiringPattern, +import type { + MatrixConfig, + MatrixDimensions, + FrameBuffer, ReadyEventDetail, StatsEventDetail, ComponentState } from './types.d.ts'; +// Frame protocol constants +const FRAME_MAGIC = 0x4D44454C; // "LEDM" in little-endian +const FRAME_HEADER_SIZE = 8; // bytes +const PANEL_SIZE = 28; // 28×28 panels + export class LEDMatrix extends HTMLElement { // Configuration private config: MatrixConfig; private lut: CoordinateLUT; - + // Canvas and rendering private canvas!: HTMLCanvasElement; private ctx!: CanvasRenderingContext2D; @@ -24,85 +28,79 @@ export class LEDMatrix extends HTMLElement { private offscreenCtx!: OffscreenCanvasRenderingContext2D; private imageData!: ImageData; private backBuffer!: Uint8ClampedArray; - + // Frame handling private currentFrame: FrameBuffer | null = null; private pendingFrame: FrameBuffer | null = null; - + // Performance monitoring private fpsCounter: FPSCounter; private fpsLimiter: FPSLimiter; private animationId: number = 0; - + // WebSocket private ws: WebSocket | null = null; private wsUrl: string = ''; private reconnectTimer: number = 0; private reconnectDelay: number = 2000; private maxReconnectDelay: number = 10000; - + // Component state private state: ComponentState; - private resizeObserver: ResizeObserver; + private lastCanvasSize: { width: number; height: number } = { width: 0, height: 0 }; + private cachedContainerSize: { width: number; height: number } = { width: 0, height: 0 }; - // Observed attributes + // Observed attributes (geometry comes from frame headers) static get observedAttributes() { return [ - 'panels-x', 'panels-y', - 'panel-cols', 'panel-rows', - 'wiring', 'pixel-size', 'gap', + 'pixel-size', 'gap', 'fps-cap', 'ws-url' ]; } constructor() { super(); - + // Initialize state this.state = { initialized: false, connected: false, rendering: false }; - - // Default configuration + + // No default configuration - will be set from frame headers this.config = { - panelsX: 3, - panelsY: 4, - panelCols: 28, - panelRows: 28, - wiring: 'serpentine', + panelsX: 0, + panelsY: 0, + panelCols: 0, + panelRows: 0, pixelSize: 'auto', gap: 1, fpsCap: 0 }; - + // Initialize utilities this.lut = new CoordinateLUT(this.config); this.fpsCounter = new FPSCounter(); this.fpsLimiter = new FPSLimiter(this.config.fpsCap); - + // Create shadow DOM this.attachShadow({ mode: 'open' }); - - // Initialize resize observer - this.resizeObserver = new ResizeObserver(() => { - this.resize(); - }); - + + // Note: ResizeObserver removed to prevent feedback loops + // Resizing will be triggered explicitly when needed + this.initializeDOM(); } connectedCallback() { console.log('LED Matrix component connected'); - - // Start observing resize - this.resizeObserver.observe(this); - - // Initialize from attributes + + // Initialize from attributes (non-geometry config only) this.updateConfigFromAttributes(); - this.initialize(); - + + // Don't initialize until we get geometry from first frame + // Connect WebSocket if URL provided if (this.wsUrl) { this.connectWebSocket(); @@ -111,18 +109,18 @@ export class LEDMatrix extends HTMLElement { disconnectedCallback() { console.log('LED Matrix component disconnected'); - + this.cleanup(); } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { if (oldValue === newValue) return; - + console.log(`Attribute ${name} changed: ${oldValue} → ${newValue}`); - + // Update configuration this.updateConfigFromAttributes(); - + // Handle specific changes if (name === 'ws-url') { if (this.ws) { @@ -131,29 +129,36 @@ export class LEDMatrix extends HTMLElement { if (newValue) { this.connectWebSocket(); } - } else if (['panels-x', 'panels-y', 'panel-cols', 'panel-rows', 'wiring'].includes(name)) { - // Geometry or wiring changed - reinitialize - this.initialize(); + // Geometry attributes are ignored - they come from frame headers + } else if (name === 'pixel-size') { + // Pixel size changed - refresh container cache if switching to auto + if (newValue === 'auto') { + this.cachedContainerSize = { width: 0, height: 0 }; // Reset cache + } + // Resize canvas immediately + if (this.state.initialized) { + this.resize(); + } } else if (name === 'fps-cap') { this.fpsLimiter.setTargetFPS(this.config.fpsCap); } } // Public API methods - + /** * Push a frame for rendering */ pushFrame(rgb: Uint8Array, opts?: { cols?: number; rows?: number }): void { const dims = this.lut.getDimensions(); const expectedLength = dims.totalPixels * 3; - + if (rgb.length !== expectedLength) { console.error(`Frame size mismatch: expected ${expectedLength}, got ${rgb.length}`); this.fpsCounter.dropFrame(); return; } - + // Create frame buffer const frame: FrameBuffer = { data: new Uint8ClampedArray(rgb.buffer.slice(rgb.byteOffset, rgb.byteOffset + rgb.byteLength)), @@ -161,7 +166,7 @@ export class LEDMatrix extends HTMLElement { rows: dims.rows, timestamp: performance.now() }; - + // Atomic swap - replace any pending frame this.pendingFrame = frame; } @@ -170,66 +175,97 @@ export class LEDMatrix extends HTMLElement { * Manually trigger resize recalculation */ resize(): void { - if (!this.canvas || !this.state.initialized) return; - - const rect = this.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - - // Update canvas display size - this.canvas.style.width = '100%'; - this.canvas.style.height = '100%'; - - // Calculate actual pixel dimensions + if (!this.canvas || !this.state.initialized) { + return; + } + + const dims = this.lut.getDimensions(); + + // Calculate CSS pixel size based on pixelSize config + let cssPixelSize: number; + if (this.config.pixelSize === 'auto') { + // Use cached container size for auto-sizing to prevent constant changes + // Only update cache if we don't have valid dimensions yet + if (this.cachedContainerSize.width === 0 || this.cachedContainerSize.height === 0) { + // Get parent container dimensions, not our own element dimensions + const parent = this.parentElement; + if (!parent) { + return; + } + const containerRect = parent.getBoundingClientRect(); + if (containerRect.width === 0 || containerRect.height === 0) { + return; + } + this.cachedContainerSize = { width: containerRect.width, height: containerRect.height }; + console.log(`📦 Cached parent container size: ${this.cachedContainerSize.width}×${this.cachedContainerSize.height}`); + } + + const scaleX = this.cachedContainerSize.width / dims.cols; + const scaleY = this.cachedContainerSize.height / dims.rows; + cssPixelSize = Math.min(scaleX, scaleY); + } else { + cssPixelSize = this.config.pixelSize; + } + + // Calculate logical canvas size (in CSS pixels) + const canvasWidth = dims.cols * cssPixelSize; + const canvasHeight = dims.rows * cssPixelSize; + + // Only update canvas if dimensions actually changed (with tolerance for floating point precision) + const tolerance = 0.1; // 0.1px tolerance + const widthChanged = Math.abs(this.lastCanvasSize.width - canvasWidth) > tolerance; + const heightChanged = Math.abs(this.lastCanvasSize.height - canvasHeight) > tolerance; + const sizeChanged = widthChanged || heightChanged; + if (!sizeChanged) { + return; // No changes needed, avoid triggering ResizeObserver loop + } + + this.lastCanvasSize = { width: canvasWidth, height: canvasHeight }; + + // Set canvas CSS size + this.canvas.style.width = `${canvasWidth}px`; + this.canvas.style.height = `${canvasHeight}px`; + + // Set canvas resolution (accounting for device pixel ratio) const devicePixelRatio = window.devicePixelRatio || 1; - const displayWidth = rect.width * devicePixelRatio; - const displayHeight = rect.height * devicePixelRatio; - - // Set canvas resolution - this.canvas.width = displayWidth; - this.canvas.height = displayHeight; - - // Scale context back to logical pixels + this.canvas.width = canvasWidth * devicePixelRatio; + this.canvas.height = canvasHeight * devicePixelRatio; + + // Scale context for device pixel ratio this.ctx.scale(devicePixelRatio, devicePixelRatio); - + // Disable image smoothing for crisp pixels this.ctx.imageSmoothingEnabled = false; - - console.log(`Canvas resized: ${displayWidth}×${displayHeight} (ratio: ${devicePixelRatio})`); } // Private methods - + private initializeDOM(): void { if (!this.shadowRoot) return; - + // Create canvas this.canvas = document.createElement('canvas'); this.canvas.style.display = 'block'; - this.canvas.style.width = '100%'; - this.canvas.style.height = '100%'; this.canvas.style.imageRendering = 'pixelated'; - + // Get context this.ctx = this.canvas.getContext('2d')!; if (!this.ctx) { throw new Error('Failed to get 2D canvas context'); } - + // Add to shadow DOM this.shadowRoot.appendChild(this.canvas); - + // Load CSS const style = document.createElement('style'); style.textContent = ` :host { - display: block; - width: 100%; - height: 100%; + display: inline-block; background: var(--led-off-bg, #111); } canvas { - width: 100%; - height: 100%; + display: block; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; @@ -239,74 +275,84 @@ export class LEDMatrix extends HTMLElement { } private updateConfigFromAttributes(): void { - this.config.panelsX = parseInt(this.getAttribute('panels-x') || '3'); - this.config.panelsY = parseInt(this.getAttribute('panels-y') || '4'); - this.config.panelCols = parseInt(this.getAttribute('panel-cols') || '28'); - this.config.panelRows = parseInt(this.getAttribute('panel-rows') || '28'); - this.config.wiring = (this.getAttribute('wiring') || 'serpentine') as WiringPattern; - + // Only update non-geometry configuration from attributes + // Geometry comes from frame headers + const pixelSize = this.getAttribute('pixel-size'); this.config.pixelSize = pixelSize === 'auto' ? 'auto' : parseFloat(pixelSize || 'auto') || 'auto'; - + this.config.gap = parseFloat(this.getAttribute('gap') || '1'); this.config.fpsCap = parseInt(this.getAttribute('fps-cap') || '0'); this.wsUrl = this.getAttribute('ws-url') || ''; } private initialize(): void { - console.log('Initializing LED Matrix...', this.config); - + console.log('🔄 Initializing LED Matrix...', this.config); + // Update coordinate mapping this.lut.updateConfig(this.config); const dims = this.lut.getDimensions(); - + console.log(`📐 Matrix dimensions calculated: ${dims.cols}×${dims.rows} (${dims.totalPixels} pixels)`); + // Create offscreen canvas for logical resolution this.offscreenCanvas = new OffscreenCanvas(dims.cols, dims.rows); this.offscreenCtx = this.offscreenCanvas.getContext('2d')!; - + console.log(`🎨 Created offscreen canvas: ${dims.cols}×${dims.rows}`); + // Create ImageData buffer this.imageData = this.offscreenCtx.createImageData(dims.cols, dims.rows); this.backBuffer = this.imageData.data; - + console.log(`💾 Created ImageData buffer: ${this.backBuffer.length} bytes`); + // Pre-fill alpha channel to 255 (opaque) for (let i = 3; i < this.backBuffer.length; i += 4) { this.backBuffer[i] = 255; } - + // Reset performance counters this.fpsCounter.reset(); this.fpsLimiter.setTargetFPS(this.config.fpsCap); - + // Start render loop this.startRenderLoop(); - + // Update state this.state.initialized = true; - + console.log(`✅ LED Matrix initialization complete: ${dims.cols}×${dims.rows}`); + + // Trigger immediate resize to set up canvas display + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + this.resize(); + + // Fallback: retry resize after a short delay in case container isn't ready + setTimeout(() => { + this.resize(); + }, 50); + }); + // Dispatch ready event const readyEvent = new CustomEvent('ready', { detail: { cols: dims.cols, rows: dims.rows } }); this.dispatchEvent(readyEvent); - - console.log(`LED Matrix initialized: ${dims.cols}×${dims.rows} (${dims.totalPixels} pixels)`); } private startRenderLoop(): void { if (this.animationId) { cancelAnimationFrame(this.animationId); } - + this.state.rendering = true; - + const render = (timestamp: number) => { if (!this.state.rendering) return; - + // Check FPS limiting if (this.fpsLimiter.shouldRender(timestamp)) { this.renderFrame(); } - + // Update stats periodically if (this.fpsCounter.shouldUpdateStats()) { const stats = this.fpsCounter.getStats(); @@ -315,52 +361,52 @@ export class LEDMatrix extends HTMLElement { }); this.dispatchEvent(statsEvent); } - + this.animationId = requestAnimationFrame(render); }; - + this.animationId = requestAnimationFrame(render); } private renderFrame(): void { const renderStart = this.fpsCounter.startFrame(); - + try { // Swap frame buffers atomically if (this.pendingFrame) { this.currentFrame = this.pendingFrame; this.pendingFrame = null; } - + // Render current frame if (this.currentFrame) { this.updateImageData(this.currentFrame); this.drawToCanvas(); } - + this.fpsCounter.endFrame(renderStart); - + } catch (error) { - console.error('Render error:', error); + console.error('❌ Render error:', error); this.fpsCounter.dropFrame(); } } private updateImageData(frame: FrameBuffer): void { const dims = this.lut.getDimensions(); - - // Copy RGB data to ImageData buffer using coordinate mapping + + // Direct copy of row-major RGB data to row-major ImageData buffer + // Both source and destination use: row * cols + col indexing (ROW-MAJOR) for (let row = 0; row < dims.rows; row++) { for (let col = 0; col < dims.cols; col++) { - const bufferOffset = this.lut.getBufferOffset(row, col); - if (bufferOffset === -1) continue; - - const srcIndex = (row * dims.cols + col) * 3; - const dstIndex = bufferOffset; - + // Both source and destination use same row-major indexing + const pixelIndex = row * dims.cols + col; // ROW-MAJOR: row * cols + col + const srcIndex = pixelIndex * 3; // RGB888 + const dstIndex = pixelIndex * 4; // RGBA + // Copy RGB (alpha already set to 255) - this.backBuffer[dstIndex] = frame.data[srcIndex]; // R - this.backBuffer[dstIndex + 1] = frame.data[srcIndex + 1]; // G + this.backBuffer[dstIndex] = frame.data[srcIndex]; // R + this.backBuffer[dstIndex + 1] = frame.data[srcIndex + 1]; // G this.backBuffer[dstIndex + 2] = frame.data[srcIndex + 2]; // B } } @@ -368,61 +414,114 @@ export class LEDMatrix extends HTMLElement { private drawToCanvas(): void { if (!this.ctx || !this.canvas) return; - + // Put image data to offscreen canvas this.offscreenCtx.putImageData(this.imageData, 0, 0); - - // Scale and draw to main canvas - const rect = this.getBoundingClientRect(); - this.ctx.clearRect(0, 0, rect.width, rect.height); - this.ctx.drawImage(this.offscreenCanvas, 0, 0, rect.width, rect.height); + + // Get canvas CSS dimensions + const canvasWidth = parseFloat(this.canvas.style.width.replace('px', '')); + const canvasHeight = parseFloat(this.canvas.style.height.replace('px', '')); + + // Clear and draw scaled image + this.ctx.clearRect(0, 0, canvasWidth, canvasHeight); + this.ctx.drawImage(this.offscreenCanvas, 0, 0, canvasWidth, canvasHeight); + } + + // Frame message handling + + private handleFrameMessage(buffer: ArrayBuffer): void { + // Check minimum header size + if (buffer.byteLength < FRAME_HEADER_SIZE) { + console.error('❌ Frame too small for header'); + this.fpsCounter.dropFrame(); + return; + } + + const headerView = new DataView(buffer, 0, FRAME_HEADER_SIZE); + + // Check magic bytes + const magic = headerView.getUint32(0, true); + if (magic !== FRAME_MAGIC) { + console.error('❌ Invalid frame magic'); + this.fpsCounter.dropFrame(); + return; + } + + // Parse header + const panelsX = headerView.getUint16(4, true); + const panelsY = headerView.getUint16(6, true); + + // Calculate expected dimensions + const expectedCols = panelsX * PANEL_SIZE; + const expectedRows = panelsY * PANEL_SIZE; + const expectedDataSize = expectedCols * expectedRows * 3; + + // Validate frame size + if (buffer.byteLength !== FRAME_HEADER_SIZE + expectedDataSize) { + console.error(`❌ Frame size mismatch: expected ${FRAME_HEADER_SIZE + expectedDataSize}, got ${buffer.byteLength}`); + this.fpsCounter.dropFrame(); + return; + } + + // Auto-configure if geometry changed or first time + if (this.config.panelsX !== panelsX || this.config.panelsY !== panelsY || !this.state.initialized) { + console.log(`🔧 Auto-configuring: ${panelsX}×${panelsY} panels (${expectedCols}×${expectedRows} pixels)`); + this.config.panelsX = panelsX; + this.config.panelsY = panelsY; + this.config.panelCols = PANEL_SIZE; + this.config.panelRows = PANEL_SIZE; + this.initialize(); + } + + // Extract RGB data and push frame + const rgbData = new Uint8Array(buffer, FRAME_HEADER_SIZE); + this.pushFrame(rgbData); } // WebSocket management - + private connectWebSocket(): void { if (!this.wsUrl) return; - + console.log(`Connecting to WebSocket: ${this.wsUrl}`); - + try { this.ws = new WebSocket(this.wsUrl); this.ws.binaryType = 'arraybuffer'; - + this.ws.onopen = () => { console.log('WebSocket connected'); this.state.connected = true; this.reconnectDelay = 2000; // Reset delay - + const event = new CustomEvent('socketopen'); this.dispatchEvent(event); }; - + this.ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { - const rgb = new Uint8Array(event.data); - this.pushFrame(rgb); + this.handleFrameMessage(event.data); } }; - + this.ws.onclose = () => { console.log('WebSocket disconnected'); this.state.connected = false; this.ws = null; - + const event = new CustomEvent('socketclose'); this.dispatchEvent(event); - + // Auto-reconnect this.scheduleReconnect(); }; - + this.ws.onerror = (error) => { console.error('WebSocket error:', error); const event = new CustomEvent('socketerror', { detail: error }); this.dispatchEvent(event); }; - + } catch (error) { console.error('Failed to create WebSocket:', error); } @@ -433,24 +532,24 @@ export class LEDMatrix extends HTMLElement { clearTimeout(this.reconnectTimer); this.reconnectTimer = 0; } - + if (this.ws) { this.ws.close(); this.ws = null; } - + this.state.connected = false; } private scheduleReconnect(): void { if (this.reconnectTimer) return; - + console.log(`Reconnecting in ${this.reconnectDelay}ms...`); - + this.reconnectTimer = window.setTimeout(() => { this.reconnectTimer = 0; this.connectWebSocket(); - + // Exponential backoff this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); }, this.reconnectDelay); @@ -463,16 +562,13 @@ export class LEDMatrix extends HTMLElement { cancelAnimationFrame(this.animationId); this.animationId = 0; } - + // Disconnect WebSocket this.disconnectWebSocket(); - - // Stop resize observation - this.resizeObserver.disconnect(); - + console.log('LED Matrix component cleaned up'); } } // Register the custom element -customElements.define('led-matrix', LEDMatrix); \ No newline at end of file +customElements.define('led-matrix', LEDMatrix); diff --git a/effectsim/src/types.d.ts b/effectsim/src/types.d.ts index adfe67c..908612d 100644 --- a/effectsim/src/types.d.ts +++ b/effectsim/src/types.d.ts @@ -1,13 +1,10 @@ // Core type definitions for LED Matrix Simulator -export type WiringPattern = 'row-major' | 'column-major' | 'serpentine'; - export interface MatrixConfig { panelsX: number; panelsY: number; panelCols: number; panelRows: number; - wiring: WiringPattern; pixelSize: number | 'auto'; gap: number; fpsCap: number; diff --git a/effectsim/src/util/lut.ts b/effectsim/src/util/lut.ts index ea16849..161d2f1 100644 --- a/effectsim/src/util/lut.ts +++ b/effectsim/src/util/lut.ts @@ -1,38 +1,25 @@ -// Coordinate mapping and lookup table utilities +// Simple coordinate mapping utilities -import type { WiringPattern, MatrixConfig, MatrixDimensions, CoordinateMapping } from '../types.d.ts'; +import type { MatrixConfig, MatrixDimensions } from '../types.d.ts'; export class CoordinateLUT { - private config: MatrixConfig; private dimensions: MatrixDimensions; - private lut: Int32Array; // Pre-computed buffer offsets for each logical position - private mappings: CoordinateMapping[]; // Detailed mapping info for debugging constructor(config: MatrixConfig) { - this.config = { ...config }; this.dimensions = this.calculateDimensions(config); - this.lut = new Int32Array(0); - this.mappings = []; - this.rebuild(); } /** - * Update configuration and rebuild LUT + * Update configuration and recalculate dimensions */ updateConfig(config: MatrixConfig): void { - this.config = { ...config }; const newDimensions = this.calculateDimensions(config); - - // Only rebuild if dimensions or wiring changed - if (!this.dimensionsEqual(newDimensions, this.dimensions) || - this.config.wiring !== config.wiring) { - this.dimensions = newDimensions; - this.rebuild(); - } + this.dimensions = newDimensions; + console.log(`Matrix dimensions: ${this.dimensions.cols}×${this.dimensions.rows} (${this.dimensions.totalPixels} pixels)`); } /** - * Get buffer offset for logical coordinate (row, col) + * Get buffer offset for coordinate (row, col) - row-major layout for ImageData * Returns -1 if coordinates are out of bounds */ getBufferOffset(row: number, col: number): number { @@ -41,16 +28,11 @@ export class CoordinateLUT { return -1; } - const logicalIndex = row * this.dimensions.cols + col; - return this.lut[logicalIndex]; - } - - /** - * Get detailed mapping info for debugging - */ - getMapping(row: number, col: number): CoordinateMapping | null { - const logicalIndex = row * this.dimensions.cols + col; - return this.mappings[logicalIndex] || null; + // Row-major layout for ImageData: row * cols + col + const index = row * this.dimensions.cols + col; + + // Convert to ImageData buffer offset (RGBA = 4 bytes per pixel) + return index * 4; } /** @@ -72,114 +54,6 @@ export class CoordinateLUT { totalPixels: cols * rows }; } - - /** - * Check if two dimensions are equal - */ - private dimensionsEqual(a: MatrixDimensions, b: MatrixDimensions): boolean { - return a.cols === b.cols && a.rows === b.rows; - } - - /** - * Rebuild the lookup table - */ - private rebuild(): void { - const { totalPixels } = this.dimensions; - - // Allocate arrays - this.lut = new Int32Array(totalPixels); - this.mappings = new Array(totalPixels); - - // Build mapping for each logical position - for (let row = 0; row < this.dimensions.rows; row++) { - for (let col = 0; col < this.dimensions.cols; col++) { - const logicalIndex = row * this.dimensions.cols + col; - const mapping = this.calculateMapping(row, col); - - this.lut[logicalIndex] = mapping.bufferOffset; - this.mappings[logicalIndex] = mapping; - } - } - - console.log(`LUT rebuilt: ${this.dimensions.cols}×${this.dimensions.rows} (${totalPixels} pixels), wiring: ${this.config.wiring}`); - } - - /** - * Calculate coordinate mapping for a single logical position - */ - private calculateMapping(logicalRow: number, logicalCol: number): CoordinateMapping { - // Determine which panel this pixel belongs to - const panelX = Math.floor(logicalCol / this.config.panelCols); - const panelY = Math.floor(logicalRow / this.config.panelRows); - - // Position within the panel - const inPanelX = logicalCol % this.config.panelCols; - const inPanelY = logicalRow % this.config.panelRows; - - // Apply wiring pattern within the panel - const physicalIndex = this.applyWiring( - panelX, panelY, - inPanelX, inPanelY, - this.config.wiring - ); - - // Convert to buffer offset (RGB888 = 4 bytes per pixel in ImageData) - const bufferOffset = physicalIndex * 4; - - return { - logicalIndex: logicalRow * this.dimensions.cols + logicalCol, - bufferOffset, - panelX, - panelY, - inPanelX, - inPanelY - }; - } - - /** - * Apply wiring pattern to convert logical panel coordinates to physical index - */ - private applyWiring( - panelX: number, - panelY: number, - inPanelX: number, - inPanelY: number, - wiring: WiringPattern - ): number { - // Calculate base offset for this panel - const panelIndex = panelY * this.config.panelsX + panelX; - const pixelsPerPanel = this.config.panelCols * this.config.panelRows; - const panelOffset = panelIndex * pixelsPerPanel; - - // Apply wiring within panel - let pixelInPanel: number; - - switch (wiring) { - case 'row-major': - pixelInPanel = inPanelY * this.config.panelCols + inPanelX; - break; - - case 'column-major': - pixelInPanel = inPanelX * this.config.panelRows + inPanelY; - break; - - case 'serpentine': - // Alternate row direction for serpentine wiring - if (inPanelY % 2 === 0) { - // Even rows: left to right - pixelInPanel = inPanelY * this.config.panelCols + inPanelX; - } else { - // Odd rows: right to left - pixelInPanel = inPanelY * this.config.panelCols + (this.config.panelCols - 1 - inPanelX); - } - break; - - default: - throw new Error(`Unknown wiring pattern: ${wiring}`); - } - - return panelOffset + pixelInPanel; - } } /** From 7f78aefe6418a3d2ce85d4c4110a52e964970488 Mon Sep 17 00:00:00 2001 From: Sam Mellor Date: Fri, 5 Sep 2025 19:09:55 -0700 Subject: [PATCH 4/9] led layout masking --- effectsim/index.html | 7 - effectsim/src/led-matrix.ts | 260 ++++++++++++++++++++++++++++++++++-- 2 files changed, 248 insertions(+), 19 deletions(-) diff --git a/effectsim/index.html b/effectsim/index.html index be2227e..ec4739a 100644 --- a/effectsim/index.html +++ b/effectsim/index.html @@ -179,10 +179,6 @@

LED Matrix Simulator

-
- - -
@@ -198,7 +194,6 @@

LED Matrix Simulator

@@ -243,7 +238,6 @@

Performance

const matrix = document.getElementById('matrix'); const controls = { pixelSize: document.getElementById('pixel-size'), - gap: document.getElementById('gap'), fpsCap: document.getElementById('fps-cap'), wsUrl: document.getElementById('ws-url') }; @@ -260,7 +254,6 @@

Performance

// Update matrix attributes when controls change function updateMatrix() { matrix.setAttribute('pixel-size', controls.pixelSize.value); - matrix.setAttribute('gap', controls.gap.value); matrix.setAttribute('fps-cap', controls.fpsCap.value); matrix.setAttribute('ws-url', controls.wsUrl.value); } diff --git a/effectsim/src/led-matrix.ts b/effectsim/src/led-matrix.ts index 0496df5..df8da58 100644 --- a/effectsim/src/led-matrix.ts +++ b/effectsim/src/led-matrix.ts @@ -99,6 +99,12 @@ export class LEDMatrix extends HTMLElement { // Initialize from attributes (non-geometry config only) this.updateConfigFromAttributes(); + // Start basic render loop for LED pattern display + // This will show LED pattern when disconnected if we have geometry from previous connection + if (!this.state.rendering) { + this.startRenderLoop(); + } + // Don't initialize until we get geometry from first frame // Connect WebSocket if URL provided @@ -175,11 +181,15 @@ export class LEDMatrix extends HTMLElement { * Manually trigger resize recalculation */ resize(): void { - if (!this.canvas || !this.state.initialized) { + if (!this.canvas) { return; } + // Allow resize for LED pattern display even if not fully initialized const dims = this.lut.getDimensions(); + if (dims.cols === 0 || dims.rows === 0) { + return; + } // Calculate CSS pixel size based on pixelSize config let cssPixelSize: number; @@ -240,6 +250,32 @@ export class LEDMatrix extends HTMLElement { // Private methods + /** + * Ensure canvas is set up for LED pattern display (lightweight initialization) + */ + private ensureLEDPatternCanvas(dims: MatrixDimensions): void { + if (this.offscreenCtx && this.imageData) return; + + try { + // Create minimal offscreen canvas setup for LED pattern + this.offscreenCanvas = new OffscreenCanvas(dims.cols, dims.rows); + this.offscreenCtx = this.offscreenCanvas.getContext('2d')!; + + // Create ImageData buffer + this.imageData = this.offscreenCtx.createImageData(dims.cols, dims.rows); + this.backBuffer = this.imageData.data; + + // Pre-fill alpha channel to 255 (opaque) + for (let i = 3; i < this.backBuffer.length; i += 4) { + this.backBuffer[i] = 255; + } + + console.log(`🎨 Created LED pattern canvas: ${dims.cols}×${dims.rows}`); + } catch (error) { + console.error('Failed to create LED pattern canvas:', error); + } + } + private initializeDOM(): void { if (!this.shadowRoot) return; @@ -378,10 +414,14 @@ export class LEDMatrix extends HTMLElement { this.pendingFrame = null; } - // Render current frame - if (this.currentFrame) { + // Render current frame or LED pattern + if (this.state.connected && this.currentFrame) { this.updateImageData(this.currentFrame); this.drawToCanvas(); + } else { + // No connection or no frame data - render LED cluster pattern + this.renderLEDPattern(); + this.drawToCanvas(); } this.fpsCounter.endFrame(renderStart); @@ -412,19 +452,211 @@ export class LEDMatrix extends HTMLElement { } } + private renderLEDPattern(): void { + const dims = this.lut.getDimensions(); + + // If we have no geometry (never connected), don't render + if (dims.cols === 0 || dims.rows === 0) { + return; + } + + // Initialize canvas for LED pattern if needed + if (!this.offscreenCtx || !this.imageData) { + this.ensureLEDPatternCanvas(dims); + } + + if (!this.offscreenCtx || !this.imageData) return; + + // Clear to dark background + this.offscreenCtx.fillStyle = '#111111'; + this.offscreenCtx.fillRect(0, 0, dims.cols, dims.rows); + + // LED cluster colors (off state - dim but visible) + const ledOffColor = '#333333'; + + // Draw LED clusters for each pixel + for (let row = 0; row < dims.rows; row++) { + for (let col = 0; col < dims.cols; col++) { + this.drawLEDCluster(col, row, ledOffColor); + } + } + } + + /** + * Apply LED cluster masking to ImageData - only light up pixels where LEDs are positioned + */ + private applyLEDClusterMask(x: number, y: number, r: number, g: number, b: number): void { + if (!this.backBuffer) return; + + const dims = this.lut.getDimensions(); + + // Much smaller LEDs with significant gaps + const ledRadius = 0.15; // LED radius (15% of pixel) + const spacing = 0.25; // Spacing between LED centers (25% of pixel) + + // LED positions within the pixel (rotated 45° checkerboard pattern) + const ledPositions = [ + { dx: 0, dy: -spacing }, // Top + { dx: -spacing, dy: 0 }, // Left + { dx: spacing, dy: 0 }, // Right + { dx: 0, dy: spacing } // Bottom + ]; + + // For each LED position in the cluster + for (const pos of ledPositions) { + // Calculate LED center position within the pixel + const ledCenterX = x + 0.5 + pos.dx; + const ledCenterY = y + 0.5 + pos.dy; + + // Only light up the single pixel at the LED center (if within bounds) + const ledPixelX = Math.round(ledCenterX); + const ledPixelY = Math.round(ledCenterY); + + if (ledPixelX >= 0 && ledPixelX < dims.cols && + ledPixelY >= 0 && ledPixelY < dims.rows) { + const pixelIndex = ledPixelY * dims.cols + ledPixelX; + const dstIndex = pixelIndex * 4; + + // Set the LED color + this.backBuffer[dstIndex] = r; // R + this.backBuffer[dstIndex + 1] = g; // G + this.backBuffer[dstIndex + 2] = b; // B + // Alpha already set to 255 + } + } + } + + /** + * Draw the image with LED masking applied at the Canvas level + */ + private drawLEDMaskedImage(canvasWidth: number, canvasHeight: number): void { + if (!this.ctx || !this.offscreenCanvas || !this.currentFrame) return; + + const dims = this.lut.getDimensions(); + const pixelWidth = canvasWidth / dims.cols; + const pixelHeight = canvasHeight / dims.rows; + + // LED cluster parameters - diamond LEDs sized so corners touch + const quarterPixel = Math.min(pixelWidth, pixelHeight) * 0.25; // Quarter pixel spacing + const ledSize = quarterPixel * Math.sqrt(2); // Size so diamond corners touch adjacent LEDs + + // LED positions within each logical pixel (2x2 grid pattern) + const ledPositions = [ + { dx: -quarterPixel, dy: -quarterPixel }, // Top-left + { dx: quarterPixel, dy: -quarterPixel }, // Top-right + { dx: -quarterPixel, dy: quarterPixel }, // Bottom-left + { dx: quarterPixel, dy: quarterPixel } // Bottom-right + ]; + + // For each logical pixel in the frame + for (let row = 0; row < dims.rows; row++) { + for (let col = 0; col < dims.cols; col++) { + const pixelIndex = row * dims.cols + col; + const srcIndex = pixelIndex * 3; + + // Get the color for this logical pixel + const r = this.currentFrame.data[srcIndex]; + const g = this.currentFrame.data[srcIndex + 1]; + const b = this.currentFrame.data[srcIndex + 2]; + + // Calculate the center of this logical pixel on the canvas + const pixelCenterX = (col + 0.5) * pixelWidth; + const pixelCenterY = (row + 0.5) * pixelHeight; + + // Set the color for drawing LEDs + this.ctx.fillStyle = `rgb(${r},${g},${b})`; + + // Draw each LED in the cluster as a rotated square + for (const pos of ledPositions) { + const ledX = pixelCenterX + pos.dx; + const ledY = pixelCenterY + pos.dy; + + // Draw rotated diamond LED (45° rotation) + this.ctx.save(); + this.ctx.translate(ledX, ledY); + this.ctx.rotate(Math.PI / 4); // Rotate 45 degrees to make diamond + this.ctx.fillRect(-ledSize / 2, -ledSize / 2, ledSize, ledSize); + this.ctx.restore(); + } + } + } + } + + private drawLEDCluster(x: number, y: number, color: string): void { + if (!this.offscreenCtx) return; + + this.offscreenCtx.fillStyle = color; + + // Each pixel contains 4 LEDs in a rotated checkerboard pattern + // Pattern rotated 45°: + // • + // • • + // • + + const ledSize = 0.2; // LED size relative to pixel + const spacing = 0.3; // Spacing between LEDs + + // Calculate LED positions (rotated 45° checkerboard) + const positions = [ + { dx: 0, dy: -spacing }, // Top + { dx: -spacing, dy: 0 }, // Left + { dx: spacing, dy: 0 }, // Right + { dx: 0, dy: spacing } // Bottom + ]; + + // Draw each LED in the cluster + for (const pos of positions) { + const ledX = x + 0.5 + pos.dx; + const ledY = y + 0.5 + pos.dy; + + // Draw LED as small rectangle + this.offscreenCtx.fillRect( + ledX - ledSize / 2, + ledY - ledSize / 2, + ledSize, + ledSize + ); + } + } + private drawToCanvas(): void { - if (!this.ctx || !this.canvas) return; + if (!this.ctx || !this.canvas || !this.offscreenCanvas) return; - // Put image data to offscreen canvas - this.offscreenCtx.putImageData(this.imageData, 0, 0); + // Put image data to offscreen canvas if we have frame data + if (this.currentFrame && this.imageData) { + this.offscreenCtx.putImageData(this.imageData, 0, 0); + } + // Note: For LED pattern, we draw directly to offscreenCtx, so no putImageData needed - // Get canvas CSS dimensions - const canvasWidth = parseFloat(this.canvas.style.width.replace('px', '')); - const canvasHeight = parseFloat(this.canvas.style.height.replace('px', '')); + // Get canvas CSS dimensions, with fallback to canvas resolution + let canvasWidth = parseFloat(this.canvas.style.width?.replace('px', '') || '0'); + let canvasHeight = parseFloat(this.canvas.style.height?.replace('px', '') || '0'); - // Clear and draw scaled image - this.ctx.clearRect(0, 0, canvasWidth, canvasHeight); - this.ctx.drawImage(this.offscreenCanvas, 0, 0, canvasWidth, canvasHeight); + // If CSS dimensions are not set, use canvas resolution + if (canvasWidth === 0 || isNaN(canvasWidth)) { + canvasWidth = this.canvas.width; + } + if (canvasHeight === 0 || isNaN(canvasHeight)) { + canvasHeight = this.canvas.height; + } + + // Ensure we have valid dimensions + if (canvasWidth <= 0 || canvasHeight <= 0) { + console.warn('Invalid canvas dimensions for drawing'); + return; + } + + // Clear with dark background + this.ctx.fillStyle = '#111111'; + this.ctx.fillRect(0, 0, canvasWidth, canvasHeight); + + // Apply LED masking when rendering + if (this.currentFrame) { + this.drawLEDMaskedImage(canvasWidth, canvasHeight); + } else { + // Just draw the offscreen canvas normally for LED pattern + this.ctx.drawImage(this.offscreenCanvas, 0, 0, canvasWidth, canvasHeight); + } } // Frame message handling @@ -470,6 +702,10 @@ export class LEDMatrix extends HTMLElement { this.config.panelsY = panelsY; this.config.panelCols = PANEL_SIZE; this.config.panelRows = PANEL_SIZE; + + // Update coordinate mapping so geometry is available even when disconnected + this.lut.updateConfig(this.config); + this.initialize(); } From b767382c26a38e94ffe7ac8d2eabb5ad31877c39 Mon Sep 17 00:00:00 2001 From: Sam Mellor Date: Fri, 5 Sep 2025 20:12:15 -0700 Subject: [PATCH 5/9] branding --- effectsim/index.html | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/effectsim/index.html b/effectsim/index.html index ec4739a..1aa9b7e 100644 --- a/effectsim/index.html +++ b/effectsim/index.html @@ -169,9 +169,10 @@ } +
-

LED Matrix Simulator

+

Hyperion Simulator

@@ -227,11 +228,6 @@

Performance

- -