{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dithered-logo",
  "type": "registry:ui",
  "title": "Dithered Logo",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "An interactive particle logo that turns image assets into a dithered canvas field with cursor ripples.",
  "files": [
    {
      "path": "components/ui/dithered-logo.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { type CSSProperties, useEffect, useRef } from \"react\";\n\nexport interface DitheredLogoProps {\n  imageSrc: string;\n  gridSize?: number;\n  scale?: number;\n  dotScale?: number;\n  invert?: boolean;\n  cornerRadius?: number;\n  threshold?: number;\n  contrast?: number;\n  gamma?: number;\n  blur?: number;\n  diffusionStrength?: number;\n  serpentine?: boolean;\n  particleColor?: string;\n  style?: CSSProperties;\n  className?: string;\n}\n\ntype PointCloud = {\n  x: Float32Array;\n  y: Float32Array;\n  dx: Float32Array;\n  dy: Float32Array;\n  dotSize: number;\n};\n\ntype Wave = { x: number; y: number; bornAt: number };\n\nconst defaults = {\n  gridSize: 200,\n  scale: 0.5,\n  dotScale: 1,\n  invert: true,\n  cornerRadius: 0.2,\n  threshold: 180,\n  contrast: 0,\n  gamma: 1,\n  blur: 3.75,\n  diffusionStrength: 1,\n  serpentine: true,\n};\n\nfunction loadAsset(source: string, signal: AbortSignal) {\n  return new Promise<HTMLImageElement>((resolve, reject) => {\n    const image = new Image();\n    const abort = () => reject(new DOMException(\"Aborted\", \"AbortError\"));\n    image.crossOrigin = \"anonymous\";\n    image.onload = () => {\n      signal.removeEventListener(\"abort\", abort);\n      resolve(image);\n    };\n    image.onerror = () => reject(new Error(`Unable to load image: ${source}`));\n    signal.addEventListener(\"abort\", abort, { once: true });\n    image.src = source;\n  });\n}\n\nfunction sampleImage(\n  image: HTMLImageElement,\n  maxDimension: number,\n  contrast: number,\n  gamma: number,\n  blur: number,\n) {\n  const ratio = image.naturalWidth / image.naturalHeight;\n  const width = Math.max(\n    1,\n    Math.round(ratio >= 1 ? maxDimension : maxDimension * ratio),\n  );\n  const height = Math.max(\n    1,\n    Math.round(ratio >= 1 ? maxDimension / ratio : maxDimension),\n  );\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = width;\n  canvas.height = height;\n  const context = canvas.getContext(\"2d\", { willReadFrequently: true });\n  if (!context) throw new Error(\"DitheredLogo could not create a 2D context.\");\n\n  context.clearRect(0, 0, width, height);\n  context.filter = blur > 0 ? `blur(${blur}px)` : \"none\";\n  context.drawImage(image, 0, 0, width, height);\n  context.filter = \"none\";\n\n  const pixels = context.getImageData(0, 0, width, height).data;\n  const luminance = new Float32Array(width * height);\n  const alpha = new Uint8Array(width * height);\n  const contrastScale =\n    contrast === 0 ? 1 : (259 * (contrast + 255)) / (255 * (259 - contrast));\n\n  for (let pixel = 0; pixel < width * height; pixel++) {\n    const offset = pixel * 4;\n    alpha[pixel] = pixels[offset + 3]!;\n    const luma =\n      pixels[offset]! * 0.2126 +\n      pixels[offset + 1]! * 0.7152 +\n      pixels[offset + 2]! * 0.0722;\n    const contrasted = contrastScale * (luma - 128) + 128;\n    luminance[pixel] =\n      255 *\n      Math.pow(\n        Math.min(1, Math.max(0, contrasted / 255)),\n        1 / Math.max(0.01, gamma),\n      );\n  }\n\n  return { width, height, luminance, alpha };\n}\n\nfunction insideRoundedRect(\n  x: number,\n  y: number,\n  width: number,\n  height: number,\n  radius: number,\n) {\n  const r = Math.max(0, Math.min(Math.min(width, height) / 2, radius));\n  const nearX = x < r ? r : x > width - r - 1 ? width - r - 1 : x;\n  const nearY = y < r ? r : y > height - r - 1 ? height - r - 1 : y;\n  return (x - nearX) ** 2 + (y - nearY) ** 2 <= r ** 2;\n}\n\nfunction diffuse(\n  input: Float32Array,\n  alpha: Uint8Array,\n  width: number,\n  height: number,\n  threshold: number,\n  strength: number,\n  serpentine: boolean,\n  invert: boolean,\n  cornerRadius: number,\n) {\n  const values = input.slice();\n  const output: number[] = [];\n  const addError = (x: number, y: number, error: number, weight: number) => {\n    if (x < 0 || x >= width || y < 0 || y >= height) return;\n    const index = y * width + x;\n    if (alpha[index]! < 128) return;\n    values[index] = values[index]! + error * weight * strength;\n  };\n\n  for (let y = 0; y < height; y++) {\n    const reverse = serpentine && y % 2 === 1;\n    for (let column = 0; column < width; column++) {\n      const x = reverse ? width - column - 1 : column;\n      const index = y * width + x;\n      if (alpha[index]! < 128) continue;\n      const on = values[index]! >= threshold;\n      const chosen = on ? 255 : 0;\n      const error = values[index]! - chosen;\n      const direction = reverse ? -1 : 1;\n\n      if (\n        (invert ? !on : on) &&\n        (!invert ||\n          insideRoundedRect(\n            x,\n            y,\n            width,\n            height,\n            cornerRadius * Math.min(width, height),\n          ))\n      ) {\n        output.push(x, y);\n      }\n\n      addError(x + direction, y, error, 7 / 16);\n      addError(x - direction, y + 1, error, 3 / 16);\n      addError(x, y + 1, error, 5 / 16);\n      addError(x + direction, y + 1, error, 1 / 16);\n    }\n  }\n  return new Float32Array(output);\n}\n\nfunction createCloud(\n  points: Float32Array,\n  gridWidth: number,\n  gridHeight: number,\n  canvasWidth: number,\n  canvasHeight: number,\n  scale: number,\n  dotScale: number,\n): PointCloud {\n  const unit =\n    (Math.min(canvasWidth, canvasHeight) * scale) /\n    Math.max(gridWidth, gridHeight);\n  const originX = (canvasWidth - gridWidth * unit) / 2;\n  const originY = (canvasHeight - gridHeight * unit) / 2;\n  const count = points.length / 2;\n  const x = new Float32Array(count);\n  const y = new Float32Array(count);\n\n  for (let index = 0; index < count; index++) {\n    x[index] = originX + points[index * 2]! * unit;\n    y[index] = originY + points[index * 2 + 1]! * unit;\n  }\n\n  return {\n    x,\n    y,\n    dx: new Float32Array(count),\n    dy: new Float32Array(count),\n    dotSize: Math.max(0.5, unit * dotScale),\n  };\n}\n\nclass ParticleRenderer {\n  private frame = 0;\n  private cloud: PointCloud | null = null;\n  private pointer = { x: 0, y: 0, active: false };\n  private waves: Wave[] = [];\n  private color = \"#000\";\n\n  constructor(\n    private canvas: HTMLCanvasElement,\n    private context: CanvasRenderingContext2D,\n  ) {}\n\n  setCloud(cloud: PointCloud) {\n    this.cloud = cloud;\n    this.play();\n  }\n\n  setColor(color: string) {\n    this.color = color;\n    this.play();\n  }\n\n  movePointer(x: number, y: number) {\n    this.pointer = { x, y, active: true };\n    this.play();\n  }\n\n  releasePointer() {\n    this.pointer.active = false;\n    this.play();\n  }\n\n  addWave(x: number, y: number) {\n    this.waves.push({ x, y, bornAt: performance.now() });\n    this.play();\n  }\n\n  resize(width: number, height: number) {\n    const dpr = Math.min(2, window.devicePixelRatio || 1);\n    this.canvas.width = Math.max(1, Math.round(width * dpr));\n    this.canvas.height = Math.max(1, Math.round(height * dpr));\n    this.context.setTransform(dpr, 0, 0, dpr, 0, 0);\n  }\n\n  stop() {\n    cancelAnimationFrame(this.frame);\n    this.frame = 0;\n  }\n\n  private play = () => {\n    if (!this.frame) this.frame = requestAnimationFrame(this.tick);\n  };\n\n  private tick = (now: number) => {\n    this.frame = 0;\n    const cloud = this.cloud;\n    if (!cloud) return;\n    const bounds = this.canvas.getBoundingClientRect();\n    this.waves = this.waves.filter((wave) => now - wave.bornAt < 720);\n    let moving = this.pointer.active || this.waves.length > 0;\n\n    this.context.clearRect(0, 0, bounds.width, bounds.height);\n    this.context.fillStyle = this.color;\n\n    for (let index = 0; index < cloud.x.length; index++) {\n      let forceX = 0;\n      let forceY = 0;\n      const px = cloud.x[index]!;\n      const py = cloud.y[index]!;\n\n      if (this.pointer.active) {\n        const vx = px + cloud.dx[index]! - this.pointer.x;\n        const vy = py + cloud.dy[index]! - this.pointer.y;\n        const distance = Math.hypot(vx, vy);\n        if (distance > 0.1 && distance < 100) {\n          const strength = (1 - distance / 100) ** 2 * 38;\n          forceX += (vx / distance) * strength;\n          forceY += (vy / distance) * strength;\n        }\n      }\n\n      for (const wave of this.waves) {\n        const age = now - wave.bornAt;\n        const vx = px - wave.x;\n        const vy = py - wave.y;\n        const distance = Math.hypot(vx, vy);\n        const ring = Math.abs(distance - age * 0.23);\n        if (distance > 0.1 && ring < 38) {\n          const strength = (1 - ring / 38) * (1 - age / 720) * 20;\n          forceX += (vx / distance) * strength;\n          forceY += (vy / distance) * strength;\n        }\n      }\n\n      cloud.dx[index] = cloud.dx[index]! * 0.84 + forceX * 0.16;\n      cloud.dy[index] = cloud.dy[index]! * 0.84 + forceY * 0.16;\n      if (\n        Math.abs(cloud.dx[index]!) > 0.02 ||\n        Math.abs(cloud.dy[index]!) > 0.02\n      ) {\n        moving = true;\n      } else {\n        cloud.dx[index] = 0;\n        cloud.dy[index] = 0;\n      }\n      this.context.fillRect(\n        px + cloud.dx[index]!,\n        py + cloud.dy[index]!,\n        cloud.dotSize,\n        cloud.dotSize,\n      );\n    }\n\n    if (moving) this.play();\n  };\n}\n\nexport function DitheredLogo({\n  imageSrc,\n  gridSize = defaults.gridSize,\n  scale = defaults.scale,\n  dotScale = defaults.dotScale,\n  invert = defaults.invert,\n  cornerRadius = defaults.cornerRadius,\n  threshold = defaults.threshold,\n  contrast = defaults.contrast,\n  gamma = defaults.gamma,\n  blur = defaults.blur,\n  diffusionStrength = defaults.diffusionStrength,\n  serpentine = defaults.serpentine,\n  particleColor = \"currentColor\",\n  style,\n  className,\n}: DitheredLogoProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const context = canvas?.getContext(\"2d\");\n    if (!canvas || !context) return;\n    const renderer = new ParticleRenderer(canvas, context);\n    const abortController = new AbortController();\n    let sampled: ReturnType<typeof sampleImage> | null = null;\n    let points: Float32Array | null = null;\n    let rebuildFrame = 0;\n\n    const resolveColor = () =>\n      particleColor === \"currentColor\"\n        ? getComputedStyle(canvas).color\n        : particleColor;\n    const rebuildCloud = () => {\n      if (!sampled || !points) return;\n      const bounds = canvas.getBoundingClientRect();\n      renderer.resize(bounds.width, bounds.height);\n      renderer.setColor(resolveColor());\n      renderer.setCloud(\n        createCloud(\n          points,\n          sampled.width,\n          sampled.height,\n          bounds.width,\n          bounds.height,\n          scale,\n          dotScale * (bounds.width <= 640 ? 0.8 : 1),\n        ),\n      );\n    };\n    const scheduleRebuild = () => {\n      cancelAnimationFrame(rebuildFrame);\n      rebuildFrame = requestAnimationFrame(rebuildCloud);\n    };\n    const localPoint = (event: PointerEvent) => {\n      const bounds = canvas.getBoundingClientRect();\n      return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };\n    };\n    const onMove = (event: PointerEvent) => {\n      const point = localPoint(event);\n      renderer.movePointer(point.x, point.y);\n    };\n    const onLeave = () => renderer.releasePointer();\n    const onUp = (event: PointerEvent) => {\n      const point = localPoint(event);\n      renderer.addWave(point.x, point.y);\n      if (event.pointerType !== \"mouse\") renderer.releasePointer();\n    };\n\n    const observer = new ResizeObserver(scheduleRebuild);\n    const themeObserver = new MutationObserver(() =>\n      renderer.setColor(resolveColor()),\n    );\n    observer.observe(canvas);\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\"],\n    });\n    canvas.addEventListener(\"pointermove\", onMove);\n    canvas.addEventListener(\"pointerleave\", onLeave);\n    canvas.addEventListener(\"pointercancel\", onLeave);\n    canvas.addEventListener(\"pointerup\", onUp);\n\n    loadAsset(imageSrc, abortController.signal)\n      .then((image) => {\n        sampled = sampleImage(image, gridSize, contrast, gamma, blur);\n        points = diffuse(\n          sampled.luminance,\n          sampled.alpha,\n          sampled.width,\n          sampled.height,\n          threshold,\n          diffusionStrength,\n          serpentine,\n          invert,\n          cornerRadius,\n        );\n        rebuildCloud();\n      })\n      .catch((error: unknown) => {\n        if (!(error instanceof DOMException && error.name === \"AbortError\")) {\n          console.error(\"DitheredLogo: failed to prepare image\", error);\n        }\n      });\n\n    return () => {\n      abortController.abort();\n      cancelAnimationFrame(rebuildFrame);\n      renderer.stop();\n      observer.disconnect();\n      themeObserver.disconnect();\n      canvas.removeEventListener(\"pointermove\", onMove);\n      canvas.removeEventListener(\"pointerleave\", onLeave);\n      canvas.removeEventListener(\"pointercancel\", onLeave);\n      canvas.removeEventListener(\"pointerup\", onUp);\n    };\n  }, [\n    blur,\n    contrast,\n    cornerRadius,\n    diffusionStrength,\n    dotScale,\n    gamma,\n    gridSize,\n    imageSrc,\n    invert,\n    particleColor,\n    scale,\n    serpentine,\n    threshold,\n  ]);\n\n  return (\n    <div\n      className={cn(\"relative h-60 w-60 text-black\", className)}\n      style={style}\n    >\n      <canvas\n        ref={canvasRef}\n        className=\"absolute inset-0 block h-full w-full touch-none\"\n        aria-label=\"Interactive dithered image\"\n        role=\"img\"\n      />\n    </div>\n  );\n}\n\nexport default DitheredLogo;\n",
      "type": "registry:ui"
    }
  ]
}