Skip to main content

ns-ui

Bias Hysteresis

A saturation/headroom gauge rendered as an analog tape deck's own AC-bias hysteresis loop instead of a bar or dial: a bright marker rides a closed H/B loop that is narrow and sharp at low drive and widens with rounded corners toward saturation at high drive, its own envelope breathing on a slower independent cycle so the loop's shape visibly changes across a five-second sample, not just the marker's position on a fixed track.

Use when Pick bias-hysteresis when the surface is a read-only saturation/headroom gauge and the loop's own widening-and-rounding shape is meant to carry the reading — distinct from meter-threshold-trip (a bimetallic-strip pass/fail latch against two fixed thresholds, no continuous loop) and meter-quota-meniscus (a calibrated liquid-rise metaphor against a scribed line, a quota fraction rather than a saturation curve). Pick meter-matrix-scan instead for an ordinary level/value meter with no drive-vs-response relationship to show.

Install

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

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// BiasHysteresis — a level/capacity gauge rendered as an analog tape deck's
// own AC-bias hysteresis loop instead of a bar or dial.
//
// SOURCE: AC-bias magnetic tape recording linearizes an inherently nonlinear
// medium by superimposing a high-frequency bias signal on the audio; the
// tape's flux density B vs. drive field H traces a hysteresis loop, not a
// straight line (Jiles-Atherton hysteresis model; documented on any pro
// reel-to-reel service manual, e.g. Studer A80 / Ampex ATR bias alignment
// procedures). As input climbs toward and past 0dB the loop widens and its
// corners round off toward saturation — the mechanism behind analog tape's
// "soft clip" character, colloquially "tape saturation".
//
// A bright marker rides the loop's edge, one full lobe traversal every 8.3s.
// The loop itself is recomputed every frame from B = Bsat*tanh(k*(H -+ Hc))
// (sign set by sweep direction — ascending vs. descending branch — which is
// what opens the two straight tanh curves into a closed lobed loop rather
// than a single line), so its own envelope amplitude breathes on an
// independent 21s cycle: the loop is visibly narrower/sharper at low drive
// and wider/rounder at high drive across a single 5-second sample, not just
// a dot moving on a fixed shape. The real bias frequency this represents
// (~150kHz on a pro deck) is documented here only — rendering it 1:1 against
// a ~60Hz paint rate would alias into a strobe, so the rendered rate is a
// decoupled, deliberately slow drive sweep instead (round-9 legibility rule).
// ---------------------------------------------------------------------------

const DRIVE_PERIOD_S = 8.3; // one full lobe traversal (0.12Hz)
const DRIVE_PHASE_OFFSET = 0.12; // cycle fraction offset — see freeze-frame note below
const ENVELOPE_PERIOD_S = 21; // saturation-amount breathing cycle
const ENVELOPE_MIN = 0.6; // peak H amplitude at envelope trough
const ENVELOPE_MAX = 1.4; // peak H amplitude at envelope peak
const COERCIVITY_HC = 0.18;
const B_SAT = 0.92;
const K_STEEPNESS = 2.4; // tanh steepness — controls corner rounding
const LOOP_POINTS = 240; // 120 ascending + 120 descending branch samples
const SQUARE_FRACTION = 0.7; // loop region side = min(w,h) * this
const H_DOMAIN = 1.5; // H-axis half-range shown — tight enough that the trough-amplitude
// loop (amp 0.6) still reads as a lens with real horizontal width, not a
// tall sliver dominated by the fixed ~0.75 vertical coercivity gap
const B_DOMAIN = 1.05; // B-axis half-range shown, padding beyond Bsat
const MARKER_RADIUS_PX = 3.5;
const AREA_FILL_ALPHA = 0.05;

// FREEZE FRAME: reduced-motion locks at drive-cycle phase 0.62 — chosen so
// that with DRIVE_PHASE_OFFSET applied, H is descending through zero right
// as the 21s envelope sits within a hair of its own peak (0.62 * 8.3s =
// 5.146s; 5.146s / 21s ~= 0.245 of the envelope's own sine, ~=0.9993 of its
// peak). At H=0 the marker sits at the widest vertical separation between
// the two branches (the coercivity gap, ~0.75 of the B range) with the loop
// itself at peak envelope amplitude — the single frame that shows the full
// open loop at its widest, rather than a thin near-origin sliver at a small
// envelope amplitude.
const FREEZE_PHASE_FRAC = 0.62;
const FREEZE_T = FREEZE_PHASE_FRAC * DRIVE_PERIOD_S;

