{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "lightboard", "title": "Light Board", "description": "A scrolling dot-matrix LED board that supports drawing and sketching.", "dependencies": [], "registryDependencies": [ "amitgajare2/ariseui/utils" ], "files": [ { "path": "components/ui/lightboard.tsx", "content": "\"use client\"\r\n\r\nimport {\r\n ComponentProps,\r\n useCallback,\r\n useEffect,\r\n useMemo,\r\n useRef,\r\n useState,\r\n} from \"react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\n/**\r\n * \"0\" = off / eraser\r\n * \"1\" | \"2\" | \"3\" = increasing draw intensity\r\n */\r\nexport type PatternCell = \"0\" | \"1\" | \"2\" | \"3\"\r\n\r\nexport type LightBoardFont = \"default\"\r\n\r\nexport interface LightBoardColors {\r\n background?: string\r\n textDim?: string\r\n textBright?: string\r\n drawLine?: string\r\n}\r\n\r\nexport type LightBoardProps = Omit, \"color\"> & {\r\n text?: string\r\n rows?: number\r\n gap?: number\r\n lightSize?: number\r\n font?: LightBoardFont\r\n updateInterval?: number\r\n disableDrawing?: boolean\r\n colors?: LightBoardColors\r\n controlledDrawState?: PatternCell\r\n onDrawStateChange?: (state: PatternCell) => void\r\n controlledHoverState?: boolean\r\n onHoverStateChange?: (state: boolean) => void\r\n}\r\n\r\nconst DEFAULT_COLORS: Required = {\r\n background: \"#0a0a0a\",\r\n textDim: \"#2a2a2a\",\r\n textBright: \"#ffffff\",\r\n drawLine: \"#7dd3fc\",\r\n}\r\n\r\nconst DRAW_OPACITY: Record = {\r\n \"0\": 0,\r\n \"1\": 0.45,\r\n \"2\": 0.72,\r\n \"3\": 1,\r\n}\r\n\r\nconst CHAR_HEIGHT = 7\r\n\r\n// 5x7 dot-matrix font. \"#\" = lit pixel, \".\" = unlit.\r\nconst FONT_5X7: Record = {\r\n \" \": [\".....\", \".....\", \".....\", \".....\", \".....\", \".....\", \".....\"],\r\n A: [\".###.\", \"#...#\", \"#...#\", \"#####\", \"#...#\", \"#...#\", \"#...#\"],\r\n B: [\"####.\", \"#...#\", \"#...#\", \"####.\", \"#...#\", \"#...#\", \"####.\"],\r\n C: [\".####\", \"#....\", \"#....\", \"#....\", \"#....\", \"#....\", \".####\"],\r\n D: [\"####.\", \"#...#\", \"#...#\", \"#...#\", \"#...#\", \"#...#\", \"####.\"],\r\n E: [\"#####\", \"#....\", \"#....\", \"####.\", \"#....\", \"#....\", \"#####\"],\r\n F: [\"#####\", \"#....\", \"#....\", \"####.\", \"#....\", \"#....\", \"#....\"],\r\n G: [\".####\", \"#....\", \"#....\", \"#..##\", \"#...#\", \"#...#\", \".####\"],\r\n H: [\"#...#\", \"#...#\", \"#...#\", \"#####\", \"#...#\", \"#...#\", \"#...#\"],\r\n I: [\"#####\", \"..#..\", \"..#..\", \"..#..\", \"..#..\", \"..#..\", \"#####\"],\r\n J: [\"..###\", \"...#.\", \"...#.\", \"...#.\", \"#..#.\", \"#..#.\", \".##..\"],\r\n K: [\"#...#\", \"#..#.\", \"#.#..\", \"##...\", \"#.#..\", \"#..#.\", \"#...#\"],\r\n L: [\"#....\", \"#....\", \"#....\", \"#....\", \"#....\", \"#....\", \"#####\"],\r\n M: [\"#...#\", \"##.##\", \"#.#.#\", \"#...#\", \"#...#\", \"#...#\", \"#...#\"],\r\n N: [\"#...#\", \"##..#\", \"#.#.#\", \"#..##\", \"#...#\", \"#...#\", \"#...#\"],\r\n O: [\".###.\", \"#...#\", \"#...#\", \"#...#\", \"#...#\", \"#...#\", \".###.\"],\r\n P: [\"####.\", \"#...#\", \"#...#\", \"####.\", \"#....\", \"#....\", \"#....\"],\r\n Q: [\".###.\", \"#...#\", \"#...#\", \"#...#\", \"#.#.#\", \"#..#.\", \".##.#\"],\r\n R: [\"####.\", \"#...#\", \"#...#\", \"####.\", \"#.#..\", \"#..#.\", \"#...#\"],\r\n S: [\".####\", \"#....\", \"#....\", \".###.\", \"....#\", \"....#\", \"####.\"],\r\n T: [\"#####\", \"..#..\", \"..#..\", \"..#..\", \"..#..\", \"..#..\", \"..#..\"],\r\n U: [\"#...#\", \"#...#\", \"#...#\", \"#...#\", \"#...#\", \"#...#\", \".###.\"],\r\n V: [\"#...#\", \"#...#\", \"#...#\", \"#...#\", \"#...#\", \".#.#.\", \"..#..\"],\r\n W: [\"#...#\", \"#...#\", \"#...#\", \"#.#.#\", \"#.#.#\", \"##.##\", \"#...#\"],\r\n X: [\"#...#\", \"#...#\", \".#.#.\", \"..#..\", \".#.#.\", \"#...#\", \"#...#\"],\r\n Y: [\"#...#\", \"#...#\", \".#.#.\", \"..#..\", \"..#..\", \"..#..\", \"..#..\"],\r\n Z: [\"#####\", \"....#\", \"...#.\", \"..#..\", \".#...\", \"#....\", \"#####\"],\r\n \"0\": [\".###.\", \"#...#\", \"#..##\", \"#.#.#\", \"##..#\", \"#...#\", \".###.\"],\r\n \"1\": [\"..#..\", \".##..\", \"..#..\", \"..#..\", \"..#..\", \"..#..\", \".###.\"],\r\n \"2\": [\".###.\", \"#...#\", \"....#\", \"...#.\", \"..#..\", \".#...\", \"#####\"],\r\n \"3\": [\"####.\", \"....#\", \"....#\", \".###.\", \"....#\", \"....#\", \"####.\"],\r\n \"4\": [\"...#.\", \"..##.\", \".#.#.\", \"#..#.\", \"#####\", \"...#.\", \"...#.\"],\r\n \"5\": [\"#####\", \"#....\", \"####.\", \"....#\", \"....#\", \"#...#\", \".###.\"],\r\n \"6\": [\"..##.\", \".#...\", \"#....\", \"####.\", \"#...#\", \"#...#\", \".###.\"],\r\n \"7\": [\"#####\", \"....#\", \"...#.\", \"..#..\", \".#...\", \".#...\", \".#...\"],\r\n \"8\": [\".###.\", \"#...#\", \"#...#\", \".###.\", \"#...#\", \"#...#\", \".###.\"],\r\n \"9\": [\".###.\", \"#...#\", \"#...#\", \".####\", \"....#\", \"...#.\", \".##..\"],\r\n \"!\": [\"..#..\", \"..#..\", \"..#..\", \"..#..\", \"..#..\", \".....\", \"..#..\"],\r\n \"?\": [\".###.\", \"#...#\", \"....#\", \"...#.\", \"..#..\", \".....\", \"..#..\"],\r\n \".\": [\".....\", \".....\", \".....\", \".....\", \".....\", \".....\", \"..#..\"],\r\n \",\": [\".....\", \".....\", \".....\", \".....\", \".....\", \"..#..\", \".#...\"],\r\n \"'\": [\"..#..\", \"..#..\", \".....\", \".....\", \".....\", \".....\", \".....\"],\r\n \"-\": [\".....\", \".....\", \".....\", \"#####\", \".....\", \".....\", \".....\"],\r\n}\r\n\r\nfunction buildMessageColumns(text: string): boolean[][] {\r\n const chars = text.toUpperCase().split(\"\")\r\n const columns: boolean[][] = []\r\n\r\n for (const char of chars) {\r\n const glyph = FONT_5X7[char] ?? FONT_5X7[\" \"]\r\n const width = glyph[0].length\r\n for (let c = 0; c < width; c++) {\r\n const col: boolean[] = []\r\n for (let r = 0; r < CHAR_HEIGHT; r++) {\r\n col.push(glyph[r][c] === \"#\")\r\n }\r\n columns.push(col)\r\n }\r\n // inter-character spacing\r\n columns.push(new Array(CHAR_HEIGHT).fill(false))\r\n }\r\n\r\n return columns\r\n}\r\n\r\nfunction makeGrid(rows: number, cols: number): PatternCell[][] {\r\n return Array.from({ length: rows }, () =>\r\n Array.from({ length: cols }, () => \"0\" as PatternCell)\r\n )\r\n}\r\n\r\nfunction resizeGrid(\r\n grid: PatternCell[][],\r\n rows: number,\r\n cols: number\r\n): PatternCell[][] {\r\n const next = makeGrid(rows, cols)\r\n for (let r = 0; r < Math.min(rows, grid.length); r++) {\r\n for (let c = 0; c < Math.min(cols, grid[r]?.length ?? 0); c++) {\r\n next[r][c] = grid[r][c]\r\n }\r\n }\r\n return next\r\n}\r\n\r\nexport function LightBoard({\r\n text = \"\",\r\n rows = 10,\r\n gap = 2,\r\n lightSize = 5,\r\n font = \"default\",\r\n updateInterval = 150,\r\n disableDrawing = true,\r\n colors,\r\n controlledDrawState,\r\n onDrawStateChange,\r\n controlledHoverState,\r\n onHoverStateChange,\r\n className,\r\n ...props\r\n}: LightBoardProps) {\r\n const mergedColors = { ...DEFAULT_COLORS, ...colors }\r\n const pitch = lightSize + gap\r\n\r\n const containerRef = useRef(null)\r\n const [cols, setCols] = useState(24)\r\n\r\n useEffect(() => {\r\n const el = containerRef.current\r\n if (!el) return\r\n\r\n const measure = () => {\r\n const width = el.clientWidth\r\n const next = Math.max(1, Math.floor((width + gap) / pitch))\r\n setCols(next)\r\n }\r\n\r\n measure()\r\n const observer = new ResizeObserver(measure)\r\n observer.observe(el)\r\n return () => observer.disconnect()\r\n }, [gap, pitch])\r\n\r\n const [internalHover, setInternalHover] = useState(false)\r\n const hoverActive = controlledHoverState ?? internalHover\r\n const setHoverActive = useCallback(\r\n (value: boolean) => {\r\n if (controlledHoverState === undefined) setInternalHover(value)\r\n onHoverStateChange?.(value)\r\n },\r\n [controlledHoverState, onHoverStateChange]\r\n )\r\n\r\n const drawPen: PatternCell = controlledDrawState ?? \"1\"\r\n\r\n const [drawGrid, setDrawGrid] = useState(() =>\r\n makeGrid(rows, cols)\r\n )\r\n useEffect(() => {\r\n setDrawGrid((prev) => resizeGrid(prev, rows, cols))\r\n }, [rows, cols])\r\n\r\n const messageColumns = useMemo(() => buildMessageColumns(text), [text])\r\n const loopLength = messageColumns.length + cols\r\n\r\n const [offset, setOffset] = useState(0)\r\n useEffect(() => {\r\n if (!text || hoverActive || loopLength === 0) return\r\n const id = setInterval(() => {\r\n setOffset((prev) => (prev + 1) % loopLength)\r\n }, updateInterval)\r\n return () => clearInterval(id)\r\n }, [text, hoverActive, updateInterval, loopLength])\r\n\r\n const textBand = Math.floor((rows - CHAR_HEIGHT) / 2)\r\n\r\n const isPointerDownRef = useRef(false)\r\n\r\n const paintCell = useCallback(\r\n (r: number, c: number) => {\r\n if (disableDrawing) return\r\n setDrawGrid((prev) => {\r\n if (prev[r]?.[c] === drawPen) return prev\r\n const next = prev.map((row) => row.slice())\r\n next[r][c] = drawPen\r\n return next\r\n })\r\n },\r\n [disableDrawing, drawPen]\r\n )\r\n\r\n const handlePointerDown = (r: number, c: number) => {\r\n if (disableDrawing) return\r\n isPointerDownRef.current = true\r\n paintCell(r, c)\r\n }\r\n\r\n const handlePointerEnter = (r: number, c: number) => {\r\n if (disableDrawing) return\r\n if (isPointerDownRef.current || hoverActive) {\r\n paintCell(r, c)\r\n }\r\n }\r\n\r\n useEffect(() => {\r\n const clear = () => {\r\n isPointerDownRef.current = false\r\n }\r\n window.addEventListener(\"pointerup\", clear)\r\n window.addEventListener(\"pointercancel\", clear)\r\n return () => {\r\n window.removeEventListener(\"pointerup\", clear)\r\n window.removeEventListener(\"pointercancel\", clear)\r\n }\r\n }, [])\r\n\r\n const cells = useMemo(() => {\r\n const grid: { bright: boolean; drawn: PatternCell }[][] = []\r\n for (let r = 0; r < rows; r++) {\r\n const row: { bright: boolean; drawn: PatternCell }[] = []\r\n const fontRow = r - textBand\r\n for (let c = 0; c < cols; c++) {\r\n let bright = false\r\n if (text && fontRow >= 0 && fontRow < CHAR_HEIGHT) {\r\n const msgIndex = (offset + c) % loopLength\r\n const col = messageColumns[msgIndex]\r\n bright = col ? col[fontRow] : false\r\n }\r\n row.push({ bright, drawn: drawGrid[r]?.[c] ?? \"0\" })\r\n }\r\n grid.push(row)\r\n }\r\n return grid\r\n }, [rows, cols, text, textBand, offset, loopLength, messageColumns, drawGrid])\r\n\r\n return (\r\n setHoverActive(true)}\r\n onPointerLeave={() => {\r\n setHoverActive(false)\r\n isPointerDownRef.current = false\r\n }}\r\n {...props}\r\n >\r\n \r\n {cells.map((row, r) =>\r\n row.map((cell, c) => {\r\n const drawLevel = DRAW_OPACITY[cell.drawn]\r\n const baseColor = cell.bright\r\n ? mergedColors.textBright\r\n : mergedColors.textDim\r\n\r\n return (\r\n handlePointerDown(r, c)}\r\n onPointerEnter={() => handlePointerEnter(r, c)}\r\n className={cn(\r\n \"rounded-full transition-[opacity,background-color] duration-100\",\r\n !disableDrawing && \"cursor-crosshair\"\r\n )}\r\n style={{\r\n backgroundColor: drawLevel > 0 ? mergedColors.drawLine : baseColor,\r\n opacity: drawLevel > 0 ? drawLevel : 1,\r\n }}\r\n />\r\n )\r\n })\r\n )}\r\n \r\n \r\n )\r\n}\r\n\r\nexport default LightBoard", "type": "registry:ui" } ], "type": "registry:ui" }