Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartElevationProfileClimb,
  ChartElevationProfileData,
  ChartElevationProfileSample,
} from "./chart-elevation-profile.contract"

export interface ChartElevationProfileProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartElevationProfileData {

Installation

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

Prompt

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.

On This Page