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.
npx shadcn add https://design.helpmarq.com /r/hero-ascii-shock-diamonds.jsonregistry/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>
);
}
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 <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.