Charts

Third Shot Outcome

A four-state pickleball third-shot report in hand-rolled SVG — one row per choice (drop, drive, lob) with its usage share, a 100% stacked won / neutral / lost bar apportioned by largest remainder, per-row n and point-win labels, a totals row, segment tooltips and arrow-key walking.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartThirdShotChoice,
  ChartThirdShotOutcomeData,
  ChartThirdShotType,
} from "./chart-third-shot-outcome.contract"

export interface ChartThirdShotOutcomeProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartThirdShotOutcomeData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartThirdShotOutcome" chart — a
pickleball third-shot selection-and-outcome report in hand-rolled SVG (no
chart library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    choices: { type: "drop" | "drive" | "lob";
               attempts: int >= 0;
               outcomes: { won: int >= 0; neutral: int >= 0; lost: int >= 0 } }[];
    meta: { player: string; context?: string } }
  with a refine on each choice: won + neutral + lost === attempts — the three
  outcomes PARTITION the attempts, that is the chart's whole premise.
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and
  className; forwardRef to the card div. No hand-written parallel interface.
- Already aggregated: one row per shot type. Repeated types are summed (a
  game-by-game dump is one aggregation) and a type never reported is a
  measured 0 whose row still renders — "never lobbed" is a finding.

Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
  piece for piece — stat line, four labelled bar rows, legend), empty (three
  faint hairlines + copy, reused when a ready payload has nothing to draw),
  error (message + "Try again" only when onRetry exists), ready.
- Ingest is a pure exported function: rows with an unreadable type or
  unreadable outcome counts are dropped and counted in a visible line; a row
  whose attempts disagree with its outcomes keeps the outcomes (they are the
  itemised record) and recomputes attempts from them, counting the repair —
  never trust a total over its own itemisation.
- Each row: a fixed label column (type name over "N% of third shots"), the
  100% stacked bar, and a right column with n and the share won. The usage
  percentages are apportioned ACROSS the three types by largest remainder
  (sum exactly 100), and each row's won / neutral / lost shares are
  apportioned WITHIN the row the same way — they are the segment widths, so a
  row that summed to 99 would leave a sliver of bare card inside a bar that
  claims to be 100%.
- A type with 0 attempts renders a muted hairline in the bar's slot, usage
  0%, n 0, win "—" — the rows never reshuffle.
- A totals row under a border repeats the treatment for all third shots
  combined; the legend chips carry the three outcome totals.
- Hover a segment for a tooltip (row, outcome, count of n, share); the whole
  figure is one Tab stop — a listbox div whose options are every non-empty
  segment, row-major with the totals last — and arrow keys walk them with
  aria-activedescendant, Home/End included. hovered wins over the keyboard
  cursor; both are derived against the current segment list so a shorter
  payload can never leave either pointing past the end. The active segment
  gets a foreground outline.

Rendering & styling
- Each bar is an svg in a fixed 100 × 10 viewBox stretched with
  preserveAspectRatio="none", so an x-unit IS a percentage point and the
  pre-apportioned shares place segments with no further rounding. Nothing
  inside an svg is text — text would stretch — so every label is HTML at real
  px sizes.
- Colour from tokens only: won var(--chart-2), neutral var(--chart-4), lost
  var(--chart-5) — the same three across every row and the totals, so a hue
  means one thing on the whole card. Neighbouring segments are seamed with a
  2px var(--card) line (vectorEffect non-scaling-stroke) so two mid-tone
  fills never fuse; hairline rows use stroke-border.
- Order encodes too: segments always run won → neutral → lost from the left,
  so the bar reads without its colours.
- Tooltips are aria-hidden echoes anchored in percentages of the bar's own
  box (no measuring, no observer), sliding near the edges; a sentence-long
  aria-label per option and one sr-only table repeat everything they say.
  Skeleton pulses carry motion-reduce:animate-none.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
  className, the root spreads remaining props and carries data-status.

Customization levers
- Choice set: TYPES / TYPE_LABEL are one list and one record — add "reset" or
  rename for another sport's shot menu; rows, usage shares, table and empty
  state all follow.
- Outcome set: BUCKETS / BUCKET_TOKEN / BUCKET_LABEL likewise — collapse to
  won / lost or add "forced error"; the largest-remainder helper takes any
  arity.
- Density: bar height is one h-7 class; the label column width (w-28) and the
  right column (w-20) are the two knobs for narrow hosts.
- Blocks: the totals row, the legend counts and the hint line are independent
  — drop any for a compact card; keep the sr table, it is the data record.
- Emphasis: swap which figure the right column bolds (share won vs n), or
  move the win % into the row label for a ranking-style read.

Concepts

  • Selection and consequence in one row — the usage column answers "what did we choose" and the stacked bar answers "how did it go", side by side per choice; either alone invites the classic misread of a choice that looks great because it was only tried six times, which is why n sits on every row.
  • A 100% bar earns its name — each row's won / neutral / lost shares are apportioned together by largest remainder so they sum to exactly 100 and are the segment widths; rounding them one at a time leaves a visible sliver of bare card or an overflow, which readers take for a rendering bug. The usage column is apportioned the same way across the types.
  • Attempts is the sum, not a second opinion — the contract refines won + neutral + lost === attempts, and at runtime a row that disagrees keeps its outcomes and has attempts recomputed from them, with the repair said out loud: the itemised record outranks its own total.
  • A zero row is a finding, not a gap — a type never reported renders as a muted hairline with its label, 0% usage and n 0, in its fixed slot: "never lobbed" is information, and rows that never reshuffle can be compared across cards.
  • One flat cursor over many bars — every non-empty segment joins a single listbox, row-major with totals last, so one Tab stop and the arrow keys reach everything the pointer can hover; the tooltip is an aria-hidden echo of the option's own aria-label, and an sr-only table repeats every number.
  • Hue means one outcome everywhere — the same three tokens colour every row and the totals, segments always run won → neutral → lost from the left, and the legend carries the outcome totals, so the card reads consistently row to row and survives greyscale by order alone.

On This Page