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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-court-position-ladder.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCourtPositionLadder" chart — a
pickleball court-position ladder in plain divs (no chart library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
rungs: { position: "baseline" | "transition" | "kitchen";
points: int >= 0; won: int >= 0 }[];
meta: { team: string; context?: string } }.
- Props = z.infer of the schema plus title?, barHeight?, onRetry?, emptyState?
and className. No hand-written parallel interface.
- Each point is tagged ONCE, by the deepest position the team reached before
the point ended, so the three rungs partition the match. Repeated positions
are summed (a game-by-game dump is one aggregation) and a missing position
is a measured 0 — its rung is still drawn, because "never stuck at the
baseline" is a finding.
Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
piece for piece — stat line, three rung rows with connector gaps, legend),
empty (a faint top-down pickleball court + copy, reused when a ready payload
has nothing countable), error (message + a "Try again" button only when
onRetry exists), ready.
- Ingest is a pure exported function: sum repeats, drop rows whose position or
point count is unreadable, clamp a win count that exceeds its rung's points,
and report both repairs in a visible line — a ladder that quietly invents or
discards points flatters the team it is meant to audit.
- The ladder reads bottom → top = baseline → transition → kitchen, rendered
top-down so "up the page" is "up the court". Each rung is a horizontal bar
whose length is that rung's share of ALL points on a 0–100% scale, split
into won and lost; "n · win %" prints at the row end in text tokens.
- The three shares are apportioned together by largest remainder so they add
to exactly 100 in every state — never rounded one at a time. The connector
arrow between two rungs carries the points that advanced past the lower
rung; its percent is the SUM of the apportioned shares above it, so
baseline share + advanced-past-baseline always prints exactly 100. The
connectors are derived from the partition, never supplied.
- Hover or keyboard focus on a rung raises a tooltip (points, share, won,
lost, win rate) and dims the other rungs; each row is a role="img" tab stop
with a sentence-long aria-label, the pointer-leave reset is guarded so
moving between rungs never blinks the highlight off, and the top rung's
tooltip flips below the bar so the card header never covers it. The tooltip
is aria-hidden — it repeats what the label already says.
- Keep the card data-only: no "kitchen vs baseline" editorial headline. The
stat line is the total and the overall win rate, nothing more.
- An sr-only summary paragraph and table repeat every count, share and rate.
Rendering & styling
- Colour from tokens only: won segments var(--chart-2), lost segments
var(--chart-5) at reduced opacity (~0.4) — a strength split on the same bar,
never a red/green pair — with a 1px bg-card hairline at the join so the
split survives themes where the two fills sit at similar lightness. Quarter
gridlines come from one repeating linear-gradient of var(--border), and a
0 / 50 / 100% scale row is printed under the bars.
- The zero-state court is real geometry, USA Pickleball dims in feet: 44 × 20
ft, the net at 22, non-volley-zone (kitchen) lines 7 ft off the net on each
side, centre lines from the NVZ line to each baseline only.
- Labels and figures wear text tokens (text-foreground / text-muted-foreground),
numbers tabular-nums. Panel rounded-xl border bg-card p-4, cn() merges
className, the root spreads remaining props and carries data-status.
- Highlight transitions respect prefers-reduced-motion; decorative nodes
(bars, connectors, scale row) are aria-hidden.
Customization levers
- barHeight (14–36 px) trades density for presence; the label and stat column
widths are single classNames (w-20 / w-24) sized to the fixed rung names.
- Swap the won/lost pair for other chart tokens, or raise LOST_OPACITY on a
busy surface; the hairline join keeps working either way.
- The connector line is one flex row — drop it for a plain three-bar read, or
replace the arrow glyph with a count-only note.
- The rung set is a fixed enum by design (the partition is the product); for
another sport re-map the three names and phrases (e.g. padel: baseline /
mid-court / net) — POSITIONS, POSITION_LABEL and POSITION_PHRASE are the
only places they live.
- The tooltip is one absolutely-positioned div per active rung; re-anchor it
to the pointer or replace it with an always-on annotation column if the
card never gets hover.Concepts
- Deepest position reached is a partition — every point is tagged once, by the furthest the team got before the point ended, so the three rungs cover the match exactly and their shares must add to 100. That is what separates this from a funnel of independent stage conversions: the rungs are not sequential filters, they are the bins of one categorical read.
- The connectors are derived, never supplied — "advanced past the baseline" is just the sum of the points above it. Printing it as the sum of the apportioned shares (not a separately rounded number) means the connector can always be checked against the labels beside it and found exact.
- Won vs lost is a strength split, not a hue pair — the lost segment is a second chart token at reduced opacity with a card-coloured hairline at the join, so the split reads for colour-blind readers and survives both themes; identity never rides on red/green.
- Share and win rate answer different questions on one bar — the bar's length says where points end (volume), the split says what happens there (quality). Reading them together is the whole diagnosis: a long baseline rung with a thin won segment is the classic "never got to the kitchen" match.
- The tooltip repeats, never reveals — every number in it is already printed or spoken (row label, aria-label, sr-only table), so hover is a convenience, not a requirement; keyboard focus raises the same tooltip and the guarded pointer-leave keeps the highlight from flickering between rungs.
- All-equal fills and empty rungs stay honest — a rung with no points keeps its row at zero length with an em-dash rate ("—"), because an unmeasured win rate is not a 0% win rate; and a ready payload whose rows were all unreadable says so instead of pretending no points were played.
Side-Out Scoring
A four-state pickleball side-out scoring strip in hand-rolled SVG — every service turn a team-coloured segment as wide as the points it scored, zero-point turns kept as hairline ticks, server 2 in a lighter fill, running-score stamps under the strip, a pinned final score and per-team points-per-turn and best-turn summaries.
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.