Charts

Rally Length Outcome

A four-state rally-length chart — points banded by shot count, each band a bar split into won and lost, the band's win rate as a dot on a fixed 0–100% scale, the busiest band called out and a one-line verdict.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartRallyLengthOutcomeBucket,
  ChartRallyLengthOutcomeData,
  ChartRallyLengthOutcomePoint,
} from "./chart-rally-length-outcome.contract"

export interface ChartRallyLengthOutcomeProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartRallyLengthOutcomeData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-rally-length-outcome.json

Prompt

Build a React + TypeScript + Tailwind "ChartRallyLengthOutcome" card — a tennis
rally-length breakdown in hand-rolled SVG (no chart library), typed by zod.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    buckets?: { label, minShots >= 1, maxShots?, won >= 0, lost >= 0 }[];
    points?:  { rallyShots >= 1, won: boolean }[];
    meta: { player, context?, pointLabel? } }
- Props = z.infer of that schema plus title?, bucketStarts? (default [1, 5, 9]),
  height? (plot px, clamped 140-320), onRetry? and the div's own props spread on
  the root. No hand-written parallel interface.
- Counting rule, stated once and honoured everywhere: rallyShots counts every
  ball struck, the serve is shot 1, the last shot counts even when it missed,
  and a fault adds nothing (a double fault is a 1-shot point that was lost).
- Two input shapes, one precedence rule: pre-aggregated `buckets` are drawn
  exactly as sent (the feed owns the labels and the edges); raw `points` are
  binned by `bucketStarts`, whose first bound is forced to 1 so no rally can
  fall outside the bands. If both arrive, buckets win and the point rows are
  reported as ignored under the chart, never silently merged.

Behavior
- Pure model first (exported, testable, no pixels): sort bands by minShots,
  drop rows with unreadable bounds or counts and count them, compute per band
  { total, rate = won/total or null, share }, then the totals, the strictly
  busiest band (-1 when two are level), and the highest / lowest win rate among
  bands holding at least a dozen points, plus how many bands that leaves —
  ranking with strict comparisons means a tie parks best and worst on the same
  band, and only the count separates "one band could be ranked" from "they came
  out level". Shares are also apportioned to whole percents by largest
  remainder, so the readout column adds up to the 100% its totals row prints
  instead of the 99 or 101 that per-row rounding leaves.
- Four first-class branches in one bg-card panel: loading (skeleton in the ready
  silhouette), empty (outlined bars + what will fill them), error (message plus
  a Try again button only when onRetry exists), ready. A ready payload that
  counts zero points degrades to the empty branch and says why.
- Ready renders, in order: headline win rate + points won + the modal band;
  the plot; a legend; a one-line verdict ("strongest inside X shots ... leaks
  from Y"); a readout table with the modal row shaded and a totals row; then
  any footnotes for dropped, ignored or overlapping bands.
- No hover state and no keyboard cursor: every number is printed, in the plot
  or in the table, so the card reads the same on a phone as under a mouse.

Rendering & styling
- Two scales, four intervals each, so both share five gridline heights: the
  left axis counts points up to a nice-stepped max, the right axis is fixed
  0-100% because a win rate is a share. The middle gridline therefore *is* 50%
  and is drawn dashed as the break-even line — painted after the bars, because
  the busiest band routinely fills the plot and an underlying reference line
  would be hidden exactly where a dot has to be read against it.
- Bars: points won grounded on the axis (so band-to-band comparison is honest),
  points lost stacked above; var(--chart-1) and var(--chart-3).
- Win rate: one dot per band on var(--chart-4), joined only between adjacent
  bands that both have a rate — an empty band breaks the line rather than
  interpolating a reading nobody measured. Each dot is placed at its exact
  rate, the same number its label prints, so rounding can never put a mark on
  the wrong side of the break-even line; the label gains one decimal in the
  single window where rounding would read "50%" for a rate that is not 50%.
- Text over paint gets a card-coloured halo (paintOrder="stroke",
  stroke="var(--card)"); the busiest band's column is washed in full-strength
  fill-muted, never a low-alpha tint that disappears on a dark card.
- Only semantic tokens: bg-card, border, muted, muted-foreground, destructive,
  ring and var(--chart-1..5). cn() merges className. The plot width comes from
  a ResizeObserver that is disconnected on unmount; band labels drop to a
  stride when columns get too narrow to hold them side by side.
- The svg is role="img" with a sentence-long aria-label; the readout table is
  real DOM, so the numbers are reachable without the image.

Customization levers
- Bands: bucketStarts is the whole story for raw points — [1, 3, 5, 7, 9]
  splits the serve-and-return exchange from the first ground stroke each;
  [1, 5, 9, 13] adds a "very long" tail. Pre-aggregated feeds carry their own.
- Density: `height` for the plot, MAX_BAR_WIDTH for chunkier or slimmer bars,
  and drop the Share column of the table on narrow cards.
- Ranking: MIN_RANKED (default 12 points) decides which bands may win or lose
  the verdict; raise it for a season, lower it for a single set.
- Wording: meta.pointLabel renames the counted event ("points", "rallies",
  "serves won"), and the verdict sentence is one template string.
- Palette: WON_INK / LOST_INK / RATE_INK are three chart tokens — swap them for
  a fixed brand pair, keeping the rate hue away from both bar hues.
- Reference: the break-even line is fixed at 50% because that is what winning
  means; re-point it at a target rate if the card is comparing to a benchmark.

Concepts

  • Two shapes, one precedence rule — the card accepts a feed that already banded its points and a feed that only knows each point's shot count. Whichever arrives, the model downstream is identical; when both arrive the pre-aggregated one wins and the ignored rows are printed, because a chart that quietly merged them would double-count the match.
  • Bucketed volume, exact rate — the bar says how many points ended at that length, the dot says how they went. The dot's height comes from the same won / total the label prints and the verdict names, so a 49.6% band can never be drawn above the break-even line; in that one rounding window the label grows a decimal rather than the mark moving.
  • Two scales that share their gridlines — four intervals on the point axis and four on a fixed 0–100% rate axis put the two readings on the same five heights, which makes the middle line be 50% instead of a rule floating between rows. That dashed break-even line is painted over the bars rather than under them: the busiest band often fills the plot, and a reference line hidden behind it is missing from the one column whose dot most needs comparing.
  • Modal band as ground — the busiest band is washed in full-strength muted behind the bars and shaded again in the table, so the "most points end here" claim is anchored in two places at once and survives a near-black card, which a low-alpha tint would not.
  • A verdict, not just a chart — the card names the band with the best win rate and the one with the worst, ignoring bands under a dozen points, so a reader who only reads one line still gets the finding: won inside four shots, leaked past nine.

On This Page