Recipes
Finished visualisations composed from the primitives — with full source.
PitchKit ships primitives, not chart types — a shot map or a pass network is a composition, a dozen lines of data prep plus two or three layers. These recipes are the common ones; every one lives in the gallery too, with "open in sandbox" buttons.
Shot map
Attacking half, vertical framing, markers sized by xG and coloured by outcome:
"use client";import { cropForHalf, getPitchDimensions } from "@pitchkit/core";import { GoalAngle, Scatter, VerticalPitch } from "@pitchkit/react";import { docsAppearance } from "./docs-appearance";// StatsBomb coordinates (120 x 80). One team's shots from a match:// location, xG, and outcome.const shots = [ { x: 112, y: 39, xg: 0.76, outcome: "goal" }, { x: 105, y: 44, xg: 0.31, outcome: "saved" }, { x: 108, y: 33, xg: 0.44, outcome: "goal" }, { x: 99, y: 47, xg: 0.13, outcome: "off target" }, { x: 102, y: 36, xg: 0.24, outcome: "blocked" }, { x: 95, y: 40, xg: 0.09, outcome: "saved" }, { x: 110, y: 46, xg: 0.52, outcome: "saved" }, { x: 91, y: 29, xg: 0.06, outcome: "off target" }, { x: 104, y: 26, xg: 0.17, outcome: "blocked" }, { x: 87, y: 43, xg: 0.05, outcome: "off target" }, { x: 107, y: 41, xg: 0.38, outcome: "saved" }, { x: 97, y: 55, xg: 0.08, outcome: "blocked" },];const bestChance = shots.reduce((a, b) => (b.xg > a.xg ? b : a));const dimensions = getPitchDimensions("statsbomb");/** * A shot map: attacking half, vertical framing, markers sized by xG and * colored by outcome, with the goal angle drawn for the best chance. */export function ShotMapGallery() { return ( <VerticalPitch type="statsbomb" appearance={docsAppearance} crop={cropForHalf(dimensions)}> <GoalAngle data={[bestChance]} x={(s) => s.x} y={(s) => s.y} fillOpacity={0.12} stroke="rgba(255, 255, 255, 0.35)" /> <Scatter data={shots} x={(s) => s.x} y={(s) => s.y} r={(s) => 3 + s.xg * 9} fill={(s) => (s.outcome === "goal" ? "#fb923c" : "#38bdf8")} fillOpacity={(s) => (s.outcome === "goal" ? 0.95 : 0.65)} stroke="white" strokeWidth={(s) => (s.outcome === "goal" ? 2 : 1)} tooltip={(s) => `${s.outcome} · xG ${s.xg.toFixed(2)}`} /> </VerticalPitch> );}Pass network
The classic: average positions as nodes sized by touches, edges weighted by pass volume between each pair. The aggregation is ordinary data prep — the pitch just draws the result:
"use client";import { Annotate, Arrows, Pitch, Scatter } from "@pitchkit/react";import { docsAppearance } from "./docs-appearance";// StatsBomb coordinates (120 x 80). Average positions plus pass counts// between player pairs — the classic pass-network recipe, built from the// Scatter + Arrows + Annotate primitives.const players = [ { id: "GK", x: 10, y: 40, touches: 42 }, { id: "LB", x: 32, y: 12, touches: 58 }, { id: "LCB", x: 26, y: 30, touches: 71 }, { id: "RCB", x: 26, y: 50, touches: 66 }, { id: "RB", x: 32, y: 68, touches: 49 }, { id: "DM", x: 44, y: 40, touches: 88 }, { id: "LCM", x: 58, y: 24, touches: 74 }, { id: "RCM", x: 58, y: 56, touches: 69 }, { id: "LW", x: 82, y: 14, touches: 46 }, { id: "ST", x: 92, y: 40, touches: 38 }, { id: "RW", x: 82, y: 66, touches: 44 },];const byId = Object.fromEntries(players.map((p) => [p.id, p]));/** Every pass endpoint below references a player id from the list above. */function node(id: string) { const player = byId[id]; if (!player) throw new Error(`Unknown player id: ${id}`); return player;}const passes = [ { from: "GK", to: "LCB", count: 18 }, { from: "GK", to: "RCB", count: 15 }, { from: "LCB", to: "LB", count: 21 }, { from: "RCB", to: "RB", count: 17 }, { from: "LCB", to: "DM", count: 24 }, { from: "RCB", to: "DM", count: 19 }, { from: "DM", to: "LCM", count: 26 }, { from: "DM", to: "RCM", count: 22 }, { from: "LB", to: "LCM", count: 14 }, { from: "RB", to: "RCM", count: 12 }, { from: "LCM", to: "LW", count: 16 }, { from: "RCM", to: "RW", count: 13 }, { from: "LCM", to: "ST", count: 9 }, { from: "RCM", to: "ST", count: 8 }, { from: "LW", to: "ST", count: 7 }, { from: "RW", to: "ST", count: 6 },];/** * A pass network: node size = touches, edge width = passes between the * pair. Aggregation is plain data prep — the pitch just draws it. */export function PassNetworkGallery() { return ( <Pitch type="statsbomb" appearance={docsAppearance}> <Arrows data={passes} x={(p) => node(p.from).x} y={(p) => node(p.from).y} x2={(p) => node(p.to).x} y2={(p) => node(p.to).y} strokeWidth={(p) => 0.5 + p.count / 6} strokeOpacity={(p) => 0.3 + Math.min(p.count / 30, 0.6)} headSize={0} tooltip={(p) => `${p.from} → ${p.to}: ${p.count} passes`} /> <Scatter data={players} x={(p) => p.x} y={(p) => p.y} r={(p) => 4 + p.touches / 12} stroke="white" strokeWidth={1.5} tooltip={(p) => `${p.id} · ${p.touches} touches`} /> <Annotate data={players} x={(p) => p.x} y={(p) => p.y} label={(p) => p.id} offsetY={-14} /> </Pitch> );}Pass flow
Passes binned by starting zone, one aggregate arrow per zone:
"use client";import { Flow, Pitch } from "@pitchkit/react";import { docsAppearance } from "./docs-appearance";// StatsBomb coordinates (120 x 80). A team's completed passes for one// half — heavier build-up down the left, direct switches to the right wing.const passes = [ { from: { x: 12, y: 40 }, to: { x: 28, y: 30 } }, { from: { x: 15, y: 35 }, to: { x: 30, y: 22 } }, { from: { x: 25, y: 18 }, to: { x: 44, y: 14 } }, { from: { x: 28, y: 24 }, to: { x: 46, y: 20 } }, { from: { x: 30, y: 15 }, to: { x: 50, y: 12 } }, { from: { x: 26, y: 20 }, to: { x: 45, y: 25 } }, { from: { x: 45, y: 18 }, to: { x: 66, y: 14 } }, { from: { x: 48, y: 22 }, to: { x: 68, y: 18 } }, { from: { x: 47, y: 15 }, to: { x: 64, y: 24 } }, { from: { x: 65, y: 16 }, to: { x: 84, y: 20 } }, { from: { x: 67, y: 20 }, to: { x: 86, y: 14 } }, { from: { x: 66, y: 24 }, to: { x: 88, y: 28 } }, { from: { x: 85, y: 18 }, to: { x: 102, y: 30 } }, { from: { x: 87, y: 24 }, to: { x: 104, y: 34 } }, { from: { x: 40, y: 45 }, to: { x: 60, y: 50 } }, { from: { x: 44, y: 52 }, to: { x: 63, y: 58 } }, { from: { x: 62, y: 55 }, to: { x: 85, y: 62 } }, { from: { x: 64, y: 60 }, to: { x: 88, y: 66 } }, { from: { x: 86, y: 64 }, to: { x: 103, y: 52 } }, { from: { x: 20, y: 55 }, to: { x: 38, y: 62 } }, { from: { x: 22, y: 60 }, to: { x: 40, y: 66 } },];/** * A pass-flow map: passes binned by start zone, one arrow per zone showing * the average direction, sized and colored by volume — mplsoccer's `flow`. */export function PassFlowGallery() { return ( <Pitch type="statsbomb" appearance={docsAppearance}> <Flow data={passes} x={(p) => p.from.x} y={(p) => p.from.y} x2={(p) => p.to.x} y2={(p) => p.to.y} binsX={6} binsY={4} colorMin="#38bdf8" colorMax="#fb923c" strokeWidthMin={1.5} strokeWidthMax={5} tooltip={(bin) => `${bin.count} passes`} /> </Pitch> );}Pressure heatmap
Event density on the canvas path (note the container-measuring pattern from the responsive guide):
"use client";import { useEffect, useRef, useState } from "react";import { Heatmap, Pitch } from "@pitchkit/react";import { docsAppearance } from "./docs-appearance";// StatsBomb coordinates (120 x 80). Pressure events — where the team wins// the ball back or forces a rushed pass. Concentrated in midfield and// pushed toward the opponent's left build-up channel.const pressures: { x: number; y: number }[] = [ { x: 52, y: 22 }, { x: 55, y: 18 }, { x: 58, y: 25 }, { x: 61, y: 20 }, { x: 63, y: 28 }, { x: 66, y: 23 }, { x: 57, y: 31 }, { x: 60, y: 35 }, { x: 64, y: 33 }, { x: 68, y: 30 }, { x: 71, y: 26 }, { x: 74, y: 22 }, { x: 54, y: 40 }, { x: 59, y: 43 }, { x: 63, y: 41 }, { x: 67, y: 45 }, { x: 48, y: 35 }, { x: 50, y: 28 }, { x: 45, y: 42 }, { x: 70, y: 38 }, { x: 76, y: 32 }, { x: 79, y: 27 }, { x: 73, y: 44 }, { x: 66, y: 52 }, { x: 61, y: 55 }, { x: 56, y: 50 }, { x: 51, y: 58 }, { x: 47, y: 52 }, { x: 82, y: 24 }, { x: 85, y: 30 }, { x: 42, y: 30 }, { x: 40, y: 48 }, { x: 36, y: 38 }, { x: 88, y: 35 }, { x: 78, y: 50 }, { x: 72, y: 60 },];const PITCH_ASPECT = 120 / 80;const FALLBACK_WIDTH = 480;/** * A pressure heatmap on the canvas path: bin counts over a fine grid. * `<Heatmap>` paints to a fixed-size canvas, so this measures its own * container to stay responsive (the same technique `<Pitch>` uses * internally for its SVG). */export function PressureHeatmapGallery() { const containerRef = useRef<HTMLDivElement>(null); const [width, setWidth] = useState(FALLBACK_WIDTH); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setWidth(entry.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); return ( <div ref={containerRef}> <Pitch type="statsbomb" width={width} height={Math.round(width / PITCH_ASPECT)} appearance={docsAppearance} > <Heatmap data={pressures} x={(p) => p.x} y={(p) => p.y} binsX={12} binsY={8} colorMin="#0f3d24" colorMax="#38bdf8" style={{ opacity: 0.85 }} /> </Pitch> </div> );}Still to come
Density/geometry recipes that need Milestone 2 marks — hexbin and KDE surfaces, positional grids, radar charts — land here as each mark ships. Track progress on the roadmap.