Charts

Surface Splits

A four-state surface-by-surface record card — win-loss bars with paired service and return sub-bars, each read against a tick at the player's own overall level, and the strongest and weakest surfaces called out above a match floor.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartSurfaceSplitsData,
  ChartSurfaceSplitsMeta,
  ChartSurfaceSplitsSurface,
} from "./chart-surface-splits.contract"

export interface ChartSurfaceSplitsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartSurfaceSplitsData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartSurfaceSplits" card — one player's
record broken down by court surface — with zod. No chart library: three CSS
bars per surface and one inline-SVG swatch.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    meta: { player, context?, servePointsWonPct, returnPointsWonPct };
    surfaces: { id, label, wins, losses, servePointsWonPct,
                returnPointsWonPct }[] }
  with a refine asserting surface ids are unique.
- Component props = z.infer of the schema, plus title?, minMatches?
  (default 10, clamped 1-500), onRetry?, emptyState? and className. Spread the
  remaining div props on the root. No hand-written parallel interface.
- meta carries the two OVERALL point rates and not the overall record: wins and
  losses are derivable by summing the rows, and a carried total could contradict
  the rows on screen, while a rate per surface cannot be pooled without the
  point counts nobody ships.

Behavior
- Four first-class branches inside one bg-card panel: loading (skeleton that
  mirrors tiles + four row blocks), empty, error (message + a Try again button
  only when onRetry exists), ready. A ready payload whose rows carry zero
  matches falls through to the zero state and says how many surfaces arrived
  empty, rather than claiming nothing arrived.
- Derive, per surface: matches = wins + losses; matches-won percent apportioned
  against its loss share by LARGEST REMAINDER so the pair adds to exactly 100;
  share of all matches apportioned the same way across the rows so the four
  shares add to exactly 100; the two point rates rounded to one decimal.
- The overall record is summed from the rows and apportioned the same way. Each
  lane's tick is that overall value: matches-won overall, meta serve, meta
  return.
- Delta per lane = the printed surface value minus the printed overall value, so
  the arithmetic on screen closes. Rank surfaces by that delta to name the
  strongest and the weakest, but only among rows at or above minMatches; ties
  break on the exact fraction, then the bigger sample, then payload order.
  Rows under the floor are drawn and labelled in full, carry a "thin sample"
  chip, and stay out of the call — then say so, listing them by name and count.
- Repair rather than crash: negative or unreadable counts become 0, fractional
  counts floor, rates over 100 clamp; count the repairs and print one line about
  them.
- Interaction: the rows are a listbox (ul role=listbox, tabIndex 0,
  aria-activedescendant) of role=option rows with aria-selected. Selection
  follows focus — Up/Down/Left/Right, Home, End — and click selects. The card
  opens on the surface with the most matches, the row the record rests on.
- Selecting a row has to pay for itself, so the readout under the list restates
  that surface's gap in MATCHES: multiply its match count by the printed overall
  percent, round to a whole match, and print what the record would have read
  ("at the overall 76%, those 85 matches would read 65-20 — Clay is 6 wins clear
  of that"). Percentage points are not a unit anybody schedules against; wins
  are. Use the printed percent, not the exact one, so the reader can redo the
  multiplication off the tile. The readout is aria-hidden because the option's
  own aria-label already says both lines, and a visually hidden table repeats
  every number including the expected-versus-actual wins.

Rendering & styling
- Semantic tokens only. Surface colour comes from the id: hard -> var(--chart-1),
  grass -> var(--chart-2), clay -> var(--chart-3), indoor -> var(--chart-4), any
  other id falls back to var(--chart-{(i % 5) + 1}). One formula feeds the
  swatch, all three bars of that row and nothing else, so a row cannot change
  colour between its own marks.
- Every lane is a 0-100% track: bg-muted rail, fill at the surface token
  (matches lane full strength, the two point lanes at 0.72 opacity so they read
  as the quieter pair while still clearing bg-card in dark mode), and the
  overall tick as a bg-foreground hairline over a bg-card halo so it survives
  the busiest fill in either theme. The matches lane also draws its loss share
  from the right in bg-foreground/15, so the two apportioned halves visibly meet
  at 100% instead of the footnote just claiming they do.
- The bar ends at the very number printed beside it — one value positions,
  colours and labels each mark.
- The swatch is a scale drawing of an ITF singles court: viewBox 0 0 23.77 8.23,
  net at 11.885 m, a service line 6.40 m either side of it, centre service line
  between them, painted in stroke-card with vectorEffect="non-scaling-stroke"
  and outlined in stroke-border.
- Accessibility: focus-visible outline on the listbox, ring on the selected row
  only when focus is keyboard-driven, role=alert on the error branch, role=status
  on the loading and repair lines, aria-hidden on every decorative swatch and
  tick, transitions disabled under motion-reduce. cn() merges className.

Customization levers
- Lanes: the LANES array is the whole vertical structure — drop the two point
  lanes for a compact win-rate-only card, or add a fourth (tie-break rate,
  deciding sets) by giving it a value and an overall tick.
- Density: lane heights (h-3 / h-2), the gutter width (w-14) and the value and
  delta columns (w-11 / w-10) are the levers that take the card from 400px to a
  sidebar width; drop the delta column and the tick still carries the read.
- The floor: minMatches decides what may be ranked. Raise it for a career card,
  drop it to 1 for a junior season where every surface is thin.
- Palette: SURFACE_INK maps id to token — re-map it to a club's own surface
  colours, or delete it to fall back to the index cycle everywhere.
- Baseline: the tick is this player's overall level. Swap in a tour average or a
  peer group by feeding those numbers as the overall values; the deltas and the
  strongest/weakest call follow automatically.
- Swatch: replace the tennis court with a badminton (13.4 x 5.18 m) or
  pickleball (13.41 x 6.10 m with a 2.13 m non-volley zone) outline for those
  codes — keep the real proportions, the viewBox is the only thing to change.

Concepts

  • Self-referenced baseline — the tick on every bar is the player's own overall level, not a tour average or a 50% mark. It turns four independent rates into one question the card can answer: which surface does this player beat himself on, and by how much.
  • Largest-remainder apportionment — the win/loss pair and the four surface shares are each floored and then handed their leftover points by largest fractional part. Rounding each slice on its own is how a four-way split prints 101 and reads as a data error.
  • Printed value positions the mark — a bar stops at the same number written beside it, and each delta is that printed number minus the printed baseline, so nothing on the card fails to subtract.
  • Sample floor without hiding — a surface under the match floor keeps its bars, its numbers and a "thin sample" chip, but is excluded from the strongest/weakest call and named as excluded. Dropping it would hide a real row; ranking it would crown a 4–3 record.
  • Derive what is derivable, carry what is not — the overall record is summed from the rows so it can never contradict them; the two overall point rates are carried, because per-surface percentages cannot be pooled without point counts.
  • Selection follows focus — the rows are a single-select listbox rather than a grid or a hover target: there are four of them, exactly one is being read at a time, and arrow keys plus a click reach it identically.
  • The readout owes the reader a new number — selecting a row that already prints all its own numbers would be decoration, so the readout restates the same gap in wins: what the record would have read at the overall rate, over that surface's own schedule. Points are how the bar measures; wins are what a season is counted in.

On This Page