Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartShotSpeedSpinData,
  ChartShotSpeedSpinOutcome,
  ChartShotSpeedSpinQuadrants,
  ChartShotSpeedSpinShot,
  ChartShotSpeedSpinType,
} from "./chart-shot-speed-spin.contract"

export interface ChartShotSpeedSpinProps

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartShotSpeedSpin" chart — a per-shot
speed × spin scatter in hand-rolled SVG (no chart library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    shots: { type: "forehand" | "backhand" | "serve" | "slice" | "volley";
             speed: number > 0; spin: number >= 0;
             outcome?: "in" | "winner" | "forced" | "unforced" }[];
    units: { speed: "km/h" | "mph"; spin?: string };
    meta: { player: string; context?: string; shotLabel?: string };
    quadrants?: { fastHighSpin, fastLowSpin, slowHighSpin, slowLowSpin } }.
- Props = z.infer of the schema plus title?, height?, onRetry?, emptyState? and
  className. No hand-written parallel interface.
- One row per shot, never a bin: a shot is an event, not an entity, so there is
  no id — dots are anonymous and named by their stroke. Values already arrive in
  units.speed; the component never converts.
- spin is a rate MAGNITUDE, never signed. Direction is implied by type (slice
  and volley carry underspin), which is why a floated slice reads high on the
  spin axis and slow on the speed axis.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: a dashed frame with a crosshair and pulsing placeholder dots, its
    box computed from the same clamped height the ready svg will use, followed
    by caption / echo / legend / key / note bars so the skeleton lands near the
    ready card's height rather than half of it;
  - empty: an empty crosshair and one explanatory line (also used when a ready
    payload has no plottable rows left);
  - error: message + a "Try again" button rendered only when onRetry exists;
  - ready: caption, plot, hover echo, legend, key, note, sr-only tables.
- Rows whose speed or spin is missing, negative or non-finite are dropped,
  counted, and the count is stated under the chart — never swallowed.
- Every dot sits on its own measured (speed, spin) pair: no binning, no
  jitter, no collision fan-out. Which side of the crosshair a dot is on must
  be its true side, because that is the whole claim of the chart.
- Crosshair = the median speed and median spin of the shots CURRENTLY SHOWN, so
  hiding a stroke re-centres the split; the axis domain is taken from every
  usable shot instead, so no dot ever moves when a stroke is toggled. Quadrant
  membership uses >= on both axes, one rule shared by the counts, the corner
  labels, the hover echo and the table.
- Legend = one <button aria-pressed> per stroke present in the data, carrying
  that stroke's count and its own median speed and spin. Those per-stroke
  numbers are computed over all of that stroke's shots, so they never move when
  another stroke is hidden. Pressed = shown. Hidden clouds stay mounted at
  opacity 0 with pointer-events none, so the change can fade.
- Every stroke hidden is a legal state: axes and grid stay, the crosshair and
  the quadrant names disappear, and the plot says so in place.
- Outcome changes the MARK, never the position: winner = solid, slightly
  larger, with a var(--card) halo so it lifts out of the cloud; forced or
  unforced = hollow ring; in play (and missing outcome) = soft disc.
- Hover is delegated from the svg via a data attribute and echoed in a fixed
  height line under the plot (stroke, speed, spin, outcome, quadrant) with a
  ring on the hovered dot — no floating tooltip to clip at the plot edge.
  Toggling off the hovered stroke clears the echo, because `active` is derived
  from `hovered` rather than stored.

Rendering & styling
- The svg is drawn at the measured card width (ResizeObserver, disconnected on
  unmount and on node change) so every label is real px and never scales down;
  left gutter is budgeted from the rotated axis title plus the widest y tick,
  ticks snap to a 1/2/5 × 10^n ladder inside a domain padded 6% each side.
- Colour comes only from tokens: a fixed var(--chart-1..5) per stroke — never
  chart-(i % 5) + 1 over whatever arrived — so a payload without volleys cannot
  repaint the slice and a toggle cannot shuffle the rest. Crosshair
  stroke-foreground/55 dashed, grid stroke-border, quadrant names
  fill-muted-foreground with paintOrder="stroke" over a var(--card) halo.
