Skip to main content

ns-ui

Catenary Contact Stagger

A live connection/sync-quality indicator built from overhead contact wire stagger: a fixed pantograph marker tracks the wire's deliberate zigzag span by span, and only arcs, briefly, on the rare span where contact genuinely breaks.

Usage

registry/core/catenary-contact-stagger/demo.tsx
"use client";

import { useState } from "react";
import { CatenaryContactStagger } from "./component";

export default function CatenaryContactStaggerDemo() {
  const [degraded, setDegraded] = useState(false);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-10 px-6">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / catenary-contact-stagger
      </p>

      <div className="w-full max-w-sm rounded-xl border border-border bg-surface px-6 py-5">
        <div className="mb-4 flex items-center justify-between">
          <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-ns-muted">
            sync status
          </span>
          <button
            type="button"
            onClick={() => setDegraded((d) => !d)}
            className="rounded-sm border border-border px-2 py-1 font-mono text-[10px] text-ns-muted transition-colors hover:border-foreground/20 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ns-accent"
          >
            {degraded ? "degraded" : "nominal"}
          </button>
        </div>

        <div style={{ height: 72 }}>
          <CatenaryContactStagger
            connectionQuality={degraded ? 0.45 : 1}
            label={degraded ? "Connection sync status, degraded" : "Connection sync status, nominal"}
          />
        </div>
      </div>

      <p className="max-w-xs text-center font-mono text-[10px] text-ns-muted">
        the strip tracks the wire&apos;s stagger every span; a rare arc
        punctuates only the span where contact genuinely breaks
      </p>
    </div>
  );
}

Component

registry/core/catenary-contact-stagger/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// CatenaryContactStagger — a live connection/sync-quality indicator built
// from overhead contact wire stagger (OLE engineering). Contact wire is
// never laid dead straight above the track: it's deliberately zigzagged
// side to side, span by span, so a pantograph's carbon collector strip wears
// evenly across its width instead of grooving one spot. The strip
// continuously tracks that lateral zigzag as spans pass beneath it; on the
// rare span where contact genuinely breaks, a brief arc punctuates an
// otherwise smooth ride.
//
// Geometry is exact, not sampled: the stagger is a true engineering zigzag —
// dead straight spans between alternating extremes at every support — so the
// wire is a polyline with vertices only at span boundaries, where it flips
// from +stagger to -stagger. A single continuous phase u(t) = elapsed /
// SPAN_MS drives both the wire's horizontal scroll AND the fixed-position
// contact marker's lateral value, sampled from the exact same triangle
// function, so the marker never drifts out of sync with the wire it's
// supposedly riding. One sweep leg (extreme to extreme) takes SPAN_MS; the
// marker's lateral position is therefore always continuous and always
// legible at a glance, at a speed (tens of px/s) nowhere near 60Hz paint.
//
// The dewirement arc is a separate, decoupled Poisson-ish process (mean
// interval independent of the sweep) so it reads as a genuine rare event,
// not a beat in the main rhythm: a brief luminance flash plus a small
// vertical wire "kick" that decays back to the smooth zigzag it interrupted.
// A `connectionQuality` prop (0-1, default 1) shortens the arc's mean
// interval as quality degrades — severity reads via event rate, never hue.
//
// Pure DOM/SVG, no canvas. Wire, strip band and marker are all
// var(--foreground) strokes/fills at full weight in both themes (the CSS var
// itself repaints on a theme swap, no JS token re-read needed since nothing
// here touches a canvas context). The arc flash uses an SVG drop-shadow
// filter seeded from var(--foreground) — glows light in dark mode, glows
// dark in light mode automatically, never introduces a colour of its own.
// ---------------------------------------------------------------------------

