Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  HRV_DEFAULT_BANDS,
  HRV_DEFAULT_RATIO,
  type ChartHrvSpectrumBand,
  type ChartHrvSpectrumBin,
  type ChartHrvSpectrumData,
  type ChartHrvSpectrumRatio,
} from "./chart-hrv-spectrum.contract"

Installation

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

Prompt

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 fromHz and toHz. 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.

On This Page