{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "demo-matrix-image",
  "title": "Matrix Image Demo",
  "description": "Upload an image and render it as a grayscale LED dot matrix with adjustable resolution.",
  "registryDependencies": [
    "@unlumen-ui/matrix-image"
  ],
  "files": [
    {
      "path": "registry/demo/components/unlumen/matrix-image/index.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { UnlumenSlider } from \"@/components/ui/unlumen-slider\";\nimport { imageToMatrix } from \"@/components/unlumen-ui/matrix-image\";\nimport { type Frame, Matrix } from \"@/components/unlumen-ui/matrix\";\n\nconst DEFAULT_SIZE = 24;\nconst MIN_SIZE = 16;\nconst MAX_SIZE = 96;\nconst MAX_CANVAS_PX = 360;\nconst SHIMMER_FRAMES = 20;\n\ntype Phase = \"idle\" | \"loading\" | \"revealing\" | \"ambient\";\n\nfunction computeLayout(\n  w: number,\n  h: number,\n  maxDim: number,\n): { rows: number; cols: number } {\n  const ratio = w / h;\n  let cols: number, rows: number;\n  if (ratio >= 1) {\n    cols = maxDim;\n    rows = Math.max(4, Math.round(maxDim / ratio));\n  } else {\n    rows = maxDim;\n    cols = Math.max(4, Math.round(maxDim * ratio));\n  }\n  return { rows, cols };\n}\n\nfunction computeSize(rows: number, cols: number): number {\n  return Math.max(2, Math.floor(MAX_CANVAS_PX / Math.max(rows, cols)) - 1);\n}\n\n// Spinning loader at arbitrary size: dots orbit the center at radius ~40% of min dimension.\nfunction makeLoaderFrames(\n  rows: number,\n  cols: number,\n  frameCount = 16,\n): Frame[] {\n  const cx = (cols - 1) / 2;\n  const cy = (rows - 1) / 2;\n  const radius = Math.min(rows, cols) * 0.38;\n  const dotCount = 8;\n  const tailLength = 6;\n\n  return Array.from({ length: frameCount }, (_, f) => {\n    const frame: Frame = Array.from({ length: rows }, () =>\n      Array.from({ length: cols }, () => 0),\n    );\n    for (let i = 0; i < dotCount; i++) {\n      const angle =\n        (f / frameCount) * Math.PI * 2 + (i / dotCount) * Math.PI * 2;\n      const x = Math.round(cx + Math.cos(angle) * radius);\n      const y = Math.round(cy + Math.sin(angle) * radius);\n      if (y >= 0 && y < rows && x >= 0 && x < cols) {\n        const brightness = Math.max(0.15, 1 - i / tailLength);\n        frame[y][x] = Math.max(frame[y][x], brightness);\n      }\n    }\n    return frame;\n  });\n}\n\n// Morph loader → image: each frame gradually replaces loader pixels with image pixels.\n// Uses a random shuffle so pixels resolve in a scattered order, not row-by-row.\nfunction makeMorphFrames(\n  loaderFrames: Frame[],\n  target: Frame,\n  rows: number,\n  cols: number,\n  steps = 24,\n): Frame[] {\n  // Build a shuffled list of all pixel positions\n  const positions: [number, number][] = [];\n  for (let r = 0; r < rows; r++)\n    for (let c = 0; c < cols; c++) positions.push([r, c]);\n  // Fisher-Yates shuffle\n  for (let i = positions.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1));\n    [positions[i], positions[j]] = [positions[j], positions[i]];\n  }\n\n  const pixelsPerStep = Math.ceil(positions.length / steps);\n  const resolved = new Set<number>();\n\n  return Array.from({ length: steps }, (_, step) => {\n    const start = step * pixelsPerStep;\n    const end = Math.min(start + pixelsPerStep, positions.length);\n    for (let i = start; i < end; i++) {\n      const [r, c] = positions[i];\n      resolved.add(r * cols + c);\n    }\n    const loaderF =\n      loaderFrames[Math.round((step / steps) * (loaderFrames.length - 1))];\n    return Array.from({ length: rows }, (_, r) =>\n      Array.from({ length: cols }, (_, c) =>\n        resolved.has(r * cols + c) ? target[r][c] : loaderF[r][c],\n      ),\n    );\n  });\n}\n\nfunction makeShimmerFrames(\n  target: Frame,\n  rows: number,\n  cols: number,\n  count = SHIMMER_FRAMES,\n): Frame[] {\n  return Array.from({ length: count }, () =>\n    Array.from({ length: rows }, (_, r) =>\n      Array.from({ length: cols }, (_, c) => {\n        const v = target[r][c];\n        if (v < 0.55) return v;\n        const delta = (Math.random() * 2 - 1) * 0.18;\n        return Math.max(0, Math.min(1, v + delta));\n      }),\n    ),\n  );\n}\n\nfunction loadImageElement(file: File): Promise<HTMLImageElement> {\n  return new Promise((resolve, reject) => {\n    const url = URL.createObjectURL(file);\n    const img = new Image();\n    img.onload = () => {\n      URL.revokeObjectURL(url);\n      resolve(img);\n    };\n    img.onerror = () => {\n      URL.revokeObjectURL(url);\n      reject(new Error(\"Failed to load image\"));\n    };\n    img.src = url;\n  });\n}\n\nexport default function MatrixImageDemo() {\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [loaderFrames, setLoaderFrames] = useState<Frame[]>([]);\n  const [revealFrames, setRevealFrames] = useState<Frame[]>([]);\n  const [shimmerFrames, setShimmerFrames] = useState<Frame[]>([]);\n  const [rows, setRows] = useState(DEFAULT_SIZE);\n  const [cols, setCols] = useState(DEFAULT_SIZE);\n  const [resolution, setResolution] = useState(DEFAULT_SIZE);\n  const [inverted, setInverted] = useState(false);\n  const [isDragging, setIsDragging] = useState(false);\n  const [fileName, setFileName] = useState<string | null>(null);\n\n  const fileRef = useRef<File | null>(null);\n  const imageRef = useRef<HTMLImageElement | null>(null);\n  const targetFrameRef = useRef<Frame | null>(null);\n  const currentRowsRef = useRef(DEFAULT_SIZE);\n  const currentColsRef = useRef(DEFAULT_SIZE);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const processFile = useCallback(\n    async (file: File, res: number, inv: boolean) => {\n      fileRef.current = file;\n      setFileName(file.name);\n      setLoaderFrames(makeLoaderFrames(res, res));\n      setPhase(\"loading\");\n\n      const [img] = (await Promise.all([\n        loadImageElement(file),\n        new Promise((resolve) => setTimeout(resolve, 600)),\n      ])) as [HTMLImageElement, void];\n      imageRef.current = img;\n\n      const layout = computeLayout(img.naturalWidth, img.naturalHeight, res);\n      const r = layout.rows;\n      const c = layout.cols;\n      setRows(r);\n      setCols(c);\n      currentRowsRef.current = r;\n      currentColsRef.current = c;\n\n      const lf = makeLoaderFrames(r, c);\n      setLoaderFrames(lf);\n\n      const target = imageToMatrix(img, r, c, inv);\n      targetFrameRef.current = target;\n\n      const morph = makeMorphFrames(lf, target, r, c);\n      setRevealFrames(morph);\n      setPhase(\"revealing\");\n    },\n    [],\n  );\n\n  const handleRevealFrame = useCallback(\n    (index: number) => {\n      if (index === revealFrames.length - 1) {\n        const target = targetFrameRef.current;\n        if (!target) return;\n        const r = currentRowsRef.current;\n        const c = currentColsRef.current;\n        const shimmer = makeShimmerFrames(target, r, c);\n        setTimeout(() => {\n          setShimmerFrames(shimmer);\n          setPhase(\"ambient\");\n        }, 0);\n      }\n    },\n    [revealFrames.length],\n  );\n\n  const handleFile = useCallback(\n    (file: File) => {\n      if (!file.type.startsWith(\"image/\")) return;\n      processFile(file, resolution, inverted);\n    },\n    [processFile, resolution, inverted],\n  );\n\n  const handleResolution = useCallback(\n    (value: number) => {\n      setResolution(value);\n      if (!fileRef.current || !imageRef.current) return;\n      const layout = computeLayout(\n        imageRef.current.naturalWidth,\n        imageRef.current.naturalHeight,\n        value,\n      );\n      const r = layout.rows;\n      const c = layout.cols;\n      setRows(r);\n      setCols(c);\n      currentRowsRef.current = r;\n      currentColsRef.current = c;\n      const target = imageToMatrix(imageRef.current, r, c, inverted);\n      targetFrameRef.current = target;\n      setShimmerFrames(makeShimmerFrames(target, r, c));\n      setPhase(\"ambient\");\n    },\n    [inverted],\n  );\n\n  const handleInvert = useCallback(() => {\n    const next = !inverted;\n    setInverted(next);\n    if (!imageRef.current) return;\n    const r = currentRowsRef.current;\n    const c = currentColsRef.current;\n    const lf = makeLoaderFrames(r, c);\n    setLoaderFrames(lf);\n    const target = imageToMatrix(imageRef.current, r, c, next);\n    targetFrameRef.current = target;\n    const morph = makeMorphFrames(lf, target, r, c);\n    setRevealFrames(morph);\n    setPhase(\"revealing\");\n  }, [inverted]);\n\n  const handleReset = useCallback(() => {\n    setPhase(\"idle\");\n    setFileName(null);\n    setInverted(false);\n    setResolution(DEFAULT_SIZE);\n    fileRef.current = null;\n    imageRef.current = null;\n    targetFrameRef.current = null;\n  }, []);\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault();\n      setIsDragging(false);\n      const file = e.dataTransfer.files[0];\n      if (file) handleFile(file);\n    },\n    [handleFile],\n  );\n\n  const size = computeSize(rows, cols);\n\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-6 p-8 min-h-[420px]\">\n      <AnimatePresence mode=\"wait\">\n        {phase === \"idle\" && (\n          <motion.div\n            key=\"upload\"\n            initial={{ opacity: 0, scale: 0.96 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.96 }}\n            transition={{ duration: 0.2 }}\n          >\n            <button\n              onClick={() => inputRef.current?.click()}\n              onDragOver={(e) => {\n                e.preventDefault();\n                setIsDragging(true);\n              }}\n              onDragLeave={() => setIsDragging(false)}\n              onDrop={handleDrop}\n              className={cn(\n                \"group flex flex-col items-center justify-center gap-3\",\n                \"w-64 h-48 rounded-xl border-2 border-dashed\",\n                \"transition-all duration-200 cursor-pointer\",\n                isDragging\n                  ? \"border-foreground/40 bg-foreground/5\"\n                  : \"border-border hover:border-foreground/30 hover:bg-muted/30\",\n              )}\n            >\n              <svg\n                width=\"28\"\n                height=\"28\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                className={cn(\n                  \"transition-colors duration-200\",\n                  isDragging\n                    ? \"text-foreground/60\"\n                    : \"text-muted-foreground group-hover:text-foreground/50\",\n                )}\n              >\n                <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" />\n                <circle cx=\"9\" cy=\"9\" r=\"2\" />\n                <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />\n              </svg>\n              <span className=\"text-xs text-muted-foreground font-mono tracking-wide\">\n                {isDragging ? \"drop image\" : \"upload image\"}\n              </span>\n            </button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {phase !== \"idle\" && (\n        <motion.div\n          initial={{ opacity: 0, scale: 0.96 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={{ duration: 0.3 }}\n          className=\"flex flex-col items-center gap-5\"\n        >\n          {phase === \"loading\" && loaderFrames.length > 0 && (\n            <Matrix\n              rows={rows}\n              cols={cols}\n              frames={loaderFrames}\n              fps={16}\n              autoplay\n              loop\n              size={size}\n              gap={1}\n            />\n          )}\n          {phase === \"revealing\" && revealFrames.length > 0 && (\n            <Matrix\n              rows={rows}\n              cols={cols}\n              frames={revealFrames}\n              fps={30}\n              autoplay\n              loop={false}\n              onFrame={handleRevealFrame}\n              size={size}\n              gap={1}\n            />\n          )}\n          {phase === \"ambient\" && shimmerFrames.length > 0 && (\n            <Matrix\n              rows={rows}\n              cols={cols}\n              frames={shimmerFrames}\n              fps={10}\n              autoplay\n              loop\n              size={size}\n              gap={1}\n            />\n          )}\n\n          {phase !== \"loading\" && (\n            <div className=\"flex flex-col gap-2.5 w-full max-w-[320px]\">\n              <div className=\"flex items-center justify-between gap-2\">\n                {fileName && (\n                  <span className=\"text-[10px] font-mono text-muted-foreground truncate max-w-[140px]\">\n                    {fileName}\n                  </span>\n                )}\n                <div className=\"flex items-center gap-3 ml-auto\">\n                  <button\n                    onClick={handleInvert}\n                    className={cn(\n                      \"flex items-center gap-1.5 text-[10px] font-mono tracking-widest uppercase transition-colors\",\n                      inverted\n                        ? \"text-foreground\"\n                        : \"text-muted-foreground hover:text-foreground/60\",\n                    )}\n                  >\n                    <span\n                      className={cn(\n                        \"inline-block w-3 h-3 rounded-full border transition-all\",\n                        inverted\n                          ? \"bg-foreground border-foreground\"\n                          : \"bg-transparent border-muted-foreground\",\n                      )}\n                    />\n                    invert\n                  </button>\n                  <button\n                    onClick={handleReset}\n                    className=\"text-[10px] font-mono tracking-widest uppercase text-muted-foreground hover:text-foreground transition-colors\"\n                  >\n                    reset\n                  </button>\n                </div>\n              </div>\n\n              <UnlumenSlider\n                value={resolution}\n                onChange={(v) => handleResolution(v as number)}\n                min={MIN_SIZE}\n                max={MAX_SIZE}\n                step={1}\n                label=\"res\"\n                valuePosition=\"right\"\n                formatValue={() => `${cols} × ${rows}`}\n              />\n            </div>\n          )}\n        </motion.div>\n      )}\n\n      <input\n        ref={inputRef}\n        type=\"file\"\n        accept=\"image/*\"\n        className=\"hidden\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          if (file) handleFile(file);\n          e.target.value = \"\";\n        }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:example"
    }
  ],
  "type": "registry:example"
}