Charts

Point Momentum Run

A four-state point-by-point momentum strip — one tick per point above or below the centre line, consecutive points by the same player merged into run blocks, break points and breaks of serve flagged, set dividers drawn and the longest run per player printed.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartMomentumRunData,
  ChartMomentumRunPoint,
  ChartMomentumRunSide,
} from "./chart-momentum-run.contract"

export interface ChartMomentumRunProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartMomentumRunData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartMomentumRun" card in hand-rolled SVG
with zod. No chart library: the mark is a run-length block, the axis is an
ordinal point counter with no time on it, and the whole strip is one pass over
an array — recharts has no primitive for that, and every number on the card is
derived from the same point stream, so the maths lives in pure functions beside
the schema.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    players: { a: { name: string }; b: { name: string } };
    points: { server: "a" | "b"; winner: "a" | "b"; isBreakPoint?: boolean;
              converted?: boolean; setIndex: int >= 0; gameIndex: int >= 0 }[];
    context?: string }.
- ARRAY ORDER IS THE AXIS. Points carry no timestamp and two points inside one
  game are told apart only by their position, so nothing is ever sorted;
  setIndex and gameIndex label the axis, they do not order it.
- `a` always draws above the centre line and `b` always below — the mapping
  never flips, so two matches read the same way.
- Props = z.infer of the schema plus title (default "Point momentum"),
  height (strip height in px, clamped 56–200, default 92), onRetry, emptyState,
  className and the div's native props. forwardRef to the card. No hand-written
  parallel interface.
- Export the arithmetic beside the component so a test can print the same
  numbers the picture is made of: ingestMomentumRun(points) -> runs, games,
  set segments, per-side totals and a repair ledger;
  momentumGameWon(a, b, tiebreak?); momentumColumnX(index, count);
  momentumRunGap(count).

Behavior — tennis, not decoration
- A RUN is a maximal stretch of consecutive points won by the same player. It is
  drawn as ONE block whose width is exactly its point count, so a 7-point streak
  is seven columns wide and a lone point is one. Runs are NOT cut at a set
  boundary: a streak that carries from the last point of one set into the first
  of the next is still one streak, and the divider is drawn across it.
- A BREAK POINT is a point the returner can win the game with. Its tick reaches
  one gutter further out than a normal tick, on the winner's own side, so a game
  that went 0-40 shows three tall ticks whichever way the points went.
- A BREAK OF SERVE (converted) draws a full-height hairline in the breaker's
  colour under the tick layer, plus a cap in that player's own outer margin.
  Winning a break point always ends the game, so `converted` implies
  `isBreakPoint` and `winner !== server`.
- Games and the SET SCORE are derived, never passed in: group neighbouring
  points by (setIndex, gameIndex), then a game is won when its winner reaches
  the target AND leads by at least 2. The target is 4 in a service game (40-0
  is 4-0, a deuce game 6-4) and 7 in a tiebreak, and the rule refuses to award
  anything short of it — a game at deuce, or a tiebreak still at 5-3, is
  labelled "in play" rather than quietly counted, so a live strip never
  over-reports the set.
- Serve changing inside one game means a tiebreak: such a game has no holder, so
  it is excluded from hold rates, it is played to 7 instead of 4, and it never
  contributes break points — a break flag on one of its points is dropped (the
  point is kept) and counted in the repair ledger, because nobody's service game
  is at stake in a tiebreak and counting it would inflate the printed conversion
  rate.
- Break chances belong to the RETURNER (`otherSide(server)`), converted breaks
  to the point's winner, so "3 of 8 break points" and "held 9 of 10" always add
  up with the printed game score.
- Feed hygiene, every repair counted and disclosed in a note under the chart:
  a point with an unreadable side, setIndex or gameIndex is dropped (there is no
  axis slot for it); `converted` on a point the SERVER won loses the flag and
  keeps the point; `converted` without `isBreakPoint` is counted as a break
  point, because it was one; a break flag inside a tiebreak loses both flags and
  keeps the point. A set index that comes back after another set is drawn as a
  second segment and counted.
- Four first-class branches in one bg-card panel: loading is an aria-hidden
  skeleton that mirrors the real layout (score line, two player blocks, a strip
  box with its centre line, a legend bar) plus an sr-only role="status" line;
  empty is the bare centre line plus copy naming both players; error is
  role="alert" plus a "Try again" button rendered only when onRetry exists;
  ready is the strip. A ready payload with nothing drawable falls through to the
  empty branch and says how many rows arrived unreadable.
- Interaction: the run is the unit. The svg is a role="listbox", each run a
  role="option" hit column spanning the full height (no dead sliver between
  runs), hover and a keyboard cursor feed one readout line, Arrow keys walk the
  runs, Home/End jump to the ends. Only :focus-visible arms the cursor, so a
  mouse click never pins a readout with no focus ring to explain it.

