Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartPoincarePlotData, ChartPoincarePlotWindow } from "./chart-poincare-plot.contract"

export interface ChartPoincarePlotProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartPoincarePlotData {
  /**
   * Side of the square plot box in px (clamped 180–520, default 320).
   *
   * It is one number for both axes on purpose: a Poincaré plot is only readable

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartPoincarePlot" HRV card in plain SVG
with zod. Not recharts: the whole figure is one ellipse rotated 45° onto axes
that are not the chart's axes, sitting on a plot that is only correct when both
axes share one domain at one scale — none of that survives a cartesian chart
library's independent x and y scales.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    beats: (number | { t: number >= 0; rr: number })[];
    recording?: { athlete?, date?, context?, device? };
    highlight?: { from: number; to: number; label?: string } }.
- beats is the RR (NN) series in payload order, in milliseconds. Bare numbers
  are allowed and their clock is reconstructed as the cumulative sum, which is
  exact rather than approximate: an RR interval IS the time from the previous
  beat to this one. Timestamped beats are for series that have been EDITED —
  see the successive-pair guard below.
- rr is deliberately NOT .positive(): consumer straps export 0 ms where beat
  detection failed, and rejecting a 300-interval payload over three of them
  blanks a card that had plenty of data. The component drops them and says how
  many it dropped.
- highlight.from/to are seconds on the same clock as beat.t.
- superRefine: ready needs >= 4 intervals (three pairs is the smallest cloud an
  ellipse can honestly be fitted to); beats must not MIX bare numbers with
  timestamped beats (a mixed array puts the highlight window on two different
  clocks); highlight.to must be after highlight.from. Guard every access so a
  ragged payload yields an issue, not a TypeError out of safeParse.
- Props = z.infer plus size (square plot side, default 320, clamped 180..520),
  onRetry, className and the div's native props; forwardRef to the card.
- Export the maths next to the component so a test can print the numbers the
  picture is made of: normaliseBeats(), buildPairs(), poincareDispersion(),
  buildPoincareModel(), nearestPair(), axisTicks().

Behavior
- SUCCESSIVE MEANS SUCCESSIVE IN TIME. Form a pair from entries i and i+1 only
  when both intervals are readable AND the clock agrees they follow each other:
  |(t[i+1] - t[i]) * 1000 - rr[i+1]| <= 25 ms. On a reconstructed clock that is
  true by construction; on a timestamped series it is what catches an ectopic
  beat that the analysis software deleted, where the array closed up but a
  second of wall clock did not. Count those refusals and say so in view — an
  ectopic pair lands far off the identity line and would inflate SD1 on its own.
  An unreadable interval likewise kills BOTH pairs it touches; it must never be
  stitched over, because RR n-1 and RR n+1 are not neighbours.
- DERIVE SD1 AND SD2, NEVER ACCEPT THEM. Rotate the pairs 45° onto the identity
  line: u = (x + y)/√2 along it, v = (y - x)/√2 across it. Then SD2 = SD(u) and
  SD1 = SD(v), both sample standard deviations (n - 1, n = pairs), and the
  ellipse's two semi-axes ARE those numbers. State in a comment that this is the
  textbook definition reached from the picture: v is the successive difference
  over √2, so SD(v) = SDSD/√2 exactly, i.e. SD1² = ½·SDSD² — and that the
  familiar RMSSD/√2 form is exact only when the successive differences average
  to zero. Take deviations from the mean in a second pass; the one-pass
  sum-of-squares shortcut cancels five figures away when the spread is 40 ms on
  intervals of 950.
- CENTRE ON THE CLOUD, NOT ON THE LINE. The ellipse sits at the centroid
  (mean x, mean y). Convention pins it to (mean RR, mean RR) on the identity
  line; that is an assumption of stationarity, and the two differ by
  (last RR - first RR)/n. Report the centre projected back onto the line,
  (x̄ + ȳ)/2, as mean RR, with 60000/mean RR as the mean heart rate.
- SQUARE, ONE DOMAIN, ONE SCALE. Both axes take the same [low, high] and the
  same px-per-ms. Anything else tilts the identity line off 45°, and
  "perpendicular to it" — the entire definition of SD1 — stops pointing
  anywhere on screen. The domain covers every plotted interval AND the ellipse's
  own bounding reach, √((SD1² + SD2²)/2) on each axis, so a fitted ellipse is
  never clipped by the frame it is being judged in.
- WINDOW. A pair belongs to highlight only when BOTH of its intervals were
  measured inside it — half a pair is not an observation of the window. Draw
  those pairs on top of the rest and report the window's own SD1/SD2/mean RR
  beside the recording's; below two pairs, say so instead of printing an SD.
- SCAN. One tab stop, role="slider" over the pairs in time order: pointer picks
  the nearest dot in pixel space, arrow keys step one pair, PageUp/PageDown jump
  a tenth of the recording, Home/End go to the ends. The current pair drops a
  dashed crosshair to BOTH axes, because one dot here is two intervals, not one
  value. Keyboard moves speak through an sr-only role=status line; pointer moves
  do not (a live region updated on every pointer sample is a queue nobody can
  listen through).
- Four first-class branches of one card: loading (skeleton readout row plus a
  comet silhouette inside a tilted ellipse, aria-hidden, with an sr-only
  role=status line), empty (worded so it cannot be mistaken for a failed fetch,
  and it explains that a stored RMSSD cannot be unrolled back into a cloud),
  error (Try again only when onRetry was passed), ready. status "ready" that
  yields fewer than three pairs renders the empty branch, carrying the drop and
  gap counts with it.
- CLEANUP: one ResizeObserver measuring the plot column so SVG user units are
  CSS pixels, disconnected on unmount and whenever the node changes. No timers,
  no rAF, no window listeners.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border for the
  frame and gridlines, muted for the skeleton, muted-foreground for axis text,
  foreground at 45% opacity for the dashed identity line, stroke-ring for the
  focus frame, and var(--chart-1) for the cloud, var(--chart-2) for the fit,
  var(--chart-3) for the highlighted window. Never a hex.
- THE FILL MUST SURVIVE BOTH THEMES. The ellipse is outlined at full ink and
  filled only as a hint — fill-chart-2/12 dark:fill-chart-2/25, because a tenth
  of a bright chart token on a near-black card lands within a few sRGB steps of
  the card itself. The stroke, not the tint, is what carries the shape.
- COLOUR IS NEVER THE ONLY CHANNEL: highlighted pairs are also drawn larger and
  ringed in the card colour, so the window survives a greyscale print and a
  colour-vision deficiency.
- SD1 and SD2 are drawn as two short spokes from the centroid along the rotated
  axes, tagged "SD1"/"SD2" with a card-coloured paint-order halo and clamped
  inside the frame; the numbers themselves live in a three-up readout row
  (SD1 / SD2 / SD1:SD2) above the plot, so the tags stay three glyphs wide and
  never fight the dots. The identity line is named in the legend, not in the
  frame — a comet lies along it, so any in-plot tag would sit in the densest
  part of the card.
- One tick set serves both axes because the domain is shared; ticks land on a
  1/2/2.5/5×10ⁿ rung at roughly one per 68 px, and the end labels switch anchor
  so they cannot overhang the frame.
- ACCESSIBILITY: role="group" on the svg (never role="img" — it is
  children-presentational and would silence the slider), aria-labelledby the
  heading and aria-describedby the summary line. An sr-only WRAPPER DIV (never
  sr-only on the table itself: CSS width is only a lower bound for a table box)
  holds up to 16 rank-spaced pairs with both intervals, the change and the
  window, under a caption carrying SD1, SD2, the ratio and the mean.
- Motion: the only animation is the skeleton pulse, and it carries
  motion-reduce:animate-none.

Customization levers
- Ellipse extent: 1 SD is the convention and the one the numbers in the readout
  refer to. Draw 2 SD for a coverage-style figure, but relabel it — an unlabelled
  2 SD ellipse reads as an SD1 twice the size of the printed one.
- The pairing guard: GAP_TOLERANCE_MS decides what "successive" means. Widen it
  for a lossy optical sensor, drop it to zero for a lab ECG where any gap is a
  real edit, or expose it as a prop when the pipeline's own artefact rules
  should win.
- Density: size is the square plot side; drop the caption paragraph and the
  centroid row for a dashboard tile, or raise the dot radius and lower its
  opacity for short recordings where every beat should read individually.
- More than one window: the highlight is a single range because two tinted arms
  stop being readable, but the model is a per-pair boolean — swap it for a
  segment id and one ink per segment if you are showing a protocol with phases.
- Extra statistics: the same dispersion pass gives SDNN via SD1² + SD2² = 2·SDNN²
  and RMSSD via SDSD; add them to the readout row when the audience is clinical,
  keep the three-up row when it is an athlete.
- Palette: re-point PAIR_INK / FIT_INK / WINDOW_INK at the host palette; keep the
  fit and the cloud on different hues, because the ellipse has to read through
  the dots it encloses.
- Interaction: for a static report card, drop the slider group and keep the
  crosshair off; for a shared cursor across a page of HRV charts, lift scanIndex
  into a controlled prop and emit onPairChange.

Concepts

  • A return map turns a series into a shape — plotting every interval against the one that followed it converts a wiggly tachogram nobody can read into a cloud whose form is the finding: long and thin along the identity line is a healthy vagal signature, a round blob is noise or artefact, and a stray arm is an ectopic beat announcing itself. The point of the chart is that this is a judgement the eye makes faster than any single number.
  • The rotation is the definition, not a drawing trick — SD1 and SD2 are the spread of the same cloud measured on axes turned 45°, so computing them in that frame makes the ellipse and the readouts one act instead of two. Numbers that were fitted to the picture cannot drift from it; numbers accepted from upstream can, and a card whose SD1 disagrees with its own ellipse is worse than a card with no ellipse.
  • Successive is a claim about time, not about array position — HRV pipelines delete ectopic beats, and the array closes over the hole while the clock remembers it. Pairing blindly would draw a dot from two beats a second apart and hand SD1 a jump the heart never made, so the component checks the clock, refuses that pair, and prints the refusal rather than quietly improving the statistic.
  • Equal aspect is a correctness constraint — stretch one axis and the identity line leaves 45°, at which point "perpendicular to it" points somewhere that is no longer SD1 and the ellipse is drawn on axes the data was never measured on. That is why the plot box is square and both axes share one domain and one scale, and why the frame is sized from the ellipse's own reach so a fit is never clipped by the box that judges it.
  • A window earns its own statistics — highlighting pairs is decoration; reporting the window's SD1 and SD2 next to the recording's is the reading. In the sample recording the paced-breathing block barely widens the cloud across the line while more than doubling it along the line, which is exactly the case where SDNN and RMSSD tell opposite stories and only the shape explains why.
  • Two intervals, two axes, one crosshair — a dot on a return map is not a value at a time, it is a pair, so the scan cursor drops a dashed line to both axes and the readout prints RR n → RR n+1 with its signed change. Keyboard moves speak through a live region while pointer moves stay silent, because a region updated on every pointer sample is a queue nobody can listen through.

On This Page