Shuttle Landing Map
A four-state badminton landing map in hand-rolled SVG — the opponent's half drawn from BWF dimensions, every shot a dot whose colour AND shape carry the stroke (clear, drop, smash, net shot), out shots hollow past the lines, with a front/mid/rear depth panel cut on the real service lines.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-shuttle-landing.jsonPrompt
Build a React + TypeScript + Tailwind "ChartShuttleLanding" chart — a
badminton landing 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";
shots: { x: -3.05..3.05 (metres from the centre line, negative left);
y: 0..6.70 (metres up the half from the net);
type: "clear" | "drop" | "smash" | "net";
out?: boolean }[];
meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and
className. No hand-written parallel interface.
- Shots stay in rally order — the keyboard walk follows it. A drive has no
place on a landing map (it is flat and taken out of the air); count drives
in a shot-mix chart or add a fifth type with its own shape and colour.
Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
piece for piece — stat line, pulsing court beside the zone-panel rows,
footnote bar), empty (faint court + copy, reused when a ready payload has
nothing placeable), error (message + a "Try again" button only when onRetry
exists), ready.
- Ingest is a pure exported function: clamp a coordinate a centimetre over
the edge (a recorded shot happened), drop a shot whose type or coordinate
is unreadable, and print the dropped count — a landing map that quietly
loses shots misreports the very pattern it shows.
- Out shots draw hollow at their recorded spot. One recorded ON the doubles
sideline or the baseline (a feed clamps what it cannot represent) is pushed
a stated OUT_NUDGE past that line, because a shuttle on the line is in —
the nudge is documented in the footnote, never silent. Out shots stay out
of the depth shares: an out call has no landing to zone.
- The side panel splits the IN shots on the court's real lines — front of the
short service line (0–1.98 m), between it and the doubles long service line
(1.98–5.94 m), behind it to the baseline (5.94–6.70 m) — with the three
shares apportioned together by largest remainder so they print exactly 100.
- Hover raises a per-dot tooltip (stroke, in/out + zone, metres from net and
centre) over a generous transparent hit circle; the same highlight dims the
other dots, with the pointer-leave reset guarded so crossing dots never
blinks. Keyboard: the svg is one role="listbox" tab stop, arrow keys walk
the shots in rally order, aria-activedescendant follows, and only
:focus-visible arms the cursor so a mouse click doesn't pin the tooltip.
Tooltips flip below the dot near the baseline edge and slide near the
sidelines, anchored in percentages of the viewBox so they stay glued at
every width. The tooltip itself is aria-hidden — each dot's aria-label
already says it all — and an sr-only summary + table repeat every shot.
Rendering & styling
- Geometry in metres from the BWF court, mapped through one scale (60 units
per metre), never pixel guesses: the half is 6.70 m from net to baseline;
doubles sidelines 6.10 m apart; singles sidelines 0.46 m inside them
(half-widths 3.05 / 2.59 m); short service line 1.98 m from the net;
doubles long service line 0.76 m inside the baseline (5.94 m); centre line
from the short service line back to the baseline. Net on the bottom edge,
drawn stronger than the paint; court lines stroke-border.
- Identity is double-encoded and never re-assigned: clear = var(--chart-1)
circle, drop = var(--chart-2) triangle, smash = var(--chart-3) square, net
shot = var(--chart-4) diamond — shapes sized to matched visual weight, so
the map survives colour-vision deficiency and greyscale. In shots wear the
type fill under a 2px var(--card) ring so overlapping dots stay separable;
out shots invert it — no fill, the type token as a 2px stroke.
- Legend chips carry shape + colour + count per stroke, plus a filled/hollow
pair for in/out. Zone meter bars ride neutral tokens (bg-muted track,
bg-muted-foreground fill) so they never compete with the four stroke hues.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
className, root spreads remaining props and carries data-status; highlight
transitions respect prefers-reduced-motion; the court group is aria-hidden.
Customization levers
- OUT_NUDGE (0.2 m) is the label offset for boundary-clamped out shots;
widen it on a dense card or set it to 0 to draw them on the line.
- DOT_R / HIT_R trade dot presence against touch-target size; the halo ring
and dim opacity are single constants.
- TYPE_INK / shape cases are the only home of the stroke identity — add a
fifth stroke (drive, push) by extending the enum, the glyph switch and the
legend, and ingest, panel and table follow.
- The zone panel derives from ZONE_BOUNDS; re-cut it (e.g. serve-length
zones) by changing the bounds — the shares, ranges and captions all read
from the same constants.
- Singles court: the singles sidelines are already drawn; filter or flag
shots wide of ±2.59 m upstream, since in/out is the feed's call, not the
map's.
- Drop the side panel for a map-only card, or the map for a zones-only strip
— each reads from the same ingest.Concepts
- Dual encoding is the identity — every stroke owns one colour and one shape (circle, triangle, square, diamond), assigned in a fixed order that never re-shuffles when a type is absent, so a reader who cannot separate the four chart hues still reads the map, and a filtered feed never silently recolours the smashes.
- In and out are fill treatments, not positions — an in shot is the type token under a 2px card-coloured ring (the ring is what keeps a pile of dots separable), an out shot is the same shape hollow with the token as its stroke; identity survives both, and the legend states the pair.
- Out is a call, not a coordinate — the true landing spot of an out shot is unknowable from a feed that clamps to the court, and the one place it cannot be is on the line itself (a shuttle on the line is in). So a boundary-clamped out shot is drawn a stated nudge past its line, the convention printed in the footnote rather than hidden in the geometry.
- Zones come from the court, not from thirds — front/mid/rear are cut at 1.98 m (the short service line) and 5.94 m (the doubles long service line), so the panel's shares can be checked against the lines drawn on the map; the three percents are apportioned together by largest remainder and always add to exactly 100.
- One tab stop, every dot reachable — the svg is a single listbox; arrow keys walk the shots in rally order with
aria-activedescendantfollowing the visible halo, and only:focus-visiblearms the cursor so a mouse click never pins a tooltip nobody asked for. The tooltip is aria-hidden because each dot already speaks a full sentence. - Nothing is dropped in silence — a coordinate a centimetre over the edge is clamped (the shot happened), but an unreadable type or a non-finite coordinate is counted and printed above the map, because a landing map that swallows shots misreports the pattern it exists to show.
Court Position Ladder
A four-state pickleball ladder chart — points grouped by the deepest court position the team reached (baseline, transition, kitchen), each rung a won/lost split bar sized to its share of all points, with connector arrows carrying the share that advanced past the rung below.
Badminton Shot Mix
A four-state badminton composition chart — one horizontal 100% stacked bar per game across five fixed strokes (clear, drop, smash, drive, net), largest-remainder shares, an attack-share tick (smash + drive) under every bar, per-game totals at the row end and a pooled match row.