function envelopeAmplitude(t: number): number {
  const center = (ENVELOPE_MIN + ENVELOPE_MAX) / 2;
  const halfRange = (ENVELOPE_MAX - ENVELOPE_MIN) / 2;
  return center + halfRange * Math.sin((2 * Math.PI * t) / ENVELOPE_PERIOD_S);
}

function driveAngle(t: number): number {
  return 2 * Math.PI * (t / DRIVE_PERIOD_S - DRIVE_PHASE_OFFSET);
}

/** ascending branch: H rising, B lags "behind" toward +Hc */
function bAscending(h: number): number {
  return B_SAT * Math.tanh(K_STEEPNESS * (h - COERCIVITY_HC));
}

/** descending branch: H falling, B lags "behind" toward -Hc */
function bDescending(h: number): number {
  return B_SAT * Math.tanh(K_STEEPNESS * (h + COERCIVITY_HC));
}

/** Full closed loop, LOOP_POINTS samples, for the current envelope amplitude. */
function buildLoop(amp: number): { h: number; b: number }[] {
  const half = LOOP_POINTS / 2;
  const pts: { h: number; b: number }[] = [];
  for (let i = 0; i <= half; i++) {
    const h = -amp + (2 * amp * i) / half;
    pts.push({ h, b: bAscending(h) });
  }
  for (let i = 0; i <= half; i++) {
    const h = amp - (2 * amp * i) / half;
    pts.push({ h, b: bDescending(h) });
  }
  return pts;
}

/** Current marker position: same math as the loop branches, evaluated
 * directly at the drive's current H and sweep direction. */
function markerPoint(t: number, amp: number): { h: number; b: number } {
  const angle = driveAngle(t);
  const h = amp * Math.sin(angle);
  const rising = Math.cos(angle) >= 0;
  const b = rising ? bAscending(h) : bDescending(h);
  return { h, b };
}

export interface BiasHysteresisProps {
  /** accessible name for the reading, e.g. "Input drive" */
  label?: string;
  /** canvas panel height in px. Default 220. */
  height?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function BiasHysteresis({
  label = "Bias saturation",
  height = 220,
  className = "",
}: BiasHysteresisProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

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

    let fg = "";
    let border = "";

    // fallbacks are CSS keywords, never literal colour values
    const readTokens = () => {
      const root = getComputedStyle(document.documentElement);
      fg = root.getPropertyValue("--foreground").trim() || "currentColor";
      border = root.getPropertyValue("--border").trim() || "currentColor";
    };

    let w = 0;
    let h = 0;
    let sized = false;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) {
        sized = false;
        return;
      }
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = rect.width;
      h = rect.height;
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      sized = true;
    };

    const draw = (t: number) => {
      if (!sized) return;
      ctx.clearRect(0, 0, w, h);

      const side = Math.min(w, h) * SQUARE_FRACTION;
      const cx = w / 2;
      const cy = h / 2;
      const xFor = (hVal: number) => cx + (hVal / H_DOMAIN) * (side / 2);
      const yFor = (bVal: number) => cy - (bVal / B_DOMAIN) * (side / 2);

      // axis crosshair — separator only, never the loop's own line
      ctx.strokeStyle = border;
      ctx.lineWidth = 1;
      ctx.globalAlpha = 1;
      ctx.beginPath();
      ctx.moveTo(cx - side / 2, cy);
      ctx.lineTo(cx + side / 2, cy);
      ctx.moveTo(cx, cy - side / 2);
      ctx.lineTo(cx, cy + side / 2);
      ctx.stroke();

      const amp = envelopeAmplitude(t);
      const loop = buildLoop(amp);

      // enclosed hysteresis area — the "energy lost to saturation" — as a
      // very low-opacity foreground fill, never a hue
      ctx.beginPath();
      loop.forEach((p, i) => {
        const x = xFor(p.h);
        const y = yFor(p.b);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      });
      ctx.closePath();
      ctx.fillStyle = fg;
      ctx.globalAlpha = AREA_FILL_ALPHA;
      ctx.fill();

      // the loop stroke itself
      ctx.globalAlpha = 1;
      ctx.strokeStyle = fg;
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      loop.forEach((p, i) => {
        const x = xFor(p.h);
        const y = yFor(p.b);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      });
      ctx.closePath();
      ctx.stroke();

      // marker riding the loop's edge — the one thing to follow
      const marker = markerPoint(t, amp);
      ctx.beginPath();
      ctx.arc(xFor(marker.h), yFor(marker.b), MARKER_RADIUS_PX, 0, Math.PI * 2);
      ctx.fillStyle = fg;
      ctx.globalAlpha = 1;
      ctx.fill();
    };

    // -- loop ----------------------------------------------------------------
    let raf = 0;
    let last = 0;
    let globalT = 0;

    const loop = (now: number) => {
      const dtMs = last ? Math.min(250, now - last) : 1000 / 60;
      last = now;
      globalT += dtMs / 1000;
      draw(globalT);
      if (!document.hidden) raf = requestAnimationFrame(loop);
    };

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

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        readTokens();
        resize();
        draw(reduced ? FREEZE_T : globalT);
      }, 150);
    };
    const ro = new ResizeObserver(onResize);
    ro.observe(canvas);

    const io = new IntersectionObserver(
      (entries) => {
        const visible = entries[0]?.isIntersecting;
        if (visible && !reduced && sized) {
          cancelAnimationFrame(raf);
          last = 0;
          raf = requestAnimationFrame(loop);
        } else if (!visible) {
          cancelAnimationFrame(raf);
        }
      },
      { threshold: 0 }
    );
    io.observe(canvas);

    const onVis = () => {
      cancelAnimationFrame(raf);
      if (!document.hidden && !reduced && sized) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    // no paint before the first token read
    readTokens();
    resize();

    if (reduced) {
      draw(FREEZE_T);
    } else {
      draw(0);
      raf = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      mo.disconnect();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [height]);

  return (
    <div className={className}>
      <div className="flex items-baseline justify-between gap-3">
        <span className="font-mono text-[11px] tracking-wide text-ns-muted">
          {label.toUpperCase()}
        </span>
        <span className="font-mono text-[11px] tracking-wide text-ns-muted">H / B LOOP</span>
      </div>
      <div role="img" aria-label={`${label}: a closed hysteresis loop, widening and rounding as drive climbs toward saturation`} className="mt-2">
        <canvas
          ref={canvasRef}
          aria-hidden="true"
          className="block w-full"
          style={{ height }}
        />
      </div>
      <div className="mt-1.5 flex items-center justify-between font-mono text-[11px] text-ns-muted">
        <span>Hc {COERCIVITY_HC.toFixed(2)}</span>
        <span>Bsat {B_SAT.toFixed(2)}</span>
      </div>
    </div>
  );
}
Build spec