- Per-stroke median crosses are drawn twice: a wide var(--card) halo path, then
  the coloured path, so a cross never dissolves into its own dots.
- A quadrant name is dropped to its bare name, then dropped entirely, when its
  box cannot hold the text — a clipped word is worse than none, and the count
  is still in the sr-only table.
- Panel rounded-xl border bg-card p-4; numbers tabular-nums; cn() merges
  className; root spreads remaining props and carries data-status.
- A11y: the plot is role="img" with a full summary label; the interactive part
  is the real <button> legend (aria-pressed, aria-controls, focus-visible ring);
  the caption is aria-live="polite" because it is the sentence a toggle
  changes; two sr-only tables carry per-stroke medians and quadrant counts
  instead of a 160-row dump of anonymous dots; motion-reduce:transition-none
  and motion-reduce:animate-none throughout.

Customization levers
- Quadrant names: pass `quadrants` to relabel all four (padel, pickleball,
  badminton, or a coach's own vocabulary); the geometry is unchanged.
- Crosshair basis: swap the median for a fixed pair (a squad benchmark, last
  season's average) by replacing the two median calls — everything downstream
  reads from the same two numbers.
- Density: `height` (160–420) and the mark radii in shotSpeedSpinMark trade
  cloud legibility against card height; drop fillOpacity toward 0.45 for
  larger samples, but keep the full-strength stroke or dots vanish on a dark
  card.
- Strokes: the TYPE_ORDER / TYPE_LABEL / TYPE_COLOR triple is the only place
  the five strokes are named — add a "drop shot" or merge slice into backhand
  there and the legend, plot and tables follow.
- Legend columns: count / median speed / median spin is a 5-column grid; add
  winners and errors for a wide card, or cut to name + swatch for a compact one.
- Outcome encoding: fold forced and unforced into one hollow mark (as shipped),
  or split them by stroke width if your feed distinguishes them reliably.

Concepts

  • The crosshair is the player, not the tour — both dividers are this player's own medians, so every quadrant name is read as "spinnier than they usually hit". A club player and a tour player get the same four boxes and completely different balls in them, which is what makes the chart usable at any level.
  • Toggling moves the reference, never the marks — hiding a stroke recomputes the median crosshair over what is left, while the axis domain stays pinned to every usable shot. So the frame is stable and only the judgement changes; the alternative (rescaling axes on toggle) makes dots jump and destroys the comparison you toggled in order to make.
  • Positioned by the value that colours it — no binning, no jitter, no collision fan-out: a dot's coordinates are its measured pair, so a ball 1 rpm above the median lands above the line and is counted above it. Nudging a dot for legibility would let a mark sit on the wrong side of the very line the chart exists to draw.
  • Outcome is a shape, not a place — winners are solid and haloed, misses are hollow rings, rally balls are soft discs. Encoding the outcome in the mark keeps the position channel honest and lets you see the winners bunch on the fast, flat side while the misses fall away to the slow half, without a second chart.
  • Per-stroke medians are toggle-invariant — the cross drawn for each stroke, and the numbers in its legend row, are computed over all of that stroke's shots. Only the global crosshair follows the filter, so a legend number never changes for a reason the reader cannot see.
  • Spin is a magnitude; direction comes from the stroke — trackers report a rate, and slice and volley carry underspin. That is why "floated slice" sits high on the spin axis, and why the axis is never read as "more is better".
  • No court on purpose — speed and spin are properties of the ball, not of a place, so this is the one chart in the tennis family that draws no court; landing position lives in chart-serve-placement, chart-tennis-depth-zones and chart-serve-miss-map. The bridge between them is time: a ball at the mock's median 100 km/h (27.8 m/s) crosses the full 23.77 m court in about 0.86 s, while a 147 km/h first serve reaches the service line — 11.885 m of the server's half plus the 6.40 m box — in roughly 0.45 s. That gap is why the fast, flat quadrant and the rally-length chart tell two halves of one story.

On This Page