Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartLactateCurveData,
  ChartLactateCurveStep,
  ChartLactateCurveThresholds,
} from "./chart-lactate-curve.contract"

export interface ChartLactateCurveProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartLactateCurveData {

Installation

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

Prompt

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.

On This Page