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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-shot-direction-flow.jsonPrompt
Build a React + TypeScript + Tailwind "ChartShotDirectionFlow" chart — a tennis
rally-pattern map in hand-rolled SVG (no chart library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
patterns: { fromZone: "deuce"|"centre"|"ad"; toZone: same;
count: int >= 0; wonCount: int >= 0;
shotType?: "forehand"|"backhand" }[];
zones: { id: same enum; contactLabel: string; landingLabel: string }[];
meta: { player: string; context?: string; shotLabel?: string;
hand?: "right"|"left"; missCount?: int >= 0 } }.
- Props = z.infer of the schema plus title?, minSample?, spread?, onRetry?,
emptyState? and className. No hand-written parallel interface.
- A "zone" is a corridor running the WHOLE length of the court — one third of
the 8.23 m singles court, named from the tracked player's end (deuce = their
right hand). One id therefore names both ends of a pattern, which is what
makes deuce→deuce read as down the line and deuce→ad as crosscourt.
- Already aggregated: one row per line played, never one row per shot. Repeats
of the same (from, to, wing) triple are summed. Shots that never landed in
live in meta.missCount, never as a pattern with count 0 — a ball in the net
has no landing corridor.
- wonCount is a SHOT count, not a point count: how many of that line's shots
were struck on points the player went on to win. One long rally puts six or
eight shots into the column for a single point, so every readout says "on
points won" — printing the sum as "points won" would claim more points than
the match had.
Behavior
- Four first-class branches in one bg-card panel: loading (two headline tiles,
the court silhouette with pulsing arcs, list bars), empty (a faint court and
one line of copy — also used when a ready payload has nothing drawable),
error (message + a "Try again" button only when onRetry exists), ready.
- Ingest is a pure exported function: sum repeats, drop rows whose corridor or
numbers are unreadable, clamp wonCount down to count, hold back zero-count
patterns, and report all three counts in a visible line — never silently.
- Names come from the geometry plus the wing: crosscourt, down the line, into
the middle, to the deuce/ad side. A forehand struck from the BACKHAND corner
is the one case geometry cannot name — inside-out crosscourt, inside-in down
the line — and which corner that is depends on meta.hand.
- Two headline tiles name the most-used pattern (busiest) and the most
effective (best win rate among patterns with at least minSample shots,
default 12); patterns under the floor are drawn and listed, and the
exclusion is stated.
- Every pattern is also a row in a ranked list of real <button>s with
aria-pressed: click pins it, hover or focus previews it, and the pinned or
previewed arrow is re-drawn over the rest with a ring halo while the others
drop to 0.3 opacity. Arrows carry the same pointer toggle; the buttons are
the keyboard path, so nothing is mouse-only.
Rendering & styling
- Geometry in metres, the court turned a quarter turn so the whole 23.77 m
fits a 420px card: 23.77 x 10.97 doubles rectangle, singles sidelines 1.37 m
in, service lines 6.40 m each side of the net, centre service line, 10 cm
centre marks, net posts 0.914 m outside the doubles sideline. Corridors are
the singles width / 3 = 2.743 m, drawn dashed because they are the analyst's
cut, not a court line.
- Arrows: quadratic arcs from a contact anchor 0.55 m behind the player's
baseline to a landing anchor at the middle of the far deep band
((6.40 + 11.885) / 2 = 9.14 m past the net) — the SAME depth for every
pattern, since only the corridor is data. Stroke width is linear in the shot
count; the head is split off the curve with de Casteljau so the head's tip,
not a stroke cap, sits on the anchor. Endpoints meeting one corridor are
stacked Sankey-style so four patterns never pile onto one point, and the arc
bows by how far the ball changed corridor, further out per sibling pattern.
- Colour is the win rate against the player's OWN overall rate: both are
rounded to whole percentage points first, so the legend's printed bounds are
the exact cuts, and a mark can never be coloured on the wrong side of a
number it also prints. Three full-strength tokens — var(--chart-2) above,
var(--chart-1) level, var(--chart-5) below — never a low-alpha wash, which
vanishes on a near-black card. A cut that lands outside 0-100 (a low overall
rate with a wide spread) can hold nothing, so that band is dropped from the
legend and the level band goes single-sided, rather than printing a "≥ 110%"
or "≤ -5%" bound.
- Layering: court lines (stroke-border), then the corridor names, then the
arrows, then the net (stroke-muted-foreground) redrawn over them because
every ball crosses it, then a transparent hit layer. Each arrow carries a
var(--card) outline immediately under its own ink, so a crossing reads as one
line passing in front of another; the three contact zones are marked as short
bars on the ground behind the player's baseline. Corridor names live in the
margins, clear of the arrow field — strike names right-aligned beside the
contact bars, landing names past the far baseline — each on its own
corridor's centre line (dominantBaseline="middle") so the whole glyph body is
inside the band it names. No halo and no plate out there: a card-coloured
outline over the bundle is what saws a ribbon in half, and a name long enough
to reach back into the court is passed over by the arrows instead. Their
shares are printed as text under the chart rather than fighting an arrow for
space, rounded by largest remainder so the three corridors of one end add to
exactly 100%. The per-pattern shares in the list, the tiles and the table are
apportioned the same way, so the patterns also add to exactly 100%.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
className, root spreads the rest and carries data-status.
- A11y: the svg is role="img" with a full sentence for a label (its insides are
repeated as buttons and as an sr-only table, so nothing is unreachable),
sr-only status while loading, focus-visible rings on every control, and
motion-reduce:transition-none / animate-none throughout.
Customization levers
- Corridor count: three thirds is the default; a five-corridor cut only needs
new ids in the enum plus their centre lines — stacking, naming, legend and
table follow.
- What colour means: swap the "against the player's own average" reference for
a fixed 50%, or for the opponent's rate, by changing the one refPct value;
widen or narrow the level band with the spread prop (1-25 points).
- Arc shape: BOW_BASE / BOW_STEP trade a fanned bundle for near-straight
chords; set both to 0 for a plain chord diagram.
- Weight range: MIN_W / MAX_W in court metres set how fat the busiest line
gets — raise MIN_W when the tail patterns matter more than the habit.
- Density: drop the two headline tiles for a compact card, or the ranked list
for a court-only tile (keep the sr-only table either way); minSample decides
how small a line may be and still be called effective.
- Domain: the corridors and the court constants are the only tennis in here —
relabel them for padel or pickleball, or point the same component at a
serve-plus-one map by feeding serve corridors as fromZone.Concepts
- One corridor, both ends — a zone is a strip of the whole court rather than a landing box, so a single id names where the ball was struck and where it landed; that is what lets
deuce → deucemean down the line anddeuce → admean crosscourt without a second vocabulary to learn. - Volume and outcome in different channels — thickness carries how often the line was played and colour carries how it paid off, deliberately kept apart: a pattern can be the thickest arrow on the card and still be losing, and the chart says so instead of blending both into one shade nobody can decode.
- Diverging against the player's own average — the colour reference is the player's win rate across every shot that landed in, not a flat 50%, so the card answers "which of my lines beat me?"; both the rate and the reference are rounded to whole points before the cut, so a printed 53% can never sit on the wrong side of a legend that reads "≥ 53%".
- Stacked endpoints, fanned arcs — arrows meeting one corridor are laid side by side inside it Sankey-style and ordered by the corridor they come from, so four patterns landing on the same side never pile onto one point, and two patterns sharing both ends (a forehand and a backhand down the same line) are pulled apart by the arc instead.
- Curvature that means something — the arc's size is the pattern's own change of direction: a down-the-line ball is drawn straight, a full crosscourt gets the whole bow. It is still spacing, not physics, and the caption says so — from above a ball's path is a straight line, and height belongs to a net-clearance chart.
- A miss has no corridor — balls into the net, long or wide are counted in
meta.missCountand never drawn, so a pattern's win rate is always of the balls that landed on that line, with the in-play share carried separately in the header. - Pointer and keyboard land on one
active— hover, focus and the pinned selection all resolve to the same pattern key, and every arrow has a real<button>twin in the ranked list, so the court can stayrole="img"without hiding anything a mouse could reach.
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.
Net Clearance
A four-state side elevation of shot quality — height over the net cord against landing depth, with the coached window banded, netted balls stacked on a to-scale net and median crosshairs.