Charts

Sprint Force-Velocity Profile

A four-state sprint force-velocity-power profile derived from split times alone — a mono-exponential velocity fit over the measured segments, the linear force-velocity line it implies with its F0 and V0 intercepts, and the power parabola peaking at the optimal velocity on its own axis.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartSprintProfileData,
  ChartSprintProfileSource,
} from "./chart-sprint-profile.contract"

export interface ChartSprintProfileProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartSprintProfileData {
  /** Height of each plot in px, axis labels excluded (clamped 110–320, default 150). */

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartSprintProfile" card in plain SVG
with zod. Recharts can draw two lines, but not a two-parameter fit of a
non-linear model, not a regression derived from that fit, and not one cursor
that walks a time axis and a velocity axis at once — so the model is a handful
of exported 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;
    bodyMass: number > 0;                       // kg — the only place mass enters
    source:
      | { kind: "splits";   splits:  { distance > 0; time > 0 }[] }
      | { kind: "velocity"; samples: { time >= 0; velocity >= 0 }[] };
    athlete?: { name?; date?; session? } }.
- The source is a discriminated union, not two optional arrays: a payload can
  never arrive with neither, or with two disagreeing versions of one run.
  Splits are cumulative distance and cumulative time (what a laser-gate export
  gives); samples are what a radar gives.
- superRefine: a ready profile needs at least three timing points — two
  parameters cannot be fitted from two readings. Guard every access so a ragged
  payload produces an issue, not a TypeError out of safeParse.
