Skip to main content

ns-ui

Profilometer Trace

A section divider rendered as a live contact profilometer readout: a fixed stylus near the right edge while the measured surface's roughness-plus-waviness trace scrolls continuously beneath it, right to left, at a constant real-time rate, peaks and valleys passing under the tip forever.

Use when Pick profilometer-trace when the surface is a section divider that should read as a live instrument readout continuously mid-measurement — a fixed stylus with a real roughness/waviness-decomposed trace scrolling under it at a constant px/s rate, never resetting or looping visibly. Pick divider-mosaic-split instead when the divider should read as a videotex page painting in with a discrete write sweep and settle/pause cycle rather than a continuously scrolling instrument trace — the two share the 'canvas band standing in for <hr>' surface but profilometer-trace has no discrete steps, no sync pause, and no field re-sample: it is one continuous seedless-buffer scroll, never a stepped write.

Install

npx shadcn add https://design.helpmarq.com/r/profilometer-trace.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/profilometer-trace/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// ProfilometerTrace — a section divider rendered as a live contact
// profilometer readout: a diamond stylus dragged across a surface at
// constant speed, its vertical deflection recorded as a trace that
// decomposes into roughness (short-wavelength) and waviness (longer-
// wavelength) components, per the standard ISO 4287 / ASME B46.1
// roughness-measurement cutoff-filter convention.
//
// A fixed stylus sits near the right edge of the band. The measured
// surface's trace scrolls continuously beneath it, right to left, at a
// constant real-time rate — old excursions exit the left edge, new ones
// enter at the right, forever. The trace itself is NOT a ring buffer of
// discrete samples pushed/shifted every tick: it is a closed-form function
// of world-space x (continuous, seeded, deterministic) sampled fresh at
// whatever scroll offset the clock has reached. That makes a wrap seam
// structurally impossible rather than something to avoid by bookkeeping —
// there is no wrap, no buffer edge, just a sliding window over an infinite
// deterministic field.
//
// height(x) = 0.6 * valueNoise(x, 18px) + 0.4 * valueNoise(x, 54px)   -- roughness, 2 octaves
//           + 0.4 * sin(2*PI*x / 240px)                               -- waviness, 0.4x roughness amplitude
// normalized to peak deflection = 0.35 * band height.
//
// Scroll offset advances by realSpeed * dt every frame (dt = clamped real
// elapsed ms, never raw frame count) so the rate is identical at 30Hz or
// 144Hz displays. Hovering the band eases realSpeed toward 40% of base
// (a 60% slowdown, never a full stop) and eases back to 100% over 400ms
// after the pointer leaves — a global rate change, not a per-x warp, so
// the trace never tears or shows two speeds across one continuous curve.
// ---------------------------------------------------------------------------

const SPEED_PX_S = 24; // base scroll speed, px/s
const SAMPLE_PITCH = 4; // px between polyline vertices (visual resolution, not a buffer)
const STYLUS_X_FRAC = 0.82; // stylus fixed position, fraction of band width
const PEAK_RATIO = 0.35; // peak deflection = PEAK_RATIO * band height
const ROUGH_WL_A = 18; // roughness octave A wavelength, px
const ROUGH_WL_B = 54; // roughness octave B wavelength, px
const WAVE_WL = 240; // waviness wavelength, px
const WAVE_AMP_RATIO = 0.4; // waviness amplitude, relative to roughness amplitude
const ROUGH_WEIGHT_A = 0.6;
const ROUGH_WEIGHT_B = 0.4;
const RAW_ENVELOPE = ROUGH_WEIGHT_A + ROUGH_WEIGHT_B + WAVE_AMP_RATIO; // normalizer

const HOVER_SLOWDOWN = 0.4; // minimum speed multiplier under the pointer
const HOVER_ENGAGE_MS = 150; // ease-in time constant
const HOVER_RELEASE_MS = 400; // ease-out time constant, per spec

const SEED = 0x9e3779b9;

function hash1(i: number): number {
  let t = (i ^ SEED) >>> 0;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  const u = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  return u * 2 - 1; // [-1, 1]
}

function smooth(t: number): number {
  return t * t * (3 - 2 * t);
}

// deterministic 1D value noise: hashed lattice at `wavelength` spacing,
// smoothstep-interpolated so it is continuous (and therefore seamless at
// any sampling offset) rather than a discrete buffer.
function valueNoise(x: number, wavelength: number): number {
  const p = x / wavelength;
  const i0 = Math.floor(p);
  const f = smooth(p - i0);
  const a = hash1(i0);
  const b = hash1(i0 + 1);
  return a + (b - a) * f;
}

