Hero ASCII Shock Diamonds

Heroloud

A full-bleed ASCII hero of a supersonic jet plume — a repeating train of oblique shocks reflecting off the jet boundary draws the classic X-crossed Mach diamonds as thin bright lines in an otherwise empty exhaust, with the pointer acting as the throttle that stretches and compresses the shock-cell spacing.

Install
npx shadcn add https://design.helpmarq.com/r/hero-ascii-shock-diamonds.json
Source
registry/loud/hero-ascii-shock-diamonds/component.tsx
"use client";

import { useEffect, useRef } from "react";
import type { ReactNode } from "react";

// ---------------------------------------------------------------------------
// ShockTrain — a full-bleed ASCII hero of a supersonic exhaust plume. This is
// gas dynamics, not a pattern: an underexpanded jet leaving a nozzle turns
// through a fan, over-expands, and is turned back by an OBLIQUE SHOCK that
// leaves the nozzle lip at the Mach angle mu = asin(1/M). That shock reflects
// specularly off the free jet boundary, crosses its mirror image from the
// opposite lip, and the whole thing repeats — a shock CELL — every
// L = 1.30 * D * sqrt(M^2 - 1) (Prandtl-Pack). Two travelling families is all
// it takes: p1 = a + b and p2 = a - b, where a is downstream distance in cells
// and b is transverse distance in cells; a cell is ON a shock when either
// phase lands near an integer. Where BOTH land near an integer the two
// families cross, and that crossing is the diamond node — it is inked 1.9x
// harder, which is the whole reason the frame reads as a string of bright X's
// on the centreline rather than a diagonal hatch. The design Mach number is
// LOW (1.5) on purpose: the Mach angle is then 42 degrees, so the two families
// cross at close to a right angle and the arms read as chevrons instead of
// merging into horizontal streaks, and the shorter cell length lets D grow to
// 0.28*H so the train is a tall band rather than a thin line. Everything else
// is blank: the shocks are hard-masked to the plume's own waisted envelope,
// and the plume's interior between shocks is empty. The plume rides HIGH in
// the frame — axis at 0.26*H — because the hero copy is anchored bottom-left
// and the two must not overlap. The pointer is the THROTTLE — its
// x position sets the nozzle pressure ratio, M is re-solved from it
// isentropically, and since L scales with sqrt(M^2 - 1) the diamonds visibly
// stretch apart to the right and compress to the left. Pointer y vectors the
// jet axis. Both ease back to the design plume when the pointer leaves.
// ---------------------------------------------------------------------------

const RAMP = " .:-=+*#%@";
const ALPHA_BUCKETS = 6;
const SHOCK_TOL = 0.035; // phase half-width of a shock, in cell lengths
const SHOCK_POW = 1.6; // line profile sharpness
const NODE_GAIN = 1.9; // crossing multiplier — makes the X's the picture
const INK_DECAY = 6.0; // shock strength e-folding, in cell lengths
const ENV_DECAY = 4.5; // plume envelope e-folding, in cell lengths
const PLUME_END = 6.0; // stop evaluating past this many cells
const THROTTLE_TAU = 0.6; // s — pointer easing time constant
const PULSE_A = 0.018; // resting breath in cell spacing
const PULSE_B = 0.008;
const CELLS_ACROSS = 3.2; // design: shock cells that fit the frame at rest
const AXIS_FRAC = 0.26; // jet axis height — copy owns the bottom of the frame
const D_MAX_FRAC = 0.28; // hard cap on nozzle diameter, as a fraction of H
const VECTOR_FRAC = 0.04; // thrust-vector travel, as a fraction of H
const NPR_LO = 0.66; // throttle window, as a multiple of the design NPR
const NPR_HI = 2.13;
const DT_MAX = 0.05;

/** isentropic (gamma 1.4) Mach number from a nozzle pressure ratio */
function machFromNpr(npr: number): number {
  const m2 = 5 * (Math.pow(Math.max(1.0001, npr), 0.2857) - 1);
  return Math.max(1.05, Math.sqrt(Math.max(0.1025, m2)));
}

/** nozzle pressure ratio that produces this design Mach number */
function nprFromMach(m: number): number {
  return Math.pow(1 + 0.2 * m * m, 3.5);
}

/** distance from p to the nearest integer, in [0, 0.5] */
function phaseDist(p: number): number {
  const f = p - Math.floor(p);
  return f < 0.5 ? f : 1 - f;
}

