Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildRaceGapModel,
  formatClock,
  formatGap,
  gapScale,
  isolatedReadings,
  pickSplitTicks,
  prepareRaceSplits,

Installation

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

Prompt

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.

On This Page