Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartGaitCycleData } from "./chart-gait-cycle.contract"

export interface ChartGaitCycleProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartGaitCycleData {
  /**
   * Hub as a fraction of the disc, clamped 0.05–0.5. The hub is the low end of
   * the value axis, so a bigger hub spends more of the radius on the part of
   * the range the readings actually live in.

Installation

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

Prompt

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.

On This Page