const SPAN_MS = 2000; // one sweep leg (extreme -> extreme) per REAL NUMBERS
const ARC_MS = 180; // dewirement arc duration
const ARC_KICK_PX = 3; // vertical wire "kick" during an arc
const DEFAULT_MIN_ARC_MS = 18000;
const DEFAULT_MAX_ARC_MS = 28000;
// degraded connections never arc faster than 4 sweep legs apart — any
// tighter and the arc joins the sweep's own rhythm instead of punctuating it.
const MIN_ARC_FLOOR_MS = SPAN_MS * 4;
const VISIBLE_SPANS = 4; // spans visible across the container at rest
const BAND_FRACTION = 0.68; // strip band height, as a fraction of min(w,h)
const AMPLITUDE_FRACTION = 0.2; // stagger half-throw, as a fraction of min(w,h) — stays inside the band with margin even mid-arc
const MARKER_R = 3.5; // px, contact marker radius at rest
const MARKER_R_ARC = 5.5; // px, contact marker radius during an arc
const WIRE_WIDTH = 1.4; // px, wire stroke at rest
const WIRE_WIDTH_ARC = 3; // px, wire stroke during an arc — weight carries the flash in both themes

/** Exact engineering zigzag: straight spans between alternating extremes.
 * Period 2 (one full extreme -> extreme -> extreme cycle), range -1..1,
 * piecewise-linear so vertices at every integer u ARE the true shape —
 * no sampling, no aliasing. */
function triWave(u: number): number {
  const m = ((u % 2) + 2) % 2; // 0..2
  return m < 1 ? m * 2 - 1 : 3 - m * 2;
}

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

export interface CatenaryContactStaggerProps {
  /** 0-1, default 1 (perfect). Degraded connectivity shortens the arc's mean
   * interval — severity reads via event rate, never colour. */
  connectionQuality?: number;
  /** floor of the randomised arc interval range at connectionQuality = 1 */
  minArcIntervalMs?: number;
  /** ceiling of the randomised arc interval range at connectionQuality = 1 */
  maxArcIntervalMs?: number;
  /** accessible label for the root status region */
  label?: string;
  /** extra classes merged onto the root element */
  className?: string;
}

