ns-ui
CMM Probe Touch
An empty-state ambient reproducing coordinate-measuring machine touch-trigger probing: a stylus indexes to 18 stations around a fixed part outline, approaches slowly along the local surface normal, dwells at contact for a luminance-only trigger flash, retracts, and travels to the next station, with older touched points rolling off a one-lap trailing fade so the inspection is always mid-pass, never finished.
Use when an empty state whose figure should read as a real inspection instrument continuously re-measuring a fixed part outline, station by station, on a literal approach/dwell/retract/travel cycle with a rolling one-lap trail of recently-touched points — mechanism is discrete touch-trigger probing at 18 evenly arc-spaced stations, never a smooth continuous trace. Pick empty-state-braille-orbit instead when the figure should read as one continuous, undamped, non-terminating trajectory (a Lissajous orbit) rather than a discrete station-to-station indexing cycle with distinct approach/contact/retract phases and a per-point fade history — braille-orbit has no notion of discrete 'stations' or a trigger event, cmm-probe-touch has no continuous curve, only 18 fixed points visited in strict sequence.
Install
npx shadcn add https://design.helpmarq.com /r/cmm-probe-touch.jsonSource
registry/core/cmm-probe-touch/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// CmmProbeTouch — an empty-state ambient built on coordinate-measuring
// machine (CMM) touch-trigger probing, standard contact metrology. A probe
// tip indexes to a programmed station on a part's outline, approaches
// slowly along the LOCAL SURFACE NORMAL, dwells at contact (the trigger
// fires — a luminance flash, never a colour change), retracts, and travels
// to the next station — an inspection pass that never finishes, because a
// CMM program loops the same routine part after part.
//
// The part is a single fixed closed contour (rounded-rect silhouette with a
// notch and a boss cut in) built once from an explicit vertex list —
// straight edges plus subdivided corner/boss arcs — then walked into one
// dense, evenly-arc-length-parameterised polyline. STATIONS = 18 stations
// sit at even arc-length fractions of that polyline; each station's local
// outward normal comes from the tangent of its two neighbours on the dense
// polyline, sign-checked against the contour's centroid so it always points
// away from the part.
//
// Per-station cycle is a literal 1.2s state machine (420ms approach / 180ms
// dwell / 300ms retract / 300ms travel-to-next) driven off ONE monotonic
// virtual clock — no per-station timers, no array of "in-flight" probes.
// Touched-point age is read analytically off that same clock: station k's
// most recent contact time is `k*1200 + APPROACH_MS` mod the 21.6s lap, so
// `age = (nowInLap - thatTime + LAP) % LAP` is always "time since k was
// last touched", whether that touch was earlier this lap or the previous
// one — no history array, no per-station timestamp bookkeeping. The clock
// starts pre-seeded a full lap plus a fractional offset ahead of zero, so
// every station already has a valid touch age at t0 (a rolling trail with
// history, never a blank contour) and the lap keeps indexing forever.
// ---------------------------------------------------------------------------
interface Vec {
x: number;
y: number;
}
const STATIONS = 18;
const APPROACH_MS = 420;
const DWELL_MS = 180;
const RETRACT_MS = 300;
const TRAVEL_MS = 300;
const STATION_MS = APPROACH_MS + DWELL_MS + RETRACT_MS + TRAVEL_MS; // 1200
const LAP_MS = STATION_MS * STATIONS; // 21600
const SEED_LAP_FRACTION = 0.42; // t0 phase within the pre-seeded lap
// local shape constants (unitless, scaled by the container's smaller dimension)
const HX = 0.82;
const HY = 0.5;
const CORNER_R = 0.12;
const BOSS_R = 0.15;
const NOTCH_HALF = 0.15;
const NOTCH_DEPTH = 0.16;
const ARC_STEPS = 14;
const APPROACH_DIST = 0.24; // station normal offset, in the same local units
const NORMAL_SAMPLE_DELTA = 0.004; // arc-length fraction used for tangent finite-difference
const FREEZE_STATION = 9;
const FREEZE_PHASE = "contact-dwell-station9";
function arcPoints(cx: number, cy: number, r: number, a0: number, a1: number, steps: number): Vec[] {
const pts: Vec[] = [];
for (let i = 0; i <= steps; i++) {
const t = a0 + (a1 - a0) * (i / steps);
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
return pts;
}
/** Explicit vertex walk of the part silhouette: top edge with a rectangular
* notch, rounded top-right corner, right edge with a semicircular boss cut
* outward, rounded bottom-right corner, bottom edge, rounded bottom-left
* corner, left edge, rounded top-left corner, closing back to start. */
function buildOutline(): Vec[] {
const pts: Vec[] = [];
const push = (p: Vec) => {
const prev = pts[pts.length - 1];
if (!prev || Math.hypot(p.x - prev.x, p.y - prev.y) > 1e-6) pts.push(p);
};
push({ x: -HX + CORNER_R, y: -HY });
push({ x: -NOTCH_HALF, y: -HY });
push({ x: -NOTCH_HALF, y: -HY + NOTCH_DEPTH });
push({ x: NOTCH_HALF, y: -HY + NOTCH_DEPTH });
push({ x: NOTCH_HALF, y: -HY });
push({ x: HX - CORNER_R, y: -HY });
for (const p of arcPoints(HX - CORNER_R, -HY + CORNER_R, CORNER_R, -Math.PI / 2, 0, ARC_STEPS)) push(p);
push({ x: HX, y: -BOSS_R });
for (const p of arcPoints(HX, 0, BOSS_R, -Math.PI / 2, Math.PI / 2, ARC_STEPS)) push(p);
push({ x: HX, y: HY - CORNER_R });
for (const p of arcPoints(HX - CORNER_R, HY - CORNER_R, CORNER_R, 0, Math.PI / 2, ARC_STEPS)) push(p);
push({ x: -HX + CORNER_R, y: HY });
for (const p of arcPoints(-HX + CORNER_R, HY - CORNER_R, CORNER_R, Math.PI / 2, Math.PI, ARC_STEPS)) push(p);
push({ x: -HX, y: -HY + CORNER_R });
for (const p of arcPoints(-HX + CORNER_R, -HY + CORNER_R, CORNER_R, Math.PI, (3 * Math.PI) / 2, ARC_STEPS)) push(p);
return pts;
}
/** A closed polyline resampled into a lookup usable at any arc-length
* fraction s in [0,1) via linear interpolation between its cumulative
* lengths — the single source of truth for both the drawn contour and the
* evenly-spaced station positions. */
class ArcPath {
private readonly pts: Vec[];
private readonly cum: number[];
readonly total: number;
readonly centroid: Vec;
constructor(pts: Vec[]) {
this.pts = pts;
const cum = [0];
let sum = 0;
let cx = 0;
let cy = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i]!;
const b = pts[(i + 1) % pts.length]!;
sum += Math.hypot(b.x - a.x, b.y - a.y);
cum.push(sum);
cx += a.x;
cy += a.y;
}
this.cum = cum;
this.total = sum;
this.centroid = { x: cx / pts.length, y: cy / pts.length };
}
at(sFrac: number): Vec {
const n = this.pts.length;
let s = ((sFrac % 1) + 1) % 1;
const target = s * this.total;
let lo = 0;
let hi = n - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.cum[mid]! <= target) lo = mid;
else hi = mid - 1;
}
const a = this.pts[lo]!;
const b = this.pts[(lo + 1) % n]!;
const segLen = this.cum[lo + 1]! - this.cum[lo]!;
const t = segLen > 1e-9 ? (target - this.cum[lo]!) / segLen : 0;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
normalAt(sFrac: number): Vec {
const a = this.at(sFrac - NORMAL_SAMPLE_DELTA);
const b = this.at(sFrac + NORMAL_SAMPLE_DELTA);
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
let nx = -dy / len;
let ny = dx / len;
const p = this.at(sFrac);
const outSign = (p.x - this.centroid.x) * nx + (p.y - this.centroid.y) * ny;
if (outSign < 0) {
nx = -nx;
ny = -ny;
}
return { x: nx, y: ny };
}
}
function easeOutCubic(t: number): number {
const u = 1 - t;
return 1 - u * u * u;
}
function easeInCubic(t: number): number {
return t * t * t;
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function clamp01(x: number): number {
return x < 0 ? 0 : x > 1 ? 1 : x;
}
/** deterministic small-int hash -> a plausible synthetic deviation reading
* in mm, stable per station index across renders and reduced-motion. */
function syntheticDeviation(i: number): string {
const h = Math.sin(i * 12.9898) * 43758.5453;
const frac = h - Math.floor(h);
const mm = (frac - 0.5) * 0.036;
const sign = mm >= 0 ? "+" : "-";
return `${sign}${Math.abs(mm).toFixed(3)}mm`;
}
export interface CmmProbeTouchProps {
/** icon/card box size in px (square) */
size?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function CmmProbeTouch({ size = 220, className = "" }: CmmProbeTouchProps) {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [activeStation, setActiveStation] = useState<number | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
const pathRef = useRef<ArcPath | null>(null);
if (!pathRef.current) pathRef.current = new ArcPath(buildOutline());
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const path = pathRef.current!;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let fg = "currentColor";
let muted = "currentColor";
let border = "currentColor";
const readTokens = () => {
const s = getComputedStyle(document.documentElement);
fg = s.getPropertyValue("--foreground").trim() || "currentColor";
muted = s.getPropertyValue("--ns-muted").trim() || fg;
border = s.getPropertyValue("--border").trim() || muted;
};
let width = 0;
let height = 0;
let dpr = 1;
let sized = false;
let disposed = false;
let visible = true;
let scale = 1;
let originX = 0;
let originY = 0;
const toScreen = (p: Vec): Vec => ({ x: originX + p.x * scale, y: originY + p.y * scale });
let raf = 0;
let last = 0;
let elapsed = LAP_MS + SEED_LAP_FRACTION * LAP_MS; // pre-seeded virtual clock, ms
const stationTouchTime = (k: number) => k * STATION_MS + APPROACH_MS;
const stationAge = (k: number, nowMs: number): number => {
const nowInLap = ((nowMs % LAP_MS) + LAP_MS) % LAP_MS;
const touch = stationTouchTime(k);
return ((nowInLap - touch + LAP_MS) % LAP_MS) || 0;
};
const stationScreenPoint = (k: number): Vec => toScreen(path.at(k / STATIONS));
const stationApproachPoint = (k: number): Vec => {
const p = path.at(k / STATIONS);
const n = path.normalAt(k / STATIONS);
return toScreen({ x: p.x + n.x * APPROACH_DIST, y: p.y + n.y * APPROACH_DIST });
};
const drawContour = () => {
ctx.beginPath();
const n = 200;
for (let i = 0; i <= n; i++) {
const p = toScreen(path.at(i / n));
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.strokeStyle = border;
ctx.lineWidth = Math.max(1, scale * 0.006);
ctx.stroke();
};
const dotR = () => Math.max(1.6, scale * 0.02);
const drawTouchedPoints = (nowMs: number) => {
const r = dotR();
// base pass: every station drawn once in --ns-muted at full alpha
ctx.fillStyle = muted;
for (let k = 0; k < STATIONS; k++) {
const p = stationScreenPoint(k);
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fill();
}
// overlay pass: --foreground crossfaded in by how recently each
// station was touched — alpha ramp only, never string arithmetic
ctx.fillStyle = fg;
for (let k = 0; k < STATIONS; k++) {
const age = stationAge(k, nowMs);
const alpha = clamp01(1 - age / LAP_MS);
if (alpha <= 0.02) continue;
const p = stationScreenPoint(k);
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const drawProbe = (nowMs: number) => {
const nowInLap = ((nowMs % LAP_MS) + LAP_MS) % LAP_MS;
const k = Math.floor(nowInLap / STATION_MS) % STATIONS;
const within = nowInLap - k * STATION_MS;
const stationS = k / STATIONS;
const contact = path.at(stationS);
const normal = path.normalAt(stationS);
const contactPt = toScreen(contact);
const approachPt = toScreen({ x: contact.x + normal.x * APPROACH_DIST, y: contact.y + normal.y * APPROACH_DIST });
let tip: Vec;
let flashT = 0;
if (within < APPROACH_MS) {
const t = easeOutCubic(within / APPROACH_MS);
tip = { x: approachPt.x + (contactPt.x - approachPt.x) * t, y: approachPt.y + (contactPt.y - approachPt.y) * t };
} else if (within < APPROACH_MS + DWELL_MS) {
tip = contactPt;
const dt = (within - APPROACH_MS) / DWELL_MS;
flashT = Math.sin(dt * Math.PI); // 0 -> 1 -> 0 across the dwell window
} else if (within < APPROACH_MS + DWELL_MS + RETRACT_MS) {
const t = easeInCubic((within - APPROACH_MS - DWELL_MS) / RETRACT_MS);
tip = { x: contactPt.x + (approachPt.x - contactPt.x) * t, y: contactPt.y + (approachPt.y - contactPt.y) * t };
} else {
const nextK = (k + 1) % STATIONS;
const nextS = nextK / STATIONS;
const nextContact = path.at(nextS);
const nextNormal = path.normalAt(nextS);
const nextApproach = toScreen({
x: nextContact.x + nextNormal.x * APPROACH_DIST,
y: nextContact.y + nextNormal.y * APPROACH_DIST,
});
const t = easeInOutCubic((within - APPROACH_MS - DWELL_MS - RETRACT_MS) / TRAVEL_MS);
tip = { x: approachPt.x + (nextApproach.x - approachPt.x) * t, y: approachPt.y + (nextApproach.y - approachPt.y) * t };
}
// stylus shaft: a short segment trailing outward from the tip along
// the current station's normal, so the probe reads as a stylus, not
// a bare dot
const shaftLen = scale * 0.14;
const shaftEnd = { x: tip.x + normal.x * shaftLen, y: tip.y + normal.y * shaftLen };
ctx.strokeStyle = fg;
ctx.lineWidth = Math.max(1, scale * 0.01);
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.moveTo(tip.x, tip.y);
ctx.lineTo(shaftEnd.x, shaftEnd.y);
ctx.stroke();
ctx.globalAlpha = 1;
const baseR = Math.max(1.8, scale * 0.024);
if (flashT > 0.01) {
// trigger flash: a luminance bump (larger radius, brighter
// overlay), never a colour tint
ctx.fillStyle = fg;
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(tip.x, tip.y, baseR * (1 + 0.9 * flashT), 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.35 * flashT;
ctx.beginPath();
ctx.arc(tip.x, tip.y, baseR * (1 + 2.2 * flashT), 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
} else {
ctx.fillStyle = fg;
ctx.beginPath();
ctx.arc(tip.x, tip.y, baseR, 0, Math.PI * 2);
ctx.fill();
}
};
const render = (nowMs: number) => {
if (!sized) return;
ctx.clearRect(0, 0, width, height);
drawContour();
drawTouchedPoints(nowMs);
drawProbe(nowMs);
};
const positionOverlay = () => {
for (let k = 0; k < STATIONS; k++) {
const btn = buttonRefs.current[k];
if (!btn) continue;
const p = stationScreenPoint(k);
btn.style.left = `${p.x}px`;
btn.style.top = `${p.y}px`;
}
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
if (width < 2 || height < 2) {
sized = false;
return;
}
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const minDim = Math.min(width, height);
scale = minDim * 0.42;
originX = width / 2;
originY = height / 2;
sized = true;
positionOverlay();
render(elapsed);
};
let resizeTimer = 0;
const onResize = () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(resize, 100);
};
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible && !reduced && !raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
});
io.observe(canvas);
function loop(now: number) {
raf = 0;
if (!visible || document.hidden) return;
if (last === 0) last = now;
const dt = Math.min(100, now - last);
last = now;
elapsed += dt;
render(elapsed);
raf = requestAnimationFrame(loop);
}
const onVis = () => {
if (!document.hidden && visible && !reduced && !raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
const mo = new MutationObserver(() => {
readTokens();
render(elapsed);
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
document.fonts.ready.then(() => {
if (disposed) return;
readTokens();
resize();
if (reduced) {
elapsed = LAP_MS + FREEZE_STATION * STATION_MS + APPROACH_MS + DWELL_MS / 2;
render(elapsed);
} else {
raf = requestAnimationFrame(loop);
}
});
return () => {
disposed = true;
cancelAnimationFrame(raf);
raf = 0;
window.clearTimeout(resizeTimer);
ro.disconnect();
io.disconnect();
mo.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [size]);
const showTooltip = (k: number) => {
const btn = buttonRefs.current[k];
const root = rootRef.current;
if (!btn || !root) return;
const rootRect = root.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
setActiveStation(k);
setTooltipPos({ x: btnRect.left - rootRect.left + btnRect.width / 2, y: btnRect.top - rootRect.top });
};
const hideTooltip = () => {
setActiveStation(null);
setTooltipPos(null);
};
return (
<div
ref={rootRef}
className={`relative ${className}`}
style={{ width: size, height: size }}
data-reduced-motion-freeze={FREEZE_PHASE}
>
<canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
{Array.from({ length: STATIONS }, (_, k) => (
<button
key={k}
ref={(el) => {
buttonRefs.current[k] = el;
}}
type="button"
aria-label={`Inspection station ${k + 1} deviation ${syntheticDeviation(k)}`}
className="absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-1"
onMouseEnter={() => showTooltip(k)}
onMouseLeave={hideTooltip}
onFocus={() => showTooltip(k)}
onBlur={hideTooltip}
/>
))}
{activeStation !== null && tooltipPos && (
<div
role="tooltip"
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-sm border border-border bg-background px-2 py-1 font-mono text-[10px] text-ns-muted"
style={{ left: tooltipPos.x, top: tooltipPos.y - 6 }}
>
station {activeStation + 1} dev {syntheticDeviation(activeStation)}
</div>
)}
</div>
);
}
Build spec
An empty-state ambient built on real CMM (coordinate-measuring machine) touch-trigger probing, standard contact metrology, rendered on a single 2D canvas sized off the container's own `getBoundingClientRect` (DPR-capped at 2). The part is a single fixed closed contour built once from an explicit vertex walk in local unit space (half-extents HX=0.82, HY=0.5): a rounded-rectangle-ish silhouette with a rectangular notch cut into the top edge (half-width 0.15, depth 0.16) and a semicircular boss (radius 0.15) bulging outward from the right edge, corners rounded at radius 0.12 with 14-segment arcs. That vertex list is wrapped in an `ArcPath` helper that walks it into cumulative arc length and exposes `at(sFraction)` (binary-searched linear interpolation along the closed loop) and `normalAt(sFraction)` (central-difference tangent at sFraction +/- 0.004, rotated 90 degrees, sign-checked against the contour's centroid so it always points outward) — this same lookup drives both the drawn contour stroke and every station position, so there is exactly one source of geometric truth. STATIONS = 18 stations sit at even arc-length fractions k/18. Animation is driven by one monotonic virtual clock (`elapsed`, ms, advanced by real rAF delta so speed never depends on frame rate), pre-seeded at mount to one full lap (21600ms) plus 0.42 of a lap, so every station already has a valid touch history at t0 — a rolling trail with partial composition visible immediately, never a blank contour. Per-station cycle is a fixed 1200ms state machine read off `elapsed mod 1200`: 0-420ms approach (ease-out-cubic from a point offset `APPROACH_DIST=0.24` local units outward along the station's normal, down to the contact point — fast then decelerating into contact, matching a real probe's slow final approach), 420-600ms dwell-at-contact (the trigger fires — a luminance-only flash: the probe tip's fill radius and a soft outer ring pulse via `Math.sin` peaking at the dwell midpoint, drawn twice at increasing alpha/radius in `--foreground`, never a colour change or `--ns-accent`), 600-900ms retract (ease-in-cubic back out along the same normal to the approach point, mirroring the approach's deceleration with an acceleration away from the part), 900-1200ms travel (ease-in-out-cubic straight-line interpolation from the current station's approach point to the next station's approach point, so both departure and arrival are visibly eased, never a blink-swap). A full lap is 18 x 1200ms = 21600ms and the station index simply increments mod 18 forever — there is no terminal state, no lap-complete pause. Touched-point age is computed analytically, with no history array: station k's most recent contact time within a lap is `k*1200 + 420`ms, so `age = ((elapsed mod 21600) - thatTime + 21600) mod 21600` is always exactly 'time since k was last touched', correct whether that touch happened earlier in the current lap or the previous one. Every station is drawn every frame in two passes, exactly mirroring the alpha-ramp convention used elsewhere in this registry: a base pass at full alpha in `--ns-muted` under all 18 stations, then an overlay pass in `--foreground` at alpha = `clamp01(1 - age/21600)` — near-opaque for a just-touched station, fully transparent (letting the muted base show through) for one about to roll off the trail's one-lap window. The probe tip itself is a small filled circle in `--foreground` with a thin trailing stylus-shaft stroke (60% alpha) drawn outward along the active station's current normal, so it reads as a stylus tip rather than a bare dot. The contour itself is stroked once per frame in `--border` (a separator token, never a fill) at roughly 0.6% of the geometry scale. Geometry scale is derived from the container's smaller dimension (`scale = min(width,height) * 0.42`), centred in the canvas, recomputed on every debounced ResizeObserver callback (which also repositions the 18 accessible overlay buttons). `--foreground`, `--ns-muted` and `--border` are read via `getComputedStyle(document.documentElement)` on mount and re-read on a `MutationObserver` watching `documentElement`'s class, with a synchronous re-render on change — no paint happens before that first read. The rAF loop is paused via `IntersectionObserver` (canvas out of view) and `document.hidden`, and resumed cleanly on either becoming true again, with `last` reset to 0 so the next frame's delta doesn't include the paused interval. Interaction: 18 zero-visual, `aria-label`-carrying `<button>` elements are absolutely positioned over each station (repositioned imperatively on resize, not on every animation frame, since station positions themselves never move) with a `focus-visible:ring-2 ring-ns-accent` focus ring — the one permitted accent use, interaction chrome only. Hover or focus shows a small `role="tooltip"` div in `--ns-muted` text reading a synthetic per-station deviation value (a deterministic sine-hash of the station index, formatted to three decimal mm, stable across renders and identical under reduced motion). This overlay is pure DOM state (`activeStation`/`tooltipPos` via `useState`) layered on top of the canvas loop; it never reads from or writes to the virtual clock, so dwelling a pointer over a station cannot stall, speed up, or otherwise perturb the global 1200ms/station indexing cadence, the same isolation rule `peen-coverage`'s hover dwell follows for its own global pass clock. Under `prefers-reduced-motion: reduce`, the rAF loop never starts; the virtual clock is set once, synchronously, to `21600 + 9*1200 + 420 + 90` (station index 9 — roughly opposite the lap's start — at the midpoint of its 180ms contact-dwell window, the single most legible frame: tip visibly touching, trigger flash mid-pulse, full one-lap trail history already resolved) and rendered once. That constant is exposed as `FREEZE_PHASE = "contact-dwell-station9"` and surfaced on the root element via `data-reduced-motion-freeze`. Cleanup on unmount cancels the rAF, disconnects the ResizeObserver, IntersectionObserver and MutationObserver, and clears the debounce timer. Zero dependencies, canvas + DOM overlay + CSS only, every colour a token (`--background` `--foreground` `--ns-muted` `--border` `--ns-accent`).
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size? | number | 220 | icon/card box size in px (square) |
| className? | string | — | extra classes merged onto the rendered root element |