export interface ShockTrainProps {
  /** grid cell size in px */
  cellSize?: number;
  /** design Mach number of the plume at rest */
  mach?: number;
  /** headline / CTA rendered over the plume */
  children?: ReactNode;
  className?: string;
}

export function ShockTrain({
  cellSize = 12,
  mach = 1.5,
  children,
  className = "",
}: ShockTrainProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

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

    const machDesign = Math.min(4.5, Math.max(1.2, mach));
    const nprDesign = nprFromMach(machDesign);
    const nprMin = nprDesign * NPR_LO;
    const nprMax = nprDesign * NPR_HI;

    let fg = "currentColor";
    let cellW = cellSize;
    let cellH = cellSize;
    let cols = 0;
    let rows = 0;
    let dpr = 1;
    let sized = false;
    let ready = false;
    let disposed = false;

    let nozzleX = 0; // x0 — nozzle exit plane
    let axisY = 0; // yc at rest — the undeflected jet axis
    let nozzleD = 0; // D — nozzle exit diameter
    let vectorMax = 0; // px of thrust-vector travel

    let charBuf = new Uint8Array(0);
    const bucketLists: number[][] = Array.from(
      { length: ALPHA_BUCKETS },
      () => []
    );

    const readTokens = () => {
      fg = getComputedStyle(canvas).color;
    };

    const measureCell = (fontFamily: string) => {
      const off = document.createElement("canvas");
      const octx = off.getContext("2d");
      if (!octx) return;
      octx.font = `${cellSize}px ${fontFamily}`;
      cellW = Math.max(4, octx.measureText("MMMMMMMMMM").width / 10);
      cellH = cellSize;
    };

    const resize = () => {
      const { width, height } = canvas.getBoundingClientRect();
      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 fontFamily = getComputedStyle(canvas).fontFamily;
      measureCell(fontFamily);
      ctx.font = `${cellSize}px ${fontFamily}`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";

      cols = Math.max(8, Math.ceil(width / cellW));
      rows = Math.max(8, Math.ceil(height / cellH));

      const w = cols * cellW;
      const h = rows * cellH;
      nozzleX = w * 0.1;
      // the jet axis is snapped to a ROW CENTRE: the on-axis diamond node sits
      // exactly at y = yc, and if that lands between two rows the brightest
      // cell in the whole picture is never sampled and the train degrades into
      // a chevron hatch. Snapping is what keeps the X's on the centreline.
      axisY =
        Math.round((h * AXIS_FRAC - cellH / 2) / cellH) * cellH + cellH / 2;
      vectorMax = h * VECTOR_FRAC;
      // D is the one free geometric parameter and it sets how TALL the train
      // is: the shock band is about 1.5*D high, so D is pushed as large as the
      // frame allows — up to 0.28*H — and the cell count is what gives way.
      // Only ~3.5 cells lay across the frame (three crossings and the lip),
      // which is the point: three legible diamonds beat six horizontal dashes.
      // Pinned to machDesign, never the live Mach — D is the hardware, and it
      // is precisely because D is fixed that a throttle change moves L.
      const kDesign = 1.3 * Math.sqrt(machDesign * machDesign - 1);
      nozzleD = Math.min(
        h * D_MAX_FRAC,
        ((w - nozzleX) * 0.98) / (CELLS_ACROSS * kDesign)
      );
      nozzleD = Math.max(nozzleD, cellH * 8);

      charBuf = new Uint8Array(cols * rows);
      sized = true;
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) draw(0, machDesign, 0);
      }, 150);
    };

    const draw = (t: number, machEff: number, vectorY: number) => {
      if (!sized) return;
      const w = cols * cellW;
      const h = rows * cellH;
      ctx.clearRect(0, 0, w, h);
      for (let b = 0; b < ALPHA_BUCKETS; b++) bucketLists[b]!.length = 0;

      const M = Math.max(1.05, machEff);
      // Prandtl-Pack shock-cell length, and the Mach angle the lip shock
      // leaves at. Both move together when the throttle moves.
      const L = 1.3 * nozzleD * Math.sqrt(M * M - 1);
      const tanMu = Math.tan(Math.asin(1 / M));
      const invBand = 1 / (L * tanMu);
      // thrust vectoring moves the axis in whole rows, so the node stays on a
      // row centre wherever it is aimed
      const yc = axisY + Math.round(vectorY / cellH) * cellH;
      const rMax = 0.5 * nozzleD * 1.55;
      const envHalf = cellH * 0.55;
      const tau = Math.PI * 2;

      // A shock is 0.035 cell-lengths of phase wide, which on this grid is
      // thinner than one row — sampled naively the line falls between cells and
      // breaks into dashes. Dilate the hit test to at least one cell while
      // keeping the physical 0.035 profile: everything inside the dilation is
      // full-strength line, and the original profile fades over the last
      // 0.035. Conservative rasterization, not a fattened shock.
      const gradMag = Math.hypot(1 / L, invBand);
      const tol = Math.max(SHOCK_TOL, 0.62 * cellH * gradMag);
      const dilate = tol - SHOCK_TOL;
      const profile = (d: number) => {
        const de = d <= dilate ? 0 : d - dilate;
        return Math.pow(Math.max(0, 1 - de / SHOCK_TOL), SHOCK_POW);
      };

      for (let gy = 0; gy < rows; gy++) {
        const py = gy * cellH + cellH / 2;
        const dy = py - yc;
        const ady = dy < 0 ? -dy : dy;
        if (ady > rMax + envHalf) continue; // outside the widest bulge: blank
        const rowBase = gy * cols;

        for (let gx = 0; gx < cols; gx++) {
          const px = gx * cellW + cellW / 2;
          const a = (px - nozzleX) / L; // downstream distance, in shock cells
          if (a < 0 || a > PLUME_END) continue;

          // free-boundary envelope: the periodic bulge/waist of an
          // underexpanded jet, dying out downstream
          const r =
            0.5 *
            nozzleD *
            (1 + 0.55 * Math.sin(tau * a + Math.PI / 2)) *
            Math.exp(-a / ENV_DECAY);

          if (ady > r) {
            // faint trace of the jet boundary itself — the waisted envelope
            if (ady - r < envHalf) {
              const idx = rowBase + gx;
              charBuf[idx] = 1;
              bucketLists[0]!.push(idx);
            }
            continue;
          }

          // the two travelling shock families, as phase coordinates
          const b = dy * invBand;
          const d1 = phaseDist(a + b);
          const d2 = phaseDist(a - b);
          const on1 = d1 < tol;
          const on2 = d2 < tol;
          if (!on1 && !on2) continue; // plume interior between shocks: blank

          let s = 0;
          if (on1) s = profile(d1);
          if (on2) {
            const s2 = profile(d2);
            if (s2 > s) s = s2;
          }
          // both families here — this is the diamond node
          if (on1 && on2) s = Math.min(1, s * NODE_GAIN);
          s *= Math.exp(-a / INK_DECAY);
          if (s <= 0.04) continue;

          const ci = Math.max(1, Math.floor(s * (RAMP.length - 1)));
          const bucket = Math.min(
            ALPHA_BUCKETS - 1,
            Math.floor(s * ALPHA_BUCKETS)
          );
          const idx = rowBase + gx;
          charBuf[idx] = ci;
          bucketLists[bucket]!.push(idx);
        }
      }

      ctx.fillStyle = fg;
      for (let b = 0; b < ALPHA_BUCKETS; b++) {
        const list = bucketLists[b]!;
        if (list.length === 0) continue;
        ctx.globalAlpha = 0.1 + (b / (ALPHA_BUCKETS - 1)) * 0.9;
        for (let k = 0; k < list.length; k++) {
          const idx = list[k]!;
          const gx = idx % cols;
          const gy = (idx - gx) / cols;
          ctx.fillText(
            RAMP[charBuf[idx]!]!,
            gx * cellW + cellW / 2,
            gy * cellH + cellH / 2
          );
        }
      }
      ctx.globalAlpha = 1;
    };

    // -- hot-path state -------------------------------------------------------
    let raf = 0;
    let last = 0;
    let t = 0;
    let npr = nprDesign; // eased nozzle pressure ratio — the throttle
    let nprTarget = nprDesign;
    let vector = 0; // eased thrust-vector offset in px
    let vectorTarget = 0;

    const loop = (now: number) => {
      const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;
      last = now;
      t += dt;

      const k = Math.min(1, dt / THROTTLE_TAU);
      npr += (nprTarget - npr) * k;
      vector += (vectorTarget - vector) * k;

      // resting breath: a small jitter in cell spacing, never a scroll
      const machEff =
        machFromNpr(npr) *
        (1 + PULSE_A * Math.sin(t * 0.85) + PULSE_B * Math.sin(t * 2.3));

      draw(t, machEff, vector);
      if (!document.hidden) raf = requestAnimationFrame(loop);
    };

    const onPointerMove = (e: PointerEvent) => {
      const rect = root.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) return;
      const fx = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
      nprTarget = nprMin + fx * (nprMax - nprMin);
      const fy = (e.clientY - rect.top - rect.height / 2) / (rect.height * 0.25);
      vectorTarget = Math.min(1, Math.max(-1, fy)) * vectorMax;
    };
    const onPointerLeave = () => {
      nprTarget = nprDesign;
      vectorTarget = 0;
    };

    const onVis = () => {
      if (!document.hidden && !reduced && ready) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) draw(0, machDesign, 0);
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    document.fonts.ready.then(() => {
      if (disposed) return;
      readTokens();
      resize();
      ready = true;
      if (reduced) {
        draw(0, machDesign, 0);
      } else {
        raf = requestAnimationFrame(loop);
      }
    });

    window.addEventListener("resize", onResize);
    if (!reduced) {
      root.addEventListener("pointermove", onPointerMove);
      root.addEventListener("pointerleave", onPointerLeave);
    }
    document.addEventListener("visibilitychange", onVis);

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      mo.disconnect();
      window.removeEventListener("resize", onResize);
      root.removeEventListener("pointermove", onPointerMove);
      root.removeEventListener("pointerleave", onPointerLeave);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [cellSize, mach]);

  return (
    <div
      ref={rootRef}
      className={`relative isolate h-full w-full overflow-hidden bg-background font-mono ${
        /\bmin-h-/.test(className) ? "" : "min-h-screen"
      } ${className}`}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="absolute inset-0 block h-full w-full text-foreground"
      />
      {children ? (
        <div className="absolute inset-0 z-10 flex flex-col items-start justify-end gap-4 p-8 sm:p-14">
          {children}
        </div>
      ) : null}
    </div>
  );
}
Use when

