Charts

Side-Out Scoring

A four-state pickleball side-out scoring strip in hand-rolled SVG — every service turn a team-coloured segment as wide as the points it scored, zero-point turns kept as hairline ticks, server 2 in a lighter fill, running-score stamps under the strip, a pinned final score and per-team points-per-turn and best-turn summaries.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartSideOutScoringData,
  ChartSideOutTeam,
  ChartSideOutTurn,
} from "./chart-side-out-scoring.contract"

export interface ChartSideOutScoringProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartSideOutScoringData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-side-out-scoring.json

Prompt

Build a React + TypeScript + Tailwind "ChartSideOutScoring" card in
hand-rolled SVG with zod — the scoring rhythm of a pickleball doubles game
under side-out scoring, where ONLY the serving team scores and every service
turn ends in a side-out (except a final turn that ends the game).

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    teams: { a: { name: string }; b: { name: string } };
    turns: { team: "a" | "b"; server: 1 | 2; points: int >= 0 }[];
    target?: int > 0 (default 11);
    context?: string }.
- ARRAY ORDER IS THE AXIS: the game's first service turn first. Nothing is
  sorted, and the running score is derived by one pass over the array — the
  stamps, the tooltip and the table all read the same derivation.
- `a` always takes var(--chart-1) and `b` var(--chart-2); the mapping never
  flips, so two strips of two games read the same way.
- Props = z.infer of the schema plus title?, height (strip px, clamped
  40-120, default 56), onRetry?, emptyState? and className; forwardRef to the
  card div. No hand-written parallel interface.
- Export the arithmetic beside the component so a test can print the same
  numbers the picture is made of: ingestSideOutTurns(turns) -> placed turns
  with running scores, per-team totals (points, turns, longest turn) and a
  dropped count; sideOutLayout(points[]) -> x/width slots.

Behavior — side-out scoring, not decoration
- A segment's width IS the points that service turn scored, on one unit
  shared by the whole strip. Two exceptions keep it honest AND readable: a
  0-point turn takes a fixed hairline tick (zero width would erase the
  side-out, and side-outs are half the story), and when no turn scored at all
  the strip degrades to uniform slots rather than a row of ticks in blank
  card.
- Server 2 keeps the team's token at reduced fill-opacity: hue is the team's
  identity, opacity is a detail of it, and the legend, tooltip and spoken
  sentences all carry the server number in words, so opacity is never the
  only channel.
- Running-score stamps sit under the strip on the same x-axis, thinned so
  they never collide (a stamp needs 14% of the strip since the last one and
  stays clear of the right edge); the final score is pinned to the right in
  foreground text and repeated big in the headline.
- Turns with an unreadable team, server or point count are dropped and
  counted in a visible line — silently losing a turn would shift every score
  after it.
- Hover a turn for a tooltip (turn #, team, server, points or "side-out, no
  points", score after); Tab focuses the strip and arrow keys walk the turns
  as a listbox with aria-activedescendant, Home/End included. hovered wins
  over the keyboard cursor; both are derived against the current turn count
  so a shorter payload can never leave either pointing past the end. Hit
  targets are full-height, gap-inclusive columns, so a hairline tick is as
  easy to hit as a five-point run.
- Per-team summary: points, service turns, points per turn, best turn — the
  side-out diagnostic trio (a team scoring 1.4 per turn is winning the serve
  battle even when the score is level).

Rendering & styling
- The strip is drawn in a fixed 1000 × 100 viewBox stretched with
  preserveAspectRatio="none": x-units follow the card's width, y-units the
  height prop. Every mark is an axis-aligned rect (non-uniform scaling cannot
  distort one) and every stroke carries vectorEffect="non-scaling-stroke";
  nothing inside the svg is text — text would stretch — so scores, stamps and
  the tooltip are HTML at real px sizes.
- Colour from tokens only: var(--chart-1) / var(--chart-2) fills, the active
  outline stroke-ring, the frame border, all text on text tokens. The tooltip
  is an aria-hidden echo anchored in percentages of the same box the strip
  stretches in; an sr-only table repeats every turn.
- Skeleton mirrors the ready layout (score line, team grid, strip, stamps
  row) with motion-reduce:animate-none on every pulse.
- 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
- Scale: TICK (hairline width) and GAP (the sliver between turns) trade
  side-out visibility against smoothness on long games; height 40-120 fits
  the strip into dashboards or hero cards.
- Encoding: SERVER2_OPACITY is one constant — raise it toward 1 to mute the
  server split, or key a pattern off server instead; SIDE_TOKEN swaps team
  hues onto other chart tokens.
- Annotations: the stamp spacing (14%) and the pinned final are independent
  of the strip — stamp every possession, or drop stamps entirely and keep
  only the final.
- Summary: the per-team block is four lines — add side-outs forced, or drop
  points-per-turn for a minimal recap card.
- Domain: rally-scoring volleyball or squash fit by relabelling — the only
  pickleball-specific assumptions are "server 1 | 2" and the game target's
  default of 11.

Concepts

  • Width is points, and only points — every segment is sized by the points its service turn scored, on one unit shared across the strip, so a four-point turn is visibly twice a two-point turn anywhere on the card; the axis is service turns in order, never wall-clock time.
  • A side-out with no points is still an event — under side-out scoring, a serve that comes straight back is half the game's story, so a 0-point turn keeps a fixed hairline tick instead of vanishing at zero width: the reader can count every change of serve, not just the scoring ones.
  • Only the serving team scores — the strip enforces the format by construction: a turn's points go to its team and nowhere else, so the colour balance of the strip is the scoreboard, and the running score is derived in one pass that the stamps, the tooltip and the table all share — they can never disagree.
  • Hue is the team, opacity is the server — server 2 reuses the team's own token a step lighter, never a third colour, and the server number is written in the legend, the tooltip and every spoken sentence, so the lighter fill is redundant with words rather than load-bearing.
  • Points per turn is the side-out diagnostic — total points divided by service turns, printed per team with the best single turn beside it: it separates "scored a lot because they held serve often" from "scored efficiently whenever they had it", which the final score alone hides.
  • Hover is an echo, the keyboard is a cursor — the tooltip repeats the option's own aria-label; Tab plus arrow keys walk the same turns through aria-activedescendant over full-height, gap-inclusive hit columns, so a hairline tick is as reachable as a five-point run.

On This Page