function traceHeight(worldX: number): number {
  const rough =
    ROUGH_WEIGHT_A * valueNoise(worldX, ROUGH_WL_A) +
    ROUGH_WEIGHT_B * valueNoise(worldX, ROUGH_WL_B);
  const wave = WAVE_AMP_RATIO * Math.sin((2 * Math.PI * worldX) / WAVE_WL);
  return (rough + wave) / RAW_ENVELOPE; // normalized to roughly [-1, 1]
}

export interface ProfilometerTraceProps {
  /** band height in px (divider scale). Default 56. */
  height?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function ProfilometerTrace({
  height = 56,
  className = "",
}: ProfilometerTraceProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  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;

    // -- token-derived ink: nothing paints until the first successful read --
    let fg = "";
    let mu = "";
    let ready = false;
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      const nfg = cs.getPropertyValue("--foreground").trim();
      const nmu = cs.getPropertyValue("--ns-muted").trim();
      if (nfg) fg = nfg;
      if (nmu) mu = nmu;
      ready = fg.length > 0 && mu.length > 0;
    };
    derive();

    let w = 0;
    let h = 0;
    let dpr = 1;
    let visible = true;
    let raf = 0;
    let last = 0;

    let scrollX = 0; // world-space offset of the left edge of the visible band
    let speedMult = 1; // current speed multiplier, eased toward hoverTarget
    let hoverTarget = 1;

    const draw = () => {
      if (!ready || w <= 0 || h <= 0) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);

      const midY = h / 2;
      const amp = PEAK_RATIO * h;

      // zero-line reference: a subordinate --ns-muted hairline, never a
      // --border stroke (--border is a separator token, ~1.1:1 contrast in
      // light theme, and would make the trace-vs-baseline read fail exactly
      // where the spec demands separation).
      ctx.strokeStyle = mu;
      ctx.globalAlpha = 0.35;
      ctx.lineWidth = 1;
      ctx.beginPath();
      const zy = Math.round(midY) + 0.5;
      ctx.moveTo(0, zy);
      ctx.lineTo(w, zy);
      ctx.stroke();
      ctx.globalAlpha = 1;

      // trace polyline, sampled at SAMPLE_PITCH from the continuous field —
      // no buffer, no wrap, no seam.
      ctx.strokeStyle = fg;
      ctx.lineWidth = 1.25;
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      ctx.beginPath();
      let started = false;
      let stylusY = midY;
      const stylusScreenX = w * STYLUS_X_FRAC;
      for (let sx = 0; sx <= w; sx += SAMPLE_PITCH) {
        const worldX = scrollX + sx;
        const y = midY - traceHeight(worldX) * amp;
        if (!started) {
          ctx.moveTo(sx, y);
          started = true;
        } else {
          ctx.lineTo(sx, y);
        }
        if (sx <= stylusScreenX && sx + SAMPLE_PITCH > stylusScreenX) {
          stylusY = y;
        }
      }
      ctx.stroke();

