Drawdown (Underwater)
A four-state underwater chart derived from a raw level series — percent below the running peak hanging from a zero line, every dip shaded and ranked, the max-drawdown trough labelled, and a recovery read-out that flags what is still under water.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-drawdown.jsonPrompt
Build a React + TypeScript + Tailwind "ChartDrawdown" card in hand-rolled SVG
(no chart library) with zod. It plots percent below the running peak — an
underwater chart — and derives every number it prints.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
points: { date: ISO string; value: positive number }[];
episodeThreshold?: 0..1; valueLabel?: string; unit?: string;
unitPlacement?: "prefix" | "suffix" }.
- Props = z.infer of the schema plus height, episodeRows, onRetry and
className; forwardRef and spread the rest on the root. No hand-written
parallel interface.
- points carries raw LEVELS, never period returns, and never a pre-computed
drawdown: the component derives the peak, the drawdown series, the episodes
and every duration, so the plot and the readouts cannot disagree.
Behavior
- Derivation, in one pure function that takes points and returns a model:
- drop points whose date does not parse or whose value is not a positive
finite number, sort a COPY by time, drop repeats of a timestamp already
taken (two levels on one date are a contradiction, not an average), and
count the drops so the card can disclose them.
- running peak = max of the values so far; drawdown = value / peak − 1, so
it is never positive and is exactly 0 at every new high (x/x is exactly 1
in IEEE-754, so "=== 0" is a safe test for "at a high").
- episodes: scan for a negative drawdown, open the episode at the previous
sample (the peak it fell from), close it at the first later sample back at
0 (the recovery) or at the end of the series (still under water), and take
the minimum drawdown inside as the trough. Resume the scan at the close,
which is itself a high.
- per episode: depth, peak→recovery time under water, trough→recovery time
to get back (null while ongoing), and whether it is ongoing.
- rank the episodes that clear episodeThreshold (default 5%) deepest-first,
ties to the earlier one; shallower dips stay drawn but unranked.
- time under water = sum of the episode spans; the peak instant and the
recovery instant are both AT a high, so the stretch strictly under water is
exactly the gap between them.
- average depth ("pain index") = (1/T) · Σ ((|dd_i| + |dd_i+1|)/2) · Δt —
the trapezoid rule over the drawn segments, so the number integrates the
shape on screen. It separates a 30% crash that snaps back from a two-year
8% grind, which share a max-drawdown headline.
- x is the calendar, not the sample index: gaps stay gaps, or every duration
the card prints would be a lie.
- Four first-class branches: loading (skeleton with the same hanging
silhouette), empty (also taken when a ready payload has fewer than two
usable points, worded so it is not mistaken for a failed fetch), error
(retry button only when onRetry is passed), ready.
- One scan cursor drives every readout: a role="slider" group with
aria-valuetext, arrow / Page / Home / End keys and a pointer hit rect that
converts through its own client box; keyboard moves announce into an
sr-only live region, pointer moves do not. It rests on the LATEST sample,
not the trough — resting on the trough parks the cursor on top of the
max-drawdown mark and hides the very drop line that identifies it.
- An sr-only table repeats every ranked episode: depth, peak, trough,
recovery, time under water, time back.
Rendering & styling
- Zero is pinned to the TOP of the frame and the area hangs from it; the y
domain runs from a snapped floor just past the trough up to 0.
- Colours are tokens only: var(--chart-5) for the underwater fill (~34%),
its outline, the per-episode trough dots and the still-under-water hatch;
var(--chart-2) for the recovery triangles on the zero line;
var(--destructive) for the max-drawdown dot and its dashed drop line;
fill-muted-foreground for sub-threshold ripples; stroke-border gridlines;
bg-card / border / text-muted-foreground for the shell. Pick a fill
strength that survives on a near-black card, not a tenth of a mid-tone.
- Marks are placed by the value that labels them: every trough dot sits at
(its own date, its own drawdown), and the max-drawdown call-out prints the
same two numbers it is anchored to.
- Label discipline: depth tags live in a lane above the zero line, anchored on
each episode's trough and dropped left-to-right as soon as two would touch;
the trough call-out flips its anchor near the frame edges and flips above
the dot when the floor is close, so nothing is ever clipped; axis ticks are
calendar-aligned (the finest day/month/year step whose ACTUAL grid fits) and
percentage ticks carry exactly the digits their own step is written with — a
2.5% step prints -2.5%, a 5% step prints -5% — so evenly spaced gridlines can
never print as uneven numbers.
- A legend row lists only the marks actually drawn: the underwater fill only
once a dip cleared the threshold, the deepest-point dot whenever the series
fell at all (it is the one mark whose in-plot call-out a narrow card drops),
and no legend at all for a series that never left its peak. The
still-under-water episode is hatched rather than merely tinted, because an
alpha step would read as depth.
- cn() merges className; numbers are tabular-nums; the skeleton pulse is
motion-reduce:animate-none; the ResizeObserver is disconnected on unmount.
Customization levers
- Density: `height` (140–420) and `episodeRows` (how many ranked episodes the
list prints before "+n more"); drop the stat tiles or the scan readout for a
compact sparkline-sized card.
- `episodeThreshold` is the editorial knob — 2% turns every ripple into a
ranked episode, 10% keeps only the ones a memo would mention.
- Palette: swap var(--chart-5) for var(--chart-1) when the card sits beside
other series in the same hue, and var(--destructive) for the same token if
you do not want the trough read as an alert.
- Marks: drop the recovery triangles for a quieter plot, or add the peak dates
as a second lane; keep the hatch, it is the only thing separating "recovered"
from "not yet".
- Domain: the same component reads any accumulating level — cash balance,
followers, MRR — by changing valueLabel and unit; nothing in the maths is
finance-specific.
- Bigger series: sub-sample upstream to weekly or monthly before passing daily
ticks, since one path vertex per sample is what keeps the durations exact.Concepts
- Running peak, not a fixed high-water mark — every point is signed against the maximum of its own past, so the line can only hang below zero and touches it again exactly when a new high is set. That is what makes the chart readable without an axis label: the top edge is always "even".
- Episode as a closed interval — a dip opens at the peak it fell from and closes on the first sample back at that peak. Both ends are at a high, so the stretch strictly under water is exactly the gap between them, and "days to recover" is measured from the trough rather than the peak — two different questions the same episode answers.
- Depth versus duration — max drawdown is one number and time under water is another; a crash that snaps back in a month and a grind that never quite recovers can share a headline. The card prints both, and the time-weighted average depth (the trapezoid integral of the underwater area over the whole span) is the number that separates them.
- Threshold as an editorial knob — a weekly series dips below its peak most of the time, so ranking every 0.3% ripple would bury the two dips that mattered. Sub-threshold dips stay drawn in muted ink — the silhouette would lie without them — but earn no label, no rank and no row.
- Marks placed by the value they report — each trough dot sits at its own date and its own drawdown, and the max-drawdown call-out prints exactly the pair it is anchored to. A mark coloured by its true value but placed by a bucketed coordinate is how a chart ends up disagreeing with its own caption.
HRV Frequency Spectrum
A four-state HRV power spectrum in plain SVG: the density estimate drawn as an area, cut and shaded at the VLF / LF / HF edges, each band integrated by the trapezoid rule into its own power and share, with an LF:HF readout and a normalised-units toggle.
Groundstroke Depth Zones
A four-state tennis groundstroke landing map in hand-rolled SVG — the opponent's half cut into short / mid-court / deep bands across the left, middle and right thirds, shaded by volume, with the "beyond the service box" and "middle third" reads printed above it and a forehand / backhand filter that re-derives both.