Charts

Draw Path

A four-state seeded route through a draw — one round per rail station with the projected opponent's rating and head-to-head, faint branches for whoever could arrive instead, Elo win chances compounded into a survival bar, and a tab panel for each round's whole field.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartDrawPathData,
  ChartDrawPathOpponent,
  ChartDrawPathPlayer,
  ChartDrawPathRound,
} from "./chart-draw-path.contract"

export interface ChartDrawPathProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartDrawPath" card — one seeded player's
route through a knockout draw, drawn as a vertical rail of rounds with a
hand-rolled SVG spine behind HTML rows (no chart library), plus zod for the
contract.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    player: { name, seed?, rating };
    event?: string;
    rounds: { round: string;
              opponents: { id, label, seed?, rating, h2hWins, h2hLosses,
                           probability }[] }[] }.
- Component props = z.infer of the schema, plus title?, defaultRound?,
  onRetry?, emptyState? and the div's own attributes. No hand-written
  parallel interface.
- `probability` is any non-negative weight — a probability, a percentage or a
  count of simulation runs — because it is normalised inside its round.
- `h2hWins` / `h2hLosses` are always the SEED's side of the head-to-head — the
  meetings the seed has won off this opponent and lost to them — never the
  opponent's, since every readout prints them in that order as "h2h wins–losses"
  and sums them into the route record.
- `rating` is Elo-scale for both the player and every opponent; ids are unique
  across the WHOLE route, since the pools that can reach you in two different
  rounds are disjoint. Refine both, and refine that a ready payload carries at
  least one round with at least one opponent.

Behavior
- Derive, never assert: a pure buildDrawPathModel(player, rounds) turns the
  payload into per-round candidates (share, printed percent, win chance),
  field rating, round win chance and the survival chain. Everything the card
  prints is read back out of that model.
- The field rating is Σ arrival odds × rating — the expected rating of whoever
  arrives, NOT the plain mean of the ratings listed under it — so it has to be
  labelled as such wherever it is printed: "field averages N by arrival odds"
  on the tile and in the spoken sentence, "expected opponent N" in the panel
  header. An unqualified "the field averages N" reads as a bug against the
  very rows the card shows.
- Arrival odds: normalise each round's weights (exactly one opponent arrives),
  then apportion whole percents by LARGEST REMAINDER — floor, then hand the
  leftover points to the biggest fractional parts, ties to the bigger weight
  and then to the earlier opponent. Every round must print percents that add
  to exactly 100. A round with no usable weights is read as evenly likely and
  says so.
- Win chance: Elo expectation 1 / (1 + 10^((opponent − player) / 400)). The
  round's chance is the odds-weighted mean over the field; survival is the
  running product down the route, so a bar can only shrink.
- Verdicts computed from the model, never hardcoded: route survival, the
  toughest round (lowest round win chance, earliest breaking ties), and the
  head-to-head across the PROJECTED opponents only, reported as "N of M
  projected opponents, K never met".
- Opponents without a usable rating are dropped and counted; a round left with
  no field is skipped and counted; both are reported under the chart rather
  than silently swallowed. A ready payload with nothing drawable falls through
  to the empty branch WITH that reason.
- The four states are first-class branches of one card: loading keeps the
  ready silhouette (tiles, narrowing bars, panel), empty draws the bare rail,
  error offers retry only when onRetry exists, ready draws the route.
- Rounds are a vertical tablist with automatic activation: click, or Arrow
  up/down (left/right too), Home and End; selection follows focus and focus
  follows selection; roving tabIndex; one tabpanel showing the selected
  round's whole field.

Rendering & styling
- Semantic tokens only: bg-card / border / bg-muted / text-muted-foreground /
  text-destructive / bg-primary for the "projected" chip, var(--chart-1) for
  survival and var(--chart-2) for arrival odds. No hex, no palette classes.
- One number, one mark: a row's bar width, its fill opacity and the percent
  printed beside it are all the same survival value; the pale tail beside it is
  exactly what that round takes out of the route. The panel's arrival bars are
  drawn at the PRINTED percent, so bar and label cannot disagree.
- The rail SVG is absolutely positioned in a fixed-width gutter and sized from
  the same row-height constant the rows use, so nothing is measured at runtime
  — no observers, no timers, nothing to tear down. Spine thickness follows
  survival; each alternate gets a faint fork whose curve opacity and dot radius
  follow its arrival odds.
- Accessibility: each tab's aria-label is a full sentence (round, projected
  opponent, rating, odds, head-to-head, round win chance, survival), the rail
  SVG is aria-hidden decoration, and an sr-only table lists every opponent of
  every round. focus-visible rings on tabs and the retry button; transitions
  carry motion-reduce:transition-none and the skeleton motion-reduce:
  animate-none.
- cn() merges className; remaining props spread on the root, which also carries
  data-slot and data-status.

Customization levers
- Rail density: ROW_H is the single row-height constant the SVG and the rows
  share — drop it to ~64 for a compact rail (and trim the alternates line), or
  raise it for a roomier one.
- How many branches are drawn: FORKS caps the fork stubs per round; the rest
  collapse into "+N more". Set it to 0 for a bare spine.
- Which numbers ride the row: the three-percent line (arrive / if you meet /
  alive) is the densest reading — drop "if you meet" for a broadcast card, or
  drop the alternates line entirely and let the panel carry the field.
- Difficulty model: swap eloWinProbability for your own (a surface-adjusted
  Elo, a logistic on ranking points, a market price) and every derived number,
  bar and verdict follows — nothing else knows where the chance came from.
- Palette: survival rides var(--chart-1) with an opacity ramp, arrival odds
  var(--chart-2); re-map either, or give each round its own token if the route
  should read as five series rather than one.
- Opening round: defaultRound picks the tab the card opens on; a preview page
  usually wants the first round, a mid-tournament card the next one to be
  played.

Concepts

  • Route as a chain, not a list — each round's survival is the previous round's survival times this round's odds-weighted win chance, so the rail can only ever narrow and the "still alive" number beside a bar is the number the bar was drawn from.
  • Arrival odds are one whole — exactly one opponent walks out of a round, so the field is apportioned together by largest remainder; every round's printed percents add to exactly 100 instead of the 101 that per-opponent rounding produces.
  • Alternative branches stay visible — the opponent who could arrive instead is drawn as a faint fork whose curve opacity and dot size follow its own arrival odds, so a 13% outsider never looks like a 66% favourite, and the full field is one tab away.
  • Difficulty is derived, never asserted — win chances come from ratings through one swappable Elo function, so the "toughest round" verdict and the head-to-head tile are consequences of the data rather than editorial labels someone typed.
  • Projected-only head-to-head — the record tile counts the likeliest arrival of each round and reports how many of them have actually been played, because averaging over every candidate would credit the seed with matches against players who will mostly never arrive.
  • Measurement-free geometry — the rail SVG derives its height from the same row-height constant the rows use, so the spine, nodes and forks land on their rows with no ResizeObserver, no timer and nothing to clean up on unmount.

On This Page