Lactate Threshold Curve
A four-state blood-lactate step test: a monotone curve through the measured stages, LT1 and LT2 as labelled guides, the three training zones derived from those two intensities, and an optional heart-rate line on its own right axis.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-lactate-curve.jsonPrompt
Build a React + TypeScript + Tailwind "ChartLactateCurve" card in plain SVG
with zod. Recharts can draw a line with reference lines, but not an
interpolation that is guaranteed monotone, and not zone bands derived from two
threshold intensities — so the model is a handful of 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;
steps: { intensity >= 0; lactate >= 0; heartRate?: > 0 }[];
thresholds: { lt1 >= 0; lt2 >= 0 };
units?: { intensity?; lactate?; heartRate? };
test?: { athlete?; date?; protocol? } }.
- intensity is whatever the protocol steps in — watts, km/h, a pace step. The
axis is labelled from units.intensity (default "W"), so the component never
assumes cycling; lactate is mmol/L and heartRate bpm.
- thresholds are INTENSITIES, never mmol/L. Every band is derived from those
two numbers, so changing the detection method (Dmax, baseline + 0.4, fixed
4 mmol/L) moves the whole zone layout without touching the chart.
- superRefine: a ready curve needs at least two steps; lt2 must sit above lt1.
Guard every access so a ragged payload produces an issue, not a TypeError
out of safeParse.
- Props = z.infer of the schema plus height (240, clamped 160–480),
showHeartRate (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: buildCurve(), valueAt(), curvePath(),
buildLactateModel(), buildZones(), zoneAt(), nearestStep().
Behavior
- buildLactateModel sorts a copy by intensity (never mutate the caller's
array), drops rows without a finite intensity/lactate and rows repeating an
intensity already placed (a curve cannot pass through two lactate values at
one intensity, and averaging them would invent a draw nobody took), and
counts every drop — the card states the count instead of absorbing it.
- The curve is a monotone cubic Hermite (Fritsch–Carlson tangents). The
limiter is the point: a plain spline overshoots after a steep step and would
draw a dip the athlete never produced. The SVG path is the Bézier whose
control points sit one third of the interval along each tangent, and
valueAt() evaluates the same polynomial — because both axis mappings are
affine, the drawn line and the numeric readout cannot disagree. Outside the
measured range valueAt returns null, never an extrapolation.
- Measured stages stay visible as dots over the line, so the interpolation
between them never passes for a reading somebody took.
- buildZones derives three bands from the two thresholds — aerobic below LT1,
threshold between them, anaerobic above LT2 — clamped to the tested range,
each carrying how many measured steps fall inside it. A threshold outside
the tested range gets no guide and is counted out loud; a payload that swaps
lt1 and lt2 still draws sane bands (min/max), the schema flags the ordering.
- Each drawn threshold is a dashed vertical guide plus a tag ("LT2 291 W") on
its own reserved row above the plot — one row per marker, so two guides can
never collide however close the thresholds are. Tags are clamped inside the
frame and painted with a card-coloured halo (paintOrder="stroke") so a guide
crossing a label never cuts through the text. A definition list under the
chart repeats each threshold as intensity · lactate · heart rate, read off
the curve at that intensity.
- Heart rate is optional per step: with two or more readings it becomes a
second monotone curve on its own padded right axis, dashed as well as
coloured. State plainly that the two scales are independent, so where the
lines cross means nothing — only their shapes do. showHeartRate={false}
hides it and gives the right gutter back.
- The readout cursor snaps to the nearest measured step: pointermove over one
transparent hit rect (converting through its own client box so it stays
correct when the SVG is scaled down), and a keyboard slider —
role="slider", tabIndex=0, arrows step one stage, PageUp/Down jump ~20%,
Home/End to the first and last stage, aria-valuetext saying intensity,
lactate, heart rate and zone. 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 visible
readout is aria-hidden, and the cursor rests on the step nearest LT2 — the
number the whole test exists to find — so the card ships with a live reading.
- Four first-class branches: loading is a deterministic pulsing rising curve
over three band placeholders (aria-hidden, motion-reduce:animate-none,
sr-only status text); empty explains what a curve needs and reports arrivals
that could not be placed; error shows "Try again" only when onRetry exists;
ready as above. status="ready" with fewer than two usable steps renders the
empty branch rather than an axis with nothing on it.
Rendering & styling
- Colors come only from tokens: lactate line var(--chart-1), heart rate
var(--chart-3), zone bands var(--chart-2) / var(--chart-4) / var(--chart-5)
at fillOpacity 0.14; grid stroke-border, axis text fill-muted-foreground,
guides stroke-foreground dashed, dots haloed with var(--card). Colour never
carries a zone alone — the legend names each band with its intensity range
and step count, the readout says the zone in words, and an sr-only table
repeats the whole split.
- Zone bands are painted first, under the gridlines: they are the background
the curve is read against, never something drawn over the data.
- Panel: rounded-xl border bg-card; header with title, an optional
athlete/date/protocol meta line and a tabular-nums summary; cn() merges
className; rest props spread on the root div.
- Axes: lactate always starts at zero (a step test is read against the resting
baseline, and a cropped bottom would exaggerate every early rise) with
nice-step ticks (1/2/2.5/5 × 10ⁿ) chosen from the available pixels; heart
rate gets its own 12%-padded window. A tick label carries exactly the decimals
its own step needs — the 2.5 rung lands on 0.25 steps, and a fixed single
decimal would label gridlines with numbers they are not drawn at, on the
lactate axis and on a non-watt intensity axis alike. Width comes from a
ResizeObserver (disconnected on unmount) with an SSR fallback viewBox.
Customization levers
- Threshold method: lt1/lt2 are inputs, not calculations — feed Dmax,
baseline + 0.4 mmol/L, fixed 2/4 mmol/L or a coach's eyeball and every band,
guide and zone count follows without a code change.
- Zone naming: the three names live in one place (Aerobic / Threshold /
Anaerobic); rename to Z1–Z3, "easy / tempo / VO2" or a lab's own scheme, and
the legend, readout and sr-only table all follow.
- Zone palette: remap the three chart tokens, or raise fillOpacity for a
presentation deck and drop it for a dense report page.
- Units: units.intensity retargets the x axis to km/h, min/km or a step index
— nothing in the maths is watt-specific.
- Density: height and showHeartRate={false} make a thumbnail for a test-history
list; the readout line still works at any height.
- Second series: the heart-rate line is one call to the same curve builder on
its own scale — swap it for VO2, ventilation or RPE by changing the field
and the right-axis caption.
- Readout wiring: the snap-to-step cursor is one state value; lift it via a
callback prop if a table beside the chart should highlight the same stage.Concepts
- Thresholds in, zones out — the component is given two intensities and derives everything else: the three bands, their edges, how many stages sit in each and where the guides go. Swapping the detection method (Dmax, baseline + 0.4, fixed 4 mmol/L) is a data change, never a chart change.
- Monotone interpolation — Fritsch–Carlson tangents keep the smoothed line inside the data on every interval, so the curve cannot invent a dip between two rising draws; the dots stay on top so smoothing never passes for measurement.
- One curve, two consumers — the SVG path is the Bézier form of the same Hermite polynomial
valueAt()evaluates, and both axis mappings are affine, so the threshold readouts are points of the drawn line rather than a parallel calculation that can drift. - Independent right axis, stated — the heart-rate line has its own padded scale, and the card says so in words: where the two lines cross carries no meaning, only their shapes do. A dual axis that hides this is the classic way to imply a relationship nobody measured.
- Snap-to-step readout — pointer and keyboard both land on real stages, never interpolated pixels, and the cursor rests on the step nearest LT2 so the card opens on the number the test was run to find.
- Honest gaps — unusable rows, repeated intensities and thresholds outside the tested range are counted and stated on the card; a stage with no heart rate simply has no bpm in its readout instead of a zero.
Anomaly Timeline
A four-state anomaly-detection timeline: one metric line with the detector's flagged readings marked as destructive diamonds, anomalous stretches hatched, ribboned and counted, and an optional expected-range band the line is judged against.
Gait Cycle Polar
A four-state polar phase chart of one cyclic movement — left and right as closed loops round 0–100% of the cycle, the gap between them shaded, phase sectors labelled at the rim, and an asymmetry index printed signed as well as absolute.