{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "signal-lines",
  "type": "registry:ui",
  "title": "Signal Lines",
  "description": "Stacked horizontal lines displaced by layered noise waves — a ridgeline, oscilloscope feel.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/signal-lines.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type SignalLinesTone = \"ink\" | \"accent\" | \"gradient\";\n\nexport interface SignalLinesProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of stacked lines. */\n  lineCount?: number;\n  /** Peak displacement, in px. */\n  amplitude?: number;\n  /** Waviness: roughly the number of features across the width. */\n  frequency?: number;\n  /** Animation speed multiplier. 0 freezes the waves. */\n  speed?: number;\n  /** Line stroke width, in px. */\n  strokeWidth?: number;\n  /** Each line hides the lines behind it, like a ridgeline plot. */\n  occlusion?: boolean;\n  /** ink: theme text colour · accent: all lines in `color` · gradient: ink at the back → `color` at the front. */\n  tone?: SignalLinesTone;\n  /** Accent colour (any CSS colour). */\n  color?: string;\n  /** A mouse pointer raises a localized bump in the nearest lines. */\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;\n\n/** On light themes (dark ink), very light colours 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 color.map((v, i) => Math.round(v + (ink[i] - v) * 0.42)) as RGB;\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\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 SAMPLE_STEP = 4; // px between samples along each line\n\n/**\n * Stacked horizontal lines displaced by layered noise and sine waves — a ridgeline /\n * oscilloscope look. Drawn back to front on one canvas; with occlusion on, each line erases\n * what lies beneath it (destination-out), so it works on any background without knowing its\n * colour. A mouse pointer raises a soft localized bump.\n */\nexport function SignalLines({\n  lineCount = 32,\n  amplitude = 40,\n  frequency = 1.5,\n  speed = 1,\n  strokeWidth = 1.25,\n  occlusion = true,\n  tone = \"ink\",\n  color = \"#FF7448\",\n  interactive = true,\n  className = \"\",\n  children,\n  ...props\n}: SignalLinesProps) {\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 noise = createNoise3D(5);\n    const lines = Math.max(2, Math.round(lineCount));\n    const amp = Math.max(0, amplitude);\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let t = 0;\n    let raf = 0;\n    let last = 0;\n    let inView = false;\n    let strokes: string[] = [];\n    let ys = new Float32Array(0);\n\n    const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, tx: 0, ty: 0, inside: false, strength: 0 };\n\n    const readTheme = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      const accent = legible(resolveColor(color, [255, 116, 72]), ink);\n      strokes = Array.from({ length: lines }, (_, i) => {\n        const depth = i / (lines - 1); // 0 = back (top), 1 = front (bottom)\n        if (tone === \"accent\") return `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${(0.45 + depth * 0.5).toFixed(3)})`;\n        if (tone === \"gradient\") {\n          const c = ink.map((v, k) => Math.round(v + (accent[k] - v) * depth));\n          return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${(0.4 + depth * 0.55).toFixed(3)})`;\n        }\n        return `rgba(${ink[0]}, ${ink[1]}, ${ink[2]}, ${(0.35 + depth * 0.4).toFixed(3)})`;\n      });\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      if (width < 2 || height < 2) return;\n\n      // Leave headroom above the first line for its peaks.\n      const top = Math.min(height * 0.45, amp * 1.3 + height * 0.06);\n      const bottom = height * 0.94;\n      const gap = (bottom - top) / (lines - 1);\n      const points = ys.length;\n      const cx = width / 2;\n      const spread = width * 0.3;\n      const scaleX = (frequency * 4) / Math.max(1, width);\n      const bumpSigmaX = 40 + width * 0.05;\n      const bumpSigmaY = Math.max(gap * 2.5, 30);\n      const bump = amp * 1.4 * ptr.strength;\n\n      ctx.lineWidth = strokeWidth;\n      ctx.lineJoin = \"round\";\n      ctx.lineCap = \"round\";\n\n      for (let i = 0; i < lines; i++) {\n        const base = top + i * gap;\n        const row = i * 0.37;\n        const dy = base - ptr.y;\n        const rowBump = bump > 0.05 ? bump * Math.exp(-(dy * dy) / (2 * bumpSigmaY * bumpSigmaY)) : 0;\n\n        for (let p = 0; p < points; p++) {\n          const x = Math.min(width, p * SAMPLE_STEP);\n          // A soft envelope keeps the edges calm and the centre lively.\n          const ex = (x - cx) / spread;\n          const envelope = 0.18 + 0.82 * Math.exp(-ex * ex);\n          const u = x * scaleX;\n          const n = noise(u, row, t) + 0.5 * noise(u * 2.3 + 9, row * 1.7, t * 1.3);\n          // Positive lobes only, normalised so peaks stay within ~1.2 × amplitude.\n          const ridge = Math.min(1.2, Math.max(0, n / 1.2) ** 1.4);\n          const ripple = 0.06 * Math.sin(u * 5 + t * 6 + i * 0.9);\n          let lift = amp * envelope * (ridge + ripple);\n          if (rowBump > 0) {\n            const dx = x - ptr.x;\n            lift += rowBump * Math.exp(-(dx * dx) / (2 * bumpSigmaX * bumpSigmaX));\n          }\n          ys[p] = base - lift;\n        }\n\n        ctx.beginPath();\n        ctx.moveTo(0, ys[0]);\n        for (let p = 1; p < points; p++) ctx.lineTo(Math.min(width, p * SAMPLE_STEP), ys[p]);\n\n        if (occlusion) {\n          // Erase everything under this line (earlier lines behind it), then stroke it.\n          ctx.save();\n          ctx.lineTo(width, height);\n          ctx.lineTo(0, height);\n          ctx.closePath();\n          ctx.globalCompositeOperation = \"destination-out\";\n          ctx.fillStyle = \"#000\";\n          ctx.fill();\n          ctx.restore();\n          ctx.beginPath();\n          ctx.moveTo(0, ys[0]);\n          for (let p = 1; p < points; p++) ctx.lineTo(Math.min(width, p * SAMPLE_STEP), ys[p]);\n        }\n        ctx.strokeStyle = strokes[i];\n        ctx.stroke();\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : 16.7);\n      last = now;\n      const f = dt / 16.7;\n\n      if (ptr.inside) {\n        const rect = canvas.getBoundingClientRect();\n        ptr.tx = ptr.clientX - rect.left;\n        ptr.ty = ptr.clientY - rect.top;\n      }\n      const follow = 1 - Math.pow(0.82, f);\n      ptr.x += (ptr.tx - ptr.x) * follow;\n      ptr.y += (ptr.ty - ptr.y) * follow;\n      const goal = ptr.inside ? 1 : 0;\n      ptr.strength += (goal - ptr.strength) * (1 - Math.pow(0.9, f));\n      t += dt * 0.00018 * speed;\n\n      draw();\n\n      const settling =\n        Math.abs(goal - ptr.strength) > 0.003 || (ptr.strength > 0.003 && Math.hypot(ptr.tx - ptr.x, ptr.ty - ptr.y) > 0.5);\n      if (speed > 0 || settling) schedule();\n      else last = 0;\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      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ys = new Float32Array(Math.ceil(width / SAMPLE_STEP) + 1);\n      draw();\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\n      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.strength < 0.01) {\n        // Fresh entry: the bump rises where the pointer is instead of sliding in.\n        ptr.x = ptr.tx = e.clientX - rect.left;\n        ptr.y = ptr.ty = e.clientY - rect.top;\n      }\n      const changed = inside !== ptr.inside;\n      ptr.inside = inside;\n      if (inside || changed) schedule();\n    };\n    const onPointerLeave = () => {\n      ptr.inside = false;\n      schedule();\n    };\n\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\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          document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n        }\n        schedule();\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n        stop();\n      }\n    });\n\n    readTheme();\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      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [lineCount, amplitude, frequency, speed, strokeWidth, occlusion, tone, color, 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/signal-lines"
}
