Component

PassFlow

Flow-field view of team passing. Bins the pitch into a regular grid, colours each cell by pass origin volume (or relative frequency), and draws a circular-mean direction arrow per cell. Gates arrows by directional consistency so sparse or chaotic bins render a neutral glyph instead of misleading you, and it also scales down into honest repeated tactical tiles when the bins stay coarse.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Stories

Canonical behaviors.

Completed-pass flow preset

StatsBomb-style look — completion filter + sequential blues. The chart default is `all` so raw data stays honest; this opts in.

Passes7216
Completion100%
Mean length20.2
Pass Origin Share0.0%2.9%
936 passes dropped by an active filter (completionFilter, directionFilter, periodFilter, or minute window).
Relative frequency, diverging

Each bin is coloured by observed share divided by uniform-spatial expectation. Midpoint = 1 = neutral. Over-represented zones lean red; under-represented lean blue.

Passes8152
Completion89%
Mean length20.7
Pass Origin Relative Frequency0.002.70
Uniform team (RF self-check)

Synthetic pass set with equal volume in every bin. Under relative-frequency with a diverging ramp, every bin should render as neutral midpoint — not saturated.

Passes120
Completion100%
Mean length8.0
Pass Origin Relative Frequency0.002.00
Attacking-half crop (right)

crop='half' bins only the attacking half in data space. attackingDirection controls visual orientation only.

Passes4640
Completion88%
Mean length20.3
Pass Origin Share0.0%2.7%
3512 passes dropped as origin falls outside the active crop.
Attacking-half crop (left)

Same attacking half in data space — visually flipped. The binning is identical to the right-facing variant.

Passes4640
Completion88%
Mean length20.3
Pass Origin Share0.0%2.7%
3512 passes dropped as origin falls outside the active crop.
SmallMultiples PassFlow Cell

Repeated pass-flow tiles work when the pitch stays horizontal, the bins stay coarse, and the header / legend chrome is removed.

Arrow length encodes volume

arrowLengthMode='scaled-by-count' makes longer arrows in busier zones. Useful for composites where a colour channel is spent elsewhere.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Arrow length encodes pass distance

arrowLengthMode='scaled-by-distance' — long arrows = long-ball zones (goal-kicks, switches of play), short arrows = tiki-taka recycling zones. An orthogonal information channel to volume.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Flow toward goal (forward passes only)

directionFilter='forward' restricts binning + circular-mean to passes that travel toward the opposition goal. Every arrow now points forward by construction; colour shows where forward progression is concentrated.

Passes1344
Completion81%
Mean length17.1
Pass Origin Share0.0%2.4%
6808 passes dropped by an active filter (completionFilter, directionFilter, periodFilter, or minute window).
Halftime adjustment

Two PassFlows side-by-side, one per half (periodFilter={[1]} / [2]). Useful for showing how a manager changed the ball-circulation pattern after the break.

First half
Passes4364
Completion89%
Mean length20.8
Pass Origin Share0.0%2.9%
3788 passes dropped by an active filter (completionFilter, directionFilter, periodFilter, or minute window).
Second half
Passes3788
Completion88%
Mean length20.6
Pass Origin Share0.0%2.8%
4364 passes dropped by an active filter (completionFilter, directionFilter, periodFilter, or minute window).
Auto-contrast arrows over a dense scale

arrowColor="contrast" picks light vs dark per bin against the cell fill (WCAG luminance), so arrows stay readable across the full magma ramp — dark arrows on the pale tail, light arrows over the deep cluster centres. Pair with arrowHalo for a thin contrasting outline that survives any background.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Arrow colour by bin volume

arrowColor accepts a callback, so you can colour arrows by any bin metric. Here: red for hot bins (count ≥ 100 on the 12×8 grid), steel-blue otherwise. The callback lives in a React wrapper because Astro serialises island props as JSON and can't pass functions across the boundary.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Marching dashes (flow feel)

