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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-momentum-run.jsonPrompt
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
winnerpicks 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.
Match Score Grid
A tennis scoreboard drawn as a chart — one row per set, one cell per game marked with who served and who won it, breaks emphasised, tiebreaks tagged with their mini-score, and hold percentages counted from the same games.
Speed vs Spin
A four-state shot scatter — every tracked shot plotted at its own speed and spin, coloured by stroke, split into four playing-style quadrants by the player's own medians, with a legend that toggles a stroke in or out.