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..8a68b74
--- /dev/null
+++ b/effectsim/README.md
@@ -0,0 +1,116 @@
+# 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
+- `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 with serpentine wiring (rows alternate L→R, R→L)
+- **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..eeb0904
--- /dev/null
+++ b/effectsim/index.html
@@ -0,0 +1,310 @@
+
+
+
+
+
+ LED Matrix Simulator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Performance
+
+ FPS:
+ 0.0
+
+
+ Render:
+ 0.00ms
+
+
+ Dropped:
+ 0
+
+
+ Resolution:
+ 0×0
+
+
+
+
+
+
+
+
\ 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..a25c133
--- /dev/null
+++ b/effectsim/server/test-server.js
@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+
+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 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) {
+ 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(`Matrix: ${PANELS_X}×${PANELS_Y} panels of ${PANEL_SIZE}×${PANEL_SIZE} = ${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 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
+ 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)`);
+ }
+ }
+
+ 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);
+
+ // 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; // 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);
+
+ const [r, g, b] = this.hsvToRgb(hue, 1, brightness);
+
+ 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..a02d0e6
--- /dev/null
+++ b/effectsim/src/led-matrix.ts
@@ -0,0 +1,576 @@
+// LED Matrix Simulator Web Component
+
+import { CoordinateLUT } from './util/lut.js';
+import { FPSCounter, FPSLimiter } from './util/fps.js';
+import { WebGLLEDRenderer } from './util/webgl-renderer.js';
+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 webglRenderer!: WebGLLEDRenderer;
+
+ // 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 lastCanvasSize: { width: number; height: number } = { width: 0, height: 0 };
+ private cachedContainerSize: { width: number; height: number } = { width: 0, height: 0 };
+
+ // Observed attributes (geometry comes from frame headers)
+ static get observedAttributes() {
+ return [
+ 'pixel-size', 'gap',
+ 'fps-cap', 'ws-url', 'lens-flare-intensity'
+ ];
+ }
+
+ constructor() {
+ super();
+
+ // Initialize state
+ this.state = {
+ initialized: false,
+ connected: false,
+ rendering: false
+ };
+
+ // No default configuration - will be set from frame headers
+ this.config = {
+ panelsX: 0,
+ panelsY: 0,
+ panelCols: 0,
+ panelRows: 0,
+ pixelSize: 'auto',
+ gap: 1,
+ fpsCap: 0,
+ lensFlareIntensity: 0.5
+ };
+
+ // 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' });
+
+
+ this.initializeDOM();
+ }
+
+ connectedCallback() {
+ console.log('LED Matrix component connected');
+
+ 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 (only if not already connected)
+ if (this.wsUrl && !this.ws) {
+ 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.ws) {
+ this.connectWebSocket();
+ }
+ } else if (name === 'pixel-size') {
+ if (newValue === 'auto') {
+ this.cachedContainerSize = { width: 0, height: 0 }; // Reset cache to recalculate
+ }
+ 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): 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) {
+ 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;
+ 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 resolutionWidth = canvasWidth * devicePixelRatio;
+ const resolutionHeight = canvasHeight * devicePixelRatio;
+ this.canvas.width = resolutionWidth;
+ this.canvas.height = resolutionHeight;
+
+ // Update WebGL renderer
+ if (this.webglRenderer) {
+ this.webglRenderer.resize(resolutionWidth, resolutionHeight);
+ }
+ }
+
+ // Private methods
+
+
+ private initializeDOM(): void {
+ if (!this.shadowRoot) return;
+
+ // Create canvas
+ this.canvas = document.createElement('canvas');
+ this.canvas.style.display = 'block';
+ this.canvas.style.imageRendering = 'pixelated';
+
+ // Initialize WebGL renderer
+ try {
+ this.webglRenderer = new WebGLLEDRenderer(this.canvas);
+ if (!this.webglRenderer.initialize()) {
+ throw new Error('WebGL renderer initialization failed');
+ }
+ } catch (error) {
+ console.error('Failed to initialize WebGL renderer:', error);
+ throw new Error('WebGL not supported or failed to initialize');
+ }
+
+ // Add to shadow DOM
+ this.shadowRoot.appendChild(this.canvas);
+
+ // Load CSS
+ const style = document.createElement('style');
+ style.textContent = `
+ :host {
+ display: inline-block;
+ background: var(--led-off-bg, #111);
+ }
+ canvas {
+ display: block;
+ }
+ `;
+ this.shadowRoot.appendChild(style);
+ }
+
+ private updateConfigFromAttributes(): void {
+
+ 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.config.lensFlareIntensity = parseFloat(this.getAttribute('lens-flare-intensity') || '0.5');
+ 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();
+ console.log(`📐 Matrix dimensions calculated: ${dims.cols}×${dims.rows} (${dims.totalPixels} pixels)`);
+
+ // Update WebGL renderer with new dimensions
+ if (this.webglRenderer) {
+ this.webglRenderer.updateDimensions(dims);
+ console.log(`🎨 Updated WebGL renderer: ${dims.cols}×${dims.rows}`);
+ }
+
+ // 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);
+ }
+
+ 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;
+ }
+
+ const dims = this.lut.getDimensions();
+ if (dims.cols === 0 || dims.rows === 0) {
+ // No geometry yet - wait for WebSocket frame headers
+ return;
+ }
+
+ if (!this.webglRenderer) {
+ console.error('WebGL renderer not initialized');
+ this.fpsCounter.dropFrame();
+ return;
+ }
+
+ const canvasWidth = this.canvas.width;
+ const canvasHeight = this.canvas.height;
+
+ if (canvasWidth === 0 || canvasHeight === 0) {
+ return;
+ }
+
+ if (this.state.connected && this.currentFrame) {
+ this.webglRenderer.updateFrame(this.currentFrame);
+ this.webglRenderer.render(canvasWidth, canvasHeight, {
+ gap: this.config.gap,
+ lensFlareIntensity: this.config.lensFlareIntensity
+ }, true);
+ } else {
+ this.webglRenderer.render(canvasWidth, canvasHeight, {
+ gap: this.config.gap,
+ lensFlareIntensity: this.config.lensFlareIntensity
+ }, false);
+ }
+
+ this.fpsCounter.endFrame(renderStart);
+
+ } catch (error) {
+ console.error('❌ Render error:', error);
+ this.fpsCounter.dropFrame();
+ }
+ }
+
+
+
+
+
+
+
+ // 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;
+
+ // Update coordinate mapping so geometry is available even when disconnected
+ this.lut.updateConfig(this.config);
+
+ 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) {
+ 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);
+ }
+ }
+
+ 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;
+ }
+
+ // Cleanup WebGL resources
+ if (this.webglRenderer) {
+ this.webglRenderer.cleanup();
+ }
+
+ // Disconnect WebSocket
+ this.disconnectWebSocket();
+
+ console.log('LED Matrix component cleaned up');
+ }
+}
+
+// Register the custom element
+customElements.define('led-matrix', LEDMatrix);
diff --git a/effectsim/src/types.d.ts b/effectsim/src/types.d.ts
new file mode 100644
index 0000000..d4c1cb3
--- /dev/null
+++ b/effectsim/src/types.d.ts
@@ -0,0 +1,59 @@
+// Core type definitions for LED Matrix Simulator
+
+export interface MatrixConfig {
+ panelsX: number;
+ panelsY: number;
+ panelCols: number;
+ panelRows: number;
+ pixelSize: number | 'auto';
+ gap: number;
+ fpsCap: number;
+ lensFlareIntensity: 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..161d2f1
--- /dev/null
+++ b/effectsim/src/util/lut.ts
@@ -0,0 +1,64 @@
+// Simple coordinate mapping utilities
+
+import type { MatrixConfig, MatrixDimensions } from '../types.d.ts';
+
+export class CoordinateLUT {
+ private dimensions: MatrixDimensions;
+
+ constructor(config: MatrixConfig) {
+ this.dimensions = this.calculateDimensions(config);
+ }
+
+ /**
+ * Update configuration and recalculate dimensions
+ */
+ updateConfig(config: MatrixConfig): void {
+ const newDimensions = this.calculateDimensions(config);
+ this.dimensions = newDimensions;
+ console.log(`Matrix dimensions: ${this.dimensions.cols}×${this.dimensions.rows} (${this.dimensions.totalPixels} pixels)`);
+ }
+
+ /**
+ * 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 {
+ if (row < 0 || row >= this.dimensions.rows ||
+ col < 0 || col >= this.dimensions.cols) {
+ return -1;
+ }
+
+ // 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;
+ }
+
+ /**
+ * 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
+ };
+ }
+}
+
+/**
+ * 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/src/util/webgl-renderer.ts b/effectsim/src/util/webgl-renderer.ts
new file mode 100644
index 0000000..7bbc814
--- /dev/null
+++ b/effectsim/src/util/webgl-renderer.ts
@@ -0,0 +1,241 @@
+// WebGL LED Matrix Renderer
+
+import type { MatrixDimensions, FrameBuffer } from '../types.d.ts';
+import {
+ WebGLShaderProgram,
+ createProgram,
+ getUniformLocation,
+ getAttribLocation,
+ createFrameTexture,
+ createQuadBuffer,
+ VERTEX_SHADER_SOURCE,
+ FRAGMENT_SHADER_SOURCE
+} from './webgl.js';
+
+export class WebGLLEDRenderer {
+ private gl: WebGLRenderingContext;
+ private program: WebGLShaderProgram | null = null;
+ private frameTexture: WebGLTexture | null = null;
+ private quadBuffer: WebGLBuffer | null = null;
+
+ // Cached uniform locations
+ private uniforms = {
+ frameTexture: null as WebGLUniformLocation | null,
+ resolution: null as WebGLUniformLocation | null,
+ matrixSize: null as WebGLUniformLocation | null,
+ ledRadius: null as WebGLUniformLocation | null,
+ ledSpacing: null as WebGLUniformLocation | null,
+ flareIntensity: null as WebGLUniformLocation | null,
+ hasFrameData: null as WebGLUniformLocation | null
+ };
+
+ // Current state
+ private currentDimensions: MatrixDimensions | null = null;
+ private isInitialized = false;
+
+ constructor(canvas: HTMLCanvasElement) {
+ const gl = canvas.getContext('webgl');
+ if (!gl) {
+ throw new Error('WebGL not supported');
+ }
+ this.gl = gl;
+
+ // Enable blending for lens flare effects
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ }
+
+ /**
+ * Initialize WebGL resources
+ */
+ initialize(): boolean {
+ try {
+ // Create shader program
+ this.program = createProgram(this.gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE);
+ if (!this.program) {
+ console.error('Failed to create shader program');
+ return false;
+ }
+
+ // Get uniform locations
+ this.uniforms.frameTexture = getUniformLocation(this.gl, this.program, 'u_frameTexture');
+ this.uniforms.resolution = getUniformLocation(this.gl, this.program, 'u_resolution');
+ this.uniforms.matrixSize = getUniformLocation(this.gl, this.program, 'u_matrixSize');
+ this.uniforms.ledRadius = getUniformLocation(this.gl, this.program, 'u_ledRadius');
+ this.uniforms.ledSpacing = getUniformLocation(this.gl, this.program, 'u_ledSpacing');
+ this.uniforms.flareIntensity = getUniformLocation(this.gl, this.program, 'u_flareIntensity');
+ this.uniforms.hasFrameData = getUniformLocation(this.gl, this.program, 'u_hasFrameData');
+
+ // Create fullscreen quad buffer
+ this.quadBuffer = createQuadBuffer(this.gl);
+ if (!this.quadBuffer) {
+ console.error('Failed to create quad buffer');
+ return false;
+ }
+
+ // Create frame texture
+ this.frameTexture = createFrameTexture(this.gl);
+ if (!this.frameTexture) {
+ console.error('Failed to create frame texture');
+ return false;
+ }
+
+ this.isInitialized = true;
+ console.log('WebGL LED renderer initialized successfully');
+ return true;
+
+ } catch (error) {
+ console.error('WebGL initialization failed:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Update matrix dimensions and reallocate resources if needed
+ */
+ updateDimensions(dimensions: MatrixDimensions): void {
+ if (!this.currentDimensions ||
+ this.currentDimensions.cols !== dimensions.cols ||
+ this.currentDimensions.rows !== dimensions.rows) {
+
+ this.currentDimensions = { ...dimensions };
+ console.log(`WebGL renderer updated to ${dimensions.cols}×${dimensions.rows}`);
+ }
+ }
+
+ /**
+ * Upload frame data to texture
+ */
+ updateFrame(frame: FrameBuffer): void {
+ if (!this.isInitialized || !this.frameTexture || !this.currentDimensions) {
+ return;
+ }
+
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.frameTexture);
+ this.gl.texImage2D(
+ this.gl.TEXTURE_2D,
+ 0, // level
+ this.gl.RGB, // internal format
+ this.currentDimensions.cols, // width
+ this.currentDimensions.rows, // height
+ 0, // border
+ this.gl.RGB, // format
+ this.gl.UNSIGNED_BYTE, // type
+ frame.data // data
+ );
+ }
+
+ /**
+ * Render the current frame with LED effects
+ */
+ render(canvasWidth: number, canvasHeight: number, config: {
+ gap: number;
+ lensFlareIntensity: number;
+ }, hasFrameData: boolean = true): void {
+
+ if (!this.isInitialized || !this.program || !this.quadBuffer || !this.currentDimensions) {
+ return;
+ }
+
+ const gl = this.gl;
+
+ // Set viewport
+ gl.viewport(0, 0, canvasWidth, canvasHeight);
+
+ // Clear with dark background
+ gl.clearColor(0.067, 0.067, 0.067, 1.0); // #111111
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ // Use shader program
+ gl.useProgram(this.program.program);
+
+ // Bind fullscreen quad
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
+
+ const positionAttrib = getAttribLocation(gl, this.program, 'a_position');
+ if (positionAttrib !== -1) {
+ gl.enableVertexAttribArray(positionAttrib);
+ gl.vertexAttribPointer(positionAttrib, 2, gl.FLOAT, false, 0, 0);
+ }
+
+ // Set uniforms
+ if (this.uniforms.resolution) {
+ gl.uniform2f(this.uniforms.resolution, canvasWidth, canvasHeight);
+ }
+
+ if (this.uniforms.matrixSize) {
+ gl.uniform2f(this.uniforms.matrixSize, this.currentDimensions.cols, this.currentDimensions.rows);
+ }
+
+ // LED parameters (matching current Canvas 2D implementation)
+ if (this.uniforms.ledRadius) {
+ gl.uniform1f(this.uniforms.ledRadius, 0.15); // 15% of pixel radius
+ }
+
+ if (this.uniforms.ledSpacing) {
+ gl.uniform1f(this.uniforms.ledSpacing, 0.25); // 25% spacing between LED centers
+ }
+
+ if (this.uniforms.flareIntensity) {
+ gl.uniform1f(this.uniforms.flareIntensity, config.lensFlareIntensity);
+ }
+
+ if (this.uniforms.hasFrameData) {
+ gl.uniform1i(this.uniforms.hasFrameData, hasFrameData ? 1 : 0);
+ }
+
+ // Bind frame texture
+ if (hasFrameData && this.frameTexture) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.frameTexture);
+
+ if (this.uniforms.frameTexture) {
+ gl.uniform1i(this.uniforms.frameTexture, 0);
+ }
+ }
+
+ // Draw fullscreen quad
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
+
+ // Clean up
+ if (positionAttrib !== -1) {
+ gl.disableVertexAttribArray(positionAttrib);
+ }
+ }
+
+ /**
+ * Handle canvas resize
+ */
+ resize(canvasWidth: number, canvasHeight: number): void {
+ if (!this.isInitialized) return;
+
+ // Update canvas size
+ this.gl.canvas.width = canvasWidth;
+ this.gl.canvas.height = canvasHeight;
+ }
+
+ /**
+ * Clean up WebGL resources
+ */
+ cleanup(): void {
+ if (!this.gl) return;
+
+ if (this.frameTexture) {
+ this.gl.deleteTexture(this.frameTexture);
+ this.frameTexture = null;
+ }
+
+ if (this.quadBuffer) {
+ this.gl.deleteBuffer(this.quadBuffer);
+ this.quadBuffer = null;
+ }
+
+ if (this.program) {
+ this.gl.deleteProgram(this.program.program);
+ this.program = null;
+ }
+
+ this.isInitialized = false;
+ console.log('WebGL renderer cleaned up');
+ }
+}
\ No newline at end of file
diff --git a/effectsim/src/util/webgl.ts b/effectsim/src/util/webgl.ts
new file mode 100644
index 0000000..11dd5af
--- /dev/null
+++ b/effectsim/src/util/webgl.ts
@@ -0,0 +1,274 @@
+// WebGL utilities for LED Matrix rendering
+
+export interface WebGLShaderProgram {
+ program: WebGLProgram;
+ uniforms: { [key: string]: WebGLUniformLocation };
+ attributes: { [key: string]: number };
+}
+
+/**
+ * Compile a WebGL shader
+ */
+export function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
+ const shader = gl.createShader(type);
+ if (!shader) {
+ console.error('Failed to create shader');
+ return null;
+ }
+
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ console.error('Shader compilation error:', gl.getShaderInfoLog(shader));
+ gl.deleteShader(shader);
+ return null;
+ }
+
+ return shader;
+}
+
+/**
+ * Create and link a WebGL program
+ */
+export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLShaderProgram | null {
+ const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
+ const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
+
+ if (!vertexShader || !fragmentShader) {
+ return null;
+ }
+
+ const program = gl.createProgram();
+ if (!program) {
+ console.error('Failed to create program');
+ return null;
+ }
+
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error('Program linking error:', gl.getProgramInfoLog(program));
+ gl.deleteProgram(program);
+ return null;
+ }
+
+ // Clean up shaders
+ gl.deleteShader(vertexShader);
+ gl.deleteShader(fragmentShader);
+
+ return {
+ program,
+ uniforms: {},
+ attributes: {}
+ };
+}
+
+/**
+ * Get and cache uniform locations
+ */
+export function getUniformLocation(gl: WebGLRenderingContext, shaderProgram: WebGLShaderProgram, name: string): WebGLUniformLocation | null {
+ if (shaderProgram.uniforms[name] !== undefined) {
+ return shaderProgram.uniforms[name];
+ }
+
+ const location = gl.getUniformLocation(shaderProgram.program, name);
+ if (location === null) {
+ console.warn(`Uniform '${name}' not found in shader program`);
+ return null;
+ }
+
+ shaderProgram.uniforms[name] = location;
+ return location;
+}
+
+/**
+ * Get and cache attribute locations
+ */
+export function getAttribLocation(gl: WebGLRenderingContext, shaderProgram: WebGLShaderProgram, name: string): number {
+ if (shaderProgram.attributes[name] !== undefined) {
+ return shaderProgram.attributes[name];
+ }
+
+ const location = gl.getAttribLocation(shaderProgram.program, name);
+ if (location === -1) {
+ console.warn(`Attribute '${name}' not found in shader program`);
+ }
+
+ shaderProgram.attributes[name] = location;
+ return location;
+}
+
+/**
+ * Create a texture for RGB frame data
+ */
+export function createFrameTexture(gl: WebGLRenderingContext): WebGLTexture | null {
+ const texture = gl.createTexture();
+ if (!texture) {
+ console.error('Failed to create texture');
+ return null;
+ }
+
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+
+ // Set texture parameters for pixel-perfect rendering
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+
+ return texture;
+}
+
+/**
+ * Create a buffer for fullscreen quad vertices
+ */
+export function createQuadBuffer(gl: WebGLRenderingContext): WebGLBuffer | null {
+ const buffer = gl.createBuffer();
+ if (!buffer) {
+ console.error('Failed to create buffer');
+ return null;
+ }
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+
+ // Fullscreen quad vertices (two triangles)
+ const vertices = new Float32Array([
+ -1.0, -1.0, // Bottom left
+ 1.0, -1.0, // Bottom right
+ -1.0, 1.0, // Top left
+ 1.0, 1.0 // Top right
+ ]);
+
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
+ return buffer;
+}
+
+// Vertex shader source - simple fullscreen quad
+export const VERTEX_SHADER_SOURCE = `
+attribute vec2 a_position;
+varying vec2 v_texCoord;
+
+void main() {
+ gl_Position = vec4(a_position, 0.0, 1.0);
+ v_texCoord = (a_position + 1.0) / 2.0;
+}
+`;
+
+// Fragment shader source - LED cluster rendering
+export const FRAGMENT_SHADER_SOURCE = `
+precision mediump float;
+
+uniform sampler2D u_frameTexture;
+uniform vec2 u_resolution;
+uniform vec2 u_matrixSize;
+uniform float u_ledRadius;
+uniform float u_ledSpacing;
+uniform float u_flareIntensity;
+uniform bool u_hasFrameData;
+
+varying vec2 v_texCoord;
+
+// Rotate a 2D point by angle (in radians)
+vec2 rotate(vec2 point, float angle) {
+ float c = cos(angle);
+ float s = sin(angle);
+ return vec2(point.x * c - point.y * s, point.x * s + point.y * c);
+}
+
+void main() {
+ // Convert screen coords to logical pixel coords
+ vec2 logicalPixel = v_texCoord * u_matrixSize;
+ vec2 pixelCenter = floor(logicalPixel) + 0.5;
+
+ // Sample RGB data for this logical pixel (or use pattern color)
+ vec3 pixelColor;
+ if (u_hasFrameData) {
+ pixelColor = texture2D(u_frameTexture, pixelCenter / u_matrixSize).rgb;
+ } else {
+ // LED pattern mode - dim LEDs for visibility
+ pixelColor = vec3(0.5, 0.5, 0.5);
+ }
+
+ // Calculate position within the logical pixel
+ vec2 localPos = logicalPixel - floor(logicalPixel) - 0.5;
+
+ // LED cluster positions (2x2 grid pattern)
+ vec2 ledPositions[4];
+ ledPositions[0] = vec2(-u_ledSpacing, -u_ledSpacing); // Top-left
+ ledPositions[1] = vec2(u_ledSpacing, -u_ledSpacing); // Top-right
+ ledPositions[2] = vec2(-u_ledSpacing, u_ledSpacing); // Bottom-left
+ ledPositions[3] = vec2(u_ledSpacing, u_ledSpacing); // Bottom-right
+
+ vec4 finalColor = vec4(0.067, 0.067, 0.067, 1.0); // #111111 background // Black background (#111)
+
+ // First pass: render LED packages and LEDs
+ for (int i = 0; i < 4; i++) {
+ vec2 ledCenter = ledPositions[i];
+ vec2 ledLocalPos = localPos - ledCenter;
+
+ // Calculate LED package size (diamond when rotated 45 degrees)
+ float packageSize = u_ledSpacing * sqrt(2.0);
+
+ // Check if we're inside the diamond package (rotated square)
+ vec2 rotatedPos = rotate(ledLocalPos, -0.785398); // -45 degrees
+ bool insidePackage = abs(rotatedPos.x) <= packageSize / 2.0 && abs(rotatedPos.y) <= packageSize / 2.0;
+
+ if (insidePackage) {
+ // Inside package - dark grey background
+ finalColor.rgb = vec3(0.2, 0.2, 0.2); // Dark grey package (#333)
+
+ // Check if we're inside the circular LED
+ // LED diameter should equal package side length
+ float distToLED = length(ledLocalPos);
+ float circleRadius = packageSize / 2.0; // Circle diameter = package side length
+ if (distToLED <= circleRadius) {
+ // Inside circular LED - apply pixel color
+ finalColor.rgb = pixelColor;
+ }
+ break;
+ }
+ }
+
+ // Second pass: apply lens flare effects from current and neighboring pixels
+ if (u_hasFrameData) {
+ // Check flare from neighboring pixels to allow bleeding across boundaries
+ for (int dy = -1; dy <= 1; dy++) {
+ for (int dx = -1; dx <= 1; dx++) {
+ // Sample neighboring pixel color
+ vec2 neighborPixelCenter = pixelCenter + vec2(float(dx), float(dy));
+ vec3 neighborPixelColor = texture2D(u_frameTexture, neighborPixelCenter / u_matrixSize).rgb;
+ float neighborBrightness = max(max(neighborPixelColor.r, neighborPixelColor.g), neighborPixelColor.b);
+
+ if (neighborBrightness > 0.1) {
+ // Calculate offset for neighbor pixel's LED positions
+ vec2 neighborOffset = vec2(float(dx), float(dy));
+
+ // Check all 4 LEDs in this neighboring pixel
+ for (int i = 0; i < 4; i++) {
+ vec2 neighborLEDCenter = ledPositions[i] + neighborOffset;
+ vec2 ledLocalPos = localPos - neighborLEDCenter;
+ float distToLED = length(ledLocalPos);
+
+ float packageSize = u_ledSpacing * sqrt(2.0);
+ float circleRadius = packageSize / 2.0;
+ float flareRadius = circleRadius * (1.0 + neighborBrightness * u_flareIntensity * 2.0);
+
+ if (distToLED <= flareRadius && distToLED > circleRadius) {
+ // Outside LED circle but within flare radius
+ float flareIntensity = max(0.0, (flareRadius - distToLED) / (flareRadius - circleRadius));
+ flareIntensity *= neighborBrightness * u_flareIntensity * 0.3;
+ // Additive blend for lens flare from neighboring LEDs
+ finalColor.rgb += neighborPixelColor * flareIntensity;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ gl_FragColor = finalColor;
+}
+`;
\ 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