Skip to main content

ns-ui

Chart Ridgeline Terrain

Unknown-Pleasures ridgeline chart whose live history recedes into scrolling ambient noise terrain, dented gravitationally by the cursor.

Use when an Unknown-Pleasures ridgeline chart where live history recedes into scrolling noise terrain, dented by the cursor; use for a live/ambient data feed with a generative-landscape feel, not when trend direction is the point; pick chart-area-aurora instead when trend direction is the story and a real tooltip and axis are needed, hero-chart-recorder when the live stream is the product at hero scale, or histogram-live-grain when the question is the distribution of a sample rather than its shape over time.

Install

npx shadcn add https://design.helpmarq.com/r/chart-ridgeline-terrain.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/core/chart-ridgeline-terrain/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// deterministic 2-octave value noise — no deps, stable across frames
// ---------------------------------------------------------------------------
function hash2(x: number, y: number) {
  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
  return n - Math.floor(n);
}
function vnoise(x: number, y: number) {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const xf = x - xi;
  const yf = y - yi;
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  const a = hash2(xi, yi);
  const b = hash2(xi + 1, yi);
  const c = hash2(xi, yi + 1);
  const d = hash2(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
function noise2(x: number, y: number) {
  return 0.65 * vnoise(x, y) + 0.35 * vnoise(x * 2.1 + 19.7, y * 2.1 + 7.3);
}

// cubic-bezier(0.22, 1, 0.36, 1) solved via Newton–Raphson
function makeBezier(p1x: number, p1y: number, p2x: number, p2y: number) {
  const cx = 3 * p1x;
  const bx = 3 * (p2x - p1x) - cx;
  const ax = 1 - cx - bx;
  const cy = 3 * p1y;
  const by = 3 * (p2y - p1y) - cy;
  const ay = 1 - cy - by;
  const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;
  const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;
  const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
  return (x: number) => {
    if (x <= 0) return 0;
    if (x >= 1) return 1;
    let t = x;
    for (let i = 0; i < 6; i++) {
      const s = slopeX(t);
      if (Math.abs(s) < 1e-6) break;
      t -= (sampleX(t) - x) / s;
    }
    return sampleY(Math.min(1, Math.max(0, t)));
  };
}
const glideEase = makeBezier(0.22, 1, 0.36, 1);

const EMPTY: number[] = [];

function parseColor(raw: string): { r: number; g: number; b: number } | null {
  const v = raw.trim();
  if (v.startsWith("#")) {
    const hex = v.slice(1);
    if (hex.length === 3) {
      const r = parseInt(hex[0] + hex[0], 16);
      const g = parseInt(hex[1] + hex[1], 16);
      const b = parseInt(hex[2] + hex[2], 16);
      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
      return { r, g, b };
    }
    if (hex.length >= 6) {
      const r = parseInt(hex.slice(0, 2), 16);
      const g = parseInt(hex.slice(2, 4), 16);
      const b = parseInt(hex.slice(4, 6), 16);
      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
      return { r, g, b };
    }
    return null;
  }
  const m = v.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
  if (!m) return null;
  return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
}

// ---------------------------------------------------------------------------
// SignalTerrain — Unknown-Pleasures ridgeline landscape where incoming data
// samples scroll forward as ridgelines (history recedes into ambient Perlin
// terrain) and the cursor dents the mesh gravitationally. Canvas 2D,
// painter's-algorithm occlusion, refs only — the rAF loop is the sole writer.
// ---------------------------------------------------------------------------
export function SignalTerrain({
  series = EMPTY,
  cols = 96,
  rows = 40,
  ambientAmplitude = 18,
  dataAmplitude = 68,
  glideMs = 600,
  dentSigma = 90,
  dentDepth = 26,
  className = "h-96",
  "aria-label": ariaLabel = "Live signal terrain",
}: {
  /** data samples, oldest → newest; newest enters at the front row */
  series?: number[];
  /** vertices per ridgeline */
  cols?: number;
  /** ridgeline count (back to front) */
  rows?: number;
  /** ambient noise height in px at the front row */
  ambientAmplitude?: number;
  /** data peak height in px at the front row */
  dataAmplitude?: number;
  /** ms for the history to glide one row back after a push */
  glideMs?: number;
  /** gaussian radius of the cursor dent in screen px */
  dentSigma?: number;
  /** max cursor dent depth in px */
  dentDepth?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible name for the terrain. Default "Live signal terrain". */
  "aria-label"?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const seriesRef = useRef<number[]>(series);
  const transRef = useRef(-1); // performance.now() of last push, -1 = settled
  const reducedRef = useRef(false);
  const drawRef = useRef<(() => void) | null>(null);

  // series is data, not render state: push detection + redraw happen in refs
  useEffect(() => {
    const prev = seriesRef.current;
    if (series === prev) return;
    const pushed =
      series.length !== prev.length ||
      (series.length > 0 &&
        series[series.length - 1] !== prev[prev.length - 1]);
    seriesRef.current = series;
    if (pushed) {
      if (reducedRef.current) drawRef.current?.();
      else transRef.current = performance.now();
    }
  }, [series]);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    reducedRef.current = reduced;

    let w = 0;
    let h = 0;
    let dpr = 1;
    const resize = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
    };
    resize();

    // hot-path state — refs/locals only, never React state
    let raf = 0;
    let last = 0;
    let visible = true;
    let hovered = false;
    let px = 0;
    let py = 0;
    let dentAmt = 0;
    let dentVel = 0;
    let smoothMax = 0;
    const xs = new Float32Array(cols);
    const ys = new Float32Array(cols);
    const invCols = 1 / Math.max(1, cols - 1);
    const invRows = 1 / Math.max(1, rows - 1);
    const twoSigma2 = 2 * dentSigma * dentSigma;

    // theme tokens — resolved from CSS custom properties, never hardcoded,
    // so occlusion fill and ridgeline stroke read correctly in light or dark
    let fillRGB = { r: 10, g: 10, b: 10 };
    let strokeLowRGB = { r: 143, g: 143, b: 143 };
    let strokeHighRGB = { r: 237, g: 237, b: 237 };
    const deriveTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      fillRGB =
        parseColor(cs.getPropertyValue("--surface")) ??
        parseColor(cs.getPropertyValue("--background")) ??
        fillRGB;
      strokeHighRGB = parseColor(cs.getPropertyValue("--foreground")) ?? strokeHighRGB;
      strokeLowRGB = parseColor(cs.getPropertyValue("--ns-muted")) ?? strokeLowRGB;
    };
    deriveTokens();
    // theme can flip after mount (class toggle on <html>, no remount) — without
    // this, fillRGB/strokeRGB stay frozen at whatever theme was active at mount
    const themeObserver = new MutationObserver(() => {
      deriveTokens();
      if (reduced) drawRef.current?.();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    const draw = (now: number) => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      const s = seriesRef.current;
      const len = s.length;

      // glide progress: 1 = settled, eases 0 → 1 over glideMs after a push
      let et = 1;
      if (!reduced && transRef.current >= 0) {
        const p = (now - transRef.current) / glideMs;
        if (p >= 1) {
          transRef.current = -1;
        } else {
          et = glideEase(p);
        }
      }

      // smoothed normalization peak over the visible window
      let mx = 1e-6;
      for (let i = Math.max(0, len - rows - 1); i < len; i++) {
        const sv = s[i] ?? 0;
        if (sv > mx) mx = sv;
      }
      smoothMax = smoothMax === 0 ? mx : smoothMax + (mx - smoothMax) * 0.05;

      const scroll = reduced ? 0 : (now / 1000) * 0.06; // 0.06 noise u/s
      const horizonY = h * 0.16;
      const baseY = h * 0.94;
      const pad = w * 0.03;
      const usable = Math.max(1, w - pad * 2);
      const hasDent = dentAmt > 0.001 || dentAmt < -0.001;

      ctx.lineJoin = "round";
      // back → front: stroke then fill below with the background so nearer
      // ridges occlude farther ones (painter's-algorithm ridgeline trick)
      for (let r = 0; r < rows; r++) {
        const t = r * invRows; // 0 = horizon, 1 = front
        const rowY = horizonY + (baseY - horizonY) * t * t; // quadratic ease
        const rowW = usable * (0.65 + 0.35 * t); // narrows 35% at the back
        const x0 = pad + (usable - rowW) / 2;

        // fractional sample index: history glides one row back per push
        const idx = len - rows + r - 1 + et;
        const i0 = Math.floor(idx);
        const f = idx - i0;
        const v0 = i0 >= 0 && i0 < len ? (s[i0] ?? 0) : 0;
        const v1 = i0 + 1 >= 0 && i0 + 1 < len ? (s[i0 + 1] ?? 0) : 0;
        const val = Math.min(1.4, Math.max(0, (v0 + (v1 - v0) * f) / smoothMax));
        const texIdx = Math.round(idx); // texture travels with the sample
        const frontScale = 0.25 + 0.75 * t;
        const ambScale = ambientAmplitude * (0.3 + 0.7 * t);
        const depthCoord = (rows - 1 - r) * 0.35 + scroll;

        for (let c = 0; c < cols; c++) {
          const cn = c * invCols;
          const x = x0 + rowW * cn;
          const amb = ambScale * noise2(cn * 4 + 7.3, depthCoord);
          const env = Math.exp(-((cn - 0.5) * (cn - 0.5)) / (2 * 0.18 * 0.18));
          const tex = 0.55 + 0.45 * noise2(cn * 9 + 3.1, texIdx * 0.7);
          let y = rowY - amb - dataAmplitude * frontScale * val * env * tex;
          if (hasDent) {
            const dx = x - px;
            const dy = y - py;
            y += dentDepth * dentAmt * Math.exp(-(dx * dx + dy * dy) / twoSigma2);
          }
          xs[c] = x;
          ys[c] = y;
        }

        // occlusion fill down to the bottom edge
        ctx.beginPath();
        ctx.moveTo(xs[0] ?? 0, ys[0] ?? 0);
        for (let c = 1; c < cols; c++) ctx.lineTo(xs[c] ?? 0, ys[c] ?? 0);
        ctx.lineTo(x0 + rowW, h + 2);
        ctx.lineTo(x0, h + 2);
        ctx.closePath();
        ctx.fillStyle = `rgb(${fillRGB.r},${fillRGB.g},${fillRGB.b})`;
        ctx.fill();

        // ridgeline stroke: muted-token color 1px at the horizon fading to
        // foreground-token color 1.5px at the front — theme-derived, not hardcoded
        const mix = Math.pow(t, 1.3);
        const rr = Math.round(strokeLowRGB.r + (strokeHighRGB.r - strokeLowRGB.r) * mix);
        const rg = Math.round(strokeLowRGB.g + (strokeHighRGB.g - strokeLowRGB.g) * mix);
        const rb = Math.round(strokeLowRGB.b + (strokeHighRGB.b - strokeLowRGB.b) * mix);
        const a = 0.25 + 0.75 * mix;
        ctx.beginPath();
        ctx.moveTo(xs[0] ?? 0, ys[0] ?? 0);
        for (let c = 1; c < cols; c++) ctx.lineTo(xs[c] ?? 0, ys[c] ?? 0);
        ctx.strokeStyle = `rgba(${rr},${rg},${rb},${a})`;
        ctx.lineWidth = 1 + 0.5 * mix;
        ctx.stroke();
      }
    };

    if (reduced) {
      // static fallback: current series at rest, no scroll, no dent;
      // redrawn instantly on data change (via drawRef) and on resize
      drawRef.current = () => draw(0);
      draw(0);
      const ro = new ResizeObserver(() => {
        resize();
        draw(0);
      });
      ro.observe(root);
      return () => {
        ro.disconnect();
        themeObserver.disconnect();
        drawRef.current = null;
      };
    }

    const loop = (now: number) => {
      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
      last = now;
      if (hovered) {
        // lerp toward full dent at 0.12/frame (framerate-normalized)
        const prev = dentAmt;
        dentAmt += (1 - dentAmt) * (1 - Math.pow(0.88, dt * 60));
        dentVel = (dentAmt - prev) / dt; // carry velocity into the release
      } else if (dentAmt !== 0 || dentVel !== 0) {
        // underdamped spring release: k = 70 s^-2, zeta = 0.6 → one rebound
        const k = 70;
        const c = 2 * 0.6 * Math.sqrt(k);
        dentVel += (-k * dentAmt - c * dentVel) * dt;
        dentAmt += dentVel * dt;
        if (Math.abs(dentAmt) < 0.0005 && Math.abs(dentVel) < 0.005) {
          dentAmt = 0;
          dentVel = 0;
        }
      }
      draw(now);
      raf = visible ? requestAnimationFrame(loop) : 0;
    };
    raf = requestAnimationFrame(loop);

    // ambient scroll never settles, so "sleep" = pause offscreen
    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && raf === 0) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(root);

    const onMove = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      px = e.clientX - rect.left;
      py = e.clientY - rect.top;
      hovered = true;
    };
    const onLeave = () => {
      hovered = false;
    };
    const ro = new ResizeObserver(resize);
    ro.observe(root);
    root.addEventListener("pointermove", onMove);
    root.addEventListener("pointerdown", onMove);
    root.addEventListener("pointerleave", onLeave);

    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      ro.disconnect();
      themeObserver.disconnect();
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerdown", onMove);
      root.removeEventListener("pointerleave", onLeave);
    };
  }, [cols, rows, ambientAmplitude, dataAmplitude, glideMs, dentSigma, dentDepth]);

  return (
    <div
      ref={rootRef}
      role="img"
      aria-label={ariaLabel}
      className={`relative w-full overflow-hidden ${className}`}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="absolute inset-0 h-full w-full"
      />
    </div>
  );
}
Build spec

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.

Props

PropTypeDefaultDescription
series?number[]EMPTYdata samples, oldest → newest; newest enters at the front row
cols?number96vertices per ridgeline
rows?number40ridgeline count (back to front)
ambientAmplitude?number18ambient noise height in px at the front row
dataAmplitude?number68data peak height in px at the front row
glideMs?number600ms for the history to glide one row back after a push
dentSigma?number90gaussian radius of the cursor dent in screen px
dentDepth?number26max cursor dent depth in px
className?string"h-96"extra classes merged onto the rendered root element
aria-label?string"Live signal terrain"accessible name for the terrain. Default "Live signal terrain".