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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-soccer-pass-network.jsonPrompt
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.
T Battle Replay
A four-state playable squash T-control replay — two tracked dots move on a metre-true WSF floor plan, a control ring on the T tints toward whoever holds it, controlled time accrues into a mirrored share bar, and rally-end ticks mark the scrubber with a running score.
Soccer xG Race
A four-state expected-goals race replayed on the match clock — cumulative xG staircases that grow only as far as the playhead, with real transport buttons, a running scoreline and goal chips that jump the clock.