Rendering & styling
- The strip is a fixed 1000 x 100 viewBox stretched with
  preserveAspectRatio="none": x follows the card's width, y follows the height
  prop, so a 60-point set and a 250-point five-setter are the same height on the
  page. Every mark is therefore an axis-aligned rect, every stroke carries
  vectorEffect="non-scaling-stroke", and NOTHING inside the svg is text —
  non-uniform scaling would stretch it. All labels are HTML at real px sizes.
- Vertical budget in y-units: 5 margin + 11 break gutter + 34 tick band on each
  side of the centre line at 50. Horizontal: a 4-unit inset so the cursor ring
  is never half-clipped, and a run gap of clamp(pitch * 0.28, 0.8, 3).
- Colour: var(--chart-1) for `a`, var(--chart-2) for `b`, at FULL opacity — a
  tinted fill sinks into a dark card. The same `side` value picks the band, the
  token and the sentence, so a block can never be drawn above the line in the
  other player's colour. Centre line and set dividers are stroke-muted-foreground
  (solid and dashed respectively), the cursor ring is stroke-ring, the error
  headline is text-destructive.
- Set labels sit in an HTML row under the strip and share the strip's x-axis by
  flex-growing in proportion to the points each set holds, so a label always
  sits under its own span.
- Accessibility: the listbox's aria-label carries the whole summary, each option
  spells out its run, and an sr-only table lists every run with its player,
  length, first point, games and break points. Skeleton pulses take
  motion-reduce:animate-none.

Customization levers
- Strip height: the `height` prop (56–200). Taller reads as a chart, shorter as
  a sparkline strip in a table row; the tick pitch never changes, only the bands.
- Vertical proportions: EDGE / GUTTER / BAND must keep summing to MID (50).
  Grow GUTTER for a louder break-point flag, grow BAND for chunkier ticks.
- Run separation: momentumRunGap's fraction and clamp. Raise it to make single
  points read as pickets, drop it to 0 for a solid ribbon.
- Palette: SIDE_TOKEN maps side -> token. Point both sides at one token and vary
  nothing else if you want an "us vs them" strip that reads by position alone.
- Density of the readout: the header keeps four lines per player (points, share,
  longest run, break points). Drop any of them, or move service holds up out of
  the sr-only summary, without touching the strip.
- Markers: break points and breaks of serve are two independent layers. Delete
  either one for a plain streak strip; the readout, the table and the totals
  keep working because they are computed in ingestMomentumRun, not in the paint.
- Sport swap: nothing here is tennis except the vocabulary and the game-won
  rule. Volleyball rallies, table-tennis points, esports rounds and darts legs
  all fit by renaming setIndex/gameIndex and replacing momentumGameWon.

Concepts

  • Run-length encoding as the mark — the chart's atom is not the point, it is the streak: neighbouring points won by the same player collapse into one block whose width is literally its point count. Reading "who is on a roll" becomes reading block widths instead of counting ticks, and the longest block is the number printed in the header.
  • One value places, colours and names a mark — a point's winner picks the band (above or below the centre line), the chart token that fills it and the sentence the screen reader hears. Because there is no second, bucketed coordinate, a block can never end up on one player's side of the line wearing the other player's colour.
  • Derived score, with an "in play" guard — games are rebuilt from the point stream and only awarded when someone reaches the target and leads by two: four points in a service game, seven in a tiebreak, which the chart recognises by the serve changing hands inside the game. A game still at deuce — or a tiebreak still at 5-3 — is labelled, not counted, so a live strip never over-reports the set.
  • Break point is a longer tick, break of serve is a hairline — the two flags live on independent layers: every point the returner could win the game on grows one gutter outward on its own side, while the point that actually completed the break draws a full-height line in the breaker's colour with a cap in that player's margin. Nothing is clustered, so a 0-40 game shows all three chances.
  • Non-uniform viewBox, zero text in the svg — the strip is a fixed 1000 × 100 box stretched with preserveAspectRatio="none", so width comes from the card and height from a prop; every mark is an axis-aligned rect and every stroke is non-scaling. Labels are HTML at real px sizes, which is why they stay legible in a 420px card and a 900px one alike.
  • Repairs are disclosed, not swallowed — an unreadable point is dropped because it has no slot on the axis, but a contradictory flag (a "break" the server won, a conversion with no break-point flag, a break point inside a tiebreak where nobody's serve is at stake) is corrected and the point kept, with every count printed under the chart. A momentum strip that quietly shortens the match is a strip that lies about the streaks.

On This Page