animate="dashes" turns every arrow into a dashed stroke that flows along its length. Purely cosmetic — reinforces the "passing field" metaphor without moving data. Honours prefers-reduced-motion.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Live filters — click a toggle to morph

usePassFlowFilters() owns the state, filterTransition="morph" makes arrows smoothly reposition when the filter changes. No enter/exit jump-cut: the same 8,152 passes, sliced differently.

Direction
Period (Full match)
Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Hover a zone to see destinations

showHoverDestinations overlays every pass destination from the hovered bin as a dot connected to the bin centre. Reveals the spread that a single mean-direction arrow hides — bimodal zones and long-ball outliers become visible.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%2.8%
Tactical 20-zone grid (positional play)

Bins aligned to positional-play zones — quarter-length strips × five channels (wide / half-space / centre / half-space / wide). zoneEdgesInCampos("20") feeds xEdges / yEdges, pitchMarkings={{zones:"20"}} draws the matching lines. Cells and markings share one source of truth.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%10.0%
Tactical 18-zone grid (equal thirds)

zoneEdgesInCampos("18") — six length strips × three width thirds. The classic "thirds of thirds" pitch division used on many analytics broadcasts.

Passes8152
Completion89%
Mean length20.7
Pass Origin Share0.0%10.4%
Heatmap on the same 20-zone grid

Heatmap also accepts xEdges / yEdges now, so you can render a positional-zone density chart without a uniform grid. Drops straight into the same zoneEdgesInCampos() call as PassFlow.

Pass origins0815
Recycle network (backward + lateral)

The inverse view — where does ball circulation happen when Man City isn't advancing? Dense lateral/backward flows mark the possession orbit.

Passes5600
Completion91%
Mean length22.3
Pass Origin Share0.0%3.3%
2552 passes dropped by an active filter (completionFilter, directionFilter, periodFilter, or minute window).
Sparse data → glyphs

With a ~30-pass fixture, most bins fail the minCountForArrow gate and render circles. Compare against the hero to see the gate in action.

Passes30
Completion83%
Mean length22.3
Pass Origin Share0.0%16.7%
Gates off — every bin arrows

Lowering dispersionFloor and minCountForArrow renders an arrow in every non-empty bin. Honest about the gate being an editorial choice.

Passes30
Completion83%
Mean length22.3
Pass Origin Share0.0%16.7%
Empty state

No passes means no bins, no legend, no silent failure.

No passes to chart
Usage

Best-practice examples.

Minimal usage

Zero-config defaults — 6×4 grid, completion-agnostic share ramp, circular-mean arrows.

                    import { PassFlow } from "@withqwerty/campos-react";
import type { PassEvent } from "@withqwerty/campos-schema";

export function TeamPassFlow({ passes }: { passes: PassEvent[] }) {
  return <PassFlow passes={passes} />;
}
                  
Completed-pass flow preset

StatsBomb-style: completion filter + sequential blues on share.

                    import { PassFlow, passFlowRecipes } from "@withqwerty/campos-react";

export function CompletedPassFlow({ passes }) {
  return (
    <PassFlow passes={passes} {...passFlowRecipes.statsbombCompleted.props} />
  );
}
                  
Relative-frequency with diverging ramp

Anchors the midpoint at 1.0 (uniform-spatial expectation); over-represented zones lean red, under-represented lean blue.

                    import { PassFlow, passFlowRecipes } from "@withqwerty/campos-react";

<PassFlow
  passes={passes}
  {...passFlowRecipes.averageDistanceArrows.props}
  valueMode="relative-frequency"
  colorScale="diverging-rdbu"
/>
                  
Repeated-grid pass-flow cell

For SmallMultiples, keep the grid coarse and suppress the header and legend chrome instead of treating each tile like a standalone page.

                    import { PassFlow, passFlowRecipes } from "@withqwerty/campos-react";

export function TeamPassFlowCell({ passes }) {
  return (
    <PassFlow
      passes={passes}
      bins={{ x: 5, y: 3 }}
      attackingDirection="right"
      {...passFlowRecipes.smallMultiples.props}
    />
  );
}
                  
