Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartContactHeightData, ChartContactHeightWing } from "./chart-contact-height.contract"

/* -------------------------------------------------------------------------- */
/* Domain geometry                                                             */
/* -------------------------------------------------------------------------- */

/**
 * Contact height is a **vertical** measure, so the only court dimension that
 * lands on this axis is the net (ITF Rules of Tennis, Appendix I): 0.914 m over

Installation

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

Prompt

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.

On This Page