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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-gait-cycle.jsonPrompt
Build a React + TypeScript + Tailwind "ChartGaitCycle" card in hand-rolled SVG
(no chart library) with zod: a polar phase chart of one cyclic movement, left
and right as two closed loops, plus an asymmetry readout.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
subtitle?: string;
metric: { label: string; unit?: string };
sides?: { left: string; right: string };
samples: { phase: number; left: number; right: number }[];
sectors: { label: string; fromPhase: number; toPhase: number }[] }.
- phase is percent of ONE cycle. 100% is the same instant as 0%, so a feed
sends 0…98, and anything outside the range is wrapped, never clipped.
- Component props = z.infer of the schema plus onRetry?, holeRatio = 0.22,
baseline = "zero" | "auto", domainMax?, tickCount = 4,
showDifference = true, locale = "en-US", className, and the rest spread on
the root. No hand-written parallel interface.
Behavior
- Four first-class branches in one bg-card panel:
- loading: the ready layout in muted skeleton — stat line, a pulsing pair of
deterministic loops (no randomness at render, so SSR and the client agree),
legend bar; animation off under motion-reduce.
- empty: a dashed double ring, "No cycle captured yet", and a line that says
what a loop needs. A "ready" payload with fewer than three usable phases
lands here too — two points draw a chord, one draws a dot.
- error: destructive headline + a "Try again" button rendered only when
onRetry exists.
- ready: sector band, grid, both loops, the shaded gap, cursor, legends,
footnotes and an sr-only table.
- Data hygiene, stated out loud rather than swallowed: readings with a
non-finite phase or side are dropped and counted; a phase already sampled
collapses to the first one seen and is counted; sectors whose ends are
written identically are dropped and counted, while 0 → 100 is honoured as a
single all-cycle sector; a phase inside more than one sector goes to the
first declared and is counted.
- Value axis: min/max across BOTH sides, extended to zero under
baseline="zero", raised to domainMax when that is bigger, then rounded out
to a 1/2/2.5/5/10 nice step. If the data passes domainMax, the rim rises and
the card says so — comparability never wins over drawing the data.
- Asymmetry: absolute index = Σ|L−R| ÷ Σ((|L|+|R|)/2) × 100, signed index the
same with the sign kept. The denominator uses magnitudes so a metric that
dips below zero cannot cancel it out. Both are printed, plus a per-sector
index, because a cycle where each side leads in a different sector reads
near zero signed while the absolute index stays large. All-zero readings
leave the index undefined ("—") rather than a flattering 0%.
- Interaction: the svg is one tab stop with role="listbox" and
aria-activedescendant; every phase is an option carrying a full sentence.
Hover or arrow keys move a radial cursor; the cycle is closed, so the arrows
wrap round instead of clamping. Home/End jump to first/last. Only
:focus-visible arms the keyboard cursor, so a mouse click doesn't pin it.
Hit regions are the angular slot each phase owns, hub to rim, so the loops
are not the only targets.
Rendering & styling
- Semantic tokens only: left loop var(--chart-1) solid, right loop
var(--chart-2) dashed (shape as well as hue, so the sides survive
greyscale), the gap var(--chart-3) at low opacity, grid and rim stroke-border,
sector band alternating fill-muted / fill-muted-foreground because sectors
are context and never a third measured quantity, text fill-muted-foreground
with a var(--card) halo via paint-order="stroke".
- Geometry: 360-unit square viewBox, phase 0 at twelve o'clock running
clockwise, radius from a hub (holeRatio × rim) out to the rim. Loops are
straight segments between measured phases closed through the seam — a spline
would invent readings. The gap is painted as one quad per segment, so a
crossing pinches to nothing and reopens the other way round.
- Every coordinate is quantized (round to 1e-3) before it enters an attribute:
server and browser Math.sin disagree by one ulp and React reports a
hydration mismatch otherwise.
- Sector labels are anchored radially outward with a per-label character
budget measured to the edge of the box; shortened or dropped names are
counted and printed in full in the readout and the sr-only table.
- cn() merges className; the panel is rounded-xl border bg-card p-4; numbers
are tabular-nums; the svg carries a focus-visible outline-ring.
Customization levers
- Domain: baseline="auto" moves the hub to the lowest reading, which magnifies
a small difference — right for a metric that never nears zero, misleading
everywhere else. domainMax pins the rim so two cards compare.
- Hub size: holeRatio 0.05–0.5. A bigger hub spends more radius on the band
the readings actually live in; a smaller one gives the loops more room.
- Density: tickCount 2–6 rings; drop the phase numerals or the sector chips
for a thumbnail; showDifference={false} leaves the two loops bare.
- Palette: three token slots — chart-1 left, chart-2 right, chart-3 gap. Swap
the dash for a second hue only if greyscale is not a requirement.
- Domain fit: it is not only gait. Pedal stroke (torque against crank angle,
sectors "Power"/"Recovery"), rowing (handle force by stroke phase), or any
two-sided cyclic signal drops in by renaming metric and sides.
- More than two sides: the loop builder takes an array of points, so a third
series (a previous session, a target curve) is one more path plus one more
legend row — keep the gap band on the two sides being compared.Concepts
- Cycle as a closed ring — the x axis is percent of one cycle, so it has no left and right edge: the loop closes through the 0% seam and the arrow keys wrap from the last phase back to the first instead of stopping at an edge the movement does not have.
- Asymmetry as a gap — two loops on one radial scale turn "left versus right" into a distance you can see at every phase, and the shaded band between them is that distance made literal; where the sides swap lead, the band pinches to nothing and reopens the other way round.
- Signed against absolute index — one number can hide a cycle whose sides lead in different sectors, so the card prints the signed index (direction), the absolute index (magnitude) and one index per phase sector, which is where a rehab decision actually gets made.
- Sectors as context, never a series — stance/swing or power/recovery live in a neutral band outside the rim, alternating in surface tones rather than taking a chart token, so nobody reads the phase labels as a third measured quantity.
- Redundant encoding — side is carried by hue and by line style (solid against dashed) and by the filled-against-hollow peak marker, so the two limbs stay apart in greyscale, in a screenshot and for a colour-blind reader.
- Honest cleaning — unusable readings, repeated phases, zero-width sectors, phases claimed by two sectors and a rim raised past its requested ceiling are all counted and printed under the chart, because a cycle chart that quietly loses a phase lies about the index too.
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.
Serve Placement Zones
A four-state tennis serve map — the receiver's half court in hand-rolled SVG, both service boxes cut into wide/body/T zones shaded by serve volume and labelled with count plus win rate.