API

Public surface.

Prop Type Default Description
passes readonly PassEvent[] required Normalized pass events in Campos coordinates.
bins { x: number; y: number } { x: 6, y: 4 } Uniform grid dimensions. Overridden by xEdges/yEdges when supplied.
xEdges / yEdges readonly number[] Explicit monotonic bin boundaries in Campos 0-100 space; first/last must match the active crop.
valueMode "count" | "share" | "relative-frequency" "share" How cell intensity is computed. Relative-frequency anchors to uniform-spatial expectation for diverging ramps.
colorScale "sequential-blues" | "sequential-reds" | "diverging-rdbu" | "custom" "sequential-blues" Named ramp. Use diverging-rdbu when pairing with relative-frequency.
completionFilter "all" | "complete" | "incomplete" "all" Completion-based filtering. Default is faithful to the input; set to 'complete' for the StatsBomb preset look.
arrowLengthMode "equal" | "scaled-by-count" | "scaled-by-resultant" | "scaled-by-distance" "equal" How each arrow's length encodes additional information beyond the cell colour. 'scaled-by-distance' reveals short-passing vs long-ball zones.
directionFilter "all" | "forward" | "backward" | "lateral" "all" Restrict which passes contribute to the mean direction and density. Use 'forward' for the canonical 'flow toward goal' view.
periodFilter readonly (1 | 2 | 3 | 4 | 5)[] Include only passes from the listed match periods. Pair two PassFlows with periodFilter=[1] and [2] for halftime-adjustment storytelling.
arrowColor string | "contrast" | ((bin) => string) theme text Scalar CSS colour, the "contrast" sentinel for per-bin WCAG-luminance auto-pick (light vs dark against bin.fill), or a callback for arbitrary per-bin colouring.
lowDispersionGlyphColor string | "contrast" arrowColor Colour for the low-dispersion glyph (circle / cross). Same scalar / "contrast" form as arrowColor; falls back to the resolved arrow colour.
arrowHalo boolean | { color?: string | "contrast"; width?: number } false Cartographic halo (thin contrasting outline) drawn behind each arrow. Survives any background colour without per-bin computation. true uses theme-default contrast halos; pass an object to pin colour / width.
arrowheadScale number 3 Arrowhead size as a multiple of strokeWidth.
animate "none" | "dashes" | "dashes-on-hover" "none" Marching-dashes animation. 'dashes' animates all arrows, 'dashes-on-hover' only the hovered bin. Honours prefers-reduced-motion.
filterTransition "none" | "morph" "none" When 'morph', arrows smoothly transition to new positions on filter-driven prop changes (e.g. directionFilter flip) rather than jump-cutting. Pairs with usePassFlowFilters().
showHoverDestinations boolean false When true, hovering a bin overlays every pass destination from that bin as a small dot connected back to the bin centre. Reveals the spread that a single mean-direction arrow hides.
hoverDestinationColor string | "contrast" arrowColor | theme primary Colour for the hover-destination overlay marks. Falls back to the resolved arrow colour when omitted.
dispersionFloor number 0.3 Mean resultant-length threshold below which a bin renders a low-dispersion glyph instead of an arrow.
minCountForArrow number 2 Minimum direction-contributing pass count required to render an arrow.
lowDispersionGlyph "circle" | "cross" | "none" "circle" Mark rendered when a bin fails the arrow gate but has passes.
attackingDirection "up" | "down" | "left" | "right" "right" Renderer-only hint. Binning is always in attacker-relative data space; this flips the visual orientation.
Use with AI
LLM Prompt
Create a React component using Campos PassFlow. Import PassFlow and passFlowRecipes from @withqwerty/campos-react. Show the minimal usage first with a PassEvent[] input. Then add one recipe-driven example and one compact repeated-grid pass-flow cell using passFlowRecipes.smallMultiples.