// Client-side Canvas Renderer for Preset Editor // Simplified implementation of building blocks for browser canvas rendering class CanvasRenderer { constructor(canvas, width = 16, height = 16) { this.canvas = canvas; this.ctx = canvas.getContext('2d', { alpha: false }); this.width = width; this.height = height; // Disable image smoothing for pixel-perfect rendering this.ctx.imageSmoothingEnabled = false; // Set canvas resolution this.canvas.width = width; this.canvas.height = height; this.frame = []; this.time = 0; this.animationStates = new Map(); this.lastFrameTime = Date.now(); } setSize(width, height) { this.width = width; this.height = height; this.canvas.width = width; this.canvas.height = height; this.frame = []; // Re-apply image smoothing setting after canvas resize this.ctx.imageSmoothingEnabled = false; } createFrame(fill = '000000') { return new Array(this.width * this.height).fill(fill); } hexToRgb(hex) { const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return { r, g, b }; } rgbToHex(rgb) { const r = Math.max(0, Math.min(255, Math.round(rgb.r))).toString(16).padStart(2, '0'); const g = Math.max(0, Math.min(255, Math.round(rgb.g))).toString(16).padStart(2, '0'); const b = Math.max(0, Math.min(255, Math.round(rgb.b))).toString(16).padStart(2, '0'); return r + g + b; } lerpRgb(rgb1, rgb2, t) { return { r: rgb1.r + (rgb2.r - rgb1.r) * t, g: rgb1.g + (rgb2.g - rgb1.g) * t, b: rgb1.b + (rgb2.b - rgb1.b) * t }; } clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } toIndex(col, row) { // Serpentine wiring if (row % 2 === 0) { return row * this.width + col; } return row * this.width + (this.width - 1 - col); } setPixelColor(frame, col, row, color, intensity = 1.0) { const index = this.toIndex(col, row); if (index < 0 || index >= frame.length) return; if (intensity >= 1.0) { frame[index] = color; } else { const currentRgb = this.hexToRgb(frame[index]); const newRgb = this.hexToRgb(color); const blended = { r: currentRgb.r + (newRgb.r - currentRgb.r) * intensity, g: currentRgb.g + (newRgb.g - currentRgb.g) * intensity, b: currentRgb.b + (newRgb.b - currentRgb.b) * intensity }; frame[index] = this.rgbToHex(blended); } } // Color generators createColorGenerator(colorConfig) { if (!colorConfig) { return () => 'ff0000'; } const type = colorConfig.type || 'solid'; switch (type) { case 'solid': return () => colorConfig.value || 'ff0000'; case 'gradient': { const rgb1 = this.hexToRgb(colorConfig.color1 || 'ff0000'); const rgb2 = this.hexToRgb(colorConfig.color2 || '0000ff'); return (t) => { const clamped = this.clamp(t, 0, 1); return this.rgbToHex(this.lerpRgb(rgb1, rgb2, clamped)); }; } case 'rainbow': { return (t) => { const pos = Math.floor(this.clamp(t, 0, 1) * 255); const wheelPos = 255 - pos; let r, g, b; if (wheelPos < 85) { r = 255 - wheelPos * 3; g = 0; b = wheelPos * 3; } else if (wheelPos < 170) { const adjusted = wheelPos - 85; r = 0; g = adjusted * 3; b = 255 - adjusted * 3; } else { const adjusted = wheelPos - 170; r = adjusted * 3; g = 255 - adjusted * 3; b = 0; } return this.rgbToHex({ r, g, b }); }; } case 'palette': { const stops = colorConfig.stops || [ { position: 0, color: '000000' }, { position: 1, color: 'ffffff' } ]; return (t) => { const clamped = this.clamp(t, 0, 1); // Find the two stops to interpolate between let stop1 = stops[0]; let stop2 = stops[stops.length - 1]; for (let i = 0; i < stops.length - 1; i++) { if (clamped >= stops[i].position && clamped <= stops[i + 1].position) { stop1 = stops[i]; stop2 = stops[i + 1]; break; } } // Interpolate between the two stops const range = stop2.position - stop1.position; const localT = range > 0 ? (clamped - stop1.position) / range : 0; const rgb1 = this.hexToRgb(stop1.color); const rgb2 = this.hexToRgb(stop2.color); return this.rgbToHex(this.lerpRgb(rgb1, rgb2, localT)); }; } default: return () => 'ff0000'; } } // Shape rendering renderCircle(frame, centerX, centerY, radius, color, intensity = 1.0) { for (let row = 0; row < this.height; row++) { for (let col = 0; col < this.width; col++) { const dx = col - centerX; const dy = row - centerY; const distance = Math.hypot(dx, dy); if (distance <= radius) { const falloff = 1 - (distance / radius); const pixelIntensity = falloff * intensity; this.setPixelColor(frame, col, row, color, pixelIntensity); } } } } renderRectangle(frame, x, y, width, height, color, intensity = 1.0) { for (let row = Math.floor(y); row < Math.min(this.height, y + height); row++) { for (let col = Math.floor(x); col < Math.min(this.width, x + width); col++) { if (row >= 0 && col >= 0) { this.setPixelColor(frame, col, row, color, intensity); } } } } renderBlob(frame, centerX, centerY, radius, color, falloffPower = 2) { for (let row = 0; row < this.height; row++) { for (let col = 0; col < this.width; col++) { const dx = col - centerX; const dy = row - centerY; const distance = Math.hypot(dx, dy); const falloff = Math.max(0, 1 - distance / radius); const intensity = Math.pow(falloff, falloffPower); if (intensity > 0.01) { this.setPixelColor(frame, col, row, color, intensity); } } } } renderPoint(frame, x, y, color, intensity = 1.0) { const col = Math.round(x); const row = Math.round(y); if (col >= 0 && col < this.width && row >= 0 && row < this.height) { this.setPixelColor(frame, col, row, color, intensity); } } renderTriangle(frame, x1, y1, x2, y2, x3, y3, color, intensity = 1.0) { // Simple filled triangle using barycentric coordinates const minX = Math.max(0, Math.floor(Math.min(x1, x2, x3))); const maxX = Math.min(this.width - 1, Math.ceil(Math.max(x1, x2, x3))); const minY = Math.max(0, Math.floor(Math.min(y1, y2, y3))); const maxY = Math.min(this.height - 1, Math.ceil(Math.max(y1, y2, y3))); for (let row = minY; row <= maxY; row++) { for (let col = minX; col <= maxX; col++) { if (this.isPointInTriangle(col, row, x1, y1, x2, y2, x3, y3)) { this.setPixelColor(frame, col, row, color, intensity); } } } } isPointInTriangle(px, py, x1, y1, x2, y2, x3, y3) { const d1 = this.sign(px, py, x1, y1, x2, y2); const d2 = this.sign(px, py, x2, y2, x3, y3); const d3 = this.sign(px, py, x3, y3, x1, y1); const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0); const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0); return !(hasNeg && hasPos); } sign(px, py, x1, y1, x2, y2) { return (px - x2) * (y1 - y2) - (x1 - x2) * (py - y2); } // Animation generators createAnimation(animConfig, layerIndex) { const type = animConfig.type; const params = animConfig.params || {}; const startTime = Date.now(); switch (type) { case 'move': return () => { const elapsed = (Date.now() - startTime) / 1000; const duration = params.duration || 2.0; const t = this.clamp((elapsed % duration) / duration, 0, 1); return { x: (params.startX || 0) + ((params.endX || this.width) - (params.startX || 0)) * t, y: (params.startY || 0) + ((params.endY || this.height) - (params.startY || 0)) * t, t }; }; case 'rotate': return () => { const elapsed = (Date.now() - startTime) / 1000; const speed = params.speed || 1.0; return { angle: elapsed * speed * Math.PI * 2, elapsed }; }; case 'pulse': return () => { const elapsed = (Date.now() - startTime) / 1000; const frequency = params.frequency || 1.0; const t = (Math.sin(2 * Math.PI * frequency * elapsed) + 1) / 2; const minScale = params.minScale || 0.5; const maxScale = params.maxScale || 1.5; return { scale: minScale + (maxScale - minScale) * t, t, elapsed }; }; case 'oscillateX': case 'oscillateY': return () => { const elapsed = (Date.now() - startTime) / 1000; const frequency = params.frequency || 0.5; const center = params.center || (type === 'oscillateX' ? this.width / 2 : this.height / 2); const amplitude = params.amplitude || 4; const phase = params.phase || 0; const value = center + amplitude * Math.sin(2 * Math.PI * frequency * elapsed + phase); return { value, elapsed }; }; case 'fade': return () => { const elapsed = (Date.now() - startTime) / 1000; const duration = params.duration || 2.0; const t = this.clamp(elapsed / duration, 0, 1); const fadeIn = params.fadeIn !== false; return { intensity: fadeIn ? t : (1 - t), t, elapsed }; }; default: return () => ({}); } } // Render a complete preset configuration renderConfiguration(configuration, speed = 1.0, brightness = 1.0) { const now = Date.now(); const deltaTime = (now - this.lastFrameTime) / 1000; this.lastFrameTime = now; this.time += deltaTime * speed; let frame = this.createFrame('000000'); // Render each layer configuration.layers?.forEach((layer, index) => { this.renderLayer(frame, layer, index, brightness); }); return frame; } renderLayer(frame, layer, layerIndex, globalBrightness) { const type = layer.type; if (type === 'shape') { this.renderShapeLayer(frame, layer, layerIndex, globalBrightness); } else if (type === 'pattern') { this.renderPatternLayer(frame, layer, layerIndex, globalBrightness); } } renderShapeLayer(frame, layer, layerIndex, globalBrightness) { let position = layer.position || { x: this.width / 2, y: this.height / 2 }; let size = layer.size || { radius: 3 }; let rotation = 0; let scale = 1.0; let intensity = (layer.intensity || 1.0) * globalBrightness; // Initialize animation if needed if (layer.animation && !this.animationStates.has(layerIndex)) { this.animationStates.set(layerIndex, this.createAnimation(layer.animation, layerIndex)); } // Apply animation if (this.animationStates.has(layerIndex)) { const animation = this.animationStates.get(layerIndex); const animState = animation(); if (animState.x !== undefined && animState.y !== undefined) { position = { x: animState.x, y: animState.y }; } if (animState.angle !== undefined) { rotation = animState.angle; } if (animState.scale !== undefined) { scale = animState.scale; } if (animState.intensity !== undefined) { intensity *= animState.intensity; } if (animState.value !== undefined && layer.animation?.axis === 'x') { position.x = animState.value; } else if (animState.value !== undefined && layer.animation?.axis === 'y') { position.y = animState.value; } } // Create color generator const colorGen = this.createColorGenerator(layer.color); const color = typeof colorGen === 'function' ? colorGen(0.5) : colorGen; // Apply scale to size const scaledSize = { radius: (size.radius || 3) * scale, width: (size.width || 5) * scale, height: (size.height || 5) * scale }; // Render shape const shapeType = layer.shape || 'circle'; switch (shapeType) { case 'circle': this.renderCircle(frame, position.x, position.y, scaledSize.radius, color, intensity); break; case 'rectangle': this.renderRectangle( frame, position.x - scaledSize.width / 2, position.y - scaledSize.height / 2, scaledSize.width, scaledSize.height, color, intensity ); break; case 'triangle': { // Triangle with rotation support const triSize = scaledSize.radius || 3; const points = [ { x: 0, y: -triSize }, { x: -triSize, y: triSize }, { x: triSize, y: triSize } ]; // Apply rotation if there's an animation with angle const cos = Math.cos(rotation); const sin = Math.sin(rotation); const rotated = points.map(p => ({ x: p.x * cos - p.y * sin, y: p.x * sin + p.y * cos })); this.renderTriangle( frame, position.x + rotated[0].x, position.y + rotated[0].y, position.x + rotated[1].x, position.y + rotated[1].y, position.x + rotated[2].x, position.y + rotated[2].y, color, intensity ); break; } case 'blob': this.renderBlob(frame, position.x, position.y, scaledSize.radius, color, layer.falloffPower || 2); break; case 'point': this.renderPoint(frame, position.x, position.y, color, intensity); break; } } renderPatternLayer(frame, layer, layerIndex, globalBrightness) { // Simplified pattern rendering for canvas const patternType = layer.pattern; const intensity = (layer.intensity || 1.0) * globalBrightness; if (patternType === 'radial' || patternType === 'spiral') { const params = layer.params || {}; const centerX = params.centerX || this.width / 2; const centerY = params.centerY || this.height / 2; const maxRadius = Math.hypot(this.width / 2, this.height / 2); const colorGen = this.createColorGenerator(layer.color); for (let row = 0; row < this.height; row++) { for (let col = 0; col < this.width; col++) { const dx = col - centerX; const dy = row - centerY; const distance = Math.hypot(dx, dy); const angle = Math.atan2(dy, dx); let t; if (patternType === 'spiral') { const arms = params.arms || 5; const rotationSpeed = params.rotationSpeed || 1.0; const spiralValue = (Math.sin(arms * (angle + this.time * rotationSpeed)) + 1) / 2; const radiusValue = distance / maxRadius; t = this.clamp(spiralValue * 0.5 + radiusValue * 0.5, 0, 1); } else { t = this.clamp(distance / maxRadius, 0, 1); } const color = colorGen(t); // Use setPixelColor for proper layer compositing this.setPixelColor(frame, col, row, color, intensity); } } } } // Draw frame to canvas drawFrame(frame) { // Clear canvas first this.ctx.fillStyle = '#000000'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); const pixelWidth = this.canvas.width / this.width; const pixelHeight = this.canvas.height / this.height; for (let row = 0; row < this.height; row++) { for (let col = 0; col < this.width; col++) { const index = this.toIndex(col, row); const color = frame[index]; if (color) { const rgb = this.hexToRgb(color); this.ctx.fillStyle = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; this.ctx.fillRect(col * pixelWidth, row * pixelHeight, pixelWidth, pixelHeight); } } } } clear() { this.ctx.fillStyle = '#000000'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); } reset() { this.animationStates.clear(); this.time = 0; this.lastFrameTime = Date.now(); } } // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = CanvasRenderer; }