{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "ascii-art", "title": "Ascii Art", "description": "Renders an image as ASCII art with configurable resolution, charset, and colors. Supports fade, typewriter, and matrix animations. | 画像をASCIIアートで描画。解像度・文字セット・色を設定可能。", "dependencies": [ "motion" ], "files": [ { "path": "components/media/ascii-art.tsx", "content": "\"use client\";\n/**\n * AsciiArt: Renders an image as ASCII art with configurable charset, colors, and animation.\n * Uses explicit variant AsciiArtStatic for non-animated output (patterns-explicit-variants).\n * API is a single component with options; could be extended with compound components later if needed.\n */\nimport React, {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useCallback,\n useId,\n} from \"react\";\nimport { motion, useInView } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nconst ASCII_CHARSETS = {\n standard: \" .,:;i1tfLCG08@\",\n blocks: \" ░▒▓█\",\n binary: \" 01\",\n dots: \" ·•●\",\n minimal: \" .:░▒\",\n dense: \" .'`^\\\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$\",\n arrows: \" ←↑→↓↔↕↖↗↘↙\",\n stars: \" ·✦✧★\",\n hash: \" -=#\",\n pipes: \" |/─\\\\│\",\n braille: \" ⠁⠃⠇⠏⠟⠿⡿⣿\",\n circles: \" ○◔◑◕●\",\n squares: \" ▢▣▤▥▦▧▨▩\",\n hearts: \" ♡♥\",\n math: \" +-×÷=≠≈∞\",\n} as const;\n\ntype CharsetPreset = keyof typeof ASCII_CHARSETS;\n\nconst isCharsetPreset = (value: string): value is CharsetPreset => {\n return value in ASCII_CHARSETS;\n};\n\nconst resolveCharset = (charset: string): string => {\n if (isCharsetPreset(charset)) {\n return ASCII_CHARSETS[charset];\n }\n return charset;\n};\n\nconst resolveCssColor = (\n color: string,\n element: HTMLElement | null\n): string => {\n if (!color) return color;\n\n if (color.startsWith(\"var(\")) {\n if (!element) return \"#ffffff\";\n\n const tempDiv = document.createElement(\"div\");\n tempDiv.style.color = color;\n element.appendChild(tempDiv);\n const computedColor = getComputedStyle(tempDiv).color;\n element.removeChild(tempDiv);\n return computedColor || \"#ffffff\";\n }\n\n return color;\n};\n\ntype AsciiArtProps = {\n src: string;\n /** Number of ASCII columns (character resolution). Higher = more detail. */\n resolution?: number;\n /** Charset preset name (\"standard\", \"blocks\", \"binary\", etc.) or custom character string */\n charset?: CharsetPreset | string;\n /** Text color for the ASCII art (ignored if colored=true) */\n color?: string;\n /** Background color */\n backgroundColor?: string;\n /** Convert to inverted colors (dark bg, light text) */\n inverted?: boolean;\n /** Enable colored ASCII (uses image colors) */\n colored?: boolean;\n /** Enable animation on load */\n animated?: boolean;\n /** Animation style */\n animationStyle?: \"fade\" | \"typewriter\" | \"matrix\" | \"none\";\n /** Duration for fade animation in seconds */\n animationDuration?: number;\n /** Font family for ASCII characters */\n fontFamily?: string;\n /** Container className - use this to control size (e.g., w-full, h-64) */\n className?: string;\n /** Only animate when in view */\n animateOnView?: boolean;\n /** How the image should fit within the ASCII grid */\n objectFit?: \"cover\" | \"contain\" | \"fill\";\n};\nconst MATRIX_CHARSET = \"ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ\";\n\ntype AsciiPixel = {\n char: string;\n r: number;\n g: number;\n b: number;\n};\n\nexport const AsciiArt: React.FC = ({\n src,\n resolution = 80,\n charset = \"standard\",\n color = \"#ffffff\",\n backgroundColor = \"transparent\",\n inverted = false,\n colored = false,\n animated = true,\n animationStyle = \"fade\",\n animationDuration = 1,\n fontFamily = \"monospace\",\n className,\n animateOnView = true,\n objectFit = \"cover\",\n}) => {\n const uniqueId = useId();\n const [asciiData, setAsciiData] = useState([]);\n const [isLoaded, setIsLoaded] = useState(false);\n const [error, setError] = useState(null);\n const [hasAnimated, setHasAnimated] = useState(false);\n const containerRef = useRef(null);\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const isInView = useInView(containerRef, { once: true, amount: 0.1 });\n\n const shouldStartAnimation = animated && animateOnView ? isInView : animated;\n const shouldShowStatic = !animated || animationStyle === \"none\";\n\n const resolvedCharset = resolveCharset(charset);\n const effectiveCharset = inverted\n ? resolvedCharset.split(\"\").reverse().join(\"\")\n : resolvedCharset;\n\n const defaultColor = inverted ? \"#ffffff\" : \"#000000\";\n const textColor = color || defaultColor;\n\n useEffect(() => {\n let isCancelled = false;\n\n const img = new Image();\n img.crossOrigin = \"anonymous\";\n img.src = src;\n\n img.onload = () => {\n if (isCancelled) return;\n\n const canvas = document.createElement(\"canvas\");\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) {\n setError(\"Canvas context not available\");\n return;\n }\n\n const imgWidth = img.naturalWidth;\n const imgHeight = img.naturalHeight;\n const imgAspect = imgWidth / imgHeight;\n const charAspectRatio = 0.55;\n\n const cols = resolution;\n const rows = Math.floor(cols * charAspectRatio);\n\n canvas.width = cols;\n canvas.height = rows;\n\n const visualAspect = 1.0;\n\n let sx = 0,\n sy = 0,\n sw = imgWidth,\n sh = imgHeight;\n\n if (objectFit === \"cover\") {\n if (imgAspect > visualAspect) {\n sw = imgHeight * visualAspect;\n sx = (imgWidth - sw) / 2;\n } else {\n sh = imgWidth / visualAspect;\n sy = (imgHeight - sh) / 2;\n }\n } else if (objectFit === \"contain\") {\n ctx.fillStyle = \"#000000\";\n ctx.fillRect(0, 0, cols, rows);\n\n let dw, dh, dx, dy;\n if (imgAspect > visualAspect) {\n dw = cols;\n dh = cols / imgAspect * charAspectRatio;\n dx = 0;\n dy = (rows - dh) / 2;\n } else {\n dh = rows;\n dw = rows * imgAspect / charAspectRatio;\n dx = (cols - dw) / 2;\n dy = 0;\n }\n ctx.drawImage(img, dx, dy, dw, dh);\n }\n\n if (objectFit !== \"contain\") {\n ctx.drawImage(img, sx, sy, sw, sh, 0, 0, cols, rows);\n }\n\n let imageData: ImageData;\n try {\n imageData = ctx.getImageData(0, 0, cols, rows);\n } catch {\n setError(\"Unable to read image data (CORS issue)\");\n return;\n }\n\n const data = imageData.data;\n const result: AsciiPixel[][] = [];\n\n for (let y = 0; y < rows; y++) {\n const row: AsciiPixel[] = [];\n for (let x = 0; x < cols; x++) {\n const idx = (y * cols + x) * 4;\n const r = data[idx];\n const g = data[idx + 1];\n const b = data[idx + 2];\n const a = data[idx + 3];\n\n const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n const adjustedBrightness = a === 0 ? 0 : brightness;\n\n const charIndex = Math.floor(\n adjustedBrightness * (effectiveCharset.length - 1)\n );\n const char = effectiveCharset[charIndex] || \" \";\n\n row.push({ char, r, g, b });\n }\n result.push(row);\n }\n\n setAsciiData(result);\n setIsLoaded(true);\n };\n\n img.onerror = () => {\n if (isCancelled) return;\n setError(\"Failed to load image\");\n };\n\n return () => {\n isCancelled = true;\n };\n }, [src, resolution, effectiveCharset, objectFit]);\n\n const drawCanvas = useCallback(\n (progress: number = 1, matrixProgress?: number) => {\n const canvas = canvasRef.current;\n const container = containerRef.current;\n if (!canvas || !container || asciiData.length === 0) return;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n const dpr = window.devicePixelRatio || 1;\n const containerWidth = container.clientWidth;\n const containerHeight = container.clientHeight;\n\n if (containerWidth === 0 || containerHeight === 0) return;\n\n canvas.width = containerWidth * dpr;\n canvas.height = containerHeight * dpr;\n canvas.style.width = `${containerWidth}px`;\n canvas.style.height = `${containerHeight}px`;\n ctx.scale(dpr, dpr);\n\n const resolvedBgColor = resolveCssColor(backgroundColor, container);\n const resolvedTextColor = resolveCssColor(textColor, container);\n\n if (resolvedBgColor !== \"transparent\") {\n ctx.fillStyle = resolvedBgColor;\n ctx.fillRect(0, 0, containerWidth, containerHeight);\n } else {\n ctx.clearRect(0, 0, containerWidth, containerHeight);\n }\n\n const rows = asciiData.length;\n const cols = asciiData[0]?.length || 0;\n if (cols === 0) return;\n\n const charWidth = containerWidth / cols;\n const charHeight = containerHeight / rows;\n const fontSize = Math.min(charWidth * 1.8, charHeight * 1.2);\n\n ctx.font = `${fontSize}px ${fontFamily}`;\n ctx.textBaseline = \"top\";\n ctx.textAlign = \"center\";\n\n const totalChars = rows * cols;\n const revealedChars = Math.floor(progress * totalChars);\n\n let charIndex = 0;\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n const pixel = asciiData[y][x];\n const cx = x * charWidth + charWidth / 2;\n const cy = y * charHeight;\n\n if (animationStyle === \"typewriter\" && charIndex >= revealedChars) {\n charIndex++;\n continue;\n }\n\n let displayChar = pixel.char;\n let displayColor = colored\n ? `rgb(${pixel.r}, ${pixel.g}, ${pixel.b})`\n : resolvedTextColor;\n\n if (animationStyle === \"matrix\" && matrixProgress !== undefined) {\n const charProgress = (x * 0.02 + y * 0.01) / 2;\n if (matrixProgress < charProgress) {\n charIndex++;\n continue;\n } else if (matrixProgress < charProgress + 0.15) {\n displayChar =\n MATRIX_CHARSET[\n Math.floor(Math.random() * MATRIX_CHARSET.length)\n ];\n displayColor = \"#00ff00\";\n ctx.shadowColor = \"#00ff00\";\n ctx.shadowBlur = 5;\n } else {\n ctx.shadowBlur = 0;\n }\n }\n\n ctx.fillStyle = displayColor;\n ctx.globalAlpha = animationStyle === \"fade\" ? progress : 1;\n ctx.fillText(displayChar, cx, cy);\n\n charIndex++;\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.shadowBlur = 0;\n },\n [\n asciiData,\n backgroundColor,\n colored,\n textColor,\n fontFamily,\n animationStyle,\n ]\n );\n\n useEffect(() => {\n if (!isLoaded || asciiData.length === 0) return;\n\n const draw = () => {\n const canvas = canvasRef.current;\n const container = containerRef.current;\n if (!canvas || !container) {\n requestAnimationFrame(draw);\n return;\n }\n\n if (shouldShowStatic || hasAnimated || !shouldStartAnimation) {\n drawCanvas(1);\n return;\n }\n\n const startTime = performance.now();\n const duration =\n animationStyle === \"fade\"\n ? animationDuration * 1000\n : animationStyle === \"typewriter\"\n ? asciiData.length * asciiData[0]?.length * 2\n : animationStyle === \"matrix\"\n ? 3000\n : 1000;\n\n const animate = (currentTime: number) => {\n const elapsed = currentTime - startTime;\n const progress = Math.min(elapsed / duration, 1);\n\n if (animationStyle === \"matrix\") {\n drawCanvas(1, progress);\n } else {\n drawCanvas(progress);\n }\n\n if (progress < 1) {\n animationRef.current = requestAnimationFrame(animate);\n } else {\n setHasAnimated(true);\n }\n };\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n const frameId = requestAnimationFrame(draw);\n\n return () => {\n cancelAnimationFrame(frameId);\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current);\n }\n };\n }, [\n isLoaded,\n shouldStartAnimation,\n shouldShowStatic,\n hasAnimated,\n animationStyle,\n animationDuration,\n drawCanvas,\n asciiData,\n ]);\n\n useIsomorphicLayoutEffect(() => {\n if (!isLoaded || asciiData.length === 0) return;\n\n const container = containerRef.current;\n const canvas = canvasRef.current;\n if (!container || !canvas) return;\n\n drawCanvas(1);\n }, [isLoaded, asciiData, drawCanvas]);\n\n useEffect(() => {\n if (!isLoaded || asciiData.length === 0) return;\n\n const container = containerRef.current;\n if (!container) return;\n\n const resizeObserver = new ResizeObserver(() => {\n drawCanvas(1);\n });\n\n resizeObserver.observe(container);\n\n return () => resizeObserver.disconnect();\n }, [isLoaded, asciiData, drawCanvas]);\n\n if (error) {\n return (\n \n Error: {error}\n \n );\n }\n\n if (!isLoaded) {\n return (\n \n Loading...\n \n );\n }\n\n const canvasElement = (\n \n );\n\n if (animationStyle === \"fade\" && animated && !hasAnimated) {\n return (\n \n {canvasElement}\n \n );\n }\n\n return (\n \n {canvasElement}\n \n );\n};\n\nexport const AsciiArtStatic: React.FC<\n Omit\n> = (props) => {\n return ;\n};\n", "type": "registry:ui" } ], "type": "registry:ui" }