- Props = z.infer of the schema plus height (150 per plot, clamped 110–320),
  showPower (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: fitSprintModel(), velocityAt(), accelerationAt(),
  timeAtVelocity(), regressForceVelocity(), buildSprintProfile(), markAt().

The maths (state the formulas in comments; they are the component)
- Mono-exponential sprint model (Furusawa–Hill–Henry; the "simple method" of
  Samozino et al.):
    v(t) = vmax·(1 − e^(−t/τ))
    d(t) = vmax·(t + τ·e^(−t/τ) − τ)        ← ∫v dt with d(0) = 0
    a(t) = (vmax/τ)·e^(−t/τ) = (vmax − v)/τ
- Fit it by separable (variable-projection) least squares: with τ held fixed
  both shapes are vmax × a known function of t, so vmax = Σ(y·g)/Σ(g²) has a
  closed form and only τ is searched. Scan τ over [0.05, 4] s, keep the global
  best, re-centre the window on it and repeat four times — deterministic, no
  random restarts, no data-dependent iteration count. Splits fit d(t) against
  distance; velocity samples fit v(t) against velocity; the RMS residual is
  reported in the unit that was fitted (m or m/s).
- Sample the fit on a uniform time grid over [0, last reading] and take
  F = m·a at each point, then regress force on velocity by OLS:
    slope = Σ(v − v̄)(F − F̄)/Σ(v − v̄)²   F0 = F̄ − slope·v̄   V0 = −F0/slope
  With drag left out the model already makes F(v) = m·(vmax − v)/τ exactly
  linear, so this recovers F0 = m·vmax/τ and V0 = vmax — the line is fitted
  rather than asserted so that adding an F_aero = ½ρ·Cd·A·v² term to the force
  samples is a one-line change the same regression absorbs.
- P(v) = F(v)·v = F0·v·(1 − v/V0) is a downward parabola through the origin and
  V0, so Pmax = F0·V0/4 at v_opt = V0/2 — which the model reaches at t = τ·ln 2.
- Refuse to profile rather than draw nonsense: a non-negative slope, a
  non-positive F0 or V0, fewer than three usable readings, or a body mass that
  is not a positive number all render the empty branch with the reason.

Behavior
- buildSprintProfile sorts a copy of the caller's array (never mutate it) and
  drops rows with unusable numbers, a repeated time, or a distance that goes
  backwards — a run cannot pass one gate twice and averaging the two readings
  would invent a split nobody recorded. Every drop is counted and stated on the
  card.
- Two stacked plots sharing one plot box, so the eye can carry a reading
  straight down:
  1. velocity–time: the fitted curve, the measured readings, and the V0
     asymptote as a labelled dashed guide. A radar sample is a dot. A timing
     gate is a *mean over its segment*, so it is drawn as a horizontal bar
     spanning that segment — by the mean value theorem a continuous curve must
     cross the bar, so a bad fit is visibly bad instead of quietly bad. The
     curve stops at the last reading rather than running on.
  2. force–velocity with power on a secondary right axis: the profile line with
     dots at both intercepts and F0 / V0 tags, drawn solid across the
     velocities the run actually reached and dashed through the extrapolation
     that produces V0 — which is why both intercepts are called theoretical.
     The power parabola is sampled from the line's own equation over the same
     [0, V0] domain, with the v_opt guide, the Pmax dot and its tag.
- Six readouts under the plots — F0 (N and N/kg), V0 (m/s and km/h), Pmax (W
  and W/kg), S_FV (N·s/m and per second, i.e. slope/mass = −1/τ), v_opt (m/s
  and the time it lands at) and τ — plus the fit residual and the number of
  readings.
- One cursor, two plots, one tab stop: role="slider" with tabIndex=0 on the
  time plot, arrows step one grid sample, PageUp/Down jump 10%, Home/End to the
  ends, aria-valuetext saying time, velocity, acceleration, force and power.
  Pointer moves work over either plot — the profile plot maps x to a velocity
  and inverts the model to a time, so both hit rects drive the same index, and
  each converts through its own client box so it stays correct when the SVG is
  scaled down. Keyboard moves also update an 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 cursor rests at peak power — the
  one instant the profile exists to find.
- Four first-class branches: loading is a deterministic pulsing rise over a
  falling line (aria-hidden, motion-reduce:animate-none, sr-only status text);
  empty explains what a profile needs, and says instead that force never fell
  as velocity rose when that is the actual reason; error shows "Try again" only
  when onRetry exists; ready as above. status="ready" that cannot be profiled
  renders the empty branch rather than axes with nothing on them.

Rendering & styling
- Colors come only from tokens: velocity var(--chart-1), horizontal force
  var(--chart-2), power var(--chart-4) — and the power curve is dashed as well
  as coloured so it survives greyscale. Grid stroke-border, axis text
  fill-muted-foreground, guides stroke-foreground dashed at 0.55 opacity, dots
  and tags haloed with var(--card) via paintOrder="stroke". No low-alpha fills:
  every mark is a stroke at full strength, so nothing dissolves on a dark card.
- The V0 tag on the profile plot gets a fill-card plate under it rather than the
  halo alone: the force line and the power parabola both land on the axis at V0
  and run straight under that tag, and a halo clears only the glyphs, so a line
  still shows through the spaces between them. The plate is the card's own
  colour, so it hides what it covers in either theme without reading as a block.
- Every tag is clamped inside the plot from the anchor it is drawn with, so a
  narrow card never loses a character off either edge, and a tick label carries
  exactly the decimals its own step needs (nice steps of 1/2/2.5/5 × 10ⁿ chosen
  from the available pixels).
- Panel: rounded-xl border bg-card; header with title, an optional
  athlete/date/session meta line and a tabular-nums summary; cn() merges
  className; rest props spread on the root div. Width comes from a
  ResizeObserver (disconnected on unmount) with an SSR fallback viewBox.
- An sr-only table repeats every reading and the whole derived profile, so
  nothing on the card is carried by pixels alone.

Customization levers
- Input shape: the discriminated source means a radar feed and a gate export
  are the same component — add a third kind (e.g. distance-time from a
  tracking system) by giving it a basis function in the fit and a mark shape.
- Drag: the F–V line is regressed, not asserted. Add
  F_aero = ½·ρ·Cd·A·v² to the force samples for an outdoor test and F0, V0,
  Pmax and the slope all follow without touching the drawing.
- Density: height per plot, and showPower={false} drops the parabola, the right
  axis and the v_opt guide for a thumbnail in a testing-history list — the Pmax
  and v_opt readouts stay, because they come from the line that is still drawn.
- Readouts: the six stats are one array of {label, value, note}; drop τ and the
  slope for a coach-facing card, or add derived ratios (F0 relative to a squad
  norm, an F–V imbalance score) as extra entries.
- Palette: three chart tokens, one per quantity; re-map them to a team's own
  scale, and keep the dash on power so colour is never the only cue.
- Grid resolution: the model is sampled 96 times over the run — raise it for a
  smoother curve on a wide card, lower it for a coarser keyboard step.
- Cursor wiring: the scan index is one state value; lift it via a callback prop
  if a table or a video scrubber beside the chart should follow the same
  instant.

Concepts

  • Two parameters, everything else derived — the card is given split times and a body mass, and computes the rest. vmax and τ come out of the fit; F0 = m·vmax/τ, V0 = vmax, S_FV = −m/τ, Pmax = F0·V0/4 and v_opt = V0/2 all follow, so changing the timing system or the athlete's mass moves every number on the card without a code change.
  • Separable least squares — only τ is actually searched: with τ fixed, both the distance model and the velocity model are vmax times a known function of time, so vmax = Σ(y·g)/Σ(g²) is exact and the search is one-dimensional. Four re-centring passes, no random restarts, the same answer on every render and on the server.
  • A mean is drawn as a bar, not a dot — a timing gate reports the mean velocity over the segment that ended at it, so it is drawn across that segment. A continuous curve has to cross every bar, which turns "does this model fit?" into something you can see rather than a residual you have to trust.
  • Fitted, not asserted — with drag neglected the model makes force fall linearly with velocity, so the regression reproduces the closed form exactly. It is still a regression, because that is what lets an aerodynamic term be added to the force samples later and absorbed without redrawing anything.
  • Extrapolation drawn as extrapolation — the profile line is solid over the velocities the run reached and dashed onward to V0. Both intercepts are called theoretical in the literature for exactly this reason, and the picture says so instead of leaving it in a footnote.
  • One cursor, two plots — the time axis and the velocity axis are the same run in different coordinates, so pointing at either drives one index: the model is inverted from velocity back to time, and the same instant is marked on the curve, on the force line and on the power parabola at once.

On This Page