Skip to main content

ns-ui / loud

Jominy Quench

A hardenability comparison card built on the real ASTM A255 / ISO 642 Jominy end-quench test: a water jet sprays one end of a heated steel bar continuously, a directional quench front creeps up the bar, and hardness falls off with distance from that face along a curve that is each alloy's signature, drawn beside a real numeric table so the comparison never lives only in the picture.

Use when Pick jominy-quench when comparing several graded variants (alloys, formulations, product tiers with a measurable falloff) along one continuous physical axis and the shape of the falloff curve, not a checkmark grid, is the comparison. compare-table-reach-rule is the better pick for a feature-by-feature plan matrix; jominy-quench is for a small number of items each carrying one signature curve.

Install

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

import { useLayoutEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// JominyQuench — a hardenability comparison card built on the real ASTM A255
// / ISO 642 Jominy end-quench test: a bar is austenitised, then a water jet is
// sprayed against ONE end only. The quenched face cools fastest and hardens
// most; hardness falls off with distance from that face along a curve that is
// the alloy's signature. Comparing several bars means comparing several
// fall-off curves — that IS the mechanic, not a metaphor bolted onto one.
//
// Each row runs the same fixed cycle: the bar reheats to a uniform glow, a
// quench front creeps left-to-right across it while the water jet continuously
// sprays the left face (a steam plume that never stops), the fall-off curve
// above the bar draws in step with the front, then a hold at full quench
// before the next reheat. Rows are phase-staggered so the cascade never
// settles into one static pose.
//
// Distinctness: weld-pool is a continuously MOLTEN free surface with no
// directional solidification front; kamacite-etch reveals an already-formed
// static crystal lattice with no cooling physics at all. This is a
// directional, one-axis COOLING front with a hardness fall-off curve as its
// output — a different physical process from both.
//
// LUMINANCE ADAPTATION: the real test is intensely hue-coded (quenched steel
// runs white -> yellow -> orange -> red -> black as it cools) but this
// registry is five value-only tokens with no orange. Temperature is mapped to
// luminance instead: the hottest, most salient point on the bar is always
// closest to --foreground (the token with maximum contrast against
// --background) and the coldest, quenched point always fades toward
// --background. That mapping self-inverts correctly between themes, because
// "closest to --foreground" already means "near-white ink on a dark ground"
// in dark theme and "near-black ink on a light ground" in light theme — hot
// is unambiguously the salient value in both.
//
// Colour is read once via getComputedStyle(document.documentElement) and
// re-read on a MutationObserver watching documentElement's class, matching
// every other canvas component in this registry (duplicated here on purpose —
// no shared lib/ helper). Direct-DOM rAF, DPR-capped backing store,
// IntersectionObserver + visibilitychange pause. --ns-accent appears only as
// the hover/focus highlight on a compared bar's row — never in the resting
// glow ramp.
// ---------------------------------------------------------------------------

export interface JominyBar {
  id: string;
  /** alloy / grade name, e.g. "4140" */
  name: string;
  /**
   * Rockwell-C hardness at each of the nine standard Jominy distances
   * (DISTANCE_LABELS: 1/16" through 2"), quenched face first. Supply fewer
   * values than the header shows and the table renders that many cells
   * against the full nine-column header — pass one entry per label to
   * avoid a mismatched row.
   */
  hardness: number[];
}

const DISTANCES_16THS = [1, 2, 4, 6, 8, 12, 16, 24, 32];
const DISTANCE_LABELS = DISTANCES_16THS.map((n) =>
  n < 16 ? `${n}/16″` : `${n / 16}″`
);

// Illustrative Jominy-curve shapes (plain-carbon vs. low/medium alloy vs.
// spring steel) — the real published J-curves for these grades vary by heat
// and mill; these are shaped to the well-documented qualitative behavior
// (more alloy content = flatter fall-off) rather than lifted from one cert
// sheet, and are labelled as such in meta.json.
const DEFAULT_BARS: JominyBar[] = [
  { id: "1045", name: "1045", hardness: [55, 50, 36, 28, 24, 22, 21, 20, 20] },
  { id: "5160", name: "5160", hardness: [58, 55, 48, 42, 37, 32, 29, 26, 24] },
  { id: "8640", name: "8640", hardness: [56, 55, 53, 51, 49, 46, 44, 41, 38] },
  { id: "4140", name: "4140", hardness: [58, 56, 53, 50, 47, 43, 40, 36, 33] },
];

// cycle phase fractions (of CYCLE_MS): reheat, quench-creep, hold
const RISE_FRAC = 0.1;
const QUENCH_FRAC = 0.58;
// HOLD_FRAC = 1 - RISE_FRAC - QUENCH_FRAC = 0.32
const CYCLE_MS = 7200;
const PHASE_STAGGER = 0.16; // per-row offset, decorrelates the cascade

const COLD_BASE = 0.15; // heat level immediately behind the front — still a hole, but not invisible
const RESIDUAL_ASYMPTOTE = 0.9; // heat level the ahead-of-front zone recovers to, short of full furnace heat
const DIP_DEPTH = 0.35; // how far below the asymptote the zone right at the front dips
const DIP_WIDTH = 0.14; // fraction of bar length the dip recovers over, ahead of the front
const FRONT_BLEND = 0.05; // fraction of bar length the front edge is soft over
const STATIC_FRONT = 0.55; // reduced-motion freeze: partway up the bar
const STATIC_CURVE_REVEAL = 1; // ...with the full signature curve drawn

type RGB = [number, number, number];

function parseHex(raw: string): RGB | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function lerpRGB(a: RGB, b: RGB, t: number): RGB {
  const c = Math.max(0, Math.min(1, t));
  return [a[0] + (b[0] - a[0]) * c, a[1] + (b[1] - a[1]) * c, a[2] + (b[2] - a[2]) * c];
}

function rgbStr([r, g, b]: RGB, a = 1): string {
  return `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${a})`;
}

function smoothstep(t: number): number {
  const c = Math.max(0, Math.min(1, t));
  return c * c * (3 - 2 * c);
}

// Heat level 0..1 at bar-fraction x for a given front position, ignoring the
// reheat blend — this is the "just been quenched to here" profile: cold
// behind the front, a soft transition band, then a dip right at the boundary
// (the metal there has only just started losing heat to the jet) that
// recovers toward RESIDUAL_ASYMPTOTE as x moves away from the front — i.e.
// the unquenched material farthest from where cooling is currently happening
// is the material that has lost the least heat, which is the residual glow
// the far end is meant to retain.
function frontHeat(x: number, front: number): number {
  const d = x - front;
  if (d <= -FRONT_BLEND) return COLD_BASE;
  const da = Math.max(0, d);
  const aheadVal = RESIDUAL_ASYMPTOTE - DIP_DEPTH * Math.exp(-da / DIP_WIDTH);
  if (d >= FRONT_BLEND) return aheadVal;
  const s = smoothstep((d + FRONT_BLEND) / (2 * FRONT_BLEND));
  return COLD_BASE + (aheadVal - COLD_BASE) * s;
}

type Phase = { front: number; curveReveal: number; reheat: number };

function phaseAt(u: number): Phase {
  const t = ((u % 1) + 1) % 1;
  if (t < RISE_FRAC) {
    const q = smoothstep(t / RISE_FRAC);
    return { front: 1, curveReveal: 1 - q, reheat: q };
  }
  const t2 = t - RISE_FRAC;
  if (t2 < QUENCH_FRAC) {
    const q = smoothstep(t2 / QUENCH_FRAC);
    return { front: q, curveReveal: q, reheat: 0 };
  }
  return { front: 1, curveReveal: 1, reheat: 0 };
}

// During "rise" (reheat), the whole bar blends from wherever the last
// quench left it back toward a uniform hot glow, so the bar never snaps —
// it visibly warms back up before the next front starts creeping.
function heatAt(x: number, ph: Phase): number {
  const front = frontHeat(x, ph.front);
  return ph.reheat > 0 ? front + (1 - front) * ph.reheat : front;
}

export interface JominyQuenchProps {
  /** bars being compared, quenched-face-first hardness per bar */
  bars?: JominyBar[];
  /** accessible title for the comparison */
  title?: string;
  className?: string;
}

export function JominyQuench({
  bars = DEFAULT_BARS,
  title = "Jominy end-quench hardenability",
  className = "",
}: JominyQuenchProps) {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const hoverRef = useRef<number>(-1);
  // set only under prefers-reduced-motion, where nothing else repaints the
  // canvas — lets hover/focus still redraw the accent highlight on demand
  const hoverRedrawRef = useRef<(() => void) | null>(null);

  useLayoutEffect(() => {
    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;

    const n = bars.length;
    const maxHRC = Math.max(1, ...bars.flatMap((b) => b.hardness)) * 1.08;
    const nPts = Math.max(2, ...bars.map((b) => b.hardness.length));

    let width = 0;
    let rowH = 0;
    let dpr = 1;
    let padX = 26;

    // Token fields start empty and are only ever assigned from a live
    // getComputedStyle read — never a hardcoded fallback — so a missing
    // token fails loud (drawFrame bails) instead of silently painting a
    // baked-in white.
    let bgC: RGB | null = null;
    let fgC: RGB | null = null;
    let mutedC: RGB | null = null;
    let accentC: RGB | null = null;

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      const get = (name: string) => cs.getPropertyValue(name).trim();
      bgC = parseHex(get("--background"));
      fgC = parseHex(get("--foreground"));
      mutedC = parseHex(get("--ns-muted"));
      accentC = parseHex(get("--ns-accent"));
    };
    readTokens();

    const resize = () => {
      const rect = root.getBoundingClientRect();
      width = rect.width;
      // rowH derives from the container's own width (the reliably-available
      // "small dimension" for a full-width stacked-row card): clamped so the
      // bar, curve and plume stay legible at small preview-card widths
      // instead of shrinking to illegibility or ballooning on a wide column.
      rowH = Math.max(48, Math.min(84, width * 0.15));
      // the plume's footprint (jet length + puff radius) scales with bar
      // thickness, which scales with rowH — a fixed left margin clips the
      // jet/puffs off-canvas at large rowH, so the margin is derived from
      // the same thickness the plume actually draws at.
      const thick = Math.max(5, Math.min(13, rowH * 0.15));
      padX = Math.max(26, thick * 3.4);
      const height = rowH * n;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.style.height = `${height}px`;
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
    };
    resize();

    let raf = 0;
    let visible = true;
    let t0 = performance.now();

    const drawPlume = (cx: number, cy: number, thick: number, tSec: number, fg: RGB) => {
      // continuous jet + steam plume at the quenched face — never stops, not
      // tied to the quench-cycle phase, only to elapsed real time
      const jetLen = thick * 2.6;
      ctx.strokeStyle = rgbStr(fg, 0.28);
      ctx.lineWidth = Math.max(1, thick * 0.22);
      ctx.beginPath();
      const jitter = Math.sin(tSec * 26) * 1.1 + Math.sin(tSec * 41 + 1.7) * 0.6;
      ctx.moveTo(cx - jetLen, cy - thick * 1.6 + jitter);
      ctx.lineTo(cx, cy);
      ctx.stroke();

      const puffs = 4;
      for (let i = 0; i < puffs; i++) {
        const ph = (tSec * 0.55 + i / puffs) % 1;
        const rise = ph * thick * 3.2;
        const wob = Math.sin(tSec * 3.1 + i * 2.3) * thick * 0.35;
        const r = thick * (0.35 + ph * 0.55);
        const a = 0.16 * (1 - ph);
        ctx.fillStyle = rgbStr(fg, a);
        ctx.beginPath();
        ctx.arc(cx - jetLen * 0.35 + wob, cy - thick * 1.3 - rise, r, 0, Math.PI * 2);
        ctx.fill();
      }
    };

    const drawRow = (
      i: number,
      ph: Phase,
      isStatic: boolean,
      bg: RGB,
      fg: RGB,
      muted: RGB,
      accent: RGB
    ) => {
      const bar = bars[i];
      const rowTop = i * rowH;
      const curveTop = rowTop + rowH * 0.08;
      const curveBaseline = rowTop + rowH * 0.56;
      const curveMaxH = curveBaseline - curveTop;
      const barThick = Math.max(5, Math.min(13, rowH * 0.15));
      const barY = curveBaseline + barThick * 1.35;
      const barLeft = padX;
      const barRight = width - padX * 0.6;
      const barLen = Math.max(1, barRight - barLeft);

      const hovered = hoverRef.current === i;

      // bar fill: horizontal gradient sampled from the heat profile so the
      // cooled/quenched zone visibly recedes toward --background and the
      // still-hot zone reads as the salient --foreground-leaning value
      const grad = ctx.createLinearGradient(barLeft, 0, barRight, 0);
      const STOPS = 20;
      for (let s = 0; s <= STOPS; s++) {
        const x = s / STOPS;
        const heat = heatAt(x, ph);
        grad.addColorStop(x, rgbStr(lerpRGB(bg, fg, heat)));
      }
      ctx.fillStyle = grad;
      const r = barThick / 2;
      ctx.beginPath();
      ctx.moveTo(barLeft + r, barY - r);
      ctx.arcTo(barRight, barY - r, barRight, barY + r, r);
      ctx.arcTo(barRight, barY + r, barLeft, barY + r, r);
      ctx.arcTo(barLeft, barY + r, barLeft, barY - r, r);
      ctx.arcTo(barLeft, barY - r, barRight, barY - r, r);
      ctx.closePath();
      ctx.fill();
      ctx.strokeStyle = hovered ? rgbStr(accent, 0.85) : rgbStr(fg, 0.22);
      ctx.lineWidth = hovered ? 1.6 : 1;
      ctx.stroke();

      // quench front marker — only visible mid-creep, the literal directional
      // cooling boundary this mechanic is named for
      if (ph.front > 0.004 && ph.front < 0.996) {
        const fx = barLeft + barLen * ph.front;
        ctx.strokeStyle = rgbStr(fg, 0.45);
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(fx, curveBaseline);
        ctx.lineTo(fx, barY + barThick);
        ctx.stroke();
      }

      // fall-off curve — this alloy's signature, drawn point-to-point,
      // clipped to curveReveal so it fills in as the front reaches each x
      const pts: [number, number][] = bar.hardness.map((h, k) => {
        const x = barLeft + (barLen * k) / (nPts - 1);
        const y = curveBaseline - (h / maxHRC) * curveMaxH * 0.94;
        return [x, y];
      });
      const revealX = barLeft + barLen * ph.curveReveal;

      ctx.beginPath();
      ctx.moveTo(pts[0][0], curveBaseline);
      let leadX = pts[0][0];
      let leadY = pts[0][1];
      ctx.lineTo(leadX, leadY);
      for (let k = 1; k < pts.length; k++) {
        const [px, py] = pts[k];
        if (px <= revealX) {
          ctx.lineTo(px, py);
          leadX = px;
          leadY = py;
        } else {
          const [ppx, ppy] = pts[k - 1];
          const seg = (revealX - ppx) / Math.max(1e-3, px - ppx);
          if (seg > 0) {
            leadX = ppx + (px - ppx) * seg;
            leadY = ppy + (py - ppy) * seg;
            ctx.lineTo(leadX, leadY);
          }
          break;
        }
      }
      ctx.lineTo(leadX, curveBaseline);
      ctx.closePath();
      ctx.fillStyle = rgbStr(muted, hovered ? 0.14 : 0.08);
      ctx.fill();

      ctx.beginPath();
      ctx.moveTo(pts[0][0], pts[0][1]);
      let lx = pts[0][0];
      let ly = pts[0][1];
      for (let k = 1; k < pts.length; k++) {
        const [px, py] = pts[k];
        if (px <= revealX) {
          ctx.lineTo(px, py);
          lx = px;
          ly = py;
        } else {
          const [ppx, ppy] = pts[k - 1];
          const seg = (revealX - ppx) / Math.max(1e-3, px - ppx);
          if (seg > 0) {
            lx = ppx + (px - ppx) * seg;
            ly = ppy + (py - ppy) * seg;
            ctx.lineTo(lx, ly);
          }
          break;
        }
      }
      ctx.strokeStyle = rgbStr(fg, hovered ? 1 : 0.85);
      ctx.lineWidth = hovered ? 2 : 1.4;
      ctx.stroke();

      // the "pen" — a lead dot at the point currently being drawn, only while
      // actively creeping (not once the curve is fully settled)
      if (ph.curveReveal > 0.003 && ph.curveReveal < 0.997) {
        ctx.fillStyle = rgbStr(fg, 0.9);
        ctx.beginPath();
        ctx.arc(lx, ly, 2.3, 0, Math.PI * 2);
        ctx.fill();
      }

      const tSec = isStatic ? 0 : (performance.now() - t0) / 1000;
      drawPlume(barLeft, barY, barThick, tSec, fg);
    };

    const drawFrame = (isStatic: boolean) => {
      // tokens are only ever assigned from a live read (never a hardcoded
      // fallback) — a genuinely missing token bails the paint rather than
      // silently substituting a baked-in colour.
      if (!bgC || !fgC || !mutedC || !accentC) return;
      const bg = bgC;
      const fg = fgC;
      const muted = mutedC;
      const accent = accentC;

      ctx.save();
      ctx.scale(dpr, dpr);
      ctx.clearRect(0, 0, width, rowH * n);
      ctx.fillStyle = rgbStr(bg);
      ctx.fillRect(0, 0, width, rowH * n);

      const now = isStatic ? 0 : (performance.now() - t0) / CYCLE_MS;
      for (let i = 0; i < n; i++) {
        const ph = isStatic
          ? { front: STATIC_FRONT, curveReveal: STATIC_CURVE_REVEAL, reheat: 0 }
          : phaseAt(now + i * PHASE_STAGGER);
        drawRow(i, ph, isStatic, bg, fg, muted, accent);
      }
      ctx.restore();
    };

    const loop = () => {
      drawFrame(false);
      raf = requestAnimationFrame(loop);
    };

    if (reduced) {
      drawFrame(true);
      hoverRedrawRef.current = () => drawFrame(true);
    } else {
      raf = requestAnimationFrame(loop);
    }

    const ro = new ResizeObserver(() => {
      resize();
      drawFrame(reduced);
    });
    ro.observe(root);

    const io = new IntersectionObserver((entries) => {
      const vis = entries[0]?.isIntersecting ?? true;
      if (vis === visible) return;
      visible = vis;
      if (reduced) return;
      if (!visible && raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      } else if (visible && !raf) {
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(root);

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

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

    return () => {
      if (raf) cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      hoverRedrawRef.current = null;
    };
    // bars/n/maxHRC are derived once at mount from the bars prop, matching
    // every other canvas component's closure-locals convention
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [bars]);

  const setHover = (i: number) => {
    hoverRef.current = i;
    hoverRedrawRef.current?.();
  };
  const clearHover = (i: number) => {
    if (hoverRef.current === i) hoverRef.current = -1;
    hoverRedrawRef.current?.();
  };

  return (
    <div ref={rootRef} className={`w-full text-foreground ${className}`.trim()}>
      {/* no bg-surface here — the canvas fills --background itself every
          frame, and --surface is a different token, so a wrapper fill would
          leave a visible seam inside the border */}
      <div className="relative w-full overflow-hidden rounded-md border border-border">
        <canvas ref={canvasRef} aria-hidden className="block w-full" />
      </div>

      <table className="mt-4 w-full border-collapse">
        <caption className="sr-only">
          {title} — Rockwell C hardness at each distance from the quenched face
        </caption>
        <thead>
          <tr className="border-b border-border">
            <th scope="col" className="py-1.5 pr-2 text-left text-[11px] font-medium uppercase tracking-[0.08em] text-ns-muted">
              Grade
            </th>
            {DISTANCE_LABELS.map((d) => (
              <th
                key={d}
                scope="col"
                className="py-1.5 px-1.5 text-right font-mono text-[10px] font-normal text-ns-muted"
              >
                {d}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {bars.map((bar, i) => (
            <tr key={bar.id} className="border-b border-border/60 last:border-b-0">
              <th scope="row" className="py-1.5 pr-2 text-left font-normal">
                <button
                  type="button"
                  onPointerEnter={() => setHover(i)}
                  onPointerLeave={() => clearHover(i)}
                  onFocus={() => setHover(i)}
                  onBlur={() => clearHover(i)}
                  className="rounded-sm text-[13px] leading-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                >
                  {bar.name}
                </button>
              </th>
              {bar.hardness.map((h, k) => (
                <td key={k} className="py-1.5 px-1.5 text-right font-mono text-[11px] tabular-nums text-foreground/90">
                  {h}
                  <span className="sr-only"> HRC</span>
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default JominyQuench;
Build spec

Build <JominyQuench bars? title? className?> where bars: JominyBar[] ({id, name, hardness: number[]}) each hold Rockwell-C hardness readings at nine standard Jominy distances from the quenched face (1/16" to 2", DISTANCE_LABELS). This is the real ASTM A255 / ISO 642 end-quench hardenability test: a bar is austenitised, then a water jet is sprayed against ONE end only; that face cools fastest and hardens most, and hardness falls off with distance along a curve that is the alloy's signature — comparing several bars means comparing several fall-off curves, which is the mechanic, not a metaphor. Default bars (1045, 5160, 8640, 4140) are shaped to the well-documented qualitative behavior — more alloy content flattens the fall-off — rather than lifted from one mill certificate, and are labelled as illustrative in the demo copy. LUMINANCE ADAPTATION (read this before touching the palette): the real test is intensely hue-coded, quenched steel running white -> yellow -> orange -> red -> black as it cools, but this registry has five value-only tokens and no orange. Temperature is deliberately mapped to LUMINANCE instead: the hottest point on a bar is always blended toward --foreground (maximum contrast against --background) and the coldest, just-quenched point always fades toward --background — a mapping that self-inverts correctly between light and dark theme because 'closest to --foreground' already means the salient value in either polarity, never a literal hue swap. ONE canvas runs all rows: a fixed 7.2s cycle per row (reheat 10%, quench-front creep 58%, hold 32%), phase-staggered by 16% per row so the cascade of bars never settles into one static pose — visibly different at t0, 2.5s and 5s with no input. Each row draws, left to right: a horizontal bar whose fill is a per-frame linear gradient sampled from the heat-at-x function (cold/quenched reads background-leaning, hot/unquenched reads foreground-leaning, with a soft residual gradient toward the far end, never a hard cliff); a vertical front-line marker at the current quench-front x, visible only mid-creep; and, above the bar, that alloy's hardness fall-off curve, revealed left-to-right in lockstep with the front position so the curve visibly draws itself as the quench proceeds, with a small lead dot at the point currently being traced. At the quenched face a continuous water-jet stroke plus four looping steam-plume puffs run on real elapsed time, independent of the quench-cycle phase — the jet never stops hitting the face, at rest or mid-cycle. Row geometry (bar thickness, curve band height, plume size) derives from rowH = clamp(48, 84, containerWidth * 0.15) so the mechanic stays legible at small preview-card widths rather than reading as a thin illegible sliver. Colour is read via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class (duplicated per-component per house convention, no shared lib/ helper); --ns-accent appears ONLY as the hover/focus highlight stroke on a compared bar's row (triggered by pointerenter/focus on that row's real <button> grade label) — never mixed into the resting heat ramp. Direct-DOM rAF, DPR-capped backing store (min(2, devicePixelRatio)), pauses on IntersectionObserver (root offscreen) and document visibilitychange. ACCESSIBILITY: the canvas is aria-hidden and purely decorative; every value the comparison depends on is real DOM text in a <table> beneath it — one row per bar with a <th scope=row> button (the same element that drives the hover highlight) and one <td> per distance holding the literal HRC number plus a sr-only ' HRC' unit, so a screen-reader user gets the full comparison independent of the canvas. prefers-reduced-motion freezes on a deliberately composed frame — front parked at 55% up the bar (so the directional-cooling mechanic is still visible) with the fall-off curve forced to FULLY drawn regardless of front position (bypassing the normal front-tied reveal specifically so the frozen frame shows both the mechanic and the complete signature curve at once) and the water jet/plume drawn once at a fixed pose, never animated. Props: bars (JominyBar[], default four illustrative grades), title (accessible caption for the table), className.

Props

PropTypeDefaultDescription
bars?JominyBar[]DEFAULT_BARSbars being compared, quenched-face-first hardness per bar
title?string"Jominy end-quench hardenability"accessible title for the comparison
className?string