Charts

Pacing Strategy

A four-state pacing chart for one athlete against a plan — diverging bars of seconds banked or lost per split around a target baseline, with the cumulative banked time overlaid on a right axis and the finish read against the target.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Bar,
  CartesianGrid,
  Cell,
  ComposedChart,
  Line,
  ReferenceLine,
  XAxis,
  YAxis,
} from "recharts"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "Pacing Strategy" chart component (zod, recharts through the
shadcn chart primitives ChartContainer / ChartTooltip / ChartTooltipContent, and a cn() class
merger). It reads ONE athlete's splits against ONE target pace: diverging bars for the seconds each
split banked or lost, and the cumulative banked time overlaid on a right axis.

Contract
- zod schema, single source of truth (props are z.infer of it plus the render options below):
  status: "loading" | "empty" | "error" | "ready"
  title: string
  targetSeconds: number > 0            // the target for ONE split — this number IS the zero baseline
  splits: { label: string; seconds: number > 0 }[]   // seconds is that split ALONE, never the race clock
  meta: { athlete?: string; event?: string; splitDistance?: string }
  refine: status "ready" needs at least one split, otherwise the payload should say "empty".
- Render options: height (px, clamped 200-520, default 280), onRetry, className, ...div props;
  forwardRef to the card, data-status on the root.
- Consumers hand over raw lap times only. Elapsed clock, per-split delta, cumulative banked time,
  the two axis domains and the split shape are all DERIVED — they never arrive pre-computed.
- All the maths live in exported pure functions beside the component so a test can print the same
  numbers the picture is made of: buildPacingModel, divergingSecondsScale, formatClock,
  formatSignedSeconds.

Behavior
- One sign convention, obeyed everywhere: + IS TIME GAINED. gain = target − seconds, so a split
  faster than plan is positive and rises off the baseline; banked is the running total of gain.
  That makes the line the literal cumulative of the bars beneath it, and the header figure is taken
  from the LAST banked value so the headline and the end of the line can never disagree.
- The bars are coloured by sign (ahead / behind) but the sign is already carried by which side of
  the baseline the bar is on — colour only repeats what position says.
- Split shape: compare the mean split of the first half against the second half; an odd count drops
  the middle split rather than handing it to one side, because the halves must be the same length
  for their means to be comparable. Inside ±0.5% of the target the two read as one pace, so the
  verdict is "even split"; otherwise "negative split" (second half quicker) or "positive split".
- Feed hygiene, disclosed rather than silently applied: splits whose seconds are NaN, Infinity, zero
  or negative are dropped and counted in a footnote; a targetSeconds that is not a usable number
  falls back to this run's own mean split, and the footnote says the shape is real but the plan is
  not. (z.number() rejects NaN, but props are z.infer types — an unparsed feed can still hand NaN
  straight in, so the repair cannot live in the schema alone.)
- Degenerate input produces a picture, not an exception: a "ready" payload with no usable split
  degrades to the empty branch; one split draws one bar and one dot and simply has no split shape.
- Four states are first-class branches of one card: loading (deterministic diverging skeleton around
  a dashed mid-line, pulse gated by motion-reduce, plus an sr-only status), empty, error (retry
  button rendered only when onRetry is passed), ready.

Rendering & styling
- Semantic tokens only — ahead var(--chart-2), behind var(--destructive), the banked line
  var(--chart-4), baseline var(--foreground) at 45% opacity, everything else --card, --border,
  --muted, --muted-foreground, --ring. No hex, rgb or oklch literals anywhere.
- BOTH y axes are symmetric around zero (−max … +max, five ticks), so the target baseline and the
  "level with the plan" row of the cumulative are the same pixel row. Letting either side auto-fit
  its domain is what makes a dual-axis pacing chart lie: bars and line would sit on two zeros that
  only look like one. Ticks come off a stopwatch ladder (0.25 / 0.5 / 1 / 2 / 2.5 / 5 / 10 / 15 /
  30 / 60 / 90 …), the left zero tick prints "target".
- ComposedChart with accessibilityLayer={false} (recharts 3 otherwise puts tabindex="0" on an svg
  inside a children-presentational role="img"); CartesianGrid pinned to the bar axis id so the
  gridlines are the bar ticks; ReferenceLine at y=0 drawn BEFORE the bars so they stand on it;
  Bar with one Cell per split for the two-token fill; Line type="linear", because banked time is
  only known at the split marks and a spline would invent a curve nobody measured.
- Clocks are m:ss.t under an hour and h:mm:ss above; deltas print signed ("+1.8s", "−2.4s"), and a
  value that rounds to zero prints without a sign rather than claiming a direction. Every figure is
  tabular-nums.
- Tooltip: one row per encoding through the ChartTooltipContent formatter — "This split" with its
  signed delta plus the raw split time, "Banked so far" with the running total; the label line is
  the split name plus the elapsed clock.
- Accessibility: ChartContainer is role="img" named by an sr-only summary that narrates the target,
  the finish against it, the quickest and slowest split and the split shape; an sr-only table (on a
  wrapping div, never on the table itself) repeats split / time / vs target / banked after /
  elapsed. Retry has a focus-visible ring.
- Motion: recharts animation is switched off under prefers-reduced-motion via a matchMedia
  subscription (a JS animation cannot be reached by motion-reduce: classes), and the skeleton pulse
  is gated by motion-reduce:animate-none.

Customization levers
- Density: height (200-520) is the main knob; maxBarSize sets how chunky the columns read, and
  dropping the legend or the footnote paragraphs trims the card for an embed.
- Baseline: swap the flat targetSeconds for a per-split plan (a course-adjusted or descending
  target) by making gain = plan[i] − seconds — nothing downstream changes, the zero line is still
  "on plan".
- Palette: ahead / behind are one token each; re-point them to any pair that differs in hue AND
  lightness, or make the banked line var(--chart-1) when --chart-4 is too quiet on your card.
- Unit: the chart is unit-agnostic once formatClock / formatSignedSeconds are replaced — metres
  gained per leg in a regatta, strokes against par per hole, watts against a plan per interval.
- Trim or extend: drop the right axis and keep the bars for a compact "how each split went" strip;
  or add a second Line for a rival's banked time on the same axis, since both are seconds.

Concepts

  • Banked time — the running total of what every split gave back or took: positive means seconds are in hand against the plan, negative means they are owed. It is nothing more than the cumulative of the bars underneath it, which is why the two can never tell different stories.
  • Target as the zero baseline — the baseline is not "no time", it is the plan. A bar is only as tall as the plan was missed by, so a race run exactly to plan is a flat line of nothing, and the eye spends all its resolution on the deviation instead of on the shared pace.
  • Aligned dual zero — the per-split axis and the cumulative axis carry the same unit but wildly different ranges, so they get separate scales; making both symmetric around zero puts their zeros on one pixel row, which is the only thing that lets a bar and the line be read against each other.
  • Negative vs positive split — the verdict comes from comparing the mean of the first half against the second, with an odd split count dropping the middle one so the halves stay the same length; anything inside half a percent of target is called even rather than dressed up as a strategy.
  • Sign carried by position — ahead is up and behind is down, so the ahead/behind tokens are redundant encoding; the chart still reads correctly in greyscale, and the tokens are free to be re-pointed at any theme.
  • Derived, not supplied — the consumer hands over raw lap times and one target; elapsed clock, deltas, cumulative, axis domains and the split shape are all computed, so no caller can feed a cumulative that disagrees with its own splits.

On This Page