Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartDrawdownData, ChartDrawdownPoint } from "./chart-drawdown.contract"

export interface ChartDrawdownProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartDrawdownData {
  /** Plot height in pixels, axis labels excluded (clamped 140–420, default 210). */
  height?: number
  /** How many ranked episodes the recovery list prints before it says "+n more" (default 3). */
  episodeRows?: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-drawdown.json

Prompt

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.

On This Page