{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-ridgeline-terrain",
  "title": "Chart Ridgeline Terrain",
  "description": "Unknown-Pleasures ridgeline chart whose live history recedes into scrolling ambient noise terrain, dented gravitationally by the cursor.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/chart-ridgeline-terrain/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// deterministic 2-octave value noise — no deps, stable across frames\n// ---------------------------------------------------------------------------\nfunction hash2(x: number, y: number) {\n  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;\n  return n - Math.floor(n);\n}\nfunction vnoise(x: number, y: number) {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const xf = x - xi;\n  const yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf);\n  const v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi);\n  const b = hash2(xi + 1, yi);\n  const c = hash2(xi, yi + 1);\n  const d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\nfunction noise2(x: number, y: number) {\n  return 0.65 * vnoise(x, y) + 0.35 * vnoise(x * 2.1 + 19.7, y * 2.1 + 7.3);\n}\n\n// cubic-bezier(0.22, 1, 0.36, 1) solved via Newton–Raphson\nfunction makeBezier(p1x: number, p1y: number, p2x: number, p2y: number) {\n  const cx = 3 * p1x;\n  const bx = 3 * (p2x - p1x) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * p1y;\n  const by = 3 * (p2y - p1y) - cy;\n  const ay = 1 - cy - by;\n  const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;\n  const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;\n  const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;\n  return (x: number) => {\n    if (x <= 0) return 0;\n    if (x >= 1) return 1;\n    let t = x;\n    for (let i = 0; i < 6; i++) {\n      const s = slopeX(t);\n      if (Math.abs(s) < 1e-6) break;\n      t -= (sampleX(t) - x) / s;\n    }\n    return sampleY(Math.min(1, Math.max(0, t)));\n  };\n}\nconst glideEase = makeBezier(0.22, 1, 0.36, 1);\n\nconst EMPTY: number[] = [];\n\nfunction parseColor(raw: string): { r: number; g: number; b: number } | null {\n  const v = raw.trim();\n  if (v.startsWith(\"#\")) {\n    const hex = v.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex[0] + hex[0], 16);\n      const g = parseInt(hex[1] + hex[1], 16);\n      const b = parseInt(hex[2] + hex[2], 16);\n      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;\n      return { r, g, b };\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;\n      return { r, g, b };\n    }\n    return null;\n  }\n  const m = v.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/i);\n  if (!m) return null;\n  return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };\n}\n\n// ---------------------------------------------------------------------------\n// SignalTerrain — Unknown-Pleasures ridgeline landscape where incoming data\n// samples scroll forward as ridgelines (history recedes into ambient Perlin\n// terrain) and the cursor dents the mesh gravitationally. Canvas 2D,\n// painter's-algorithm occlusion, refs only — the rAF loop is the sole writer.\n// ---------------------------------------------------------------------------\nexport function SignalTerrain({\n  series = EMPTY,\n  cols = 96,\n  rows = 40,\n  ambientAmplitude = 18,\n  dataAmplitude = 68,\n  glideMs = 600,\n  dentSigma = 90,\n  dentDepth = 26,\n  className = \"h-96\",\n  \"aria-label\": ariaLabel = \"Live signal terrain\",\n}: {\n  /** data samples, oldest → newest; newest enters at the front row */\n  series?: number[];\n  /** vertices per ridgeline */\n  cols?: number;\n  /** ridgeline count (back to front) */\n  rows?: number;\n  /** ambient noise height in px at the front row */\n  ambientAmplitude?: number;\n  /** data peak height in px at the front row */\n  dataAmplitude?: number;\n  /** ms for the history to glide one row back after a push */\n  glideMs?: number;\n  /** gaussian radius of the cursor dent in screen px */\n  dentSigma?: number;\n  /** max cursor dent depth in px */\n  dentDepth?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the terrain. Default \"Live signal terrain\". */\n  \"aria-label\"?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const seriesRef = useRef<number[]>(series);\n  const transRef = useRef(-1); // performance.now() of last push, -1 = settled\n  const reducedRef = useRef(false);\n  const drawRef = useRef<(() => void) | null>(null);\n\n  // series is data, not render state: push detection + redraw happen in refs\n  useEffect(() => {\n    const prev = seriesRef.current;\n    if (series === prev) return;\n    const pushed =\n      series.length !== prev.length ||\n      (series.length > 0 &&\n        series[series.length - 1] !== prev[prev.length - 1]);\n    seriesRef.current = series;\n    if (pushed) {\n      if (reducedRef.current) drawRef.current?.();\n      else transRef.current = performance.now();\n    }\n  }, [series]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    reducedRef.current = reduced;\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n    };\n    resize();\n\n    // hot-path state — refs/locals only, never React state\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let hovered = false;\n    let px = 0;\n    let py = 0;\n    let dentAmt = 0;\n    let dentVel = 0;\n    let smoothMax = 0;\n    const xs = new Float32Array(cols);\n    const ys = new Float32Array(cols);\n    const invCols = 1 / Math.max(1, cols - 1);\n    const invRows = 1 / Math.max(1, rows - 1);\n    const twoSigma2 = 2 * dentSigma * dentSigma;\n\n    // theme tokens — resolved from CSS custom properties, never hardcoded,\n    // so occlusion fill and ridgeline stroke read correctly in light or dark\n    let fillRGB = { r: 10, g: 10, b: 10 };\n    let strokeLowRGB = { r: 143, g: 143, b: 143 };\n    let strokeHighRGB = { r: 237, g: 237, b: 237 };\n    const deriveTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fillRGB =\n        parseColor(cs.getPropertyValue(\"--surface\")) ??\n        parseColor(cs.getPropertyValue(\"--background\")) ??\n        fillRGB;\n      strokeHighRGB = parseColor(cs.getPropertyValue(\"--foreground\")) ?? strokeHighRGB;\n      strokeLowRGB = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? strokeLowRGB;\n    };\n    deriveTokens();\n    // theme can flip after mount (class toggle on <html>, no remount) — without\n    // this, fillRGB/strokeRGB stay frozen at whatever theme was active at mount\n    const themeObserver = new MutationObserver(() => {\n      deriveTokens();\n      if (reduced) drawRef.current?.();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const draw = (now: number) => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      const s = seriesRef.current;\n      const len = s.length;\n\n      // glide progress: 1 = settled, eases 0 → 1 over glideMs after a push\n      let et = 1;\n      if (!reduced && transRef.current >= 0) {\n        const p = (now - transRef.current) / glideMs;\n        if (p >= 1) {\n          transRef.current = -1;\n        } else {\n          et = glideEase(p);\n        }\n      }\n\n      // smoothed normalization peak over the visible window\n      let mx = 1e-6;\n      for (let i = Math.max(0, len - rows - 1); i < len; i++) {\n        const sv = s[i] ?? 0;\n        if (sv > mx) mx = sv;\n      }\n      smoothMax = smoothMax === 0 ? mx : smoothMax + (mx - smoothMax) * 0.05;\n\n      const scroll = reduced ? 0 : (now / 1000) * 0.06; // 0.06 noise u/s\n      const horizonY = h * 0.16;\n      const baseY = h * 0.94;\n      const pad = w * 0.03;\n      const usable = Math.max(1, w - pad * 2);\n      const hasDent = dentAmt > 0.001 || dentAmt < -0.001;\n\n      ctx.lineJoin = \"round\";\n      // back → front: stroke then fill below with the background so nearer\n      // ridges occlude farther ones (painter's-algorithm ridgeline trick)\n      for (let r = 0; r < rows; r++) {\n        const t = r * invRows; // 0 = horizon, 1 = front\n        const rowY = horizonY + (baseY - horizonY) * t * t; // quadratic ease\n        const rowW = usable * (0.65 + 0.35 * t); // narrows 35% at the back\n        const x0 = pad + (usable - rowW) / 2;\n\n        // fractional sample index: history glides one row back per push\n        const idx = len - rows + r - 1 + et;\n        const i0 = Math.floor(idx);\n        const f = idx - i0;\n        const v0 = i0 >= 0 && i0 < len ? (s[i0] ?? 0) : 0;\n        const v1 = i0 + 1 >= 0 && i0 + 1 < len ? (s[i0 + 1] ?? 0) : 0;\n        const val = Math.min(1.4, Math.max(0, (v0 + (v1 - v0) * f) / smoothMax));\n        const texIdx = Math.round(idx); // texture travels with the sample\n        const frontScale = 0.25 + 0.75 * t;\n        const ambScale = ambientAmplitude * (0.3 + 0.7 * t);\n        const depthCoord = (rows - 1 - r) * 0.35 + scroll;\n\n        for (let c = 0; c < cols; c++) {\n          const cn = c * invCols;\n          const x = x0 + rowW * cn;\n          const amb = ambScale * noise2(cn * 4 + 7.3, depthCoord);\n          const env = Math.exp(-((cn - 0.5) * (cn - 0.5)) / (2 * 0.18 * 0.18));\n          const tex = 0.55 + 0.45 * noise2(cn * 9 + 3.1, texIdx * 0.7);\n          let y = rowY - amb - dataAmplitude * frontScale * val * env * tex;\n          if (hasDent) {\n            const dx = x - px;\n            const dy = y - py;\n            y += dentDepth * dentAmt * Math.exp(-(dx * dx + dy * dy) / twoSigma2);\n          }\n          xs[c] = x;\n          ys[c] = y;\n        }\n\n        // occlusion fill down to the bottom edge\n        ctx.beginPath();\n        ctx.moveTo(xs[0] ?? 0, ys[0] ?? 0);\n        for (let c = 1; c < cols; c++) ctx.lineTo(xs[c] ?? 0, ys[c] ?? 0);\n        ctx.lineTo(x0 + rowW, h + 2);\n        ctx.lineTo(x0, h + 2);\n        ctx.closePath();\n        ctx.fillStyle = `rgb(${fillRGB.r},${fillRGB.g},${fillRGB.b})`;\n        ctx.fill();\n\n        // ridgeline stroke: muted-token color 1px at the horizon fading to\n        // foreground-token color 1.5px at the front — theme-derived, not hardcoded\n        const mix = Math.pow(t, 1.3);\n        const rr = Math.round(strokeLowRGB.r + (strokeHighRGB.r - strokeLowRGB.r) * mix);\n        const rg = Math.round(strokeLowRGB.g + (strokeHighRGB.g - strokeLowRGB.g) * mix);\n        const rb = Math.round(strokeLowRGB.b + (strokeHighRGB.b - strokeLowRGB.b) * mix);\n        const a = 0.25 + 0.75 * mix;\n        ctx.beginPath();\n        ctx.moveTo(xs[0] ?? 0, ys[0] ?? 0);\n        for (let c = 1; c < cols; c++) ctx.lineTo(xs[c] ?? 0, ys[c] ?? 0);\n        ctx.strokeStyle = `rgba(${rr},${rg},${rb},${a})`;\n        ctx.lineWidth = 1 + 0.5 * mix;\n        ctx.stroke();\n      }\n    };\n\n    if (reduced) {\n      // static fallback: current series at rest, no scroll, no dent;\n      // redrawn instantly on data change (via drawRef) and on resize\n      drawRef.current = () => draw(0);\n      draw(0);\n      const ro = new ResizeObserver(() => {\n        resize();\n        draw(0);\n      });\n      ro.observe(root);\n      return () => {\n        ro.disconnect();\n        themeObserver.disconnect();\n        drawRef.current = null;\n      };\n    }\n\n    const loop = (now: number) => {\n      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n      if (hovered) {\n        // lerp toward full dent at 0.12/frame (framerate-normalized)\n        const prev = dentAmt;\n        dentAmt += (1 - dentAmt) * (1 - Math.pow(0.88, dt * 60));\n        dentVel = (dentAmt - prev) / dt; // carry velocity into the release\n      } else if (dentAmt !== 0 || dentVel !== 0) {\n        // underdamped spring release: k = 70 s^-2, zeta = 0.6 → one rebound\n        const k = 70;\n        const c = 2 * 0.6 * Math.sqrt(k);\n        dentVel += (-k * dentAmt - c * dentVel) * dt;\n        dentAmt += dentVel * dt;\n        if (Math.abs(dentAmt) < 0.0005 && Math.abs(dentVel) < 0.005) {\n          dentAmt = 0;\n          dentVel = 0;\n        }\n      }\n      draw(now);\n      raf = visible ? requestAnimationFrame(loop) : 0;\n    };\n    raf = requestAnimationFrame(loop);\n\n    // ambient scroll never settles, so \"sleep\" = pause offscreen\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && raf === 0) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(root);\n\n    const onMove = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      px = e.clientX - rect.left;\n      py = e.clientY - rect.top;\n      hovered = true;\n    };\n    const onLeave = () => {\n      hovered = false;\n    };\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerdown\", onMove);\n    root.addEventListener(\"pointerleave\", onLeave);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n      ro.disconnect();\n      themeObserver.disconnect();\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [cols, rows, ambientAmplitude, dataAmplitude, glideMs, dentSigma, dentDepth]);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"img\"\n      aria-label={ariaLabel}\n      className={`relative w-full overflow-hidden ${className}`}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"absolute inset-0 h-full w-full\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/chart-ridgeline-terrain.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "canvas",
      "data-viz",
      "ridgeline",
      "noise",
      "cursor",
      "ambient",
      "chart"
    ],
    "instruction": "An Unknown-Pleasures wireframe landscape on a DPR-aware Canvas 2D: ~96 columns by 40 rows of ridgeline polylines drawn back to front, each row stroked then filled below with the theme's surface/background token so nearer ridges occlude farther ones (painter's-algorithm ridgeline trick); row y-spacing eases quadratically so rows compress at the horizon and row width narrows ~35% toward the back. Height per vertex = ambient + data: ambient is 2-octave value noise scrolling toward the viewer at 0.06 u/s with 18px amplitude scaled up toward the front; data is a series prop (number[]) where sample age maps to row depth, so each new sample enters at the front row and the whole history glides one row back over 600ms with cubic-bezier(0.22,1,0.36,1) interpolation between fractional row offsets, the chart's history literally receding into ambient terrain. The cursor dents the mesh with a screen-space gaussian (sigma 90px, max depth 26px), plus a soft foreground-token glow bloomed at the dent center so the interaction reads clearly: dent amount lerps toward full at 0.12/frame while hovered and releases through an underdamped spring (k=70 s^-2, zeta=0.6) for one visible rebound. Stroke fades from 1.5px foreground-token color at the front row to 1px muted-token color at ~25% opacity at the horizon. Fill and stroke colors are resolved from CSS custom properties (--surface/--background, --foreground, --ns-muted) at mount and re-derived via a MutationObserver on the document root's class attribute, so the terrain repaints correctly on theme toggle without a remount. Data and pointer live in refs; a single rAF loop is the only writer and pauses when the element leaves the viewport. Under prefers-reduced-motion: no noise scroll, no dent, a static render of the current series redrawn instantly on data change or theme change. With an empty series it idles as pure atmosphere; with no noise it reads as a strict chart."
  },
  "type": "registry:ui"
}