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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-sprint-profile.jsonPrompt
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.
vmaxandτcome out of the fit;F0 = m·vmax/τ,V0 = vmax,S_FV = −m/τ,Pmax = F0·V0/4andv_opt = V0/2all 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
vmaxtimes a known function of time, sovmax = Σ(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.
Poincaré Plot (HRV)
A four-state Poincaré return map for heart-rate variability that derives its own statistics — SD1, SD2 and their ratio measured in the 45°-rotated frame, an ellipse drawn from exactly those two numbers about the cloud's centroid, and pairs formed only from beats the recording's own clock agrees are successive.
Partial Dependence + ICE
A four-state partial dependence plot with the ICE bundle it is the average of — one faint line per instance under the bold mean, a centring toggle that re-bases every curve on the first grid point, curves that move against the average dashed and counted, and a decile rug showing where the training data actually lives.