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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-elevation-profile.jsonPrompt
Build a React + TypeScript + Tailwind "ChartElevationProfile" card in plain
SVG with zod. Recharts has an area chart, but not an area whose colour changes
by the grade between consecutive samples, and not climb bands whose numbers
are measured from the samples under them — so the model is a handful of pure
functions beside the schema. No new dependency, no d3.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
samples: { km >= 0; elevation: number }[];
climbs?: { fromKm >= 0; toKm >= 0; label: string }[] }.
- elevation is metres and may be negative — below-sea-level roads are real,
and clamping them at 0 would move every mark on the chart.
- Grade is DERIVED between consecutive samples, never carried in the data, so
it can never disagree with the shape it is painted on.
- superRefine: a ready profile needs at least two samples; each climb must end
after it starts. Guard every access so a ragged payload produces an issue,
not a TypeError out of safeParse.
- Props = z.infer of the schema plus height (200, clamped 120–480), moderateAt
(3, clamped 0.5–15), steepAt (8, kept >= moderateAt + 0.5 so the moderate
class cannot be silently deleted), showLegend (true), onRetry, className and
the div's native props, forwardRef to the card.
- Export the model beside the component so a test can print the same numbers
the picture is made of: buildProfileModel(), buildClimbs(), elevationAt(),
nearestSample().
Behavior
- buildProfileModel sorts a copy by km (never mutate the caller's array),
drops rows without a finite km/elevation and rows repeating a km already
placed (a zero-length segment has no grade), and counts every drop — the
card states the count instead of quietly absorbing it.
- Each consecutive pair becomes a segment with signed grade = rise over run in
percent; |grade| < moderateAt is flat, < steepAt moderate, else steep.
Adjacent same-class segments merge into runs, and each run paints one area
path (fillOpacity ~0.28, closed to the plot bottom) plus one 2px stroke —
adjacent runs share their boundary sample, so the profile never gaps.
- Summary line in the header: total km, total gain and loss (sum of positive /
negative rises), high point and where it is, steepest grade, climb count.
- Climbs are validated against the sampled span: clamped to it, skipped (and
counted out loud) when inverted or entirely outside it. Each drawn climb is
a muted band under the profile plus a bracket and an elided label in a lane
reserved above the plot (only when climbs exist), with the full name, span,
net gain and average grade — measured via linear interpolation of the
samples — in a <title> and in an sr-only table.
- The readout cursor snaps to the nearest sample: pointermove over one
transparent hit rect (converting through its own client box so it stays
correct when the SVG is scaled down), and a keyboard slider —
role="slider", tabIndex=0, arrows step one sample, PageUp/Down jump ~10%,
Home/End go to start and finish, aria-valuetext says km, elevation, grade,
class and the climb the point is on. Keyboard moves also update a sr-only
role="status" line; pointer moves do not, because a live region updated on
every pointer sample is a queue nobody can listen through.
- The visible readout under the plot ("km 42.5 · 1,284 m · +6.3% (moderate) ·
on Col de Bellefont") is aria-hidden — the slider already announces it. The
cursor rests on the high point before anyone moves it, so the card ships
with a live reading.
- Four first-class branches: loading is a deterministic pulsing mountain
silhouette (aria-hidden, motion-reduce:animate-none, sr-only status text);
empty explains what a profile needs (two samples) and reports arrivals that
could not be placed; error shows "Try again" only when onRetry exists;
ready as above. status="ready" with fewer than two usable samples renders
the empty branch rather than an axis with nothing on it.
Rendering & styling
- Colors come only from tokens: flat = var(--chart-2), moderate =
var(--chart-4), steep = var(--chart-5); grid stroke-border, axis text
fill-muted-foreground, climb band fill-muted, cursor stroke-foreground with
a var(--card) halo on the dot. Colour never carries the class alone — the
legend prints each threshold and the km spent in it, the readout says the
class in words, and an sr-only table repeats the split.
- Panel: rounded-xl border bg-card; header with title + tabular-nums summary;
cn() merges className; rest props spread on the root div.
- Axes: nice-step ticks (1/2/2.5/5 × 10ⁿ) spaced by available pixels, y in
metres with an 8% padded domain so the summit never touches the frame,
rotated "elevation · m" caption, "distance · km" below. Width comes from a
ResizeObserver (disconnected on unmount) with an SSR fallback viewBox.
Customization levers
- Thresholds: moderateAt / steepAt are the whole classification — 2/6 for
running, 3/8 (default) for road cycling, 5/12 for MTB. The legend re-labels
itself from the same numbers.
- Class palette: remap CLASS_INK to any three chart tokens; keep three
distinct tokens so runs stay tellable apart in greyscale via the legend.
- Density: height and showLegend={false} make a thumbnail for route cards;
the readout line still works at any height.
- Climb lane: the reserved headroom only exists when climbs are drawn; drop
the climbs prop and the chart tightens by that lane automatically.
- Units: the formatters are Intl.NumberFormat in one place each — switch to
miles/feet by converting in the data layer, or swap the axis captions.
- Readout wiring: the snap-to-sample cursor is one state value; lift it via a
callback prop if a map alongside should highlight the same km.Concepts
- Grade classification — grade is derived between consecutive samples and bucketed by two thresholds into flat / moderate / steep; the colour can never disagree with the drawn shape, because both come from the same pairs of points.
- Area by runs — adjacent segments of one class merge into a single area path that shares its boundary samples with its neighbours, so the profile reads as one continuous mountain, not a bar chart of slopes.
- Climb bands measured, not asserted — a climb marker only says where and what it is called; its length, gain and average grade are interpolated from the samples underneath, so the label cannot claim numbers the profile does not show.
- Snap-to-sample readout — pointer and keyboard both land on real samples, never interpolated pixels, so every readout is a measurement that exists in the data; the cursor rests on the high point so the card ships with a live reading.
- Honest drops — unusable samples and impossible climb markers are counted and stated on the card, never silently absorbed into a total.
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.
Sleep Stages
A four-state sleep hypnogram — stage-colored segments stepping across fixed Awake/REM/Light/Deep lanes over the night, with time-in-stage summary bars and percentages.