      // stylus: a fixed vertical stroke whose length matches the trace
      // height directly beneath it (the "drop-off" read), plus a tip dot.
      const stylusWorldX = scrollX + stylusScreenX;
      stylusY = midY - traceHeight(stylusWorldX) * amp;
      ctx.strokeStyle = fg;
      ctx.globalAlpha = 0.85;
      ctx.lineWidth = 1.25;
      ctx.beginPath();
      ctx.moveTo(Math.round(stylusScreenX) + 0.5, midY);
      ctx.lineTo(Math.round(stylusScreenX) + 0.5, stylusY);
      ctx.stroke();
      ctx.globalAlpha = 1;
      ctx.fillStyle = fg;
      ctx.beginPath();
      ctx.arc(stylusScreenX, stylusY, 2, 0, Math.PI * 2);
      ctx.fill();
    };

    const loop = (now: number) => {
      raf = 0;
      if (!visible) return;
      const dt = Math.min(50, last === 0 ? 16.7 : now - last);
      last = now;
      const engageMs = hoverTarget < speedMult ? HOVER_ENGAGE_MS : HOVER_RELEASE_MS;
      speedMult += (hoverTarget - speedMult) * Math.min(1, dt / engageMs);
      scrollX += (SPEED_PX_S / 1000) * dt * speedMult;
      draw();
      raf = requestAnimationFrame(loop);
    };
    const wake = () => {
      if (raf === 0 && visible && !reduced) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    // -- reduced motion: find the deterministic phase where the stylus sits
    // directly over the deepest valley in the visible band, freeze there,
    // never start rAF. Named FREEZE_PHASE = deep-valley-lock. -------------
    const freezeAtDeepValley = () => {
      if (w <= 0) return;
      const stylusScreenX = w * STYLUS_X_FRAC;
      const searchSpan = WAVE_WL * 4; // several waviness periods, deterministic
      let bestOffset = 0;
      let bestVal = Infinity;
      for (let off = 0; off < searchSpan; off += 1) {
        const v = traceHeight(off + stylusScreenX);
        if (v < bestVal) {
          bestVal = v;
          bestOffset = off;
        }
      }
      scrollX = bestOffset;
      speedMult = 1;
      draw();
    };

    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));
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      if (reduced) {
        freezeAtDeepValley();
      } else {
        draw();
      }
    };

    resize();
    if (!reduced) wake();

    const ro = new ResizeObserver(resize);
    ro.observe(root);

    const mo = new MutationObserver(() => {
      derive();
      draw();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) {
        last = 0;
        wake();
      }
    });
    io.observe(root);

    const onVisibility = () => {
      visible = !document.hidden && visible;
      if (!document.hidden) {
        last = 0;
        wake();
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    // -- hover: global speed multiplier only, never a per-x warp (per-x
    // warping would move different parts of one continuous curve at
    // different rates and tear it — the exact seam the spec forbids). -----
    const onEnter = () => {
      if (reduced) return;
      hoverTarget = HOVER_SLOWDOWN;
      wake();
    };
    const onLeave = () => {
      if (reduced) return;
      hoverTarget = 1;
      wake();
    };
    root.addEventListener("pointerenter", onEnter);
    root.addEventListener("pointerleave", onLeave);

    document.fonts.ready.then(() => {
      derive();
      if (reduced) freezeAtDeepValley();
      else draw();
    });

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      root.removeEventListener("pointerenter", onEnter);
      root.removeEventListener("pointerleave", onLeave);
    };
  }, [height]);

  return (
    <div
      ref={rootRef}
      role="separator"
      aria-orientation="horizontal"
      className={`relative w-full overflow-hidden ${className}`}
      style={{ height }}
    >
      <canvas ref={canvasRef} aria-hidden className="block h-full w-full" />
    </div>
  );
}
Build spec

