Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartPowerCurveData,
  ChartPowerCurveSeries,
} from "./chart-power-curve.contract"

export interface ChartPowerCurveProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartPowerCurveData {
  /** Plot height in pixels, excluding axis labels (clamped 140–480, default 240). */

Installation

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

Prompt

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.

On This Page