Race Splits
A four-state race-splits spaghetti chart: every athlete's cumulative gap to the split leader against a zero baseline, lines named at their ends, lead changes and DNFs disclosed, every split keyboard-reachable.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-race-splits.jsonPrompt
Build a React + TypeScript + Tailwind "Race Splits" chart component (zod, a cn() class merger, a
useResizeObserver hook; hand-rolled SVG, no chart library). It draws each athlete's cumulative gap
to the race leader at every split as one spaghetti line, with the zero baseline being whoever leads.
Contract
- zod schema, one source of truth (props are z.infer of it plus the render options below):
status: "loading" | "empty" | "error" | "ready"
title: string, caption?: string
splits: string[] // checkpoint names in course order, e.g. ["5K", …, "Finish"]
athletes: { label: string; splitTimes: (number | null)[] }[]
splitTimes are CUMULATIVE elapsed seconds aligned with splits by index. A shorter array is a DNF
(the athlete stopped reporting), not damage; null means no time at that checkpoint.
- Render options on top of the contract: height (px, clamped 160-480, default 260), maxTicks (3-8),
onRetry, className, ...div props. forwardRef to the card.
- All the maths live in exported pure functions beside the component, so a test can print the same
numbers the picture is made of: prepareRaceSplits, buildRaceGapModel, formatGap, formatClock,
gapScale, pickSplitTicks, isolatedReadings, separateEndLabels.
Behavior
- The leader is a ROLE, not an athlete: at each split it is whoever holds the lowest cumulative
time, and gap[athlete][split] = time - best. The y axis is "seconds behind", zero at the TOP and
growing DOWNWARD, so the front of the race sits on the baseline and falling back reads as falling.
- A tie keeps the standing leader — an overtake needs a smaller number, not an equal one. Lead
changes are derived from consecutive reported leaders and each one is named in the summary; Page
Up / Page Down jump between them.
- Feed hygiene, each repair counted and disclosed in a note under the chart: a reading that is
non-finite, negative, or EARLIER than the athlete's own previous reading (a cumulative race clock
cannot run backwards) becomes a gap in that line; entries past the last named split are ignored.
A null is never bridged — a DNF's line simply stops, and its end label says "DNF · 30K" with a
dashed leader line so the label never pretends the line reached the finish.
- Degenerate input must produce a picture, not an exception: zero splits or athletes falls through
to the empty branch even when status is "ready"; one checkpoint draws dots (a one-command subpath
paints nothing); a dead-level race (all gaps zero) gets one second of axis headroom instead of a
zero-height plot; an athlete with no usable times is disclosed in text and drawn as nothing.
- Standings order finishers by final time, then non-finishers by how far they got; the standings
pick the end-label priority, the sr-only table order and the paint order (leader painted last, so
the front of the race is never buried under the chase).
- Reading it with a pointer: the plot divides into hit bands out to the midpoints between splits,
so anywhere in the plot selects the nearest split, draws a crosshair plus a card-ringed dot on
every line, and fills a readout line ("Half — Kiprotich leads at 1:01:23; Alvarez +4.0s, …").
- Reading it with a keyboard: the plot is ONE tab stop; the splits are options with a roving
tabindex. Arrows step, Home / End jump to the course ends, movement clamps and never wraps. If a
feed update removes the focused split, the tab stop takes focus back so it never lands on <body>.
- Four states are first-class branches: loading (deterministic skeleton, pulse gated by
motion-reduce), empty, error (retry button only when onRetry is passed), ready.
Rendering & styling
- Semantic tokens only: athlete i gets var(--chart-{(i % 5) + 1}) by INPUT order, never re-ranked —
a lead change must not repaint anybody. Everything else uses --card, --foreground,
--muted-foreground, --border, --ring. No hex, rgb or oklch colour literals anywhere.
- Colour is never the only channel: every drawn line ends in its athlete's name plus a second line
(winner's clock, gap behind, or "DNF · 30K"), the second palette lap is dashed and the third
dotted, and an sr-only table repeats the whole field as text.
- End labels sit in a right gutter (clamped ~92-168px), pushed apart by a separation pass that
moves the LABELS and never the endpoints; the label budget is whatever fits the plot height, and
any athlete past it is disclosed in text rather than silently dropped.
- Split markers on x: every checkpoint gets a recessive border-token vertical rule and an axis
tick; tick LABELS thin out to what fits (always keeping the first and the last), via a truncating
foreignObject so long names never overflow the card.
- Gap ticks come from a clock-natural ladder (0.5 / 1 / 2 / 5 / 10 / 15 / 30 / 60 / 90 / 120 …
seconds), printed as "+4.2s" under a minute and "+1:24" above; the zero tick is labelled
"leader". Numbers are tabular-nums so the readout never jitters.
- SSR-stable and deterministic: the viewBox always matches the width the geometry was computed
for, so the frame before the ResizeObserver reports is drawn scaled to fit rather than clipped.
No Math.random and no Date.now at render — the skeleton silhouette is a constant.
- Accessibility: the svg is role="group" (NOT role="img" — that is children-presentational and
would silence the focusable stops), named by the heading and described by an sr-only summary that
narrates winner, margin, every lead change and every DNF. Each stop option carries a
full-sentence aria-label; the visible readout is aria-hidden (a focused stop already announces
itself) and a separate polite live region carries the pointer-driven readout.
- Motion: the only animation is the loading pulse, gated with motion-reduce:animate-none. The
chart is complete with animation off.
Customization levers
- Density: height (160-480) and maxTicks are the two knobs; shrink the right gutter toward 92px
for short names, widen toward 168px for long ones.
- Emphasis: raise the winner's strokeWidth or dim non-podium lines to strokeOpacity 0.5 for a
broadcast look; the standings order is already computed for exactly this kind of styling.
- Palette: the colour formula cycles five chart tokens by input index; re-map to fixed tokens per
team (trade team kit colours for --chart-N) by replacing inkOf, and keep the dash laps if more
than five athletes survive.
- Trim: drop the header stat block for an embed, drop the vertical split rules when checkpoints
are dense, drop the footnote sentence in space-tight cards — the sr-only summary keeps the facts.
- Extend: swap seconds for any monotone cumulative unit (metres in a regatta, points in a rally)
by replacing formatGap / formatClock; the leader-role maths never changes.Concepts
- Zero baseline as a role — the baseline is not an athlete, it is whoever leads at each split. Gaps are measured to that role, so the chart survives the leader changing, dropping out, or being filtered away — no re-basing, no repainting.
- Gap to leader, not absolute time — subtracting the best clock at every split throws away the shared pace and keeps the race: a 2-second move at 40K is invisible on an absolute axis and obvious here.
- Lead change — derived by comparing consecutive split leaders, with ties keeping the standing leader (an overtake needs a smaller number, not an equal one). Every change is named in the summary and is one Page Down away.
- DNF breaks the line — a null is never bridged: the line stops where the timing did, and its end label says so with a dashed leader line, because a bridged gap invents splits nobody ran.
- Direct end labels instead of a legend — every line ends in its athlete's name and result, separated so they never overlap while the endpoints stay true; identity is never carried by colour alone.
- Colour follows the athlete — palette tokens are assigned by input order and never re-ranked, so a lead change or a filter cannot repaint the survivors mid-read.
Heart Rate Zones
A four-state workout view that colors a session strip by heart-rate zone and totals time-in-zone into share-labelled bars, from bpm samples and zone floors.
Form Guide
A four-state team form guide — ranked rows of W/D/L result chips, a rolling points-per-game sparkline and season totals, driven by one contract.