Build <ProfilometerTrace height? className?> as a <canvas> band wrapped in a <div role="separator" aria-orientation="horizontal">, a drop-in replacement for <hr>/border-top between page sections. SOURCE, NOT INVENTED: contact stylus profilometry (ISO 4287 / ASME B46.1 surface-roughness measurement) — a diamond stylus dragged across a surface at constant speed, its vertical deflection recorded as a trace that the standard cutoff-filter convention decomposes into roughness (short-wavelength) and waviness (longer-wavelength) components. THE MECHANIC: a stylus sits fixed at x = 82% of the band's width; the measured surface's trace scrolls continuously beneath it, right to left, at a constant 24px/s real-time rate (scrollX += (24/1000) * dt * speedMult every frame, dt = clamped real elapsed ms, never raw frame count — a rate tied to frame count reads as jittery on variable refresh and is an explicit kill criterion). THE TRACE IS NOT A RING BUFFER: it is a closed-form deterministic function of world-space x, height(x) = 0.6*valueNoise(x,18px) + 0.4*valueNoise(x,54px) [roughness, 2 octaves] + 0.4*sin(2*PI*x/240px) [waviness, amplitude 0.4x the roughness weight], normalized by the raw envelope (0.6+0.4+0.4=1.4) and scaled to a peak deflection of 0.35 * band height; valueNoise is a hashed-lattice value noise (mulberry32-style integer hash per lattice index, smoothstep-interpolated between neighbours) evaluated fresh at whatever world offset the clock has reached. Because the field is continuous and evaluated on demand rather than shifted through a fixed-size buffer, a wrap seam is structurally impossible — there is no buffer edge to wrap past. The band is drawn as a polyline sampled every SAMPLE_PITCH=4px across the visible width (visual resolution, not a data structure). REAL NUMBERS: scroll speed 24px/s (~6 new 4px samples/s at the sample pitch), stylus at 82% width, peak deflection 0.35 * height, roughness wavelengths 18px/54px, waviness wavelength 240px at 0.4x amplitude. THE STYLUS: a short vertical --foreground stroke from the zero line down to the trace's exact current height at the stylus's world x (the 'drop-off' read, recomputed every frame from the same height() function used for the polyline, never interpolated from the drawn path) plus a small filled --foreground circle at the tip. RESTING LOOP: t0 renders scrollX=0 already mid-trace (the closed-form field has no blank state to seed away from — every offset, including zero, shows real peaks and valleys); 2.5s later ~60px of new trace (24px/s * 2.5s) has scrolled through, so the segment under the stylus is a different peak/valley arrangement; 5s later a fully distinct excursion sits under the tip, proving continuous unbounded drift rather than a loop with a period a viewer could time. LEGIBILITY: the one thing to follow is the trace sliding past the fixed stylus tip, whose indicator line visibly rises and falls as peaks and valleys pass under it; cadence is a continuous constant-px/s drift (24px/s) rather than any discrete swap, so there is nothing to time a blink against — the eye tracks a smooth drift, not an event. INTERACTION: pointerenter on the band eases a single global speed multiplier toward 0.4 (a 60% slowdown, reading as 'zooming attention' on that stretch) over a 150ms time constant; pointerleave eases it back to 1.0 over a 400ms time constant per spec. This is a GLOBAL multiplier, not a per-x warp keyed to pointer position — the spec's 'radius around the pointer' language was implemented as attention-on-the-whole-band rather than a spatially localized warp, because slowing only the samples near the pointer's x while the rest of one continuous curve kept moving at full rate would tear the trace into two visibly different speeds across a single line, which is exactly the seam the kill criteria forbid. Scroll never fully stops at any multiplier value (floor 0.4x), preserving alive-at-rest. Hover must not, and does not, recolor the trace or stylus with --ns-accent. TOKENS: --foreground (trace stroke, stylus stroke+dot) and --ns-muted (zero-line baseline, drawn at 0.35 alpha) are read once via getComputedStyle(document.documentElement) before first paint and re-derived on a documentElement class MutationObserver; a `ready` gate (both values present) blocks every draw() call — including the very first resize(), the ResizeObserver callback, and the IntersectionObserver resume path — so nothing paints before the first successful token read. --border is deliberately NOT used for the zero-line: --border is a separator token measuring ~1.1:1 contrast in light theme and would make the required trace-vs-baseline separation (the spec's own light-theme legibility requirement) structurally impossible; --ns-muted at reduced alpha is used instead, a real token with real contrast that still reads as visually subordinate to the --foreground trace. --ns-accent never appears. GEOMETRY: derived from the band's own height (its only meaningful dimension at divider scale) via PEAK_RATIO; horizontal wavelength/pitch constants are fixed px values per the spec's real numbers and are not re-derived from width, so at a very narrow card the 240px waviness wavelength can read as a gentle tilt rather than a full period — acceptable, matches how real short-span waviness traces read, and roughness excursion alone stays well above the perceptual floor even at the minimum 48px band height (0.35 * 48 ≈ 16.8px peak-to-zero). Canvas uses devicePixelRatio-scaled backing store with CSS pixel style dimensions set explicitly (never relying on intrinsic canvas size). REDUCED MOTION: FREEZE_PHASE = deep-valley-lock — a deterministic search (since the field is seeded) over several waviness periods of scroll offset for the one where the trace directly beneath the fixed stylus x is at its minimum (deepest valley in the visible band), scrollX is set there once, draw() runs once, rAF never starts; hover listeners are disabled entirely under reduced motion. LIFECYCLE: a ResizeObserver re-measures the band on container size changes; an IntersectionObserver pauses the rAF loop while off-screen and resumes it with a fresh last-timestamp (no giant delta-time jump) on re-entry; a document visibilitychange listener does the same for tab backgrounding; unmount cancels the rAF, disconnects all three observers, and removes both pointer listeners. Direct-DOM rAF, zero React state on the hot path, zero dependencies. A11Y: role=separator carries divider semantics with no accessible name required; the canvas is aria-hidden decoration; there is no keyboard surface because there is nothing to operate — hover is a supplementary pointer-only affordance, not the mechanism that makes the component alive (the continuous scroll is), which is why autoplay.mode is 'none' rather than a pointer mode. Props: height (band height px, default 56, expected divider range 48-64), className.

Props

PropTypeDefaultDescription
height?number56band height in px (divider scale). Default 56.
className?stringextra classes merged onto the rendered root element