Pick hero-ascii-shock-diamonds when the hero should read as thrust — a horizontal exhaust of bright X-crossings whose spacing the pointer physically throttles apart and back together. hero-ascii-schlieren is the pick when the subject is a knife-edge instrument reading density boundaries of a rising plume rather than a standing shock train; hero-vortex-street for periodic shedding across a flow; hero-ascii-eclipse when the payoff is one alignment event rather than a continuously adjustable parameter.

Build spec

Build <ShockTrain cellSize? mach? children? className?> as a full-bleed Canvas 2D hero that reproduces the shock-cell structure of an underexpanded supersonic jet, not a decorative diagonal hatch. THE WHOLE DESIGN IS SIZED SO THE DIAMONDS READ AS DIAMONDS AT DESKTOP SCALE — a tall band of chevrons, never a thin horizontal streak. GEOMETRY: a nozzle exit of diameter D at x0 = 0.10*W on the jet axis yc = 0.26*H. The axis sits HIGH, not at mid-height, because the optional children are anchored bottom-left; the plume band and the copy must occupy disjoint regions of the frame with no scrim, blur or fade between them. D is the one free geometric parameter and it sets how TALL the train is (the shock band is roughly 1.5*D high), so it is pushed as large as the frame allows: solved at resize as min(0.28*H, 0.98*(W-x0)/(3.2*k)) with k = 1.30*sqrt(Mdesign^2 - 1), and at least 8 grid rows. The cell COUNT is what gives way — only about 3.5 shock cells lay across a 16:10 frame (three crossings plus the lip), rising to about 5 on short wide frames where the 0.28*H cap binds first, which is the point: three or four legible diamonds beat six horizontal dashes. D is fixed hardware and is pinned to the design Mach, never the live one, which is precisely why a throttle change moves the cell length instead of the nozzle. The axis is snapped to a ROW CENTRE — the on-axis node sits exactly at y = yc, and if that lands between two rows the brightest cell in the picture is never sampled and the train degrades into a hatch. DESIGN MACH IS LOW BY DESIGN: default 1.5 (clamped 1.2..4.5). At M 1.5 the Mach angle mu = asin(1/M) is 41.8 degrees, so tan(mu) = 0.89 and the two shock families cross at close to a right angle — the arms read as chevrons. A high design Mach is the failure mode this component exists to avoid: at M 2.4 tan(mu) is 0.46, the arms lie down almost flat, they merge into horizontal streaks, and the short D that a high cell count then forces leaves the band only a handful of rows tall. PLUME BOUNDARY: r(x) = 0.5*D*(1 + 0.55*sin(2*pi*(x-x0)/L + pi/2)) * exp(-(x-x0)/(4.5*L)) — the periodic bulge/waist of a real free jet, decaying downstream; it is traced at the faintest ramp level only where a cell is within about half a row of it, so the envelope reads as a whisper, not an outline. SHOCK-CELL LENGTH is the Prandtl-Pack relation L = 1.30 * D * sqrt(M^2 - 1). SHOCK LINES: the oblique shock leaves the nozzle lip at the Mach angle and reflects specularly off the free boundary, so the whole train collapses to two travelling phase coordinates, p1 = (x-x0)/L + (y-yc)/(L*tan(mu)) and p2 = (x-x0)/L - (y-yc)/(L*tan(mu)); a cell is on a shock when frac(p1) or frac(p2) is within 0.035 of an integer. Ink strength is the max over the two families of pow(1 - dist/0.035, 1.6), multiplied by a downstream decay exp(-(x-x0)/(6.0*L)) — a slow decay, so the third and fourth crossings survive at readable opacity instead of ghosting out — and HARD-MASKED to zero outside |y - yc| > r(x): the shocks exist only inside the plume. WHERE BOTH FAMILIES ARE WITHIN TOLERANCE the strength is multiplied by 1.9 and clamped to 1: that crossing is the diamond node, and it is the entire reason the frame reads as a string of bright X's on the centreline rather than a hatch. RASTERIZATION: 0.035 cell-lengths of phase is thinner than one glyph row, so the hit test is conservatively dilated to at least 0.62 of a cell measured in phase units (hypot(1/L, 1/(L*tan(mu))) is the phase gradient magnitude); everything inside the dilation is full-strength line and the physical 0.035 profile fades over the last stretch — without this the lines break into dashes. SPARSITY: only two thin line families inside a tapering wedge are ever inked; the entire frame outside the plume, and the plume's own interior between shocks, stay blank — measured ink coverage around 3% of the frame. MEASURED at 1440x900 with cellSize 12 and the defaults: D = 252px, L = 366px, 3.56 cells across, the plume occupies 33 of 75 glyph rows and the arms above half opacity occupy 17 rows, all of it in the top 48% of the frame. AMBIENT MOTION: the train breathes with a calm resting pulse, M_eff = M * (1 + 0.018*sin(t*0.85) + 0.008*sin(t*2.3)) — a small jitter in cell spacing, never a scroll or a march. POINTER — the pointer is the THROTTLE: its normalized x maps to a nozzle pressure ratio spanning 0.66x to 2.13x the design NPR — for the default Mach 1.5 the design NPR is (1 + 0.2*M^2)^3.5 = 3.67, so the throttle sweeps NPR from 2.42 to 7.82 and M from 1.20 to 2.00, with the design plume sitting at the centre of pointer travel and L stretching from 0.59x to 1.55x its design value. The window must be anchored to the design NPR rather than to a fixed absolute span: a literal low end solves subsonic and there is no shock structure to draw at all. NPR is eased with a 0.6s time constant and M is re-solved from it isentropically as M = sqrt(5*(npr^0.2857 - 1)) at gamma 1.4, clamped to at least 1.05. Higher NPR means a higher M, a longer L and a shallower Mach angle, so the diamonds visibly stretch apart and the plume lengthens as the pointer moves right and compress as it moves left. Pointer y within +/-0.25*H tilts the jet axis by up to 0.04*H (thrust vectoring), quantized to whole rows so the node stays on a row centre wherever it is aimed, and eased with the same time constant; the travel is deliberately small so a downward vector never pushes the band into the copy. On pointerleave both ease back to the design plume (M 1.5, yc 0.26*H). RENDER: direct-DOM rAF, zero React state on the hot path; two-pass into a Uint8Array ramp-index buffer with 6 luminance buckets and exactly one ctx.globalAlpha write per bucket, from the shared ' .:-=+*#%@' ramp. Ink is read via getComputedStyle(canvas).color and re-derived on a documentElement class MutationObserver so theme flips are live; the mono cell is measured with an offscreen canvas measureText after document.fonts.ready. prefers-reduced-motion renders exactly one static frame at t=0 with M at the design value and no vectoring, and skips both the rAF loop and the pointer listeners. The loop pauses on document.hidden and resumes on visibilitychange. Optional children render bottom-left anchored, in the empty lower half of the frame that the raised jet axis leaves for them — the copy is never crossed by the plume, and no scrim, blur or gradient is used to separate them. Props: cellSize (grid cell px, default 12), mach (design Mach number at rest, default 1.5, clamped to 1.2..4.5), children, className.

Tags
heroasciicanvascursorshocksupersonicmach