Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartCourtPosition,
  ChartCourtPositionLadderData,
  ChartCourtPositionLadderRung,
} from "./chart-court-position-ladder.contract"

export interface ChartCourtPositionLadderProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartCourtPositionLadderData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-court-position-ladder.json

Prompt

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.

On This Page