{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ascii-field",
  "type": "registry:ui",
  "title": "ASCII Field",
  "description": "An animated ASCII-art field: noise, waves or rings sampled on a character grid and mapped to a density ramp.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/ascii-field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type AsciiFieldMode = \"noise\" | \"waves\" | \"rings\";\n\nexport interface AsciiFieldProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Density ramp from empty to full. The first character is drawn for the lowest values. */\n  charset?: string;\n  /** Row height in px; the column width follows the monospace glyph width. */\n  cellSize?: number;\n  /** Animation speed multiplier. 0 freezes the field (pointer feedback still works). */\n  speed?: number;\n  /** Field function sampled on the character grid. */\n  mode?: AsciiFieldMode;\n  /** Tint the densest characters toward the accent colour. */\n  colorize?: boolean;\n  /** Accent colour for dense characters, hover glow and ripples (any CSS colour). */\n  accent?: string;\n  /** Brighten the field under a mouse pointer and send a ripple out from clicks and taps. */\n  interactive?: boolean;\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\nconst mix = (a: RGB, b: RGB, t: number): RGB => [0, 1, 2].map((i) => Math.round(a[i] + (b[i] - a[i]) * t)) as RGB;\n\n/** On light themes (dark ink), very light accents are pulled toward the ink so they stay visible. */\nfunction legible(color: RGB, ink: RGB): RGB {\n  if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;\n  return mix(color, ink, 0.45);\n}\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ---------------------------------- Noise ---------------------------------- */\n\n/** Compact seeded 3D simplex noise (after Stefan Gustavson). Returns values in roughly [-1, 1]. */\nfunction createNoise3D(seed = 7) {\n  const p = new Uint8Array(256);\n  for (let i = 0; i < 256; i++) p[i] = i;\n  let s = seed >>> 0 || 1;\n  for (let i = 255; i > 0; i--) {\n    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;\n    const j = s % (i + 1);\n    const t = p[i];\n    p[i] = p[j];\n    p[j] = t;\n  }\n  const perm = new Uint8Array(512);\n  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];\n  const grad = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];\n  const F3 = 1 / 3;\n  const G3 = 1 / 6;\n\n  const corner = (hash: number, x: number, y: number, z: number) => {\n    let t = 0.6 - x * x - y * y - z * z;\n    if (t < 0) return 0;\n    const g = (hash % 12) * 3;\n    t *= t;\n    return t * t * (grad[g] * x + grad[g + 1] * y + grad[g + 2] * z);\n  };\n\n  return (x: number, y: number, z: number) => {\n    const sk = (x + y + z) * F3;\n    const i = Math.floor(x + sk);\n    const j = Math.floor(y + sk);\n    const k = Math.floor(z + sk);\n    const t = (i + j + k) * G3;\n    const x0 = x - (i - t);\n    const y0 = y - (j - t);\n    const z0 = z - (k - t);\n    let i1, j1, k1, i2, j2, k2;\n    if (x0 >= y0) {\n      if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];\n      else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];\n      else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];\n    } else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];\n    else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];\n    else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];\n    const ii = i & 255;\n    const jj = j & 255;\n    const kk = k & 255;\n    const n0 = corner(perm[ii + perm[jj + perm[kk]]], x0, y0, z0);\n    const n1 = corner(perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]], x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3);\n    const n2 = corner(perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]], x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3);\n    const n3 = corner(perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]], x0 - 0.5, y0 - 0.5, z0 - 0.5);\n    return 32 * (n0 + n1 + n2 + n3);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Component -------------------------------- */\n\nconst DEFAULT_CHARSET = \" .:-=+*#%@\";\nconst FONT = 'ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace';\nconst FRAME_MS = 1000 / 30; // ASCII reads best (and costs least) at ~30fps\nconst HOVER_RADIUS = 120;\nconst RIPPLE_SPEED = 280; // px per second\nconst RIPPLE_LIFE = 2.2; // seconds\nconst MAX_RIPPLES = 6;\n\nconst clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);\nconst smoothstep = (a: number, b: number, x: number) => {\n  const t = clamp01((x - a) / (b - a));\n  return t * t * (3 - 2 * t);\n};\n\n/**\n * An animated ASCII-art field: a noise, wave or ring function is sampled once per character\n * cell and mapped onto a density ramp. Glyphs are pre-rendered into a small atlas (one tinted\n * copy per ramp step), so each frame is a batch of `drawImage` blits rather than `fillText` calls.\n */\nexport function AsciiField({\n  charset = DEFAULT_CHARSET,\n  cellSize = 14,\n  speed = 1,\n  mode = \"noise\",\n  colorize = true,\n  accent = \"#FF7448\",\n  interactive = true,\n  className = \"\",\n  children,\n  ...props\n}: AsciiFieldProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const glyphs = Array.from(charset.length ? charset : DEFAULT_CHARSET);\n    const levels = glyphs.length;\n    const rowH = Math.max(6, cellSize);\n    const fontPx = Math.round(rowH * 0.86);\n    const noise = createNoise3D(23);\n    const atlas = document.createElement(\"canvas\");\n    const actx = atlas.getContext(\"2d\");\n    if (!actx) return;\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let colW = rowH * 0.6;\n    let cols = 0;\n    let rows = 0;\n    let time = 0;\n    let raf = 0;\n    let last = 0;\n    let sinceDraw = FRAME_MS;\n    let inView = false;\n    let accentRGB: RGB = [255, 116, 72];\n    let atlasDirty = true;\n\n    const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, hover: 0 };\n    let ripples: { x: number; y: number; age: number }[] = [];\n\n    /** Pre-renders every glyph of the ramp, each in its own tint and opacity. */\n    const buildAtlas = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      const dark = luminance(ink) > 0.5;\n      accentRGB = legible(resolveColor(accent, [255, 116, 72]), ink);\n      actx.font = `500 ${fontPx}px ${FONT}`;\n      colW = Math.max(3, actx.measureText(\"M\").width);\n      atlas.width = Math.ceil(levels * colW * dpr);\n      atlas.height = Math.ceil(rowH * dpr);\n      actx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      actx.clearRect(0, 0, levels * colW, rowH);\n      actx.font = `500 ${fontPx}px ${FONT}`;\n      actx.textAlign = \"center\";\n      actx.textBaseline = \"middle\";\n      for (let i = 0; i < levels; i++) {\n        const t = levels > 1 ? i / (levels - 1) : 1;\n        const tint = colorize ? mix(ink, accentRGB, smoothstep(0.45, 1, t)) : ink;\n        const alpha = dark ? 0.2 + 0.72 * t : 0.16 + 0.66 * t;\n        actx.fillStyle = `rgba(${tint[0]}, ${tint[1]}, ${tint[2]}, ${alpha.toFixed(3)})`;\n        actx.fillText(glyphs[i], i * colW + colW / 2, rowH / 2 + 0.5);\n      }\n      cols = Math.ceil(width / colW);\n      rows = Math.ceil(height / rowH);\n      atlasDirty = false;\n    };\n\n    const noiseFreq = 1 / 240;\n\n    /** Field value in [0, 1] at a cell centre. */\n    const sample = (x: number, y: number, t: number) => {\n      if (mode === \"waves\") {\n        return (\n          0.5 +\n          0.5 *\n            (Math.sin(x * 0.017 + t * 0.9) * 0.42 +\n              Math.sin(y * 0.034 - t * 0.6 + x * 0.006) * 0.34 +\n              Math.sin((x * 0.6 - y) * 0.013 + t * 0.45) * 0.24)\n        );\n      }\n      if (mode === \"rings\") {\n        const dx = x - width / 2;\n        const dy = y - height / 2;\n        const d = Math.sqrt(dx * dx + dy * dy);\n        const warp = noise(x * noiseFreq, y * noiseFreq, t * 0.12) * 1.6;\n        return 0.5 + 0.46 * Math.sin(d * 0.042 - t * 1.5 + warp) * (0.55 + 0.45 * Math.exp(-d / (Math.max(width, height) * 0.6)));\n      }\n      const n = 0.68 * noise(x * noiseFreq, y * noiseFreq * 1.4, t * 0.16) + 0.32 * noise(x * noiseFreq * 2.3, y * noiseFreq * 3, t * 0.28 + 9);\n      return 0.5 + 0.8 * n;\n    };\n\n    const draw = () => {\n      if (atlasDirty) buildAtlas();\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      const cw = colW * dpr;\n      const ch = rowH * dpr;\n      const hover = interactive ? ptr.hover : 0;\n      const r2 = HOVER_RADIUS * HOVER_RADIUS;\n      const top = levels - 1;\n      for (let row = 0; row < rows; row++) {\n        const y = (row + 0.5) * rowH;\n        for (let col = 0; col < cols; col++) {\n          const x = (col + 0.5) * colW;\n          let v = sample(x, y, time);\n          if (hover > 0.01) {\n            const dx = x - ptr.x;\n            const dy = y - ptr.y;\n            v += 0.6 * hover * Math.exp(-(dx * dx + dy * dy) / r2);\n          }\n          for (const rp of ripples) {\n            const dx = x - rp.x;\n            const dy = y - rp.y;\n            const ring = Math.sqrt(dx * dx + dy * dy) - rp.age * RIPPLE_SPEED;\n            if (ring > -60 && ring < 60) v += 0.7 * (1 - rp.age / RIPPLE_LIFE) * Math.exp(-(ring * ring) / 700);\n          }\n          const level = Math.round(clamp01(v) * top);\n          if (glyphs[level] === \" \") continue;\n          ctx.drawImage(atlas, level * cw, 0, cw, ch, col * cw, row * ch, cw, ch);\n        }\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(100, last ? now - last : FRAME_MS);\n      last = now;\n      time += (dt / 1000) * speed;\n\n      if (ptr.inside) {\n        const rect = canvas.getBoundingClientRect();\n        ptr.x = ptr.clientX - rect.left;\n        ptr.y = ptr.clientY - rect.top;\n      }\n      ptr.hover += ((ptr.inside ? 1 : 0) - ptr.hover) * (1 - Math.pow(0.88, dt / 16.7));\n      for (const rp of ripples) rp.age += dt / 1000;\n      ripples = ripples.filter((rp) => rp.age < RIPPLE_LIFE);\n\n      sinceDraw += dt;\n      if (sinceDraw >= FRAME_MS - 2) {\n        sinceDraw = 0;\n        draw();\n      }\n      // With speed 0 the loop idles once the pointer glow and ripples have settled.\n      const busy = speed > 0 || ripples.length > 0 || ptr.inside || ptr.hover > 0.01;\n      if (busy) schedule();\n      else draw();\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      const nextDpr = Math.min(2, window.devicePixelRatio || 1);\n      if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && nextDpr === dpr && cols > 0) return;\n      width = rect.width;\n      height = rect.height;\n      dpr = nextDpr;\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      atlasDirty = true;\n      draw();\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      atlasDirty = true;\n      if (!raf) draw();\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && e.pointerType !== \"pen\") return;\n      const rect = canvas.getBoundingClientRect();\n      ptr.clientX = e.clientX;\n      ptr.clientY = e.clientY;\n      const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;\n      if (inside && !ptr.inside && ptr.hover < 0.01) {\n        ptr.x = e.clientX - rect.left;\n        ptr.y = e.clientY - rect.top;\n      }\n      ptr.inside = inside;\n      if (inside) schedule();\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const y = e.clientY - rect.top;\n      if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;\n      ripples.push({ x, y, age: 0 });\n      if (ripples.length > MAX_RIPPLES) ripples.shift();\n      schedule();\n    };\n    const onPointerLeave = () => {\n      ptr.inside = false;\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    // Pointer tracking lives on the window (only while visible), so content layered above the\n    // field never blocks it and the field never blocks the content.\n    const listenPointer = interactive && !reduceMotion;\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        if (listenPointer) {\n          window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n          window.addEventListener(\"pointerdown\", onPointerDown, { passive: true });\n          document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n        }\n        schedule();\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"pointerdown\", onPointerDown);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n        ptr.inside = false;\n        stop();\n      }\n    });\n\n    // Reduced motion: a single still frame from a moment where every mode has good contrast.\n    if (reduceMotion) time = 4;\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    io.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [charset, cellSize, speed, mode, colorize, accent, interactive, reduceMotion]);\n\n  return (\n    <div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100\"\n      />\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/ascii-field"
}
