// ============================================================ // LED Matrix — animated dot display // ============================================================ // Renders a grid of "LEDs" that can show scrolling text, images, // or animated patterns. Honors the brand's mono / 3-bit / 12-bit modes. const { useState, useEffect, useRef, useMemo, useCallback } = React; // 5x7 pixel font for scrolling text const FONT_5x7 = { ' ': ['00000','00000','00000','00000','00000','00000','00000'], '!': ['00100','00100','00100','00100','00100','00000','00100'], '?': ['01110','10001','00010','00100','00100','00000','00100'], '.': ['00000','00000','00000','00000','00000','00000','00100'], ',': ['00000','00000','00000','00000','00100','00100','01000'], ':': ['00000','00100','00000','00000','00100','00000','00000'], "'": ['00100','00100','00000','00000','00000','00000','00000'], '-': ['00000','00000','00000','01110','00000','00000','00000'], '+': ['00000','00000','00100','01110','00100','00000','00000'], '*': ['00000','10101','01110','11111','01110','10101','00000'], '/': ['00001','00010','00010','00100','01000','01000','10000'], '%': ['11001','11010','00100','01011','10011','00000','00000'], '0': ['01110','10001','10011','10101','11001','10001','01110'], '1': ['00100','01100','00100','00100','00100','00100','01110'], '2': ['01110','10001','00001','00010','00100','01000','11111'], '3': ['11110','00001','00001','01110','00001','00001','11110'], '4': ['00010','00110','01010','10010','11111','00010','00010'], '5': ['11111','10000','11110','00001','00001','10001','01110'], '6': ['00110','01000','10000','11110','10001','10001','01110'], '7': ['11111','00001','00010','00100','01000','01000','01000'], '8': ['01110','10001','10001','01110','10001','10001','01110'], '9': ['01110','10001','10001','01111','00001','00010','01100'], 'A': ['01110','10001','10001','10001','11111','10001','10001'], 'B': ['11110','10001','10001','11110','10001','10001','11110'], 'C': ['01110','10001','10000','10000','10000','10001','01110'], 'D': ['11110','10001','10001','10001','10001','10001','11110'], 'E': ['11111','10000','10000','11110','10000','10000','11111'], 'F': ['11111','10000','10000','11110','10000','10000','10000'], 'G': ['01110','10001','10000','10111','10001','10001','01110'], 'H': ['10001','10001','10001','11111','10001','10001','10001'], 'I': ['01110','00100','00100','00100','00100','00100','01110'], 'J': ['00111','00010','00010','00010','00010','10010','01100'], 'K': ['10001','10010','10100','11000','10100','10010','10001'], 'L': ['10000','10000','10000','10000','10000','10000','11111'], 'M': ['10001','11011','10101','10101','10001','10001','10001'], 'N': ['10001','10001','11001','10101','10011','10001','10001'], 'O': ['01110','10001','10001','10001','10001','10001','01110'], 'P': ['11110','10001','10001','11110','10000','10000','10000'], 'Q': ['01110','10001','10001','10001','10101','10010','01101'], 'R': ['11110','10001','10001','11110','10100','10010','10001'], 'S': ['01111','10000','10000','01110','00001','00001','11110'], 'T': ['11111','00100','00100','00100','00100','00100','00100'], 'U': ['10001','10001','10001','10001','10001','10001','01110'], 'V': ['10001','10001','10001','10001','10001','01010','00100'], 'W': ['10001','10001','10001','10101','10101','10101','01010'], 'X': ['10001','10001','01010','00100','01010','10001','10001'], 'Y': ['10001','10001','01010','00100','00100','00100','00100'], 'Z': ['11111','00001','00010','00100','01000','10000','11111'], }; function textToBitmap(text, spacing = 1) { const upper = text.toUpperCase(); const cols = []; for (const ch of upper) { const g = FONT_5x7[ch] || FONT_5x7[' ']; for (let c = 0; c < 5; c++) { const col = []; for (let r = 0; r < 7; r++) col.push(g[r][c] === '1' ? 1 : 0); cols.push(col); } for (let s = 0; s < spacing; s++) cols.push([0,0,0,0,0,0,0]); } return cols; // array of columns, each of 7 rows } // Quantize a hex color into 3-bit (8 colors) or 12-bit palette function quantizeColor(hex, mode) { if (mode === 'mono') return '#E13F92'; if (!hex) return '#E13F92'; const r = parseInt(hex.slice(1,3), 16); const g = parseInt(hex.slice(3,5), 16); const b = parseInt(hex.slice(5,7), 16); if (mode === '3bit') { const rq = r > 127 ? 255 : 0; const gq = g > 127 ? 255 : 0; const bq = b > 127 ? 255 : 0; return `rgb(${rq},${gq},${bq})`; } if (mode === '12bit') { const q = (v) => Math.round(v / 17) * 17; return `rgb(${q(r)},${q(g)},${q(b)})`; } return hex; } // Hue cycle palette for rainbow effects function hueColor(t, mode) { const hue = (t * 360) % 360; const sat = 100, light = 55; // hsl -> hex approx via canvas? Cheaper to use hsl directly and let quantize handle it. const c = (1 - Math.abs(2 * light/100 - 1)) * sat/100; const x = c * (1 - Math.abs(((hue/60) % 2) - 1)); const m = light/100 - c/2; let r=0,g=0,b=0; if (hue < 60) { r=c; g=x; } else if (hue < 120) { r=x; g=c; } else if (hue < 180) { g=c; b=x; } else if (hue < 240) { g=x; b=c; } else if (hue < 300) { r=x; b=c; } else { r=c; b=x; } const hex = '#' + [r+m, g+m, b+m].map(v => Math.round(v*255).toString(16).padStart(2,'0')).join(''); return quantizeColor(hex, mode); } // ============================================================= // LEDMatrix component // Props: // rows, cols grid dimensions // pitch pixel pitch in CSS px (dot size + gap) // mode 'mono' | '3bit' | '12bit' // content { kind: 'text', text, color, scroll?: bool, speed? } // | { kind: 'rainbow' } // | { kind: 'pulse', color } // | { kind: 'plasma' } // className optional // ============================================================= function LEDMatrix({ rows = 16, cols = 96, pitch = 8, mode = '12bit', content, className = '', style }) { const [frame, setFrame] = useState(0); const raf = useRef(null); const last = useRef(0); useEffect(() => { let mounted = true; const tick = (t) => { if (!mounted) return; if (t - last.current > 60) { last.current = t; setFrame(f => (f + 1) % 100000); } raf.current = requestAnimationFrame(tick); }; raf.current = requestAnimationFrame(tick); return () => { mounted = false; cancelAnimationFrame(raf.current); }; }, []); // Compute on/off + color per cell const cells = useMemo(() => { const out = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { let on = false, color = '#000'; if (!content) { on = false; } else if (content.kind === 'text') { const text = content.text || 'VIVIDPIX'; const bitmap = textToBitmap(text, 2); const speed = content.speed || 1; const totalWidth = bitmap.length + cols; const offset = content.scroll === false ? 0 : Math.floor((frame * speed) % totalWidth) - cols; const charRowOffset = Math.floor((rows - 7) / 2); const localR = r - charRowOffset; const localC = c + offset; if (localR >= 0 && localR < 7 && localC >= 0 && localC < bitmap.length) { if (bitmap[localC][localR]) { on = true; color = quantizeColor(content.color || '#E13F92', mode); } } } else if (content.kind === 'rainbow') { on = true; const t = (frame * 0.01) + (c / cols) + (r / rows * 0.3); color = hueColor(t, mode); } else if (content.kind === 'pulse') { const cx = cols / 2, cy = rows / 2; const d = Math.sqrt((c - cx) ** 2 + (r - cy) ** 2); const wave = Math.sin(d * 0.5 - frame * 0.15); if (wave > 0.2) { on = true; color = quantizeColor(content.color || '#C813AC', mode); } } else if (content.kind === 'plasma') { const t = frame * 0.05; const v = Math.sin(c * 0.2 + t) + Math.sin(r * 0.3 + t * 1.3) + Math.sin((c + r) * 0.15 + t * 0.7); if (v > -0.5) { on = true; const hue = (v + 3) / 6; color = hueColor(hue, mode); } } else if (content.kind === 'gif-stripes') { // colorful holi-like stripes that drift on = true; const hue = ((c + frame * 0.3) / cols + Math.sin(r * 0.3) * 0.1) % 1; color = hueColor(hue < 0 ? hue + 1 : hue, mode); } out.push({ on, color }); } } return out; }, [rows, cols, frame, content, mode]); const dotSize = pitch * 0.62; return (