Pass Network
A four-state soccer pass network in hand-rolled SVG — players at their average positions on a pitch, marker area by touches, edges by pass count above a threshold, hover to light one player's links.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-pass-network.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPassNetwork" card that draws a
soccer pass network on a hand-rolled SVG pitch (no chart library) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
players: { id, number (jersey), label, x 0–100, y 0–100, touches >= 0 }[];
passes: { from, to, count >= 0 }[]; minPasses >= 0 }.
- x runs along the pitch length (0 = own goal line, 100 = opponent's), y across
the width (0 = left touchline). Component props = z.infer of the schema plus
onRetry?: () => void, emptyState?: ReactNode and className; forwardRef the
root div and spread remaining props on it. No hand-written parallel interface.
Behavior
- Four first-class branches inside one bg-card panel: loading = a pulsing
pitch-shaped skeleton (aria-hidden, sr-only "Loading" status); empty = icon +
zero-data copy (replaceable via emptyState); error = message + "Try again"
rendered only when onRetry exists; ready = the pitch.
- Roll passes up into undirected pairs (both directions summed). Drop records
whose from/to is unknown or self-referential and say how many were dropped —
never swallow rows silently. Draw a pair's edge only when its total reaches
minPasses, and state how many quieter connections stay hidden.
- Each player is a circle at (x, y): area — via sqrt — tracks touches between a
min and max radius, jersey number centered inside. Edge stroke width scales
linearly with the pair total, with a visible floor.
- Hovering or keyboard-focusing a player lights their drawn edges (accent
token), dims unrelated players and edges to low opacity, shows their name
label, and fills a readout line under the pitch (touches, passes played and
received, heaviest link). Guard pointerleave so crossing between markers
doesn't blink the highlight off.
Rendering & styling
- Semantic tokens only: panel rounded-xl border bg-card; pitch markings (outer
rect, halfway line, center circle, penalty and goal areas, spots) in
stroke-border over a fill-muted pitch at low opacity; markers fill
var(--chart-1) with a stroke-card rim and fill-card jersey numbers; default
edges stroke-muted-foreground, highlighted edges var(--chart-2). cn() merges
className. No #hex, rgb() or oklch() anywhere.
- Fixed viewBox of a 105×68 pitch plus padding, width 100% so it scales with
the card; clamp name labels inside the viewBox (the SVG root clips silently)
and paint them with a stroke-card halo (paint-order: stroke).
- Accessibility: each marker is focusable (tabIndex 0) with a full sentence as
aria-label and a <title> tooltip; an sr-only summary names totals, the
busiest player and the heaviest link; dim/highlight transitions respect
prefers-reduced-motion via motion-reduce:transition-none. No timers, RAF or
observers — nothing to clean up.
Customization levers
- Threshold: minPasses is the noise filter — 0 draws every connection, raise it
until only the team's real passing channels remain.
- Marker scale: the min/max radius pair sets how loudly touches speak; narrow
the range for a calmer, more positional picture.
- Edge weight: the stroke floor/ceiling pair controls contrast between the
heaviest channel and a bare-threshold one.
- Palette: markers read var(--chart-1) and highlights var(--chart-2) — remap to
team tokens, or split the two center-backs onto a second token for asymmetry
analysis.
- Pitch furniture: markings live in one aria-hidden group — strip it to a bare
outline for thumbnails, or drop the pitch entirely to reuse the component for
any positioned network (court, rink, warehouse floor).
- Orientation: the x/y → pitch mapping is two one-line functions; swap them to
attack top-to-bottom for a portrait layout.Concepts
- Average-position layout — unlike a force-directed graph, every marker sits at a coordinate that is data: the player's mean position on a real 105×68 pitch, so the team's shape (a high line, a lopsided midfield) is the first thing the chart says.
- Threshold gating — passing data is mostly noise;
minPassesgates edges on the pair's summed total, and the chart states how many quieter connections it hid instead of silently pruning them. - Size-by-touches — marker area (not radius) tracks ball touches via a square root, so a player with four times the touches reads as four times the ink, not sixteen.
- Ego-network highlight — hover or focus isolates one player's links: their edges take the accent token, everything unconnected drops to low opacity, and a readout line speaks the exact numbers the picture only sketches.
- Direction rollup — the contract keeps directed
from → torecords, but the drawing sums both directions per pair; the split survives in the accessible summary, so the picture stays calm without the data lying.
Pitch Heatmap
A hand-rolled SVG soccer pitch whose zone grid shades by event count — one chart token at stepped opacity, printed legend bounds, a direction-of-play arrow, and every zone readable by pointer, keyboard or screen reader.
xG Timeline
A cumulative expected-goals step chart — two team lines over 90+ minutes, emphasized goal dots with minute labels, a half-time rule, and final xG totals in the header.