{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "topo-lines",
  "type": "registry:ui",
  "title": "Topographic Lines",
  "description": "Animated contour lines traced with marching squares over an evolving noise terrain.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/topo-lines.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface TopoLinesProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of contour levels across the height range. */\n  levels?: number;\n  /** Contour stroke width, in px. Accent (index) lines are drawn 1.4× wider. */\n  lineWidth?: number;\n  /** How fast the terrain evolves. 0 freezes it. */\n  speed?: number;\n  /** Highlight every Nth contour in the accent colour. 0 disables index lines. */\n  accentEvery?: number;\n  /** Accent colour for index lines (any CSS colour). */\n  accent?: string;\n  /** Terrain zoom: higher values give broader, smoother landforms. */\n  scale?: number;\n  /** Marching-squares cell size, in px. Smaller is smoother but costs more CPU. */\n  cellSize?: number;\n  /** A mouse pointer raises a soft hill under it. */\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/* ----------------------------- Marching squares ---------------------------- */\n\n/**\n * Edge pairs per marching-squares case. Corners: TL = 8, TR = 4, BR = 2, BL = 1 (bit set when\n * above the level). Edges: 0 top, 1 right, 2 bottom, 3 left. Saddles (5, 10) use two segments.\n */\nconst CASES: number[][] = [\n  [],\n  [3, 2],\n  [2, 1],\n  [3, 1],\n  [0, 1],\n  [3, 0, 2, 1],\n  [0, 2],\n  [3, 0],\n  [3, 0],\n  [0, 2],\n  [0, 1, 3, 2],\n  [0, 1],\n  [3, 1],\n  [2, 1],\n  [3, 2],\n  [],\n];\n\nconst FRAME_MS = 1000 / 30; // contour redraws are capped at 30fps\nconst HILL_HEIGHT = 0.38;\nconst HILL_RADIUS = 110;\n\n/**\n * Animated topographic contour lines: an evolving fractal-noise height field is sampled on a\n * coarse grid and traced with marching squares into two batched paths (regular + index lines).\n * Redraws are capped at 30fps, stop offscreen and in hidden tabs, and reduced motion draws one\n * still map.\n */\nexport function TopoLines({\n  levels = 16,\n  lineWidth = 1,\n  speed = 1,\n  accentEvery = 5,\n  accent = \"#FF7448\",\n  scale = 1,\n  cellSize = 6,\n  interactive = true,\n  className = \"\",\n  children,\n  ...props\n}: TopoLinesProps) {\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(23);\n    const cell = Math.max(3, Math.round(cellSize));\n    const levelCount = Math.max(2, Math.round(levels));\n    const every = Math.max(0, Math.round(accentEvery));\n    const frequency = 0.0021 / Math.max(0.2, scale);\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let cols = 0;\n    let rows = 0;\n    let heights = new Float32Array(0);\n    let z = 0;\n    let raf = 0;\n    let last = 0;\n    let lastDraw = 0;\n    let inView = false;\n    let lineColor = \"\";\n    let accentColor = \"\";\n\n    let dirty = false;\n    const hill = { 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 a = legible(resolveColor(accent, [255, 116, 72]), ink);\n      lineColor = `rgba(${ink[0]}, ${ink[1]}, ${ink[2]}, 0.3)`;\n      accentColor = `rgba(${a[0]}, ${a[1]}, ${a[2]}, 0.9)`;\n    };\n\n    /** Samples the height field (two noise octaves + pointer hill) at every grid vertex. */\n    const sample = () => {\n      const sigma2 = 2 * HILL_RADIUS * HILL_RADIUS;\n      const lift = HILL_HEIGHT * hill.strength;\n      for (let r = 0; r <= rows; r++) {\n        const y = r * cell;\n        for (let c = 0; c <= cols; c++) {\n          const x = c * cell;\n          const n = 0.72 * noise(x * frequency, y * frequency, z) + 0.28 * noise(x * frequency * 2.3 + 17, y * frequency * 2.3, z * 1.4);\n          let h = 0.5 + 0.55 * n;\n          if (lift > 0.002) {\n            const dx = x - hill.x;\n            const dy = y - hill.y;\n            h += lift * Math.exp(-(dx * dx + dy * dy) / sigma2);\n          }\n          heights[r * (cols + 1) + c] = h;\n        }\n      }\n    };\n\n    const draw = () => {\n      sample();\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      const regular = new Path2D();\n      const index = new Path2D();\n      const stride = cols + 1;\n\n      for (let r = 0; r < rows; r++) {\n        const y0 = r * cell;\n        for (let c = 0; c < cols; c++) {\n          const x0 = c * cell;\n          const tl = heights[r * stride + c];\n          const tr = heights[r * stride + c + 1];\n          const br = heights[(r + 1) * stride + c + 1];\n          const bl = heights[(r + 1) * stride + c];\n          const min = Math.min(tl, tr, br, bl);\n          const max = Math.max(tl, tr, br, bl);\n          // Only the levels that actually cross this cell are visited.\n          const from = Math.floor(min * levelCount) + 1;\n          const to = Math.floor(max * levelCount);\n          for (let l = from; l <= to; l++) {\n            const v = l / levelCount;\n            const kind = (tl > v ? 8 : 0) | (tr > v ? 4 : 0) | (br > v ? 2 : 0) | (bl > v ? 1 : 0);\n            const edges = CASES[kind];\n            if (!edges.length) continue;\n            const path = every > 0 && l % every === 0 ? index : regular;\n            for (let e = 0; e < edges.length; e += 2) {\n              for (let k = 0; k < 2; k++) {\n                const edge = edges[e + k];\n                let x: number;\n                let y: number;\n                // Linear interpolation of the crossing point along the edge.\n                if (edge === 0) {\n                  x = x0 + cell * ((v - tl) / (tr - tl));\n                  y = y0;\n                } else if (edge === 1) {\n                  x = x0 + cell;\n                  y = y0 + cell * ((v - tr) / (br - tr));\n                } else if (edge === 2) {\n                  x = x0 + cell * ((v - bl) / (br - bl));\n                  y = y0 + cell;\n                } else {\n                  x = x0;\n                  y = y0 + cell * ((v - tl) / (bl - tl));\n                }\n                if (k === 0) path.moveTo(x, y);\n                else path.lineTo(x, y);\n              }\n            }\n          }\n        }\n      }\n\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      ctx.lineWidth = lineWidth;\n      ctx.strokeStyle = lineColor;\n      ctx.stroke(regular);\n      if (every > 0) {\n        ctx.lineWidth = lineWidth * 1.4;\n        ctx.strokeStyle = accentColor;\n        ctx.stroke(index);\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(100, last ? now - last : 16.7);\n      last = now;\n      const f = dt / 16.7;\n\n      if (hill.inside) {\n        const rect = canvas.getBoundingClientRect();\n        hill.tx = hill.clientX - rect.left;\n        hill.ty = hill.clientY - rect.top;\n      }\n      // Frame-rate independent easing of the hill's position and height.\n      const follow = 1 - Math.pow(0.8, f);\n      hill.x += (hill.tx - hill.x) * follow;\n      hill.y += (hill.ty - hill.y) * follow;\n      const goal = hill.inside ? 1 : 0;\n      hill.strength += (goal - hill.strength) * (1 - Math.pow(0.92, f));\n      z += dt * 0.00005 * speed;\n\n      const settling =\n        Math.abs(goal - hill.strength) > 0.003 ||\n        (hill.strength > 0.003 && Math.hypot(hill.tx - hill.x, hill.ty - hill.y) > 0.5);\n      const pending = speed > 0 || settling || dirty;\n\n      // Skip frames to stay at ≤30fps; the timeline above still advances smoothly.\n      if (pending && now - lastDraw >= FRAME_MS - 2) {\n        lastDraw = now;\n        dirty = false;\n        draw();\n      }\n\n      if (pending) 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      cols = Math.ceil(width / cell);\n      rows = Math.ceil(height / cell);\n      heights = new Float32Array((cols + 1) * (rows + 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      hill.clientX = e.clientX;\n      hill.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 && hill.strength < 0.01) {\n        // Fresh entry: the hill grows where the pointer is instead of sliding in.\n        hill.x = hill.tx = e.clientX - rect.left;\n        hill.y = hill.ty = e.clientY - rect.top;\n      }\n      const changed = inside !== hill.inside;\n      hill.inside = inside;\n      // Moves elsewhere on the page never wake the loop.\n      if (inside || changed) {\n        dirty = true;\n        schedule();\n      }\n    };\n    const onPointerLeave = () => {\n      hill.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  }, [levels, lineWidth, speed, accentEvery, accent, scale, cellSize, 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/topo-lines"
}
