Skip to main content

ns-ui / loud

Hero ASCII Shock Diamonds

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.

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.

Install

npx shadcn add https://design.helpmarq.com/r/hero-ascii-shock-diamonds.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/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;
  /** extra classes merged onto the rendered root element */
  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>
  );
}
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.

Props

PropTypeDefaultDescription
cellSize?number12grid cell size in px
mach?number1.5design Mach number of the plume at rest
children?ReactNodeheadline / CTA rendered over the plume
className?stringextra classes merged onto the rendered root element