Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartShuttleLandingData,
  ChartShuttleLandingShot,
  ChartShuttleShotType,
} from "./chart-shuttle-landing.contract"

export interface ChartShuttleLandingProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartShuttleLandingData {

Installation

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

Prompt

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-activedescendant following the visible halo, and only :focus-visible arms 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.

On This Page