{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "lightboard", "type": "registry:ui", "description": "Interactive lightboard component with customizable grid and lighting effects", "files": [ { "path": "registry/default/ui/lightboard.tsx", "content": "\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\n\nexport type PatternCell = \"0\" | \"1\" | \"2\" | \"3\"\ntype Pattern = PatternCell[][]\n\ninterface LightBoardProps {\n gap?: number\n rows?: number\n lightSize?: number\n updateInterval?: number\n text: string\n font?: \"default\" | \"7segment\"\n colors?: Partial\n disableDrawing?: boolean\n controlledDrawState?: PatternCell\n onDrawStateChange?: (newState: PatternCell) => void\n controlledHoverState?: boolean\n onHoverStateChange?: (isHovered: boolean) => void\n}\n\ninterface LightBoardColors {\n drawLine: string // Color for moderately lit text\n background: string // Color for inactive lights\n textDim: string // Color for dimly lit text\n textBright: string // Color for brightly lit text\n}\n\nconst defaultColors: LightBoardColors = {\n drawLine: \"rgba(160, 160, 200, 0.7)\",\n background: \"rgba(30, 30, 40, 0.3)\",\n textDim: \"rgba(100, 100, 140, 0.5)\",\n textBright: \"rgba(220, 220, 255, 0.9)\",\n}\n\n// This function takes some text and makes sure there's enough space between words\nconst normalizeText = (text: string, minSpacing: number = 3): string => {\n const trimmed = text.trim().toUpperCase() // Remove extra spaces and make all letters big\n const spacedText = ` ${trimmed} `.replace(/\\s+/g, \" \".repeat(minSpacing)) // Add spaces between words\n return spacedText\n}\n\n// This function turns text into a pattern of lights\nconst textToPattern = (\n text: string,\n rows: number,\n columns: number,\n font: { [key: string]: Pattern }\n): Pattern => {\n // First, we make the letters bigger if we have more rows\n const letterHeight = font[\"A\"].length\n const scale = Math.max(1, Math.floor(rows / letterHeight))\n\n // We make each letter in the font bigger\n const scaledFont = Object.fromEntries(\n Object.entries(font).map(([char, pattern]) => [\n char,\n pattern\n .flatMap((row) => Array(scale).fill(row))\n .map((row) =>\n row.flatMap((cell: PatternCell) =>\n Array(scale).fill(cell === \"1\" ? \"1\" : \"3\")\n )\n ),\n ])\n )\n // We add spaces to the text\n const normalizedText = normalizeText(text)\n\n // We turn each letter into a pattern of lights\n const letterPatterns = normalizedText\n .split(\"\")\n .map((char) => scaledFont[char] || scaledFont[\" \"])\n\n // We combine all the letter patterns into one big pattern\n let fullPattern: Pattern = Array(scaledFont[\"A\"].length)\n .fill([])\n .map(() => [])\n\n letterPatterns.forEach((letterPattern) => {\n fullPattern = fullPattern.map((row, i) => [...row, ...letterPattern[i]])\n })\n\n // We add empty space above and below the pattern to center it\n const totalRows = rows\n const patternRows = fullPattern.length\n const topPadding = Math.floor((totalRows - patternRows) / 2)\n const bottomPadding = totalRows - patternRows - topPadding\n\n const paddedPattern = [\n ...Array(topPadding).fill(Array(fullPattern[0].length).fill(\"0\")),\n ...fullPattern,\n ...Array(bottomPadding).fill(Array(fullPattern[0].length).fill(\"0\")),\n ]\n\n // We make the pattern wider by repeating it\n const extendedPattern = paddedPattern.map((row) => {\n while (row.length < columns * 2) {\n row = [...row, ...row]\n }\n return row\n })\n\n return extendedPattern\n}\n\n// This function decides what color each light should be\nfunction getLightColor(\n state: PatternCell,\n colors: Partial\n): string {\n const mergedColors = { ...defaultColors, ...colors }\n\n switch (state) {\n case \"1\":\n return mergedColors.textDim\n case \"2\":\n return mergedColors.drawLine\n case \"3\":\n return mergedColors.textBright\n default:\n return mergedColors.background\n }\n}\n\nconst defaultDrawState: PatternCell = \"2\"\n\nfunction LightBoard({\n text,\n gap = 1,\n lightSize = 4,\n rows = 5,\n font = \"default\",\n updateInterval = 10,\n colors = {},\n controlledDrawState,\n disableDrawing = true,\n controlledHoverState,\n onHoverStateChange,\n}: LightBoardProps) {\n // We decide how many rows and columns of lights we need\n const containerRef = useRef(null)\n const [columns, setColumns] = useState(0)\n const mergedColors = { ...defaultColors, ...colors }\n\n // We choose which font to use for our text\n const selectedFont = font === \"default\" ? defaultFont : sevenSegmentFont\n // We keep track of whether the mouse is over our board\n\n // We keep track of whether we're drawing on the board\n const [isDrawing, setIsDrawing] = useState(false)\n // Use controlled state if provided, otherwise use local state\n const [internalHoverState, setInternalHoverState] = useState(false)\n // This is the brightness of the lights we're drawing (0 to 3)\n\n // This is our pattern of lights that make up the text\n const [basePattern, setBasePattern] = useState(() => {\n return textToPattern(normalizeText(text), rows, columns, selectedFont)\n })\n // This helps us move the text across the board\n const [offset, setOffset] = useState(0)\n // This is how we draw on our light board (it's like a special piece of paper)\n const canvasRef = useRef(null)\n // This remembers where we last drew on the board\n const lastDrawnPosition = useRef<{ x: number; y: number } | null>(null)\n // This helps us know when to update our animation\n const [lastUpdateTime, setLastUpdateTime] = useState(0)\n\n const drawState =\n controlledDrawState !== undefined ? controlledDrawState : defaultDrawState\n\n const isHovered =\n controlledHoverState !== undefined\n ? controlledHoverState\n : internalHoverState\n\n // Calculate the number of columns based on container width\n useEffect(() => {\n const calculateColumns = () => {\n if (containerRef.current) {\n const containerWidth = containerRef.current.offsetWidth\n const calculatedColumns = Math.floor(containerWidth / (lightSize + gap))\n setColumns(calculatedColumns)\n }\n }\n\n calculateColumns()\n window.addEventListener(\"resize\", calculateColumns)\n return () => window.removeEventListener(\"resize\", calculateColumns)\n }, [lightSize, gap])\n\n // This function draws all our lights on the board\n const drawToCanvas = useCallback(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const patternWidth = basePattern[0].length\n\n basePattern.forEach((row, rowIndex) => {\n for (let colIndex = 0; colIndex < columns; colIndex++) {\n const patternColIndex = (colIndex + offset) % patternWidth\n const state = row[patternColIndex]\n\n ctx.fillStyle = getLightColor(state as PatternCell, mergedColors)\n ctx.beginPath()\n ctx.arc(\n colIndex * (lightSize + gap) + lightSize / 2,\n rowIndex * (lightSize + gap) + lightSize / 2,\n lightSize / 2,\n 0,\n 2 * Math.PI\n )\n ctx.fill()\n }\n })\n }, [basePattern, offset, columns, lightSize, gap, mergedColors])\n\n // This makes our text move across the board\n useEffect(() => {\n let animationFrameId: number\n\n const animate = () => {\n if (!isHovered) {\n // If the mouse isn't over the board, we move the text\n setOffset((prevOffset) => (prevOffset + 1) % basePattern[0].length)\n }\n drawToCanvas()\n animationFrameId = requestAnimationFrame(animate)\n }\n\n animationFrameId = requestAnimationFrame(animate)\n\n // We clean up our animation when we're done\n return () => cancelAnimationFrame(animationFrameId)\n }, [basePattern, isHovered, drawToCanvas])\n\n // This updates our light pattern when the text changes\n useEffect(() => {\n setBasePattern(\n textToPattern(normalizeText(text), rows, columns, selectedFont)\n )\n }, [text, rows, columns, selectedFont])\n\n // This is another way we make our text move\n const animate = useCallback(() => {\n const currentTime = Date.now()\n if (currentTime - lastUpdateTime >= updateInterval && !isHovered) {\n setOffset((prevOffset) => (prevOffset + 1) % basePattern[0].length)\n setLastUpdateTime(currentTime)\n }\n drawToCanvas()\n }, [updateInterval, isHovered, basePattern, drawToCanvas, lastUpdateTime])\n\n // This keeps our animation going\n useEffect(() => {\n let animationFrameId: number\n\n const loop = () => {\n animate()\n animationFrameId = requestAnimationFrame(loop)\n }\n\n animationFrameId = requestAnimationFrame(loop)\n\n // We clean up our animation when we're done\n return () => cancelAnimationFrame(animationFrameId)\n }, [animate])\n\n // This function helps us draw a line on our light board\n const drawLine = useCallback(\n (startX: number, startY: number, endX: number, endY: number) => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n // We figure out which direction we're drawing\n const dx = Math.abs(endX - startX)\n const dy = Math.abs(endY - startY)\n const sx = startX < endX ? 1 : -1\n const sy = startY < endY ? 1 : -1\n let err = dx - dy\n\n // We keep drawing until we reach the end of our line\n while (true) {\n // We figure out which light we're on\n const colIndex = Math.floor(startX / (lightSize + gap))\n const rowIndex = Math.floor(startY / (lightSize + gap))\n\n // If we're still on the board...\n if (\n rowIndex >= 0 &&\n rowIndex < rows &&\n colIndex >= 0 &&\n colIndex < columns\n ) {\n // We figure out which light to change in our pattern\n const actualColIndex = (colIndex + offset) % basePattern[0].length\n\n // If this light isn't already the brightness we want...\n if (basePattern[rowIndex][actualColIndex] !== drawState) {\n // We update our pattern of lights\n setBasePattern((prevPattern) => {\n const newPattern = [...prevPattern]\n newPattern[rowIndex] = [...newPattern[rowIndex]]\n newPattern[rowIndex][actualColIndex] = drawState\n return newPattern\n })\n\n // We draw the new light on our board\n ctx.fillStyle = getLightColor(drawState, mergedColors)\n\n ctx.beginPath()\n ctx.arc(\n colIndex * (lightSize + gap) + lightSize / 2,\n rowIndex * (lightSize + gap) + lightSize / 2,\n lightSize / 2,\n 0,\n 2 * Math.PI\n )\n ctx.fill()\n }\n }\n\n // If we've reached the end of our line, we stop\n if (startX === endX && startY === endY) break\n\n // We figure out where to draw next\n const e2 = 2 * err\n if (e2 > -dy) {\n err -= dy\n startX += sx\n }\n if (e2 < dx) {\n err += dx\n startY += sy\n }\n }\n },\n [\n basePattern,\n columns,\n drawState,\n gap,\n lightSize,\n offset,\n rows,\n mergedColors,\n ]\n )\n\n // _________DRAWING HANDLING_________\n\n const handleInteractionStart = useCallback(\n (x: number, y: number) => {\n if (disableDrawing) return\n setIsDrawing(true)\n lastDrawnPosition.current = null\n drawLine(x, y, x, y)\n },\n [disableDrawing, drawLine]\n )\n\n const handleInteractionMove = useCallback(\n (x: number, y: number) => {\n if (!isDrawing || disableDrawing) return\n if (lastDrawnPosition.current) {\n drawLine(lastDrawnPosition.current.x, lastDrawnPosition.current.y, x, y)\n } else {\n drawLine(x, y, x, y)\n }\n lastDrawnPosition.current = { x, y }\n },\n [isDrawing, disableDrawing, drawLine]\n )\n\n const handleInteractionEnd = useCallback(() => {\n setIsDrawing(false)\n lastDrawnPosition.current = null\n }, [])\n\n // This happens when we press the mouse button to start drawing\n const handleMouseDown = useCallback(\n (event: React.MouseEvent) => {\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = event.clientX - rect.left\n const y = event.clientY - rect.top\n handleInteractionStart(x, y)\n },\n [handleInteractionStart]\n )\n\n const handleMouseMove = useCallback(\n (event: React.MouseEvent) => {\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = event.clientX - rect.left\n const y = event.clientY - rect.top\n handleInteractionMove(x, y)\n },\n [handleInteractionMove]\n )\n\n const handleMouseUp = handleInteractionEnd\n\n const handleTouchStart = useCallback(\n (event: React.TouchEvent) => {\n event.preventDefault()\n const touch = event.touches[0]\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = touch.clientX - rect.left\n const y = touch.clientY - rect.top\n handleInteractionStart(x, y)\n },\n [handleInteractionStart]\n )\n\n const handleTouchMove = useCallback(\n (event: React.TouchEvent) => {\n event.preventDefault()\n const touch = event.touches[0]\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = touch.clientX - rect.left\n const y = touch.clientY - rect.top\n handleInteractionMove(x, y)\n },\n [handleInteractionMove]\n )\n\n const handleTouchEnd = handleInteractionEnd\n\n // Update hover state\n const updateHoverState = useCallback(\n (newState: boolean) => {\n if (controlledHoverState === undefined) {\n setInternalHoverState(newState)\n }\n onHoverStateChange?.(newState)\n },\n [controlledHoverState, onHoverStateChange]\n )\n\n return (\n
\n {columns > 0 && (\n \n controlledHoverState === undefined && updateHoverState(true)\n }\n onMouseLeave={() => {\n controlledHoverState === undefined && updateHoverState(false)\n handleInteractionEnd()\n }}\n onTouchStart={!disableDrawing ? handleTouchStart : undefined}\n onTouchEnd={handleTouchEnd}\n onTouchMove={handleTouchMove}\n style={{\n cursor: disableDrawing ? \"default\" : \"pointer\",\n touchAction: \"none\",\n userSelect: \"none\",\n }}\n />\n )}\n
\n )\n}\n\nexport { LightBoard }\n\nconst sevenSegmentFont: { [key: string]: Pattern } = {\n \"0\": [\n [\"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\"],\n ],\n \"1\": [\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n ],\n // Add more digits as needed\n}\n\nconst defaultFont: { [key: string]: Pattern } = {\n \" \": [\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n ],\n A: [\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n B: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n ],\n C: [\n [\"0\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"0\", \"1\", \"1\", \"1\"],\n ],\n D: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n ],\n E: [\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"1\"],\n ],\n F: [\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n ],\n G: [\n [\"0\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"1\"],\n ],\n H: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n I: [\n [\"1\", \"1\", \"1\"],\n [\"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\"],\n [\"1\", \"1\", \"1\"],\n ],\n J: [\n [\"0\", \"0\", \"1\", \"1\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n ],\n K: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"1\", \"1\", \"0\", \"0\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n L: [\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"1\"],\n ],\n M: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n ],\n N: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n O: [\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n ],\n P: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n ],\n Q: [\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\", \"1\"],\n ],\n R: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n S: [\n [\"0\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"0\", \"1\", \"1\", \"0\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n ],\n T: [\n [\"1\", \"1\", \"1\", \"1\", \"1\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n ],\n U: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n ],\n V: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n ],\n W: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\", \"1\"],\n [\"1\", \"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n ],\n X: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n Y: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n ],\n Z: [\n [\"1\", \"1\", \"1\", \"1\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"1\"],\n ],\n}\n", "type": "registry:ui" } ] }