Rally Pattern Tree
An icicle tree of shot sequences rooted at the serve — branch width is the rallies that took it, tint is the win rate down it, and everything under the sample cut is pruned with its share stated.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-rally-pattern-tree.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRallyPatternTree" card: a rally
pattern tree drawn as an icicle, hand-rolled SVG, zod for the contract, no
charting library.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string;
meta?: { player: string; context?: string };
sport?: "tennis" | "badminton" | "pickleball"; // default tennis
minSample: number >= 1;
nodes: { id, parentId?, shot, count, wonCount, depth }[] }
- Props = z.infer of that schema plus rowHeight?, onRetry?, emptyState? and
className. No parallel hand-written interface.
- Counting rule, stated once in the contract and obeyed everywhere: `count` is
the rallies in which this exact SEQUENCE was struck, `wonCount` how many of
those the tracked player eventually won — at the end of the point, not on
this shot. So a node's win rate is the win rate down that branch.
- The tree is a prefix tree: a node is a path, not a shot. The same shot name
recurs at every different way of arriving at it, and those nodes are never
merged — merging by name answers "how often does he hit it" when the
question is "what happens after the wide serve".
Model (pure, exported, pixel-free — widths live in rallies)
- Reject unusable rows: missing or duplicate id, non-finite or negative count,
wonCount above count, non-integer depth. Count them; never drop in silence.
- Attach strictly by depth: a node joins a parent only if the parent sits
exactly one level above. That is what makes a parent chain impossible to
loop, so no walk can hang on a feed where A's parent is B and B's is A.
- Per node derive `ended` = count minus the children's counts (the rallies
that finished on this shot — a winner, an error, an untouched serve) and
`endedWon` = wonCount minus the children's wins, clamped to [0, ended].
- Prune top-down: a branch is drawn when it clears minSample AND its parent
was drawn. Collect the rallies of the branches that miss once each, at the
point they leave the tree, so endedDrawn + prunedRallies === total.
- Lay out breadth-first, siblings widest first (ties: more wins, then payload
order), each child packed inside its parent's span from the left. The order
that falls out is the paint order and the arrow-key order.
- Pick the verdict: the highest win rate among branches at least three shots
deep, compared by cross-multiplication so two equal rates cannot swap on a
float wobble. A serve on its own is not a pattern.
Behavior
- Four first-class branches: loading (a pulsing three-row silhouette that
narrows shot by shot), empty (a small stack-of-branches glyph plus copy),
error (message + a Try again button only when onRetry exists), ready.
- A ready payload with nothing drawable is its own state: say that no serve
clears the cut and print the numbers, rather than showing "no data".
- One selectable branch at a time. Pointer hover selects; the whole path from
the serve lights up, not just the branch, because the path is the thing
being read. Keyboard: left/right along one shot, down into the widest branch
that follows, up to the shot before, Home/End to the ends of the row.
- Readout under the plot: the sequence in plain words joined by arrows, then
rallies / share of all rallies / won and win rate, then the split of what
happens NEXT — one entry per drawn child plus "below the cut" and "the point
ended here".
- Those next-shot percentages partition one whole, so apportion them by
largest remainder (floor everything, hand the leftover points to the biggest
fractional parts, ties to the bigger count then the earlier slot). Rounding
each slot on its own is how a four-way split prints 101.
- Every sentence the card prints is computed — the verdict, the pruned share,
the branch count, the depth reached. Nothing is written into the copy.
Rendering & styling
- Rows: a caption line ("Shot 3 · serve +1" on the left, the ball's cumulative
court travel on the right), the row of branch rects, then a gap. A closing
strip under the last row catches the deepest caps.
- Branch tint = win rate: one chart token per side of even (var(--chart-2)
above 50%, var(--chart-5) below) at an opacity that climbs with the distance
from even and saturates around 15% / 85%. Opacity over bg-card, never a
fixed colour, so it re-themes and survives a near-black card. An even branch
is meant to look uncommitted — it is.
- Two kinds of short cap sit at the right end of a parent's span in the row
below it: a tinted solid one for the rallies that ended on that shot, a
dashed muted one for the rallies that went below the cut. Half height, so a
closure never reads as another shot.
- Cell text degrades a step at a time as a branch narrows: name over numbers,
name alone, numbers alone, bare count, nothing. Ellipsis, never a clipped
glyph. Text carries a var(--card) paint-order halo so it stays legible over
the strongest tint in either theme.
- Distances come from real court dimensions, not from the drawing: a serve is
charged half the court plus the middle of the service box, every rally ball
after it the full court — tennis 23.77 m with the service line 6.40 m from
the net, badminton singles 13.40 m with the short service line at 1.98 m,
pickleball 13.41 m with a 2.13 m non-volley zone.
- Accessibility: the svg is role="listbox" with tabIndex 0 and
aria-activedescendant; every branch rect is a role="option" whose aria-label
is its sequence and its numbers. Decorative groups are aria-hidden, the
visible readout too (the option already speaks it), and an sr-only table
repeats every branch with its ended / below-the-cut columns. cn() merges
className, the root spreads the remaining props, the ResizeObserver that
measures the plot is disconnected on unmount and on node change, and the
skeleton's pulse is dropped under prefers-reduced-motion.
Customization levers
- rowHeight (30-72): 44 fits a name over its numbers; drop to ~32 for a dense
scouting page and the second line is omitted automatically.
- minSample lives in the data, not in the component — raise it to trade depth
for confidence. Everything under it stays counted and stated.
- Verdict depth: "at least three shots" is one constant; raise it to only ever
praise longer patterns, or lower it to let the return compete.
- Tint: swap the two chart tokens for a single-hue ramp when the host palette
has no second accent, or widen the saturation edge (0.35 of the way from
even) to make near-even branches paler.
- Sport: the shot role names ("serve +1" vs "third shot") and the distances
come from one lookup table — add a court and its service box to cover squash
or table tennis.
- Trim the card: the legend, the distance captions, the closing caps and the
footnote paragraph are each one block and can be dropped independently; the
sr-only table should not be.Concepts
- Prefix tree, not a flow — a node is the whole sequence that reaches it, so "forehand cross-court" exists once per way of arriving at it. Merging those (what a Sankey does) answers a different question and hides the very split this chart exists to show.
- Width is sample, tint is result — one rally is the same number of pixels everywhere on the card, so a wide pale branch (common, even) and a narrow dark one (rare, decisive) stay told apart instead of being averaged into one bar.
- The remainder is data — the gap between a branch and its children is the rallies that ended on that shot, drawn as a tinted cap with its own win rate. Aces, return errors and put-aways all live in that remainder; hiding it would leave the tree looking like it leaks rallies.
- Pruning with a stated share — branches under
minSampleare not drawn but are still counted, once, at the point they leave the tree, and the card prints how many rallies and how many branches that was. A tree that silently stops is a tree nobody can audit. - Largest-remainder split — the next-shot percentages partition one branch, so they are apportioned together rather than rounded one by one; every selection sums to exactly 100, the "ended here" and "below the cut" slots included.
- Depth by attachment, not by walking — a child is only ever hung on a parent exactly one level above it, which makes a looping parent chain unrepresentable rather than merely unlikely, and keeps a bad feed a sentence on the card instead of a frozen tab.
Serve +1 Patterns
A four-state tennis serve +1 flow in hand-rolled SVG — serve target, then the +1 wing and direction, then won or lost, joined by bands sized by points and coloured by win rate against a benchmark, with the most-used and the most-effective pattern named.
Surface Splits
A four-state surface-by-surface record card — win-loss bars with paired service and return sub-bars, each read against a tick at the player's own overall level, and the strongest and weakest surfaces called out above a match floor.