HRV Frequency Spectrum
A four-state HRV power spectrum in plain SVG: the density estimate drawn as an area, cut and shaded at the VLF / LF / HF edges, each band integrated by the trapezoid rule into its own power and share, with an LF:HF readout and a normalised-units toggle.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-hrv-spectrum.jsonPrompt
Build a React + TypeScript + Tailwind "ChartHrvSpectrum" card in plain SVG with
zod. Recharts can draw an area, but not one that is cut at exact frequencies
and integrated into the numbers printed beside it — 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;
spectrum: { frequency >= 0; power >= 0 }[];
bands?: { id; label; fromHz >= 0; toHz > 0 }[];
ratio?: { numeratorId; denominatorId };
units?: { frequency?; power?; density? };
recording?: { subject?; date?; method?; duration? } }.
- spectrum.power is a DENSITY — ms² per Hz, not the power of the bin. That is
what makes the arithmetic possible: band power is an area, so it stays
correct for the uneven bin spacing a Lomb–Scargle estimate produces. Say so
in the field's JSDoc; feeding per-bin power in would multiply every band by
the bin width a second time.
- Band ranges are half-open [fromHz, toHz), so two bands may share an edge
(LF ends at 0.15, HF starts at 0.15) without a bin being counted twice.
- Export HRV_DEFAULT_BANDS (Task Force 1996: VLF 0.003–0.04, LF 0.04–0.15,
HF 0.15–0.4) and HRV_DEFAULT_RATIO (lf ÷ hf); the component falls back to
them when bands/ratio are omitted, and nothing in the code knows the strings
"lf" and "hf" — the ratio resolves its operands by id.
- superRefine: a ready spectrum needs two bins; toHz > fromHz; no two bands may
overlap (the overlap would land in two shares and the column could not add
up); a ratio naming a band that is not on the card is an issue. Guard every
access so a ragged payload produces an issue, not a TypeError.
- Props = z.infer of the schema plus height (200, clamped 140–420),
defaultUnitMode ("absolute"), showUnitToggle (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: sanitizeSpectrum(), densityAt(), clipCurve(),
trapezoid(), largestRemainder(), buildSpectrumModel(), bandAt(),
nearestBin(), unbandedRanges().
Behavior
- sanitizeSpectrum sorts a copy by frequency (never mutate the caller's array),
drops non-finite or negative rows and repeats of a frequency already placed,
and counts every drop — the card states the count instead of absorbing it.
- clipCurve(bins, from, to) is the heart of it: an interpolated point exactly
ON each band edge, then every measured bin strictly inside. The edges are the
band's own fromHz/toHz, never the nearest bin — that is what keeps the shaded
area on the same pixel as the boundary guide.
- Band power = trapezoid(clipCurve(...)) — Σ ½·(yᵢ + yᵢ₊₁)·(xᵢ₊₁ − xᵢ) over the
very polyline that gets filled, so the ms² and the shape are one measurement,
not two that can drift.
- Shares are of the banded total (Σ band power), rounded by largest remainder
so the column adds to exactly 100.0 at one decimal. Sampled power that falls
in no band — the trend below the lowest edge, anything above the highest — is
computed, drawn as a bare line and disclosed in words; it never joins a share.
A residual under a billionth of the estimate is float noise, not power, so it
is clamped to zero: an estimate trimmed to the bands must not disclose an
outside stretch of 0 ms².
- Normalised units: a band as a share of the banded total MINUS the lowest
band, which is the Task Force definition once VLF is the lowest band, stated
generically so a re-cut band set still works. The excluded band has no n.u.
of its own; with fewer than three bands the measure collapses to a constant
100, so the toggle switches itself off rather than printing it.
- The ratio readout divides the two named bands' absolute powers and shows the
division underneath. Because both n.u. divide by the same denominator, the
ratio is the same in either unit — say that on the card. A denominator band
with no power gives null, printed "—" with the reason, never Infinity.
- Per band the card also derives: the peak bin (picked from measured bins only,
never from an interpolated edge), the bin count, and the stretch actually
integrated. A band declared wider than the estimate reaches is measured over
what exists and says so; a band entirely outside the estimate reports
"unmeasured", which is not the same as zero.
- Readout cursor snaps to the nearest bin: 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 a bin, PageUp/Down jump ~12%, Home/End to the ends, aria-valuetext
saying frequency, density and band. Keyboard moves update an sr-only
role="status"; pointer moves do not, because a live region updated on every
pointer sample is a queue nobody can listen through. The cursor rests on the
peak of the band carrying the most power.
- The cursor dot is coloured and named by the band containing the exact
frequency it is drawn at — never by a bin index bucketed to a band, which is
how a mark ends up on the wrong side of its own boundary.
- Unit toggle is a radiogroup (two readings of one column, one printed at a
time) with roving tabindex and arrow/Home/End keys, and focus follows the
selection — otherwise the ring stays on the option the arrows just left and a
screen reader announces nothing.
- Four first-class branches: loading is a deterministic pulsing spectrum
silhouette with two band guides (aria-hidden, motion-reduce:animate-none,
sr-only status); empty explains that a band power is an area and needs the
estimate itself; error shows "Try again" only when onRetry exists; ready as
above. status="ready" with fewer than two usable bins renders empty rather
than an axis with nothing under it.
Rendering & styling
- Colors come only from tokens: band i uses var(--chart-{(i % 5) + 1}) for its
fill, its top-edge stroke, its legend swatch and the cursor dot inside it —
one formula, four consumers. Fill opacity ≈ 0.42 so the tint survives a
near-black card, and the top edge is stroked at full strength so no band
depends on the tint alone. Unbanded stretches are stroke-muted-foreground
hairlines with no fill; band edges are dashed stroke-foreground at 0.35;
grid stroke-border; axis text fill-muted-foreground.
- Band tags sit in a reserved lane above the plot and elide in two steps —
"LF 43.9%", then "LF", then nothing. A tag is only drawn when it fits inside
its own band's width, so two tags can never collide however narrow a band is,
and the full numbers live in the rows below either way.
- Both axes start at zero: the area under the curve is the quantity the card is
about, and either cropped baseline would inflate every band. Nice-step ticks
(1/2/2.5/5 × 10ⁿ) chosen from the available pixels, each label carrying
exactly the decimals its own step needs; density ticks in compact notation
("20K") so the left gutter stays narrow.
- Panel: rounded-xl border bg-card; header with title, an optional
subject/date/duration 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.
Customization levers
- Band set: bands is data. Add ULF (0–0.003), split HF, or hand in a lab's own
cut and every fill, tag, share, n.u. denominator and sr-only row follows
without a code change.
- Ratio: ratio picks the two ids to divide — LF:HF by default, but HF:total or
a custom pair works the same way.
- Units: units.frequency / power / density retarget the axes and readouts; the
maths is unit-agnostic, so a respiratory or EEG spectrum drops straight in.
- Density: height plus showUnitToggle={false} makes a thumbnail for a history
list; the readout line still works at any height.
- Palette and weight: bandInk cycles five chart tokens — remap it to a fixed
token per band id, and raise or drop the fill opacity for a deck versus a
dense report page.
- Readout wiring: the scan cursor is one state value; lift it via a callback
prop if a table beside the chart should highlight the same bin.Concepts
- The number is the area of the drawn shape — each band's power is the trapezoid integral of the very polyline that gets filled, so the ms² beside a band and the ink under it are one measurement rather than two that can drift apart. Change the shading and the number moves with it.
- Cut on the edge, not on the nearest bin — a band's polygon is clipped by interpolating the density exactly at
fromHzandtoHz. Clipping to the nearest sample instead would leave the shading and the boundary guide on different pixels and quietly hand a slice of LF to HF. - Density in, power out — the contract takes ms²/Hz rather than per-bin power, so integration is the only step that turns it into ms². That is what keeps an unevenly spaced Lomb–Scargle estimate as correct as a regular FFT grid.
- Shares that add up — percentages are taken against the summed band total and rounded by largest remainder, so the column reads exactly 100.0 (LF's 43.96 prints as 43.9 to pay for it). Power that falls in no band is measured, drawn as a bare line and stated in words instead of being folded into somebody's share.
- Normalised units name what they exclude — n.u. divides by the total minus the lowest band, so the card prints that denominator and marks the excluded band "—". Both n.u. share a denominator, which is why the LF:HF ratio is identical in either unit — also said on the card.
- Coloured by the value that placed it — the cursor dot is filled and named by the band containing the exact frequency it is drawn at, so a mark can never land inside one band's shading while claiming another's name.
Body Load Map
Front and back human silhouettes in hand-rolled SVG whose muscle areas shade by reported load or soreness — one chart token at stepped opacity, hatching for areas nobody reported, leader-lined labels on the worst areas and a ranked list underneath.
Drawdown (Underwater)
A four-state underwater chart derived from a raw level series — percent below the running peak hanging from a zero line, every dip shaded and ranked, the max-drawdown trough labelled, and a recovery read-out that flags what is still under water.