Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Goal, RefreshCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  ChartPassNetworkData,
  ChartPassNetworkPlayer,
} from "./chart-pass-network.contract"

export interface ChartPassNetworkProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartPassNetworkData {

Installation

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

Prompt

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; minPasses gates 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 → to records, but the drawing sums both directions per pair; the split survives in the accessible summary, so the picture stays calm without the data lying.

On This Page