export function CatenaryContactStagger({
  connectionQuality = 1,
  minArcIntervalMs = DEFAULT_MIN_ARC_MS,
  maxArcIntervalMs = DEFAULT_MAX_ARC_MS,
  label = "Connection sync status",
  className = "",
}: CatenaryContactStaggerProps) {
  const reduced = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const wireRef = useRef<SVGPolylineElement>(null);
  const bandRef = useRef<SVGRectElement>(null);
  const markerRef = useRef<SVGCircleElement>(null);
  const [arcing, setArcing] = useState(false);
  const [announce, setAnnounce] = useState("Contact wire tracking nominal.");
  const quality = Math.max(0, Math.min(1, connectionQuality));

  useEffect(() => {
    const root = rootRef.current;
    const svg = svgRef.current;
    const wire = wireRef.current;
    const band = bandRef.current;
    const marker = markerRef.current;
    if (!root || !svg || !wire || !band || !marker) return;

    let disposed = false;
    let visible = true;
    let w = 0;
    let h = 0;
    let sized = false;
    let spanWidth = 1;
    let amplitude = 1;
    let bandCenterY = 0;
    let bandHeight = 0;
    // origin of the phase clock — set once, on first successful measure, and
    // never reset afterward. A visibility pause/resume (tab hidden, card
    // scrolled off) must NOT jump the phase back to a t0 extreme: the wire
    // is a continuous physical process, so elapsed time keeps accumulating
    // against wall-clock time whether or not a frame was painted for it.
    let originTime = 0;
    let raf = 0;
    let arcStart: number | null = null;
    let arcTimer = 0;
    let arcEndTimer = 0;
    let arcPending = false;

    const arcInterval = () => {
      const min = Math.max(MIN_ARC_FLOOR_MS, minArcIntervalMs * quality || MIN_ARC_FLOOR_MS);
      const max = Math.max(min + 500, maxArcIntervalMs * quality || min + 500);
      return min + Math.random() * (max - min);
    };

    const cancelArcSchedule = () => {
      window.clearTimeout(arcTimer);
      arcTimer = 0;
      arcPending = false;
    };

    const scheduleArc = () => {
      if (disposed || reduced || arcPending) return;
      arcPending = true;
      arcTimer = window.setTimeout(() => {
        if (disposed) return;
        arcPending = false;
        arcStart = performance.now();
        setArcing(true);
        setAnnounce("Momentary contact loss.");
        arcEndTimer = window.setTimeout(() => {
          if (disposed) return;
          arcStart = null;
          setArcing(false);
          setAnnounce("Contact wire tracking nominal.");
          if (visible) scheduleArc();
        }, ARC_MS);
      }, arcInterval());
    };

    const measure = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      if (w < 2 || h < 2) {
        sized = false;
        return;
      }
      sized = true;
      const minDim = Math.min(w, h);
      spanWidth = w / VISIBLE_SPANS;
      amplitude = minDim * AMPLITUDE_FRACTION;
      bandHeight = minDim * BAND_FRACTION;
      bandCenterY = h / 2;
      svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
      band.setAttribute("x", "0");
      band.setAttribute("y", `${bandCenterY - bandHeight / 2}`);
      band.setAttribute("width", `${w}`);
      band.setAttribute("height", `${bandHeight}`);
    };

    // exact zigzag vertices: straight spans between alternating extremes,
    // vertex n sits at screenX = centerX + spanWidth * (n - u), y alternates
    // -amplitude / +amplitude by n's parity — the true engineering shape,
    // not a sampled approximation.
    const render = (u: number, kick: number) => {
      const centerX = w / 2;
      const half = VISIBLE_SPANS / 2;
      const nMin = Math.floor(u - half) - 2;
      const nMax = Math.ceil(u + half) + 2;
      const pts: string[] = [];
      for (let n = nMin; n <= nMax; n++) {
        const x = centerX + spanWidth * (n - u);
        const y = bandCenterY + amplitude * (n % 2 === 0 ? -1 : 1) + kick;
        pts.push(`${x.toFixed(1)},${y.toFixed(1)}`);
      }
      wire.setAttribute("points", pts.join(" "));
      const contactY = bandCenterY + amplitude * triWave(u) + kick;
      marker.setAttribute("cy", `${contactY.toFixed(2)}`);
      marker.setAttribute("cx", `${centerX.toFixed(2)}`);
    };

    const loop = (now: number) => {
      raf = 0;
      if (!visible || !sized) return;
      const elapsed = now - originTime;
      const u = elapsed / SPAN_MS;
      let kick = 0;
      if (arcStart != null) {
        const p = Math.min(1, (now - arcStart) / ARC_MS);
        kick = ARC_KICK_PX * Math.sin(Math.PI * p);
      }
      render(u, kick);
      raf = requestAnimationFrame(loop);
    };

    // measures the container and, once sized, (re)activates whatever isn't
    // already running — the rAF loop and the arc scheduler. Safe to call
    // repeatedly (from resize, from the font-ready settle, from IO resume):
    // it's idempotent whenever the loop/scheduler are already live.
    const activate = () => {
      measure();
      if (!sized) return; // will retry on the next resize/IO callback
      if (originTime === 0) originTime = performance.now();
      if (reduced) {
        // dead-centre, maximum contact margin — the "everything is fine"
        // frame, never a stagger extreme and never mid-arc.
        render(0.5, 0);
        return;
      }
      if (visible && !raf) raf = requestAnimationFrame(loop);
      if (visible) scheduleArc();
    };

    activate();

    let resizeTimer = 0;
    const onResize = () => {
      window.clearTimeout(resizeTimer);
      resizeTimer = window.setTimeout(() => {
        if (disposed) return;
        activate();
      }, 80);
    };
    const ro = new ResizeObserver(onResize);
    ro.observe(root);

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) {
        activate();
      } else {
        cancelArcSchedule(); // don't keep announcing arcs nobody can see
      }
    });
    io.observe(root);

    document.fonts.ready.then(() => {
      if (!disposed) onResize();
    });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      cancelArcSchedule();
      window.clearTimeout(arcEndTimer);
      window.clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [reduced, quality, minArcIntervalMs, maxArcIntervalMs]);

  return (
    <div
      ref={rootRef}
      role="group"
      aria-label={label}
      className={`relative h-full w-full ${className}`}
    >
      <svg
        ref={svgRef}
        aria-hidden="true"
        focusable="false"
        className="pointer-events-none block h-full w-full"
      >
        <defs>
          <filter id="ccs-arc-glow" x="-200%" y="-200%" width="500%" height="500%">
            <feDropShadow dx="0" dy="0" stdDeviation="2.4" floodColor="var(--foreground)" floodOpacity="0.9" />
          </filter>
        </defs>

        {/* strip band — fixed reference, the zigzag must stay inside it */}
        <rect
          ref={bandRef}
          fill="none"
          stroke="var(--foreground)"
          strokeOpacity={0.28}
          strokeWidth={1}
        />

        {/* contact wire — exact zigzag polyline, span-by-span alternation.
            An arc flash reads through WEIGHT (stroke bumps up), not colour —
            the glow filter is secondary polish, never the sole signal, so
            it still reads against a light card. */}
        <polyline
          ref={wireRef}
          fill="none"
          stroke="var(--foreground)"
          strokeWidth={arcing ? WIRE_WIDTH_ARC : WIRE_WIDTH}
          strokeLinejoin="round"
          strokeLinecap="round"
          style={{
            filter: arcing ? "url(#ccs-arc-glow)" : "none",
            opacity: arcing ? 1 : 0.85,
            transition: "opacity 60ms linear, stroke-width 40ms ease-out",
          }}
        />

        {/* contact marker — the one followable thing, riding the wire's
            lateral value at the pantograph's fixed position. Starts off the
            visible canvas until the first measured frame writes real
            coordinates, so there's never a stray dot at the SVG origin. */}
        <circle
          ref={markerRef}
          cx={-999}
          cy={-999}
          r={arcing ? MARKER_R_ARC : MARKER_R}
          fill="var(--foreground)"
          style={{
            filter: arcing ? "url(#ccs-arc-glow)" : "none",
            transition: "r 80ms ease-out",
          }}
        />
      </svg>

      <p role="status" aria-live="polite" className="sr-only">
        {announce}
      </p>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/catenary-contact-stagger.tsx, so point the import there.

Use when a live connection or sync-quality read where the ambient, continuous motion IS the signal and a rare fault should read as a genuine punctuation against a smooth, followable rhythm rather than a colour swap; pick status-glyph-cadence instead when the states are discrete task states (working/searching/blocked/done) that need five distinct at-a-glance cadences rather than one continuous sweep with an occasional rare event layered on top.

Install

npx shadcn add https://design.helpmarq.com/r/catenary-contact-stagger.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.

Build spec

Renders a live connection/sync-quality indicator as overhead contact wire stagger (OLE engineering): contact wire is deliberately zigzagged side to side, span by span, so a pantograph's carbon collector strip wears evenly across its width rather than grooving one spot. A fixed-width strip band (a thin var(--foreground) stroked rectangle at 0.28 opacity, sized to the container's smaller dimension x 0.68, centred vertically) is the static reference the zigzag must stay inside. The wire itself is an exact engineering zigzag, not a sampled curve: straight spans between alternating +/- extremes at every support, rendered as a <polyline> whose vertices are computed directly from a single continuous phase u(t) = elapsedMs / 2000 (one sweep leg, extreme to extreme, takes 2.0s) via triWave(n) = alternating -1/+1 at every integer n, vertex screenX = centerX + spanWidth * (n - u(t)) where spanWidth = containerWidth / 4 (four visible spans at rest). A small filled circle at the container's fixed horizontal centre — the pantograph's contact point — samples the exact same triWave(u(t)) function (not a re-derivation), so the marker's lateral position is always perfectly consistent with the wire riding under it; this marker is the one followable thing, per the legibility line, and its cadence is exactly the wire's: one full sweep every 2.0s. On the rare span where contact genuinely breaks, a dewirement arc fires: a self-rescheduling setTimeout loop (never a fixed interval, and never re-armed while it's already pending) draws a mean interval from a uniform 18-28s range at connectionQuality=1, scaled down as the optional connectionQuality prop (0-1, default 1) drops toward 0 — floored at 4 sweep legs (8s) so a degraded connection still reads as a distinct rare punctuation rather than joining the sweep's own 2s rhythm — severity reads via event rate, never colour or hue. Each arc lasts ~180ms and reads primarily through WEIGHT, not colour, so it survives a light card: the wire's stroke and the marker's radius both step up for the duration (1.4px to 3px, 3.5px to 5.5px), a Math.sin(pi * progress) vertical kick of up to 3px is added to both the wire polyline and the marker's y (decaying back to zero at both ends, so it reads as a bump, not a snap), and an SVG feDropShadow filter seeded from var(--foreground) rides along as secondary polish — a pure luminance glow, never accent, that renders as a light halo in dark theme and a dark halo in light theme automatically since it's built from the same token rather than a literal. The whole apparatus is direct-DOM: geometry is computed inside a single rAF loop that writes the polyline's `points` and the circle's cx/cy attributes on every frame via refs, no React state on the animation hot path (arcing is a low-frequency boolean flip, twice per arc, used only to toggle stroke weight/radius/filter). A ResizeObserver re-measures the container and recomputes spanWidth/amplitude/band geometry (both derived from the container's SMALLER dimension so it stays legible at card scale) and, if the container wasn't yet laid out at mount (0px on first measure — a hidden tab, an unlaid-out catalog card), retries activation on every subsequent resize until it succeeds. The phase clock's origin is set exactly once, on the first successful measure, and never reset: an IntersectionObserver pauses the rAF loop off-screen and cancels only the pending next-arc timer (an arc already 180ms into flight finishes naturally), but resuming on re-entry continues the same wall-clock phase rather than snapping back to a t0 extreme, since the wire is a continuous process whether or not a frame was painted for it. prefers-reduced-motion freezes on triWave(0.5) — dead centre over the strip, maximum contact margin, the 'everything is fine' frame — never at a stagger extreme and never mid-arc; the arc scheduler simply never starts in this mode. Accessibility: the root is role=group with an accessible label describing the connection this indicator reflects; a separate sr-only role=status aria-live=polite paragraph holds only 'Contact wire tracking nominal.' or, for the ~180ms of an arc, 'Momentary contact loss.', reverting immediately after — kept out of the labelled group so aria-atomic re-reads never duplicate the group's own label. No interaction: this is a pure ambient status read, nothing latches, nothing can be pressed. Props: connectionQuality (0-1, optional), minArcIntervalMs/maxArcIntervalMs (override the 18-28s range at quality=1), label, className. Zero dependencies, no canvas, pure SVG/DOM.

Props

PropTypeDefaultDescription
connectionQuality?number10-1, default 1 (perfect). Degraded connectivity shortens the arc's mean interval — severity reads via event rate, never colour.
minArcIntervalMs?numberDEFAULT_MIN_ARC_MSfloor of the randomised arc interval range at connectionQuality = 1
maxArcIntervalMs?numberDEFAULT_MAX_ARC_MSceiling of the randomised arc interval range at connectionQuality = 1
label?string"Connection sync status"accessible label for the root status region
className?string—extra classes merged onto the root element