PitchKit
Guides

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:

GKLBLCBRCBRBDMLCMRCMLWSTRW
"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.

On this page