{"name":"dither","type":"registry:component","files":[{"path":"dither.tsx","type":"registry:component","target":"components/shaders/dither.tsx","content":"\"use client\";\nimport React, {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type DitheringMode = \"bayer\" | \"halftone\" | \"noise\" | \"crosshatch\";\nexport type DitherColorMode = \"original\" | \"grayscale\" | \"duotone\" | \"custom\";\nexport type DitherSourceMode = \"image\" | \"waves\";\n\ninterface DitherShaderBaseProps {\n  /** Size of the dithering grid cells */\n  gridSize?: number;\n  /** Type of dithering pattern */\n  ditherMode?: DitheringMode;\n  /** Color processing mode */\n  colorMode?: DitherColorMode;\n  /** Invert the dithered output colors */\n  invert?: boolean;\n  /** Pixelation multiplier (1 = no pixelation, higher = more pixelated) */\n  pixelRatio?: number;\n  /** Primary color for duotone mode */\n  primaryColor?: string;\n  /** Secondary color for duotone mode */\n  secondaryColor?: string;\n  /** Custom color palette array for custom mode */\n  customPalette?: string[];\n  /** Brightness adjustment (-1 to 1) */\n  brightness?: number;\n  /** Contrast adjustment (0 to 2, 1 = normal) */\n  contrast?: number;\n  /** Background color behind the dithered image */\n  backgroundColor?: string;\n  /** Object fit behavior */\n  objectFit?: \"cover\" | \"contain\" | \"fill\" | \"none\";\n  /** Threshold bias for dithering (0 to 1) */\n  threshold?: number;\n  /** Enable animation effect */\n  animated?: boolean;\n  /** Animation speed (lower = slower) */\n  animationSpeed?: number;\n  /** Number of channel levels retained in original color mode */\n  colorCount?: number;\n  /** Travel speed of the procedural wave field */\n  waveSpeed?: number;\n  /** Frequency multiplier between wave octaves */\n  waveFrequency?: number;\n  /** Amplitude multiplier between wave octaves */\n  waveAmplitude?: number;\n  /** Base color of the procedural wave field */\n  waveColor?: string;\n  /** Let the pointer darken and reshape the procedural wave field */\n  enableMouseInteraction?: boolean;\n  /** Radius of the pointer influence in normalized canvas units */\n  mouseRadius?: number;\n  /** Additional CSS classes for the container (use this to set size via Tailwind) */\n  className?: string;\n}\n\ninterface DitherImageSourceProps {\n  /** Render a source image. This is the default mode. */\n  sourceMode?: \"image\";\n  /** Source image URL */\n  src: string;\n  /** Alternative text for the source image */\n  alt: string;\n}\n\ninterface DitherWaveSourceProps {\n  /** Render a procedural wave field instead of an image. */\n  sourceMode: \"waves\";\n  src?: never;\n  alt?: never;\n}\n\nexport type DitherShaderProps = DitherShaderBaseProps &\n  (DitherImageSourceProps | DitherWaveSourceProps);\n\n// 4x4 Bayer matrix for ordered dithering\nconst BAYER_MATRIX_4x4 = [\n  [0, 8, 2, 10],\n  [12, 4, 14, 6],\n  [3, 11, 1, 9],\n  [15, 7, 13, 5],\n];\n\n// 8x8 Bayer matrix for finer dithering\nconst BAYER_MATRIX_8x8 = [\n  [0, 32, 8, 40, 2, 34, 10, 42],\n  [48, 16, 56, 24, 50, 18, 58, 26],\n  [12, 44, 4, 36, 14, 46, 6, 38],\n  [60, 28, 52, 20, 62, 30, 54, 22],\n  [3, 35, 11, 43, 1, 33, 9, 41],\n  [51, 19, 59, 27, 49, 17, 57, 25],\n  [15, 47, 7, 39, 13, 45, 5, 37],\n  [63, 31, 55, 23, 61, 29, 53, 21],\n];\n\nconst DEFAULT_CUSTOM_PALETTE = [\"#000000\", \"#ffffff\"];\n\nfunction parseColor(color: string): [number, number, number] {\n  if (color.startsWith(\"#\")) {\n    const hex = color.slice(1);\n    if (hex.length === 3) {\n      return [\n        parseInt(hex[0] + hex[0], 16),\n        parseInt(hex[1] + hex[1], 16),\n        parseInt(hex[2] + hex[2], 16),\n      ];\n    }\n    return [\n      parseInt(hex.slice(0, 2), 16),\n      parseInt(hex.slice(2, 4), 16),\n      parseInt(hex.slice(4, 6), 16),\n    ];\n  }\n  const match = color.match(/rgb\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\)/i);\n  if (match) {\n    return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];\n  }\n  return [0, 0, 0];\n}\n\nfunction getLuminance(r: number, g: number, b: number): number {\n  return 0.299 * r + 0.587 * g + 0.114 * b;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.max(min, Math.min(max, value));\n}\n\nfunction smoothstep(edgeStart: number, edgeEnd: number, value: number) {\n  const position = clamp((value - edgeStart) / (edgeEnd - edgeStart), 0, 1);\n  return position * position * (3 - 2 * position);\n}\n\nfunction noise2d(x: number, y: number) {\n  const x0 = Math.floor(x);\n  const y0 = Math.floor(y);\n  const xFraction = x - x0;\n  const yFraction = y - y0;\n  const fadeX = xFraction * xFraction * xFraction *\n    (xFraction * (xFraction * 6 - 15) + 10);\n  const fadeY = yFraction * yFraction * yFraction *\n    (yFraction * (yFraction * 6 - 15) + 10);\n  const hash = (hashX: number, hashY: number) => {\n    const value = Math.sin(hashX * 127.1 + hashY * 311.7) * 43758.5453;\n    return (value - Math.floor(value)) * 2 - 1;\n  };\n  const top = hash(x0, y0) + (hash(x0 + 1, y0) - hash(x0, y0)) * fadeX;\n  const bottom =\n    hash(x0, y0 + 1) +\n    (hash(x0 + 1, y0 + 1) - hash(x0, y0 + 1)) * fadeX;\n  return top + (bottom - top) * fadeY;\n}\n\nfunction waveFbm(\n  x: number,\n  y: number,\n  frequency: number,\n  amplitude: number,\n) {\n  let value = 0;\n  let octaveAmplitude = 1;\n  let sampleX = x;\n  let sampleY = y;\n\n  for (let octave = 0; octave < 4; octave += 1) {\n    value += octaveAmplitude * Math.abs(noise2d(sampleX, sampleY));\n    sampleX *= frequency;\n    sampleY *= frequency;\n    octaveAmplitude *= amplitude;\n  }\n\n  return value;\n}\n\nexport const DitherShader: React.FC<DitherShaderProps> = ({\n  sourceMode = \"image\",\n  src,\n  alt,\n  gridSize = 4,\n  ditherMode = \"bayer\",\n  colorMode = \"original\",\n  invert = false,\n  pixelRatio = 1,\n  primaryColor = \"#000000\",\n  secondaryColor = \"#ffffff\",\n  customPalette = DEFAULT_CUSTOM_PALETTE,\n  brightness = 0,\n  contrast = 1,\n  backgroundColor = \"transparent\",\n  objectFit = \"cover\",\n  threshold = 0.5,\n  animated,\n  animationSpeed = 0.02,\n  colorCount = 4,\n  waveSpeed = 0.05,\n  waveFrequency = 3,\n  waveAmplitude = 0.3,\n  waveColor = \"#808080\",\n  enableMouseInteraction = true,\n  mouseRadius = 1,\n  className,\n}) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const animationRef = useRef<number | null>(null);\n  const timeRef = useRef<number>(0);\n  const pointerRef = useRef({ active: false, x: 0.5, y: 0.5 });\n  const renderCurrentFrameRef = useRef<(() => void) | null>(null);\n  const imageRef = useRef<HTMLImageElement | null>(null);\n  const imageDataRef = useRef<ImageData | null>(null);\n\n  const [dimensions, setDimensions] = useState<{\n    width: number;\n    height: number;\n  }>({ width: 0, height: 0 });\n  const [ready, setReady] = useState(false);\n\n  const parsedPrimaryColor = useMemo(\n    () => parseColor(primaryColor),\n    [primaryColor],\n  );\n  const parsedSecondaryColor = useMemo(\n    () => parseColor(secondaryColor),\n    [secondaryColor],\n  );\n  const parsedCustomPalette = useMemo(\n    () =>\n      (customPalette.length >= 2 ? customPalette : DEFAULT_CUSTOM_PALETTE).map(\n        parseColor,\n      ),\n    [customPalette],\n  );\n  const parsedWaveColor = useMemo(() => parseColor(waveColor), [waveColor]);\n  const isAnimated = animated ?? sourceMode === \"waves\";\n\n  const applyDithering = useCallback(\n    (\n      ctx: CanvasRenderingContext2D,\n      displayWidth: number,\n      displayHeight: number,\n      time: number = 0,\n    ) => {\n      const canvas = canvasRef.current;\n      const imageData = imageDataRef.current;\n      if (!canvas || (sourceMode === \"image\" && !imageData)) return;\n\n      // Clear with background\n      if (backgroundColor !== \"transparent\") {\n        ctx.fillStyle = backgroundColor;\n        ctx.fillRect(0, 0, displayWidth, displayHeight);\n      } else {\n        ctx.clearRect(0, 0, displayWidth, displayHeight);\n      }\n\n      const sourceData = imageData?.data;\n      const sourceWidth = imageData?.width ?? 0;\n      const sourceHeight = imageData?.height ?? 0;\n\n      const cellSize = Math.max(1, gridSize);\n      const effectivePixelSize = Math.max(1, Math.floor(cellSize * pixelRatio));\n      const matrixSize = cellSize <= 4 ? 4 : 8;\n      const bayerMatrix = cellSize <= 4 ? BAYER_MATRIX_4x4 : BAYER_MATRIX_8x8;\n      const matrixScale = matrixSize === 4 ? 16 : 64;\n\n      // Process pixels\n      for (let y = 0; y < displayHeight; y += effectivePixelSize) {\n        for (let x = 0; x < displayWidth; x += effectivePixelSize) {\n          let r: number;\n          let g: number;\n          let b: number;\n\n          if (sourceMode === \"waves\") {\n            const aspect = displayWidth / displayHeight;\n            const waveX = (x / displayWidth - 0.5) * aspect;\n            const waveY = y / displayHeight - 0.5;\n            const travelledX = waveX - time * waveSpeed;\n            const travelledY = waveY - time * waveSpeed;\n            const nestedWave = waveFbm(\n              travelledX,\n              travelledY,\n              waveFrequency,\n              waveAmplitude,\n            );\n            let intensity = waveFbm(\n              waveX + nestedWave,\n              waveY + nestedWave,\n              waveFrequency,\n              waveAmplitude,\n            );\n\n            const pointer = pointerRef.current;\n            if (enableMouseInteraction && pointer.active) {\n              const pointerX = (pointer.x - 0.5) * aspect;\n              const pointerY = pointer.y - 0.5;\n              const distance = Math.hypot(\n                waveX - pointerX,\n                waveY - pointerY,\n              );\n              const influence = 1 - smoothstep(\n                0,\n                Math.max(0.001, mouseRadius),\n                distance,\n              );\n              intensity -= 0.5 * influence;\n            }\n\n            intensity = clamp(intensity, 0, 1);\n            r = parsedWaveColor[0] * intensity;\n            g = parsedWaveColor[1] * intensity;\n            b = parsedWaveColor[2] * intensity;\n          } else {\n            const srcX = Math.floor((x / displayWidth) * sourceWidth);\n            const srcY = Math.floor((y / displayHeight) * sourceHeight);\n            const srcIdx = (srcY * sourceWidth + srcX) * 4;\n            const alpha = sourceData?.[srcIdx + 3] ?? 0;\n\n            if (alpha < 10) continue;\n            r = sourceData?.[srcIdx] ?? 0;\n            g = sourceData?.[srcIdx + 1] ?? 0;\n            b = sourceData?.[srcIdx + 2] ?? 0;\n          }\n\n          // Apply brightness and contrast\n          r = clamp((r - 128) * contrast + 128 + brightness * 255, 0, 255);\n          g = clamp((g - 128) * contrast + 128 + brightness * 255, 0, 255);\n          b = clamp((b - 128) * contrast + 128 + brightness * 255, 0, 255);\n\n          // Calculate luminance\n          const luminance = getLuminance(r, g, b) / 255;\n\n          // Get dither threshold based on mode\n          let ditherThreshold: number;\n          const matrixX = Math.floor(x / cellSize) % matrixSize;\n          const matrixY = Math.floor(y / cellSize) % matrixSize;\n\n          switch (ditherMode) {\n            case \"bayer\":\n              ditherThreshold = bayerMatrix[matrixY][matrixX] / matrixScale;\n              break;\n            case \"halftone\": {\n              const angle = Math.PI / 4;\n              const scale = cellSize * 2;\n              const rotX = x * Math.cos(angle) + y * Math.sin(angle);\n              const rotY = -x * Math.sin(angle) + y * Math.cos(angle);\n              const pattern =\n                (Math.sin(rotX / scale) + Math.sin(rotY / scale) + 2) / 4;\n              ditherThreshold = pattern;\n              break;\n            }\n            case \"noise\": {\n              const noiseVal =\n                Math.sin(x * 12.9898 + y * 78.233 + time * 100) * 43758.5453;\n              ditherThreshold = noiseVal - Math.floor(noiseVal);\n              break;\n            }\n            case \"crosshatch\": {\n              const line1 = (x + y) % (cellSize * 2) < cellSize ? 1 : 0;\n              const line2 =\n                (x - y + cellSize * 4) % (cellSize * 2) < cellSize ? 1 : 0;\n              ditherThreshold = (line1 + line2) / 2;\n              break;\n            }\n            default:\n              ditherThreshold = bayerMatrix[matrixY][matrixX] / matrixScale;\n          }\n\n          // Adjust threshold with user setting\n          ditherThreshold = ditherThreshold * (1 - threshold) + threshold * 0.5;\n\n          // Determine output color based on color mode\n          let outputColor: [number, number, number];\n\n          switch (colorMode) {\n            case \"grayscale\": {\n              const shouldBeDark = luminance < ditherThreshold;\n              outputColor = shouldBeDark ? [0, 0, 0] : [255, 255, 255];\n              break;\n            }\n            case \"duotone\": {\n              const shouldBeDark = luminance < ditherThreshold;\n              outputColor = shouldBeDark\n                ? parsedPrimaryColor\n                : parsedSecondaryColor;\n              break;\n            }\n            case \"custom\": {\n              if (parsedCustomPalette.length === 2) {\n                const shouldBeDark = luminance < ditherThreshold;\n                outputColor = shouldBeDark\n                  ? parsedCustomPalette[0]\n                  : parsedCustomPalette[1];\n              } else {\n                // Quantize to closest palette color with dithering\n                const adjustedLuminance =\n                  luminance + (ditherThreshold - 0.5) * 0.5;\n                const paletteIndex = Math.floor(\n                  clamp(adjustedLuminance, 0, 1) *\n                    (parsedCustomPalette.length - 1),\n                );\n                outputColor = parsedCustomPalette[paletteIndex];\n              }\n              break;\n            }\n            case \"original\":\n            default: {\n              // Apply dithering while preserving colors\n              const ditherAmount = ditherThreshold - 0.5;\n              const adjustedR = clamp(r + ditherAmount * 64, 0, 255);\n              const adjustedG = clamp(g + ditherAmount * 64, 0, 255);\n              const adjustedB = clamp(b + ditherAmount * 64, 0, 255);\n\n              // Quantize to fewer levels for dithered look\n              const levels = Math.max(2, Math.round(colorCount));\n              outputColor = [\n                Math.round(adjustedR / (255 / levels)) * (255 / levels),\n                Math.round(adjustedG / (255 / levels)) * (255 / levels),\n                Math.round(adjustedB / (255 / levels)) * (255 / levels),\n              ];\n              break;\n            }\n          }\n\n          // Apply inversion\n          if (invert) {\n            outputColor = [\n              255 - outputColor[0],\n              255 - outputColor[1],\n              255 - outputColor[2],\n            ];\n          }\n\n          // Draw the pixel\n          ctx.fillStyle = `rgb(${outputColor[0]}, ${outputColor[1]}, ${outputColor[2]})`;\n          ctx.fillRect(x, y, effectivePixelSize, effectivePixelSize);\n        }\n      }\n    },\n    [\n      gridSize,\n      ditherMode,\n      colorMode,\n      invert,\n      pixelRatio,\n      parsedPrimaryColor,\n      parsedSecondaryColor,\n      parsedCustomPalette,\n      brightness,\n      contrast,\n      backgroundColor,\n      threshold,\n      sourceMode,\n      waveSpeed,\n      waveFrequency,\n      waveAmplitude,\n      parsedWaveColor,\n      enableMouseInteraction,\n      mouseRadius,\n      colorCount,\n    ],\n  );\n\n  // Setup resize observer for responsive sizing\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const resizeObserver = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const { width, height } = entry.contentRect;\n        if (width > 0 && height > 0) {\n          setDimensions({ width, height });\n        }\n      }\n    });\n\n    resizeObserver.observe(container);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  // Prepare the selected source and apply dithering when its inputs change.\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || dimensions.width === 0 || dimensions.height === 0) return;\n\n    let isCancelled = false;\n    setReady(false);\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\",\n    ).matches;\n    const dpr = window.devicePixelRatio || 1;\n    const displayWidth = Math.max(1, Math.round(dimensions.width));\n    const displayHeight = Math.max(1, Math.round(dimensions.height));\n\n    canvas.width = Math.floor(displayWidth * dpr);\n    canvas.height = Math.floor(displayHeight * dpr);\n\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    ctx.resetTransform();\n    ctx.scale(dpr, dpr);\n\n    const renderCurrentFrame = () => {\n      applyDithering(ctx, displayWidth, displayHeight, timeRef.current);\n    };\n    renderCurrentFrameRef.current = renderCurrentFrame;\n\n    const startRendering = () => {\n      renderCurrentFrame();\n      setReady(true);\n\n      if (isAnimated && !reduceMotion) {\n        const animate = () => {\n          if (isCancelled) return;\n          timeRef.current += animationSpeed;\n          renderCurrentFrame();\n          animationRef.current = requestAnimationFrame(animate);\n        };\n        animationRef.current = requestAnimationFrame(animate);\n      }\n    };\n\n    const cleanup = () => {\n      isCancelled = true;\n      if (animationRef.current !== null) {\n        cancelAnimationFrame(animationRef.current);\n        animationRef.current = null;\n      }\n      if (renderCurrentFrameRef.current === renderCurrentFrame) {\n        renderCurrentFrameRef.current = null;\n      }\n    };\n\n    if (sourceMode === \"waves\") {\n      imageDataRef.current = null;\n      startRendering();\n      return cleanup;\n    }\n\n    if (!src) return cleanup;\n\n    const processImage = (img: HTMLImageElement) => {\n      if (isCancelled) return;\n\n      // Create offscreen canvas to get image data\n      const offscreen = document.createElement(\"canvas\");\n      const iw = img.naturalWidth || displayWidth;\n      const ih = img.naturalHeight || displayHeight;\n\n      let dw = displayWidth;\n      let dh = displayHeight;\n      let dx = 0;\n      let dy = 0;\n\n      if (objectFit === \"cover\") {\n        const scale = Math.max(displayWidth / iw, displayHeight / ih);\n        dw = Math.ceil(iw * scale);\n        dh = Math.ceil(ih * scale);\n        dx = Math.floor((displayWidth - dw) / 2);\n        dy = Math.floor((displayHeight - dh) / 2);\n      } else if (objectFit === \"contain\") {\n        const scale = Math.min(displayWidth / iw, displayHeight / ih);\n        dw = Math.ceil(iw * scale);\n        dh = Math.ceil(ih * scale);\n        dx = Math.floor((displayWidth - dw) / 2);\n        dy = Math.floor((displayHeight - dh) / 2);\n      } else if (objectFit === \"fill\") {\n        dw = displayWidth;\n        dh = displayHeight;\n      } else {\n        dw = iw;\n        dh = ih;\n        dx = Math.floor((displayWidth - dw) / 2);\n        dy = Math.floor((displayHeight - dh) / 2);\n      }\n\n      offscreen.width = displayWidth;\n      offscreen.height = displayHeight;\n      const offCtx = offscreen.getContext(\"2d\");\n      if (!offCtx) return;\n\n      offCtx.drawImage(img, dx, dy, dw, dh);\n\n      try {\n        imageDataRef.current = offCtx.getImageData(\n          0,\n          0,\n          displayWidth,\n          displayHeight,\n        );\n      } catch {\n        setReady(false);\n        return;\n      }\n\n      startRendering();\n    };\n\n    // If image is already loaded, reprocess it\n    const resolvedSource = new URL(src, window.location.href).href;\n    if (\n      imageRef.current &&\n      imageRef.current.complete &&\n      imageRef.current.src === resolvedSource\n    ) {\n      processImage(imageRef.current);\n    } else {\n      // Load the image\n      const img = new Image();\n      img.crossOrigin = \"anonymous\";\n      img.src = src;\n\n      img.onload = () => {\n        if (isCancelled) return;\n        imageRef.current = img;\n        processImage(img);\n      };\n\n      img.onerror = () => {\n        setReady(false);\n      };\n    }\n\n    return cleanup;\n  }, [\n    src,\n    sourceMode,\n    dimensions,\n    objectFit,\n    isAnimated,\n    animationSpeed,\n    applyDithering,\n  ]);\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (sourceMode !== \"waves\" || !enableMouseInteraction) return;\n    const bounds = event.currentTarget.getBoundingClientRect();\n    pointerRef.current = {\n      active: true,\n      x: clamp((event.clientX - bounds.left) / bounds.width, 0, 1),\n      y: clamp((event.clientY - bounds.top) / bounds.height, 0, 1),\n    };\n    renderCurrentFrameRef.current?.();\n  };\n\n  const handlePointerLeave = () => {\n    if (sourceMode !== \"waves\") return;\n    pointerRef.current.active = false;\n    renderCurrentFrameRef.current?.();\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className={`relative h-full w-full overflow-hidden ${className ?? \"\"}`.trim()}\n      onPointerLeave={handlePointerLeave}\n      onPointerMove={handlePointerMove}\n    >\n      {sourceMode === \"image\" && src ? (\n        <img\n          alt={alt ?? \"\"}\n          className={`absolute inset-0 size-full transition-opacity duration-300 ${ready ? \"opacity-0\" : \"opacity-100\"}`}\n          src={src}\n          style={{ objectFit }}\n        />\n      ) : null}\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className={`absolute inset-0 h-full w-full transition-opacity duration-300 ${ready ? \"opacity-100\" : \"opacity-0\"}`}\n        style={{ imageRendering: \"pixelated\" }}\n      />\n    </div>\n  );\n};\n\nexport default DitherShader;\n"}]}