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.
Canonical behaviors.
StatsBomb-style look — completion filter + sequential blues. The chart default is `all` so raw data stays honest; this opts in.
Each bin is coloured by observed share divided by uniform-spatial expectation. Midpoint = 1 = neutral. Over-represented zones lean red; under-represented lean blue.
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.
crop='half' bins only the attacking half in data space. attackingDirection controls visual orientation only.
Same attacking half in data space — visually flipped. The binning is identical to the right-facing variant.
Repeated pass-flow tiles work when the pitch stays horizontal, the bins stay coarse, and the header / legend chrome is removed.
arrowLengthMode='scaled-by-count' makes longer arrows in busier zones. Useful for composites where a colour channel is spent elsewhere.
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.
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.
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.
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.
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.
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.
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.
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.
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.
zoneEdgesInCampos("18") — six length strips × three width thirds. The classic "thirds of thirds" pitch division used on many analytics broadcasts.
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.
The inverse view — where does ball circulation happen when Man City isn't advancing? Dense lateral/backward flows mark the possession orbit.
With a ~30-pass fixture, most bins fail the minCountForArrow gate and render circles. Compare against the hero to see the gate in action.
Lowering dispersionFloor and minCountForArrow renders an arrow in every non-empty bin. Honest about the gate being an editorial choice.
No passes means no bins, no legend, no silent failure.
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}
/>
);
}
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. |
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.