Contact Height
A four-state contact-height chart — one density ridge per wing on a shared centimetre axis, strike-zone tiers cut from the player's own height, the net cord marked, and the ideal-window share printed per wing.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-contact-height.jsonPrompt
Build a React + TypeScript + Tailwind "ChartContactHeight" chart — a per-wing
contact-height distribution in hand-rolled SVG (no chart library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
wings: { id: string; label: string; contactsCm: number[] }[];
meta: { player: string; context?: string; playerHeightCm: number;
shotLabel?: string } }.
- Props = z.infer of the schema plus title?, binCm?, scale?, idealTier?,
lowFlagPct?, tierLabels?, onRetry?, emptyState? and className. No
hand-written parallel interface.
- contactsCm is one raw height per shot, never pre-binned into zones: the
zones are cut from the player's body, so a feed that already bucketed them
has chosen a body it may not know, and its edges can never be re-cut.
Behavior
- Strike-zone tiers are derived, not configured: knee 0.285, hip 0.530 and
shoulder 0.818 of standing height (Drillis & Contini segment ratios),
rounded to whole centimetres — and the rounded value is what classifies,
what is drawn and what is printed, so the edge a reader sees is the edge
the counts were cut at. Tiers are half-open, lower edge included, and
ordered top-down so drawing order, option order and table order are one.
- Four first-class branches in one bg-card panel: loading (chips + banded
ladder skeleton), empty (a faint ladder and one explanatory line), error
(message + a "Try again" button only when onRetry exists), ready. A ready
payload whose player height is unreadable, or that has no usable contact
at all, falls back to the zero state with the reason spelled out.
- Every printed figure — tier counts, shares, medians, the below-net-cord
count — is computed from the individual heights. The histogram is only a
shape: changing binCm must not move a single number on the card. A wing's four
tier shares are apportioned by largest remainder so that wing's column adds to
exactly 100.
- Headline per wing: the share struck in the ideal tier (default hip to
shoulder). A wing whose below-knee share passes lowFlagPct (default 20) is
badged "struck low" and named in a sentence under the plot, together with
its share struck below the net cord.
- Hover or keyboard focus selects a strike zone: the svg is role="listbox"
with one role="option" band per tier spanning the full card width, arrow
up/down plus Home/End walking the four zones, aria-activedescendant
announcing the active one. Arm the keyboard cursor only on :focus-visible,
so a mouse click never pins a readout with no focus ring to explain it.
The cursor rests on the ideal tier until a key moves it.
- Heights outside 0-300 cm or not finite are dropped, counted, and reported
in a visible line under the chart — never swallowed.
Rendering & styling
- One fixed viewBox, no measuring: a left gutter for the tier name and its
range, then one column per wing, then a footer row with the wing name and
its sample size. Nothing is measured, so SSR and the first client frame
agree and there is no observer to clean up.
- Height axis runs from the court surface (0) to just above the tallest
contact, and always far enough above the shoulder line to leave the
open-topped tier a drawable band.
- Each wing is one closed step polygon, symmetric about its column centre,
every bin drawn across its own slice of the axis. Fill is the wing's chart
token at ~0.35 opacity and the same token strokes the outline at full
opacity, so the silhouette survives on a near-black card where a low-alpha
fill alone would vanish.
- The one court dimension that lives on a vertical axis is the net: a dashed
muted-foreground line at 91.4 cm (the centre strap; the posts are at
1.07 m). Its label is parked in the right margin at the end of the rule,
over two lines to keep that margin narrow — never over the plot, where it
would have to punch a var(--card) hole in a ridge and its median tick to
stay readable. Court length, width and the service line are landing-zone
figures and are deliberately absent.
- Colour comes only from tokens: var(--chart-N) per wing, fill-primary/10
with a dark:fill-primary/20 step for the ideal band, stroke-border for the
zone edges, stroke-ring for the active band, text-destructive for the
"struck low" badge. Panel rounded-xl border bg-card p-4, numbers
tabular-nums, cn() merges className, root spreads the rest and carries
data-status.
- A11y: sr-only status while loading, focus-visible outline-ring on the svg,
an sr-only summary plus a tiers x wings table repeating every count and
share, and motion-reduce:animate-none on the skeleton.
Customization levers
- Zone derivation: the three ratio constants are the whole model — replace
them with measured joint heights from a calibration step, or add a fourth
edge (waist, eye level) by extending the tier table; drawing, options and
the table follow.
- Ideal window: idealTier moves the headline (a doubles volley card may want
above-shoulder), lowFlagPct sets how sensitive the "struck low" call is,
and tierLabels renames the four zones for another language.
- Ridge shape: binCm 2-20 trades noise for detail; scale="share" normalises
each wing to its own total when the sample sizes are lopsided.
- Density: GUTTER / COL_W / PLOT_H are the layout; drop the footer line, or
the range under each tier name, for a compact card.
- Palette: wings cycle var(--chart-1..5) by payload order — pin a fixed
token per wing id if forehand must always be the same colour across cards.
- Domain: relabel the wings and zones for another racket sport, or for a
bat sport where the same question is asked of the strike zone.Concepts
- Thresholds cut from the body, not the tape — knee, hip and shoulder come from the player's own standing height, so the same ball is "above shoulder" for a 1.63 m junior and "hip height" for a 1.98 m adult; a fixed centimetre ladder would quietly grade every player against the tallest one on the roster.
- Bins are shape, values are truth — the ridge is a histogram, but every count, share and median printed on the card is taken from the individual heights, so changing the bin width changes how the distribution looks and nothing it says.
- The mark sits where its value sits — each bin spans its own slice of the centimetre axis and the median tick is drawn at the median that is printed under the column, so no mark is ever coloured or labelled by one number while standing at another.
- The net cord is the only court dimension on this axis — contact height is vertical, so the 0.914 m centre strap (1.07 m at the posts) is the one line the court contributes; a ball struck under it has to be lifted, and depth or lateral zones belong to a different chart.
- One band, every wing — the interactive unit is a strike zone rather than a single bar, so hovering or arrowing to "knee–hip" reads out forehand and backhand together, which is the comparison the card exists to make.
- A diagnosis, not just a distribution — the ideal-window share is the headline and the below-knee share is a named threshold with a printed call-out, so the card tells a coach what to work on instead of leaving them to eyeball two silhouettes.
Speed vs Spin
A four-state shot scatter — every tracked shot plotted at its own speed and spin, coloured by stroke, split into four playing-style quadrants by the player's own medians, with a legend that toggles a stroke in or out.
Shot Direction Flow
A four-state tennis pattern map in hand-rolled SVG — curved arrows from the three contact corridors on the player's baseline to the three landing corridors across the net, thick for shot count and coloured by win rate, naming the most-used and the most-effective line.