Charts

Shuttle Speed Decay

A four-state badminton physics chart in hand-rolled SVG — one speed-against-distance curve per shot type, each marked where the receiver's racket meets it, with the flight time integrated from the curve itself and a distance cursor you can drag or arrow along.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartShuttleSpeedDecayData,
  ChartShuttleSpeedDecayShot,
} from "./chart-shuttle-speed-decay.contract"

export interface ChartShuttleSpeedDecayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartShuttleSpeedDecayData {
  /** Card heading — what the chart is. Whose shots they are comes from `meta`. */

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartShuttleSpeedDecay" chart — shuttle
speed against distance travelled, one curve per shot type, 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: { id: string; label: string; launchKph: number (> 0);
             samples: { metres: number; kph: number (>= 0) }[];
             count?: positive int — flights behind this median curve }[];
    courtLengthMetres: number (> 0);
    receiverDistanceMetres: number (> 0);
    meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, height?, defaultCursorMetres?,
  onRetry?, emptyState? and the native div props, spread on the root.
  forwardRef. No hand-written parallel interface.
- Distance is measured along the flight path from the racket face, not along
  the floor: a lobbed clear covers more metres than the court is long, and the
  chart is allowed to say so.

Behavior
- Four first-class branches in one bg-card panel: loading (two stat tiles, a
  frame of decaying silhouettes, a legend strip — the ready layout, pulsing),
  empty (faint decay curve with a receiver rule plus copy, reused when a ready
  payload has nothing drawable, and then carrying the reason), error (message
  plus a "Try again" button only when onRetry exists), ready.
- Ingest is a pure exported function that reports everything it will not
  honour: a sample at or before 0 m is dropped (launchKph already stands
  there), a repeated distance is dropped rather than averaged (two speeds at
  one distance are a contradiction, and the mean of a contradiction is a
  number nobody measured), a non-finite or negative reading is dropped, and a
  shot left with fewer than two vertices keeps a table row saying so. Each
  curve is (0, launchKph) followed by every usable sample, ascending.
- Two derived quantities, both pure and exported. Speed at any distance is the
  measured vertex or the straight-line interpolation between neighbours —
  exactly the segment drawn, so a readout always names a point of the visible
  line, and null outside the flight rather than an extrapolation. Flight time
  is the integral of ds / v along that same polyline by the trapezoid rule on
  1 / v, in seconds, null if a sample sits at zero speed.
- Every curve is marked where the shuttle is met: on the receiver rule when it
  gets there (a disc), at the end of its flight when it is met sooner (a
  square). One value positions the mark, colours it and prints beside it. The
  labels are de-collided down a single column beside the rule with leader
  lines back to their marks, because three arrivals within 40 kph of each
  other would otherwise stack into one smear.
- The reader scrubs a distance cursor: role="slider" over metres with
  aria-valuemin / max / now and an aria-valuetext naming every curve's speed
  and elapsed time at that distance. Pointer move scrubs, pointer down takes
  pointer capture so a drag off the plot (or a touch) keeps feeding it, arrows
  step 0.1 m, PageUp/PageDown 1 m, Home/End jump the ends. Only keyboard moves
  push a sentence into the sr-only live region — a live region updated on
  every pointer sample is a queue nobody can listen through.
- The table prints launch speed, arrival speed, flight time and, when every
  drawable shot carries a count, its share of the tracked flights. Those
  shares partition one whole, so they are apportioned by LARGEST REMAINDER —
  floor, then hand leftovers to the biggest fractions — and add to exactly
  100. Ratios that are not slices of a whole (speed kept on arrival, the loss
  over the first and last quarter of a flight) are rounded on their own.

Rendering & styling
- Semantic tokens only. Identity is double-encoded and never re-assigned:
  curve i takes var(--chart-{(i % 5) + 1}) and its own dash pattern from a
  fixed table indexed by payload order, so a shot that ships empty cannot
  shuffle the colours of the ones that did, and greyscale still reads.
- Court paint: gridlines stroke-border, the receiver rule dashed
  stroke-muted-foreground with a tag above the frame, the court length a
  finer dotted rule ticked only when it lands inside the frame with room of
  its own. Marks wear a var(--card) ring, every label paintOrder="stroke"
  with a var(--card) halo, so nothing is lost over a curve in either theme.
- The axis follows the longest flight, never the court length: clipping an arc
  at the court would hide the stretch where it has already reached terminal
  speed. Speed always starts at 0 — a decay chart with a floating baseline
  lies about how much is left.
- Panel rounded-xl border bg-card, numbers tabular-nums, cn() merges
  className, the root spreads remaining props and carries data-status.
  Skeleton pulses respect prefers-reduced-motion. A ResizeObserver measures
  the plot so label sizes stay in pixels at any card width; it is disconnected
  on unmount and on node change.

Customization levers
- height sets the plot box (clamped 160–420); the y tick density follows it,
  so a short card thins its own gridlines instead of crowding them.
- defaultCursorMetres decides where the card opens — the receiver rule by
  default, which is the reading the card exists to make; pass 0 to open at the
  racket instead.
- CURVE_INK / CURVE_DASH are the only home of curve identity: re-map them to
  fixed tokens per shot id (team colours, "smash is always red") without
  touching the model.
- LABEL_GAP and LABEL_ROOM tune the mark-label column — raise the gap for a
  larger type scale, lower the room threshold to keep labels on the right of
  the rule in a narrow card.
- Drop the mix column by omitting count on any shot; drop the court reference
  by passing a court length beyond the axis. Both hide themselves rather than
  printing an empty header.
- Sport swap: nothing here is badminton except the payload. Feed tennis
  serves over 23.77 m, a squash drive to the front wall, or a pitched
  baseball, and rename the receiver rule through the payload distances.

Concepts

  • Decay is read against distance, not time — drag on a shuttlecock grows with the square of speed, so speed collapses geometrically with the metres flown: the first quarter of the smash's flight in this card takes 42% of its launch speed off it, the last quarter 6%. Plotting against distance is what makes that shape visible; plotting against time would smear the interesting part into the first pixel.
  • The number that matters is the one at the far end — every curve is marked where the receiver's racket reaches it, and the mark is positioned, coloured and labelled by that single value. A shot met before the rule (a drop dying in the forecourt) is marked at the end of its own flight with a different glyph, and the card says how far short it stopped, rather than reporting a speed nobody could have hit.
  • Time is integrated, never assumed — flight seconds come from the trapezoid integral of ds / v along the very polyline on screen, so "0.28 s to the receiver" is derived from the same vertices the eye is reading. Distance divided by launch speed would have claimed 0.10 s and flattered the smash by a factor of three.
  • A cursor is a slider, not a tooltip — the distance readout is one tab stop with role="slider", aria-valuenow in metres and an aria-valuetext that names every curve's speed and elapsed time there; pointer capture keeps a drag alive off the plot, and only keyboard moves speak, so a live region is never flooded by mouse motion.
  • Largest remainder for the shares, plain rounding for the ratios — the mix column partitions one whole, so it is apportioned together and adds to exactly 100 (this card's own data rounds naively to 99). Speed kept on arrival is not a slice of anything, so it is rounded on its own — mixing the two rules is how a card ends up printing 101%.
  • The court is a reference, not a frame — the axis follows the longest tracked flight, so a clear whose arc measures 15.4 m is drawn whole and the 13.40 m court length appears as an internal tick. Clipping at the court would have hidden exactly the stretch where the shuttle has already fallen to terminal speed.

On This Page