ASCII-textured boxplot family with one shared fence handle: dragging or keying it re-cuts every box's whiskers live against the real sample, and outliers fade in or reclassify back into the whisker as the cut moves.
npx shadcn add https://design.helpmarq.com /r/boxplot-ascii-whisker.jsonregistry/core/boxplot-ascii-whisker/component.tsx"use client";
import { useEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// BoxplotAsciiWhisker — the registry's first distribution instrument.
// Nearest neighbours: histogram-live-grain tallies a rolling window into
// fixed bins, stem-and-leaf-live keeps every raw digit in ascending stem
// rows, chart-ridgeline-terrain renders many distributions as overlapping
// density silhouettes — none of the three computes quartiles, a fence, or
// classifies individual samples as outliers.
//
// The mechanic: ONE shared fence handle — a real <input type="range">,
// visually replaced by a custom track/thumb the way slider-range-shear
// carries its a11y — controls k, the IQR multiplier that defines the
// whisker fence (Q1 - k*IQR .. Q3 + k*IQR) for every box on the chart at
// once. Dragging or keying it re-cuts the whisker caps live against the
// REAL underlying sample (not a cached summary): the farthest sample still
// inside the fence becomes the new whisker end, and every sample outside it
// renders as an outlier glyph that fades in; a sample that re-enters the
// fence as k grows fades back into the whisker instead of vanishing. Box
// bodies are filled with the family's shared ASCII ramp ' .:-=+*#%@' tiled
// at a constant mid density — box height already encodes the interquartile
// spread, so ink here is texture, not a second value channel — pure
// var(--foreground), var(--accent) reserved for the focused/hovered group
// and the fence handle itself.
// ---------------------------------------------------------------------------
const RAMP = " .:-=+*#%@";
const BOX_CH = RAMP[6];
export interface BoxplotGroup {
label: string;
samples: number[];
}
export interface BoxplotAsciiWhiskerProps {
groups?: BoxplotGroup[];
title?: string;
className?: string;
}
// deterministic synthetic samples — three clearly fabricated batches
function defaultGroups(): BoxplotGroup[] {
let seed = 11;
const rand = () => {
seed = (seed * 48271) % 2147483647;
return (seed - 1) / 2147483646;
};
const gauss = (mean: number, sd: number) => {
let u = 0;
let v = 0;
while (u === 0) u = rand();
while (v === 0) v = rand();
return mean + sd * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
const make = (mean: number, sd: number, n: number, outliers: number[]) => {
const out: number[] = [];
for (let i = 0; i < n; i++) out.push(Math.round(gauss(mean, sd) * 10) / 10);
return out.concat(outliers);
};
return [
{ label: "Batch A", samples: make(40, 6, 34, [12, 68]) },
{ label: "Batch B", samples: make(52, 9, 34, [18, 22, 96]) },
{ label: "Batch C", samples: make(34, 4, 34, [55]) },
];
}
function quantile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = p * (sorted.length - 1);
const lo = Math.floor(idx);
const hi = Math.ceil(idx);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
}
const K_MIN = 0.5;
const K_MAX = 3;
const K_STEP = 0.1;
interface GroupStats {
label: string;
sorted: number[];
q1: number;
median: number;
q3: number;
iqr: number;
// samples that are outliers at the tightest possible fence (k = K_MIN) —
// the maximal candidate pool across the whole draggable range
candidates: number[];
}
function computeStats(g: BoxplotGroup): GroupStats {
const sorted = [...g.samples].sort((a, b) => a - b);
const q1 = quantile(sorted, 0.25);
const median = quantile(sorted, 0.5);
const q3 = quantile(sorted, 0.75);
const iqr = Math.max(1e-6, q3 - q1);
const lowAtMin = q1 - K_MIN * iqr;
const highAtMin = q3 + K_MIN * iqr;
const candidates = sorted.filter((v) => v < lowAtMin || v > highAtMin);
return { label: g.label, sorted, q1, median, q3, iqr, candidates };
}
interface Tokens {
fg: string;
bg: string;
muted: string;
border: string;
accent: string;
}
function readTokens(): Tokens {
const cs = getComputedStyle(document.documentElement);
const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;
return {
fg: get("--foreground", "#171717"),
bg: get("--background", "#ffffff"),
muted: get("--muted", "#4d4d4d"),
border: get("--border", "#ebebeb"),
accent: get("--accent", "#006bff"),
};
}
function useTokens(): Tokens {
const [tokens, setTokens] = useState<Tokens>(() =>
typeof document === "undefined"
? { fg: "#171717", bg: "#ffffff", muted: "#4d4d4d", border: "#ebebeb", accent: "#006bff" }
: readTokens()
);
useEffect(() => {
const sync = () => setTokens(readTokens());
sync();
const mo = new MutationObserver(sync);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
return () => mo.disconnect();
}, []);
return tokens;
}
const GROUP_W = 110;
const BOX_W = 46;
const PLOT_H = 220;
const TOP_PAD = 16;
const BOTTOM_PAD = 30;
const LEFT_PAD = 40;
const RIGHT_PAD = 16;
export function BoxplotAsciiWhisker({ groups, title = "Chart", className = "" }: BoxplotAsciiWhiskerProps) {
const data = useMemo(() => groups ?? defaultGroups(), [groups]);
const stats = useMemo(() => data.map(computeStats), [data]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const tokens = useTokens();
const [k, setK] = useState(1.5);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const uidRef = useRef(`baw-${Math.random().toString(36).slice(2, 8)}`);
const uid = uidRef.current;
const trackRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const reducedRef = useRef(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
reducedRef.current = mq.matches;
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
const globalMin = useMemo(() => Math.min(...stats.map((s) => s.sorted[0] ?? 0)), [stats]);
const globalMax = useMemo(() => Math.max(...stats.map((s) => s.sorted[s.sorted.length - 1] ?? 0)), [stats]);
const span = Math.max(1e-6, globalMax - globalMin);
const pad = span * 0.08;
const domainMin = globalMin - pad;
const domainMax = globalMax + pad;
const domainSpan = domainMax - domainMin;
const n = stats.length;
const viewW = LEFT_PAD + n * GROUP_W + RIGHT_PAD;
const viewH = TOP_PAD + PLOT_H + BOTTOM_PAD;
const valueToY = (v: number) => TOP_PAD + PLOT_H - ((v - domainMin) / domainSpan) * PLOT_H;
const cuts = useMemo(
() =>
stats.map((s) => {
const lowFence = s.q1 - k * s.iqr;
const highFence = s.q3 + k * s.iqr;
const within = s.sorted.filter((v) => v >= lowFence && v <= highFence);
const whiskerLow = within.length ? within[0] : s.q1;
const whiskerHigh = within.length ? within[within.length - 1] : s.q3;
const outliers = s.candidates.filter((v) => v < lowFence || v > highFence);
return { whiskerLow, whiskerHigh, outliers };
}),
[stats, k]
);
// animated display state: per-group whisker ends lerp toward target;
// per-candidate alpha lerps toward 1 (outlier) or 0 (reclassified inlier)
const displayWhiskerRef = useRef<{ lo: number; hi: number }[]>([]);
const alphaRef = useRef<number[][]>([]);
const rafRef = useRef(0);
useEffect(() => {
if (displayWhiskerRef.current.length !== n) {
displayWhiskerRef.current = cuts.map((c) => ({ lo: c.whiskerLow, hi: c.whiskerHigh }));
}
if (alphaRef.current.length !== n) {
alphaRef.current = stats.map((s) => s.candidates.map(() => 0));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, stats]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = viewW * dpr;
canvas.height = viewH * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const draw = () => {
ctx.clearRect(0, 0, viewW, viewH);
ctx.strokeStyle = tokens.border;
ctx.lineWidth = 1;
[0, 0.25, 0.5, 0.75, 1].forEach((f) => {
const y = Math.round(TOP_PAD + PLOT_H * f) + 0.5;
ctx.beginPath();
ctx.moveTo(LEFT_PAD, y);
ctx.lineTo(viewW - RIGHT_PAD, y);
ctx.stroke();
});
ctx.fillStyle = tokens.muted;
ctx.font = `9px "GeistMono", ui-monospace, monospace`;
ctx.textAlign = "right";
ctx.fillText(domainMax.toFixed(0), LEFT_PAD - 6, TOP_PAD + 4);
ctx.fillText(domainMin.toFixed(0), LEFT_PAD - 6, TOP_PAD + PLOT_H);
let stillAnimating = false;
stats.forEach((s, i) => {
const cx = LEFT_PAD + i * GROUP_W + GROUP_W / 2;
const boxX = cx - BOX_W / 2;
// hoverIndex alone carries real interaction (pointer hover AND
// keyboard focus both set it) — activeIndex is only roving-tabindex
// bookkeeping and must never tint a resting, unfocused group
const isActive = hoverIndex === i;
const ink = isActive ? tokens.accent : tokens.fg;
const cut = cuts[i];
const dw = displayWhiskerRef.current[i] ?? { lo: cut.whiskerLow, hi: cut.whiskerHigh };
const nextLo = reducedRef.current ? cut.whiskerLow : dw.lo + (cut.whiskerLow - dw.lo) * 0.25;
const nextHi = reducedRef.current ? cut.whiskerHigh : dw.hi + (cut.whiskerHigh - dw.hi) * 0.25;
displayWhiskerRef.current[i] = {
lo: Math.abs(nextLo - cut.whiskerLow) < 0.05 ? cut.whiskerLow : nextLo,
hi: Math.abs(nextHi - cut.whiskerHigh) < 0.05 ? cut.whiskerHigh : nextHi,
};
if (
Math.abs(displayWhiskerRef.current[i].lo - cut.whiskerLow) >= 0.05 ||
Math.abs(displayWhiskerRef.current[i].hi - cut.whiskerHigh) >= 0.05
) {
stillAnimating = true;
}
const yWhiskerLo = valueToY(displayWhiskerRef.current[i].lo);
const yWhiskerHi = valueToY(displayWhiskerRef.current[i].hi);
const yQ1 = valueToY(s.q1);
const yQ3 = valueToY(s.q3);
const yMed = valueToY(s.median);
// whisker stem + caps
ctx.strokeStyle = ink;
ctx.lineWidth = 1.25;
ctx.beginPath();
ctx.moveTo(cx, yWhiskerHi);
ctx.lineTo(cx, yQ3);
ctx.moveTo(cx, yQ1);
ctx.lineTo(cx, yWhiskerLo);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx - 10, yWhiskerHi);
ctx.lineTo(cx + 10, yWhiskerHi);
ctx.moveTo(cx - 10, yWhiskerLo);
ctx.lineTo(cx + 10, yWhiskerLo);
ctx.stroke();
// box body — glyph texture fill, clipped, constant mid density
ctx.save();
ctx.beginPath();
ctx.rect(boxX, yQ3, BOX_W, Math.max(1, yQ1 - yQ3));
ctx.clip();
ctx.fillStyle = ink;
ctx.font = `9px "GeistMono", ui-monospace, monospace`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
for (let y = yQ3; y < yQ1; y += 10) {
for (let x = boxX; x < boxX + BOX_W; x += 8) {
ctx.fillText(BOX_CH, x, y);
}
}
ctx.restore();
ctx.strokeStyle = ink;
ctx.lineWidth = 1.25;
ctx.strokeRect(boxX + 0.5, yQ3 + 0.5, BOX_W, Math.max(1, yQ1 - yQ3));
// median — heavier solid row
ctx.fillStyle = ink;
ctx.fillRect(boxX, yMed - 1, BOX_W, 2);
// outliers — animated alpha per candidate
const alphas = alphaRef.current[i] ?? [];
s.candidates.forEach((v, ci) => {
const target = cut.outliers.includes(v) ? 1 : 0;
const cur = alphas[ci] ?? 0;
const next = reducedRef.current ? target : cur + (target - cur) * 0.28;
alphas[ci] = Math.abs(next - target) < 0.01 ? target : next;
if (Math.abs(alphas[ci] - target) >= 0.01) stillAnimating = true;
if (alphas[ci] <= 0.01) return;
const y = valueToY(v);
ctx.globalAlpha = alphas[ci];
ctx.beginPath();
ctx.arc(cx, y, 3, 0, Math.PI * 2);
ctx.strokeStyle = ink;
ctx.lineWidth = 1.25;
ctx.stroke();
ctx.globalAlpha = 1;
});
alphaRef.current[i] = alphas;
ctx.fillStyle = tokens.muted;
ctx.font = `9.5px "GeistMono", ui-monospace, monospace`;
ctx.textAlign = "center";
ctx.fillText(s.label, cx, TOP_PAD + PLOT_H + BOTTOM_PAD - 12);
});
if (stillAnimating && !reducedRef.current) {
rafRef.current = requestAnimationFrame(draw);
}
};
draw();
return () => cancelAnimationFrame(rafRef.current);
}, [stats, cuts, tokens, viewW, viewH, domainMin, domainSpan, hoverIndex, activeIndex]);
const focusGroup = (i: number) => {
if (i < 0 || i >= n) return;
setActiveIndex(i);
document.getElementById(`${uid}-hit-${i}`)?.focus();
};
const kFromClientX = (clientX: number): number => {
const el = trackRef.current;
if (!el) return k;
const rect = el.getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
const raw = K_MIN + frac * (K_MAX - K_MIN);
return Math.round(raw / K_STEP) * K_STEP;
};
const hovered = hoverIndex !== null ? stats[hoverIndex] : null;
const hoveredCut = hoverIndex !== null ? cuts[hoverIndex] : null;
return (
<figure className={`ns-baw inline-block ${className}`} aria-label={`${title}, box plot`}>
<style>{CSS}</style>
<div className="flex items-center justify-between gap-3 pb-2">
<span className="font-mono text-xs tracking-widest text-muted">{title.toUpperCase()}</span>
<span className="font-mono text-[11px] text-muted tabular-nums">k = {k.toFixed(1)}× IQR</span>
</div>
<div className="relative" style={{ width: viewW, maxWidth: "100%" }}>
<canvas
ref={canvasRef}
aria-hidden="true"
style={{ width: viewW, height: viewH, maxWidth: "100%", display: "block" }}
/>
{stats.map((s, i) => (
<button
key={s.label}
type="button"
id={`${uid}-hit-${i}`}
tabIndex={activeIndex === i ? 0 : -1}
aria-label={`${s.label}: median ${s.median.toFixed(1)}, Q1 ${s.q1.toFixed(1)}, Q3 ${s.q3.toFixed(1)}, ${cuts[i].outliers.length} outlier${cuts[i].outliers.length === 1 ? "" : "s"}`}
className="ns-baw-hit absolute cursor-pointer border-0 bg-transparent p-0 outline-none"
style={{ left: LEFT_PAD + i * GROUP_W, top: TOP_PAD - 10, width: GROUP_W, height: PLOT_H + 10 }}
onPointerEnter={() => setHoverIndex(i)}
onPointerLeave={() => setHoverIndex((c) => (c === i ? null : c))}
onFocus={() => {
setActiveIndex(i);
setHoverIndex(i);
}}
onBlur={() => setHoverIndex((c) => (c === i ? null : c))}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
focusGroup(i - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
focusGroup(i + 1);
}
}}
/>
))}
{hovered && hoveredCut && (
<div
aria-hidden="true"
className="ns-baw-tip pointer-events-none absolute z-10 rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] shadow-sm"
style={{
left: `${((LEFT_PAD + hoverIndex! * GROUP_W + GROUP_W / 2) / viewW) * 100}%`,
top: `${(Math.max(0, valueToY(hovered.q3) - 46) / viewH) * 100}%`,
transform: "translateX(-50%)",
}}
>
<strong className="text-foreground">{hovered.median.toFixed(1)}</strong>{" "}
<span className="text-muted">
med · Q1 {hovered.q1.toFixed(1)} · Q3 {hovered.q3.toFixed(1)} · {hoveredCut.outliers.length} out
</span>
</div>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<span className="font-mono text-[10px] tracking-widest text-muted">FENCE</span>
<div
ref={trackRef}
className="ns-baw-track relative h-4 flex-1 cursor-pointer rounded-full bg-border/60"
onPointerDown={(e) => {
setK(kFromClientX(e.clientX));
inputRef.current?.focus();
const move = (ev: PointerEvent) => setK(kFromClientX(ev.clientX));
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}}
>
<input
ref={inputRef}
type="range"
min={K_MIN}
max={K_MAX}
step={K_STEP}
value={k}
onChange={(e) => setK(Number(e.target.value))}
aria-label="Whisker fence, multiplier of interquartile range"
className="ns-baw-range sr-only"
/>
<div
aria-hidden="true"
className="ns-baw-thumb absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2 border-accent bg-background"
style={{ left: `${((k - K_MIN) / (K_MAX - K_MIN)) * 100}%`, transform: "translate(-50%, -50%)" }}
/>
</div>
</div>
</figure>
);
}
const CSS = `
.ns-baw-hit { touch-action: manipulation; }
.ns-baw-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ns-baw-range:focus-visible ~ .ns-baw-thumb { outline: 2px solid var(--accent); outline-offset: 2px; }
`;
comparing the spread and outliers of several sampled distributions where the definition of "outlier" itself needs to be interactively adjustable — pick histogram-live-grain for a single rolling tally instead, or chart-ridgeline-terrain when the shape of the whole density curve matters more than its five-number summary.
The registry's first distribution instrument: one box-and-whisker plot per group, quartiles computed by linear-interpolation quantile over the real sample (not a pre-aggregated summary). Box bodies are filled with the family's shared ASCII ramp ' .:-=+*#%@' tiled at a constant mid density purely as texture — box height already encodes the interquartile spread, so this is not a second value channel, matching the family's redundant-density convention elsewhere. The real mechanic is the single shared fence handle below the chart: a real <input type="range"> (k, the IQR multiplier, 0.5 to 3.0 in 0.1 steps), visually replaced by a custom track and thumb the way slider-range-shear carries its accessibility, draggable by pointer or fully operable by the native input's own arrow/Home/End/PageUp/PageDown handling. Moving it recomputes, for every box on the chart at once, the low/high fence (Q1 - k*IQR .. Q3 + k*IQR) against the REAL underlying sample: the farthest sample still inside the fence becomes the new whisker cap (eased into position over roughly 300ms, not snapped), and every sample outside the fence renders as a small outlier ring that fades in — a sample that re-enters the fence as k grows fades back out of view instead of disappearing instantly, so the reclassification itself is visible motion, not a jump cut. Each group also has its own real hit button (roving tabindex, ArrowLeft/ArrowRight moving focus) whose hover or focus tints that box's whiskers and outline to var(--accent) and opens a small tooltip with its median, Q1, Q3 and current outlier count; var(--accent) is otherwise reserved for the fence thumb, matching the family's convention of accent for interaction only, never as a value channel. Tokens are read via getComputedStyle at mount and re-read through a MutationObserver on the document root's class attribute, so both themes repaint correctly on toggle. prefers-reduced-motion snaps whisker and outlier changes to their final state in one paint instead of lerping; the fence handle remains fully operable either way. Zero dependencies.