Power Curve
A four-state cycling mean-maximal power curve on a log time axis — current period vs all-time best, an optional dashed critical-power asymptote, and a scan that reads watts at any duration.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-power-curve.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPowerCurve" card in plain SVG
(no chart library) with zod. It plots cycling mean-maximal power: best
average watts held for each effort duration, 1s sprint to multi-hour.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
series: { label: string; points: { seconds > 0; watts >= 0 }[] }[];
criticalPower?: number }.
- Component props = z.infer of the schema, plus height?: number (clamped
140–480), onRetry?: () => void and the usual div props via a forwardRef
root spread. No hand-written parallel interface.
- Convention: series[0] is the current period, series[1] the all-time
best; extras cycle the chart tokens.
Behavior
- Four first-class branches: loading (pulsing axis bars + two deterministic
decaying skeleton curves, aria-hidden, motion-reduce:animate-none),
empty (dashed decaying outline + copy explaining the curve needs per-
duration bests), error (message + "Try again" only when onRetry exists),
ready (the plot).
- Sanitize per series: drop non-finite points and seconds <= 0 (count and
state the drops in visible copy), sort by seconds, collapse duplicate
durations keeping the HIGHER watts — mean-maximal is a max, never an
average. status "ready" with nothing drawable renders the empty branch.
- X axis is log10(seconds). Ticks come from a canonical duration list
(1s, 5s, 30s, 1m, 5m, 20m, 1h, 3h…) filtered to the data's domain and
thinned to the width budget — never raw decade powers.
- Y axis is linear watts from 0, ticks on a 1/2/2.5/5 × 10^n step.
- criticalPower (when finite and > 0) draws a dashed horizontal line with
an on-line "CP {n} W" label haloed in var(--card), and joins the y-max.
- Hover/scan: a transparent hit rect converts pointer x through its own
client box into log-space, snaps to the nearest measured duration, and
moves one shared scan line. Per series the readout shows exact watts at
a measured point, linear-in-log interpolation between points, and "—"
outside the series' range (never extrapolate). With exactly two series
also print the watt gap ("−23 W vs All-time best").
- The same scan is a keyboard slider: role="slider" on an SVG group,
tabIndex 0, Arrow/PageUp/PageDown/Home/End, aria-valuemin/max/now and an
aria-valuetext sentence; keyboard moves also update an sr-only
role="status" line (pointer moves stay silent). A focus ring rect
appears via stroke-ring while focused.
- Width comes from a ResizeObserver on the plot box, disconnected on
unmount; below ~300px the SVG scales down as a whole via viewBox.
Rendering & styling
- Semantic tokens only: curves stroke var(--chart-1..5) with a dash cycle
so identity survives greyscale; grid stroke-border, text
fill-muted-foreground, CP line stroke-muted-foreground, card is
rounded-xl border bg-card; cn() merges className; numbers tabular-nums.
- Durations format as "45s" / "5m" / "1h 30m", watts via Intl NumberFormat.
- An sr-only table repeats every duration × series reading, plus the CP
estimate in its caption; the visible readout row is aria-hidden so the
slider is the single spoken source.
Customization levers
- Canonical tick list: extend it (e.g. 12h for ultra events) or swap
labels to your locale's duration wording; the thinning budget keeps
narrow cards clean either way.
- Plot height: the height prop (140–480); the card's summary/readout rows
reflow on their own.
- Series count and meaning: the token + dash cycle handles more than two
series (per-year curves), but the gap readout is written for exactly
two — remove it or generalize when you add more.
- CP styling: swap the dash pattern or move the label to the right edge;
keep it on stroke-muted-foreground so it never reads as a data series.
- Interpolation policy: wattsAt is one function — replace linear-in-log
with monotone spline, or return null between points to force
measured-only readouts.
- Y axis floor: starts at 0 for honest area proportions; for elite,
flat-ish curves you can floor at a token-free minimum by changing toY.Concepts
- Mean-maximal, not time-series — every x is an effort duration and every y the best average watts ever held for it, so duplicate durations collapse to the max: averaging two claims for the same duration would invent a number nobody rode.
- Log time axis with human ticks — 1s and 3h only share a readable axis in log space, and the ticks are drawn from the durations riders actually quote (5s, 1m, 20m, 1h), never raw powers of ten.
- Current-vs-best envelope — the all-time curve is the ceiling and the current-period curve hangs under it; where the gap is widest is the training story, and the two-series readout prints that gap in watts.
- CP as asymptote, not a series — the dashed critical-power line is a reference the curve flattens toward; it stays on the muted token with its label on the line so it can never be mistaken for a third rider.
- One scan, two inputs — pointer hover and the keyboard slider move the same snapped scan line, so the readout (exact at measured points, interpolated in log-space between them, never extrapolated past the range) is identical for both.
- Four-state contract — loading, empty, error and ready are first-class branches of one zod contract, so the card drops into a real data layer without wrapper conditionals.
Player Percentile Pizza
A scouting percentile pizza in hand-rolled SVG — one wedge per stat, radius as cohort percentile rank, wedges grouped and coloured by skill category, with guide rings at 25/50/75.
Elevation Profile
A four-state route elevation profile in plain SVG — the distance-vs-elevation area split into flat, moderate and steep runs by grade, labelled climb bands measured from the samples underneath them, and a hover or keyboard readout of km, elevation and grade.