Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartShotDirectionFlowData,
  ChartShotDirectionFlowPattern,
  ChartShotDirectionFlowShotType,
  ChartShotDirectionFlowZoneId,
} from "./chart-shot-direction-flow.contract"

export interface ChartShotDirectionFlowProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-shot-direction-flow.json

Prompt

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 → deuce mean down the line and deuce → ad mean 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.missCount and 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 stay role="img" without hiding anything a mouse could reach.

On This Page