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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-shuttle-speed-decay.jsonPrompt
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 / valong 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-valuenowin metres and anaria-valuetextthat 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.
Doubles Formation
A four-state doubles serve-formation card — a real tennis, badminton or pickleball court in metres, one tab per formation with its usage share and win rate, and four player markers that glide to the new stance.
Deuce / Ad Split
A four-state deuce-versus-ad court split — one server's metrics mirrored around the centre mark, every row drawn as a rate of its own side's whole so two different sample sizes compare fairly, mixes apportioned by largest remainder, and the wide gaps flagged toward the better half.