Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  RALLY_COURT_M,
  rallyBallTravelM,
  rallyShotRole,
  type ChartRallyPatternTreeData,
  type ChartRallyPatternTreeNode,
} from "./chart-rally-pattern-tree.contract"

export interface ChartRallyPatternTreeProps

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-rally-pattern-tree.json

Prompt

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 minSample are 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.

On This Page