Charts

T Dominance

A four-state squash T-control card — one mirrored bar per game split at each player's share of rally time on the T around a 50% centre rule, the score under every bar, a match row on top, and the games that defied the T called out.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartTDominanceData, ChartTDominanceGame } from "./chart-t-dominance.contract"

export interface ChartTDominanceProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartTDominanceData {
  /** Card heading — what the chart is. Who is playing comes from `players`. */
  title?: string
  /** Height of one game bar in px, clamped 8–28 (default 14). The match row adds 4. */
  barHeight?: number

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartTDominance" card — a squash
T-control report drawn as one mirrored bar per game around a 50% centre rule,
in hand-rolled CSS (no chart library) with zod for the contract.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    players: { a, b };
    games: { label, tShareA: 0..1, points: { a >= 0, b >= 0 } }[] }
  Component props = z.infer of that schema plus title?, barHeight? (8-28,
  default 14), onRetry?, emptyState? and className. No parallel interface.
- Semantics, stated in the schema's JSDoc: tShareA is player A's share of
  TRACKED RALLY TIME holding the T; B's share is 1 - A by construction, so
  only one number travels. Points stay separate on purpose — holding the T
  and winning the game are different claims, and their gap is the story.
- Player a is always the left side of every bar and var(--chart-1); b mirrors
  right in var(--chart-2). The chart never reorders them.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: an aria-hidden skeleton keeping the row silhouette (label line,
    bar, score line), plus one sr-only role=status line.
  - empty: a plan-view squash court glyph (WSF 9.75 x 6.4 m, short line
    4.26 m off the back wall, two 1.6 m service boxes, half-court line) with
    the T itself as the one emphasised dot, and copy naming what will appear.
  - error: a destructive message and a "Try again" button only when onRetry
    is supplied.
  - ready: legend, match row, a separator, one row per game, an axis caption
    and a footnote.
- Layout maths live in one exported pure function returning the rows (repaired
  share, printed pair, points, winner), the match aggregate and the games that
  defied the T (majority holder lost on points).
- The match share is the EQUAL-WEIGHT mean of the game shares — rally
  durations never travel with the feed, so weighting games by anything would
  be invented precision; say so in the footnote. Games and points pool the
  ordinary way.
- The two printed ends of a bar partition one whole, so the pair goes through
  largest-remainder rounding and sums to exactly 100 on every row.
- Out-of-range or non-finite shares and counts are repaired (clamped/floored)
  and the repair is REPORTED under the chart, never swallowed.
- Hover or keyboard focus on a row shows a tooltip (both shares, the score,
  who won); rows are tabIndex=0 with a full aria-label and a focus-visible
  ring, and hover/focus are tracked separately.

Rendering & styling
- Per row: the track itself is painted var(--chart-2) (player B), player A's
  var(--chart-1) segment is laid over it from the left at tShareA — the two
  sides always meet at exactly the split with no seam arithmetic. The 50%
  centre rule is a 1px color-mix(var(--foreground) 55%, transparent) hairline
  drawn last, over both fills; identical row padding makes it run straight
  down the whole card.
- Printed shares sit outside both ends of the track in a fixed 2.25rem column
  (tabular-nums), so every bar starts and ends on the same x and the centre
  rules align. The game label sits above the bar, the score line ("11-7" plus
  the winner's name) below it, both indented to the bar's left edge.
- The match row is drawn first, slightly taller (bar + 4px), separated from
  the games by a border-t list item.
- One-line axis caption under the rows: "share of rally time on the T -
  centre rule marks 50%".
- Semantic tokens only: bg-card, border, text-muted-foreground, bg-popover for
  the tooltip, var(--chart-1) / var(--chart-2), color-mix on var(--foreground)
  for the centre rule. cn() merges className, remaining props spread on the
  root, skeleton pulses carry motion-reduce:animate-none, decorative bar
  internals are aria-hidden.

Customization levers
- Row vocabulary: rows are whatever the feed sends in order — swap games for
  sets, for five-minute windows of one game, or for a season of matches with
  one row per opponent.
- Aggregate rule: the match mean is equal-weight; if rally durations DO travel
  with your feed, weight by them in the layout function and update the
  footnote sentence — never silently.
- Density: barHeight (8-28) plus the gap-3 list spacing set the card height;
  drop the per-row labels to sr-only for a compact scoreboard variant.
- Palette: sides read var(--chart-1) / var(--chart-2); remap to any two chart
  tokens, but keep both solid — the centre rule has to survive on top of each.
- Upset call-out: the "held the T, lost the game" sentence keys off winner vs
  share majority; tighten it to only fire past a threshold (e.g. share > 0.55)
  for noisy tracking data.
- The scores under the bars come straight from points; hide them if the feed
  has share data but no scores, and the bars still stand alone.

Concepts

  • Territory vs scoreboard — the bar says who owned mid-court, the score line under it says who took the game, and the card refuses to merge them into one number. The most coachable game is the one where they disagree, so those games are named in the footnote instead of left for the reader to spot.
  • 50% centre rule — every bar is judged against the same vertical hairline, drawn over the fills in a foreground-derived ink because a border token would vanish on top of two solid colours. Identical row padding is what lets one rule run straight down the card without measuring anything.
  • Largest-remainder pair — the two printed ends partition one whole, so they are rounded as a pair that sums to exactly 100; rounding each end alone drifts to 99/101 and the reader stops trusting the bar between them.
  • Equal-weight match mean — the match row averages game shares without weights because rally durations don't travel with the feed; weighting by points would smuggle the scoreboard into a territory statistic. The footnote says so out loud.
  • Sides never swap — player A is the left of every bar and the first chart token from contract to legend to tooltip, so colour and side both mean the same player all the way down; a chart that reorders by winner would repaint the same player two colours in one card.
  • The T as a place, not a metaphor — the zero state draws the court in plan from the WSF measurements (9.75 × 6.4 m, short line 4.26 m off the back wall, 1.6 m service boxes) with the T junction as the one emphasised dot, so the card teaches what is being measured before any data lands.

On This Page