Charts

Soccer Pass Network

A replayable soccer pass network in hand-rolled SVG — raw completed passes cut into match windows, a disc at each player's average position sized by involvements, lines weighted by passes exchanged, and a play / pause / step / scrub transport running on a real match clock.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ChevronLeft,
  ChevronRight,
  MoveRight,
  Pause,
  Play,
  RotateCcw,
  SkipBack,
  SkipForward,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-soccer-pass-network.json

Prompt

Build a React + TypeScript + Tailwind "ChartSoccerPassNetwork" chart — a soccer
pass network on a pitch, replayable through the match, in hand-rolled SVG (no
chart library), with zod for the contract and lucide-react for the transport
icons.

Contract
- One zod schema is the single source of truth, and the props are z.infer of it
  plus the render levers — never a parallel interface:
    { status: "loading" | "empty" | "error" | "ready";
      team: string; context?: string; formation?: string;
      players: { id: string; name: string; number?: 1-99; role?: string;
                 onMinute?: number; offMinute?: number }[];
      passes: { period: 1 | 2; minute: number; stoppage?: number;
                from: string; to: string;
                x: number; y: number; endX: number; endY: number }[] }.
- passes are COMPLETED passes only, one row per pass, in any order: the card
  cuts them into windows itself, so one flat list drives every frame.
- Props on top: title, windowMinutes (5-45, default 15), mode ("window" |
  "cumulative", default "window"), minLink (default 2, floor 1), frame /
  defaultFrame / onFrameChange, frameMs (200-10000, default 1600), onRetry,
  emptyState, className. forwardRef, remaining props spread on the root,
  data-status on it.
- Coordinates are metres on a 105 x 68 pitch, always normalised to this team
  attacking left to right: x from their own goal line, y from their left
  touchline seen from above. The component never flips a half — a network that
  silently mirrors half the match cannot be checked against the video.

Geometry and maths — exported pure functions, so a test can print the same
numbers the picture is made of
- Pitch to the laws of the game, in metres: 105 x 68, penalty area 16.5 deep and
  40.32 wide, six-yard box 5.5 x 18.32, spot at 11, arc radius 9.15 meeting the
  box edge 7.31 either side of the spot, goals 7.32 wide, corner arcs 1. The svg
  viewBox IS the pitch, so no scale function stands between a metre and its mark.
- elapsedMinute(period, minute): the clock a window is cut on. Stoppage never
  moves a pass past its half — 45+3 folds onto minute 45 and stays in the window
  that closes the first half, which is where a fan puts it — and a second-half
  minute below 45 is a broken feed pulled up to the restart.
- buildWindows(windowMinutes): half-time is a hard boundary, so an awkward
  divisor leaves a short window at the end of each half rather than one window
  straddling the interval. windowIndexOf puts a pass in exactly one of them.
- ingestPasses: drop passes naming somebody off the teamsheet, passes a player
  made to himself, and passes with an unreadable clock or coordinate; CLAMP
  coordinates that sit off the pitch (a throw-in is taken from behind the line
  and is still a pass). Count every rejection by reason and print the counts.
- buildNetwork per frame: a player's position is the plain mean of every place
  he played or received the ball in that frame, involvements = passes made plus
  received, and a pair is one line carrying both directions plus the split.
- nodeRadius: area, not radius, tracks involvements. linkWidth: linear in
  passes, so twice the passes is twice the ink. linkArrow: a chevron at 62% of
  the line pointing at the end that received more, drawn only when the pair is
  lopsided by at least 2 passes AND a fifth of its volume — under that, an arrow
  would invent a story.
- placeNodes: a substitution inside one window puts two players on nearly the
  same average spot, and two discs on top of each other are one unreadable disc.
  The bigger disc anchors, the smaller is pushed out to exactly one clearance
  along the line between them, and it says so: a dashed hairline runs back to
  the position it holds, every printed metre is still the true mean, and the
  nudged disc flips its name to the other side so two names never stack. Sorted
  by radius then id, with coincident marks leaving along a fixed angle, so the
  same payload always draws the same picture.
- playedInWindow: on = onMinute or 0, off = offMinute or 90, and a player whose
  off is not after his on (an unused substitute) was never on the pitch at all.

Behavior
- Four first-class branches in one bg-card panel: loading (pitch silhouette,
  pulsing discs, a skeleton strip and rail), empty (faint pitch and one
  explanatory line, reused when a ready payload has nothing readable, with the
  reason), error (message plus a Try again button only when onRetry exists),
  ready.
- The playhead is a frame index and nothing else — never a wall clock. Support
  the whole triple: frame (controlled), defaultFrame (uncontrolled start),
  onFrameChange (fired by every tick, step, scrub and keypress). The index is
  derived and clamped on every render, so a shorter payload can never leave the
  card pointing past the end, and a paused card at frame N renders identically
  every time.
- Transport is real UI: jump to kick-off, previous window, play/pause (which
  becomes replay at full time), next window, jump to full time, plus a rail that
  is a role="slider" — pointer capture for dragging, Arrow/Home/End for keys,
  a tick per frame and a half-time mark on the rail itself.