Build <BiasHysteresis label? height? className?> as a card-scale <canvas> panel, a drop-in saturation/headroom gauge that plots a closed H (drive field, x) vs. B (flux density, y) hysteresis loop instead of a bar or dial. SOURCE: AC-bias magnetic tape recording linearizes an inherently nonlinear medium by superimposing a high-frequency bias signal on the audio; the tape's flux density B vs. drive field H traces a hysteresis loop, not a straight line (Jiles-Atherton hysteresis model; documented on any pro reel-to-reel service manual, e.g. Studer A80 / Ampex ATR bias alignment procedures) — as input climbs toward and past 0dB the loop widens and its corners round off toward saturation, the mechanism behind analog tape's 'soft clip' character, colloquially 'tape saturation'. MATH: each branch is B = Bsat*tanh(k*(H -+ Hc)) with Bsat=0.92, Hc=0.18 (coercivity), k=2.4 (steepness constant controlling corner rounding) — the ascending branch (H rising) uses B = Bsat*tanh(k*(H-Hc)), the descending branch (H falling) uses B = Bsat*tanh(k*(H+Hc)); using the opposite sign per sweep direction is what opens the two tanh curves into a single closed lobed loop rather than one line. The full loop is rebuilt every frame at LOOP_POINTS=240 resolution (120 ascending-branch samples spanning H from -amp to +amp, 120 descending-branch samples spanning +amp back to -amp, closed into one path), never cached, because its own envelope amplitude changes continuously. DRIVE: H oscillates as a slow LFO, period 8.3s (0.12Hz, DRIVE_PERIOD_S), H = amp*sin(2*PI*(t/8.3 - 0.12)) — the 0.12-cycle phase offset exists purely so the reduced-motion freeze frame (below) lands on a specific, chosen state, not an arbitrary one. ENVELOPE: the loop's own saturation amount breathes on an independent 21s cycle (ENVELOPE_PERIOD_S), amp = 1.0 + 0.4*sin(2*PI*t/21), modulating peak H amplitude between 0.6 and 1.4 — this is what makes the loop's overall width/roundedness visibly different across a five-second sample, not just the marker's position on a fixed loop: narrow and sharp near the 0.6 trough, wide and rounded near the 1.4 peak. MARKER: computed directly from the same branch formulas at the drive's current H and sweep direction (ascending if cos(angle) >= 0, else descending) rather than searched out of the loop's point array — one full lobe traversal every 8.3s, ~29 points/second equivalent smoothness, nowhere near paint-rate aliasing risk; this is the ONE thing a viewer should follow, and 8.3s per lap is slow enough to track continuously with the eye, never a discrete jump. REAL BIAS FREQUENCY: professional decks bias at roughly 150kHz — documented here in this instruction and in the component's own code comments only, never rendered 1:1 (that would alias against a ~60Hz paint rate into a strobe, the exact round-9 legibility failure mode this registry has already shipped once) — the rendered drive rate is a decoupled, deliberately slow 8.3s sweep instead. GEOMETRY: the loop is plotted in a square region of side min(containerWidth, containerHeight) * 0.7, centered in the canvas, so it holds its shape at card scale regardless of the panel's own aspect ratio; H maps across an H_DOMAIN of +-1.5 (tight padding beyond the 1.4 max envelope amplitude, deliberately kept close so the trough-amplitude loop at amp=0.6 still reads as a lens with real horizontal width instead of a tall sliver dominated by the fixed ~0.75 vertical coercivity gap) and B across a B_DOMAIN of +-1.05 (padding beyond Bsat), each independently scaled to the square's half-side. RENDER ORDER per frame: clear, draw a thin border-token axis crosshair (vertical H=0, horizontal B=0) first so it always sits under the loop, then fill the closed loop path at 0.05 foreground-alpha (the enclosed hysteresis area, read as 'energy lost to saturation'), then stroke the same closed path at full foreground opacity, then paint the marker as a filled foreground circle (radius 3.5px) on top. TOKENS: fg/border are read once via getComputedStyle(document.documentElement) against --foreground/--border before the first paint (no paint before the first read), re-derived on a documentElement class MutationObserver so a theme flip repaints live; the loop stroke is --foreground at full opacity, the axis crosshair is --border (a separator only, never the loop's own line, never a fill), the enclosed-area fill is a very low-opacity (0.05) --foreground tint — never a hue anywhere, and light theme is the harder case for that low-opacity fill (verify it doesn't visually disappear against a near-white --background before shipping). --ns-accent never appears: there is no interactive affordance in this build (a hover-driven 'current drive level' readout could be added later, but it must never recolor the loop stroke or fill with accent — accent stays reserved for a focus ring on interactive chrome only, never the loop itself). RESTING LOOP: t0 — marker somewhere on the loop, loop at whatever phase of its 21s envelope-breath it started at; 2.5s — marker has traveled roughly 30% of one lobe, loop width has shifted measurably (~12% of a full 21s breath elapsed); 5s — marker has crossed into the opposite lobe, loop shape visibly wider or narrower than at t0. Unbounded, zero input, alive at rest. REDUCED MOTION: freezes at drive-cycle phase 0.62 (FREEZE_T = 0.62 * 8.3s = 5.146s) — chosen because with the 0.12-cycle DRIVE_PHASE_OFFSET applied, H is descending through zero at exactly that instant while the 21s envelope sits within a hair of its own peak amplitude (5.146s / 21s ~= 0.245 of the envelope's own sine, ~=0.9993 of its peak). At H=0 the marker sits at the widest vertical separation between the two branches (the coercivity gap, ~0.75 of the plotted B range) with the loop itself at peak envelope amplitude — the single frame that shows the full open loop at its widest, rather than a thin near-origin sliver at a small envelope amplitude. Checked via matchMedia('(prefers-reduced-motion: reduce)') at mount, no live-change listener needed since this component has no other motion state to reconcile. LIFECYCLE: canvas sized via getBoundingClientRect with devicePixelRatio (capped at 2) applied through ctx.setTransform, resize handled by a debounced ResizeObserver (150ms), the rAF loop paused by an IntersectionObserver when off-screen and by document.hidden via a visibilitychange listener (both resume with a fresh last-timestamp, no giant delta-time jump), and every rAF/observer/listener is torn down on unmount. A11Y: the canvas itself is aria-hidden (decorative raster); it is wrapped in a role=img element carrying an aria-label summarizing the reading in prose ('a closed hysteresis loop, widening and rounding as drive climbs toward saturation') since a continuously-reshaping loop has no single scalar aria-valuenow to expose the way an ordinary meter would. Below the label row, a static Geist Mono caption row reads the two REAL NUMBERS that never change — 'Hc 0.18' / 'Bsat 0.92' — so those constants are legible from a single still frame independent of motion. No canvas colour literal, no dependency: 2D canvas, DOM+canvas+CSS only. Props: label (accessible name and visible caption, default 'Bias saturation'), height (canvas panel height px, default 220), className.

Props

PropTypeDefaultDescription
label?string"Bias saturation"accessible name for the reading, e.g. "Input drive"
height?number220canvas panel height in px. Default 220.
className?stringextra classes merged onto the rendered root element