Chart Ridgeline Terrain

Data viz

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

Install
npx shadcn add https://design.helpmarq.com/r/chart-ridgeline-terrain.json
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;
  className?: string;
  "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("--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>
  );
}
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.

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, --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.

Tags
canvasdata-vizridgelinenoisecursorambientchart