- Playback is one window.setTimeout owned by one effect: pausing, scrubbing,
  reaching the whistle and unmounting all clear it. A visibilitychange listener
  pauses when the tab hides, so nothing burns frames off-screen.
- Never the native disabled attribute on a transport button: aria-disabled plus
  a guard in the handler, so the control under the finger at kick-off or at full
  time never vanishes and drops focus onto body.
- Pointing at a disc traces that player — his links stay lit, the rest dim to a
  fifth, and the read-out line names him with his involvements, his mean
  position in metres and his busiest partner.
- The keyboard equivalent is a player strip built from the TEAMSHEET, not from
  the frame: roving tabIndex, arrow keys wrap, Home/End jump, Enter toggles the
  trace (aria-pressed) and Escape clears it. Built from the fixed squad because
  a strip that re-mounted on every substitution would drop the focused button —
  and the focus with it — the moment the clock ticked past a change. Players not
  on the pitch this frame are drawn dashed and stay selectable; selecting one
  says so instead of pretending they were never there.
- Frames cut, they do not tween: a network is an aggregate over a window, and
  interpolating between two aggregates would draw a player at a position he
  never averaged. Only the opacity of the network layer is animated, and only
  behind motion-safe, so with motion off the marks simply appear and disappear
  per step and nothing depends on an in-flight animation to become readable.

Rendering & styling
- Tokens only: var(--chart-1) for the links (opacity ramps with volume) and the
  direction chevron, var(--chart-2) for the discs (a card under-disc, a tint and
  a ring), pitch markings stroke-border with fill-border spots, foreground for
  the shirt numbers and the traced halo, muted-foreground for names and the
  hairline. No hex, no rgb, no oklch anywhere, pitch markings included.
- Nothing is carried by colour alone: volume is width, involvement is area,
  direction is a chevron, the traced player also takes a foreground halo, and
  every disc prints its own shirt number with the name beside it.
- Names are elided to ten characters with the full name in a title, and drawn
  with paintOrder="stroke" and a card halo so they stay legible over the busiest
  line in either theme.
- The svg is role="img" with a one-sentence summary; a polite live region
  repeats that sentence on every frame change, and an sr-only table lists every
  drawn player with their true mean position, passes played and received, and
  busiest partner. Panel rounded-xl border bg-card p-4, numbers tabular-nums,
  cn() merges className, and every transition carries
  motion-reduce:transition-none.

Customization levers
- Clock resolution: windowMinutes is the one knob for how finely the match is
  cut — 45 gives a half-by-half card, 5 gives a near-continuous replay. Windows
  always break at half-time; extend the period union and the window table for
  extra time.
- Aggregation: mode "window" answers what they were doing right then,
  "cumulative" answers what the match has looked like so far. Pair cumulative
  with a higher minLink (4-6) or the full-match picture turns into wool.
- Threshold: minLink trades completeness for legibility, and whatever it hides
  is counted under the rail — never silently dropped.
- Pace and control: frameMs is the hold per frame; wire frame/onFrameChange to
  your own scrubber, to a video player's currentTime or to a screenshot script
  and the card becomes a controlled figure.
- Encoding: R_MIN/R_MAX and W_MIN/W_MAX set how loud the size and width channels
  are, ARROW_MIN_GAP and ARROW_MIN_SHARE decide how lopsided a pair must be
  before it earns an arrow, and NODE_GAP is the daylight between two discs.
- Palette: swap the two chart tokens per team — home and away cards side by side
  read best as chart-1/chart-2 against chart-4/chart-5 — without touching any
  geometry.
- Domain: the ingest, the windows and the statistics are shape agnostic. Swap
  the pitch constants and the labels for hockey, handball or futsal and the
  replay works unchanged.

Concepts

  • The playhead is derived, never a wall clock — the card holds a frame index and supports the full value / defaultValue / onChange triple, so playback, a keyboard seek, a drag on the rail and a parent component all move the same number. A paused card at frame N draws the same picture on every render, which is what makes it safe to screenshot and safe to render on the server.
  • The clock is the sport's own — minute plus stoppage, first half and second half, with 45+3 folded onto the end of its half instead of colliding with the 47th minute of the second. Half-time is a hard cut in the window table, because no team plays through the interval.
  • Window versus cumulative is a question, not a setting — the window aggregation answers what the team was doing right then, the cumulative one answers what the match has looked like so far. Same passes, same code path, two different questions, and the caption always says which one is on screen.
  • Positions are computed, not supplied — the contract takes raw completed passes, so the card owns the arithmetic: a player sits at the plain mean of everywhere he played or received the ball inside the frame, and his disc area is his involvement count. Feeding pre-averaged coordinates would freeze the picture at one aggregation and make the transport a lie.
  • A nudged glyph still tells the truth — a substitution inside one window puts two players on nearly the same spot, so the smaller disc is pushed to exactly one clearance with a hairline back to the position it holds. The glyph moves, the printed metres never do, and the card says how many discs moved.
  • Absence and rejection are drawn out loud — passes off the pitch are pulled back onto the grass and counted, passes naming somebody off the teamsheet are counted, pairs below the link threshold are counted with the passes they hold, and players on the pitch who never touched the ball are counted. A network that quietly discards a tenth of the passes lies confidently about who ran the game.

On This Page