@pitchkit/react 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -139,6 +139,28 @@ the `<Pitch>` tree must originate inside a `"use client"` component — React Se
139
139
  can't pass functions across the client boundary. SSR still happens; only the prop-serialisation
140
140
  boundary moves. `<Heatmap>` is Canvas-backed and therefore client-only.
141
141
 
142
+ ## Agent Skill
143
+
144
+ No model has PitchKit in its training data, so coding agents asked for a shot map tend to
145
+ invent an mplsoccer-flavoured API. This package ships an Agent Skill — `skills/pitchkit/`
146
+ inside the tarball — that documents the real one.
147
+
148
+ ```bash
149
+ npx @pitchkit/react skills install # -> .claude/skills/pitchkit/
150
+ npx @pitchkit/react skills install --dir .cursor/skills
151
+ npx @pitchkit/react skills path # where it lives in node_modules
152
+ ```
153
+
154
+ Install *symlinks* the target at the copy inside `node_modules`, so `npm update
155
+ @pitchkit/react` moves the skill with it and an agent can't end up reading last version's
156
+ API. On a filesystem that won't take a symlink it copies instead and says so — that copy is
157
+ a snapshot, so re-run with `--force` after upgrading.
158
+
159
+ The default directory suits Claude Code; for any other agent, point `--dir` at wherever it
160
+ reads skills from, or hand it the `skills path` output to read directly. The layout follows
161
+ the `skills/<name>/SKILL.md` convention, so generic installers like `skills-npm` find it in
162
+ `node_modules` without needing this CLI at all.
163
+
142
164
  ## Links
143
165
 
144
166
  - [Documentation & gallery](https://pitchkitjs.com)
@@ -0,0 +1,223 @@
1
+ // The `npx @pitchkit/react skills install` implementation, kept separate from
2
+ // bin/pitchkit.mjs so it can be unit-tested without spawning a process or
3
+ // doing the "am I the main module?" dance (which npx's bin symlinks make
4
+ // unreliable). Zero dependencies on purpose: this runs in a consumer's
5
+ // project, via npx, before anything of ours is necessarily installed.
6
+ import { cp, lstat, mkdir, readdir, rm, symlink } from "node:fs/promises";
7
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const SKILL_NAME = "pitchkit";
11
+
12
+ /**
13
+ * Claude Code's project-scoped skills directory. Chosen as the default
14
+ * because it's the one convention with a settled on-disk layout; every
15
+ * other agent gets pointed at `--dir` in the help text rather than us
16
+ * guessing at a location it may not read.
17
+ */
18
+ export const DEFAULT_SKILLS_DIR = join(".claude", "skills");
19
+
20
+ /** `bin/` -> the package root, so this resolves inside a consumer's node_modules too. */
21
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
22
+
23
+ export const skillSourceDir = join(packageRoot, "skills", SKILL_NAME);
24
+
25
+ export class CliError extends Error {}
26
+
27
+ export function formatHelp() {
28
+ return `pitchkit — ships the PitchKit Agent Skill with the package you installed
29
+
30
+ Usage
31
+ npx @pitchkit/react skills install [options] Link the skill into your project
32
+ npx @pitchkit/react skills path Print the skill's location in node_modules
33
+ npx @pitchkit/react --help
34
+
35
+ Options
36
+ --dir <path> Skills directory to install into (default: ${DEFAULT_SKILLS_DIR})
37
+ --force Replace an existing installation
38
+ -h, --help Show this message
39
+
40
+ <dir>/${SKILL_NAME}/ is symlinked to the copy inside node_modules, so the skill stays in
41
+ step with the installed version; it falls back to a plain copy on filesystems that
42
+ won't take a link. The default directory suits Claude Code; for another agent, point
43
+ --dir at wherever it reads skills from, or use \`skills path\` and read SKILL.md
44
+ directly.`;
45
+ }
46
+
47
+ export function parseArgs(argv) {
48
+ const options = { command: null, dir: DEFAULT_SKILLS_DIR, force: false, help: false };
49
+ const positional = [];
50
+
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const arg = argv[i];
53
+ if (arg === "-h" || arg === "--help") {
54
+ options.help = true;
55
+ } else if (arg === "--force" || arg === "-f") {
56
+ options.force = true;
57
+ } else if (arg === "--dir") {
58
+ const value = argv[i + 1];
59
+ if (value === undefined || value.startsWith("-")) {
60
+ throw new CliError("--dir needs a path, e.g. --dir .claude/skills");
61
+ }
62
+ options.dir = value;
63
+ i += 1;
64
+ } else if (arg.startsWith("--dir=")) {
65
+ const value = arg.slice("--dir=".length);
66
+ if (value === "") throw new CliError("--dir needs a path, e.g. --dir .claude/skills");
67
+ options.dir = value;
68
+ } else if (arg.startsWith("-")) {
69
+ throw new CliError(`Unknown option: ${arg}`);
70
+ } else {
71
+ positional.push(arg);
72
+ }
73
+ }
74
+
75
+ // `skills install` and a bare `install` both work — the former is what the
76
+ // docs show, the latter is what people type.
77
+ const words = positional[0] === "skills" ? positional.slice(1) : positional;
78
+ options.command = words[0] ?? null;
79
+
80
+ if (words.length > 1) {
81
+ throw new CliError(`Unexpected argument: ${words[1]}`);
82
+ }
83
+
84
+ return options;
85
+ }
86
+
87
+ /**
88
+ * `lstat`, not `access`, so a dangling symlink left by an uninstalled
89
+ * dependency still counts as present and gets cleaned up by `--force`
90
+ * rather than colliding with EEXIST.
91
+ */
92
+ async function entryExists(path) {
93
+ try {
94
+ await lstat(path);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Links the destination at the skill inside node_modules, so `npm update
103
+ * @pitchkit/react` carries the skill with it — the property that makes a
104
+ * bundled skill worth more than scraped docs, and the one a plain copy
105
+ * silently loses. Falls back to copying where the filesystem won't take a
106
+ * link (Windows without Developer Mode, some network and container mounts).
107
+ *
108
+ * Returns "link" or "copy" so the caller can tell the user which they got;
109
+ * the update semantics differ, so this isn't an implementation detail.
110
+ */
111
+ async function linkOrCopy(destination) {
112
+ try {
113
+ if (process.platform === "win32") {
114
+ // Junctions need an absolute target, but unlike real symlinks they
115
+ // don't need elevation or Developer Mode.
116
+ await symlink(skillSourceDir, destination, "junction");
117
+ } else {
118
+ // Relative, so the link survives the project being moved or checked
119
+ // out at a different path with node_modules already in place.
120
+ await symlink(relative(dirname(destination), skillSourceDir), destination, "dir");
121
+ }
122
+ return "link";
123
+ } catch {
124
+ await cp(skillSourceDir, destination, { recursive: true, force: true });
125
+ return "copy";
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Installs `skills/pitchkit/` from the installed package into the consumer's
131
+ * project. Returns the destination, how it was installed, and what's now
132
+ * readable there.
133
+ */
134
+ export async function installSkill({
135
+ cwd = process.cwd(),
136
+ dir = DEFAULT_SKILLS_DIR,
137
+ force = false,
138
+ } = {}) {
139
+ if (!(await entryExists(skillSourceDir))) {
140
+ throw new CliError(
141
+ `Could not find the bundled skill at ${skillSourceDir}. ` +
142
+ "Is @pitchkit/react installed, and new enough to ship one?",
143
+ );
144
+ }
145
+
146
+ const skillsDir = isAbsolute(dir) ? dir : resolve(cwd, dir);
147
+ const destination = join(skillsDir, SKILL_NAME);
148
+
149
+ if (await entryExists(destination)) {
150
+ if (!force) {
151
+ throw new CliError(
152
+ `${relative(cwd, destination) || destination} already exists. Re-run with --force to replace it.`,
153
+ );
154
+ }
155
+ // `rm` unlinks a symlink rather than following it, so this can't reach
156
+ // through an existing link and delete the copy inside node_modules.
157
+ await rm(destination, { recursive: true, force: true });
158
+ }
159
+
160
+ await mkdir(skillsDir, { recursive: true });
161
+ const method = await linkOrCopy(destination);
162
+
163
+ // Plain codepoint sort, so SKILL.md leads and the references/ paths follow.
164
+ return { destination, method, files: (await listFiles(destination)).sort() };
165
+ }
166
+
167
+ async function listFiles(dir, prefix = "") {
168
+ const entries = await readdir(dir, { withFileTypes: true });
169
+ const files = [];
170
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
171
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
172
+ if (entry.isDirectory()) {
173
+ files.push(...(await listFiles(join(dir, entry.name), name)));
174
+ } else {
175
+ files.push(name);
176
+ }
177
+ }
178
+ return files;
179
+ }
180
+
181
+ export async function run(argv, { cwd = process.cwd(), log = console.log } = {}) {
182
+ const options = parseArgs(argv);
183
+
184
+ if (options.help || options.command === null || options.command === "help") {
185
+ log(formatHelp());
186
+ return 0;
187
+ }
188
+
189
+ if (options.command === "path") {
190
+ log(skillSourceDir);
191
+ return 0;
192
+ }
193
+
194
+ if (options.command !== "install") {
195
+ throw new CliError(`Unknown command: ${options.command}\n\n${formatHelp()}`);
196
+ }
197
+
198
+ const { destination, method, files } = await installSkill({
199
+ cwd,
200
+ dir: options.dir,
201
+ force: options.force,
202
+ });
203
+ const shown = relative(cwd, destination) || destination;
204
+
205
+ if (method === "link") {
206
+ log(`Linked the PitchKit skill at ${shown}`);
207
+ } else {
208
+ log(`Copied the PitchKit skill to ${shown}`);
209
+ }
210
+ for (const file of files) log(` ${shown}/${file}`);
211
+ log("");
212
+
213
+ if (method === "link") {
214
+ log("It points into node_modules, so `npm update @pitchkit/react` updates the");
215
+ log("skill too. Add it to .gitignore unless you want the link committed.");
216
+ } else {
217
+ log("This filesystem wouldn't take a symlink, so the skill was copied instead —");
218
+ log("it's a snapshot. Re-run with --force after upgrading @pitchkit/react.");
219
+ }
220
+ log("Start a new agent session so it picks the skill up.");
221
+
222
+ return 0;
223
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // The package's only bin, so `npx @pitchkit/react skills install` resolves to
3
+ // it despite the bin name not matching the (scoped) package name.
4
+ import { CliError, run } from "./install-skill.mjs";
5
+
6
+ try {
7
+ process.exitCode = await run(process.argv.slice(2));
8
+ } catch (error) {
9
+ if (error instanceof CliError) {
10
+ console.error(error.message);
11
+ process.exitCode = 1;
12
+ } else {
13
+ throw error;
14
+ }
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitchkit/react",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Thin React bindings for @pitchkit/core: declarative <Pitch> + layer components, responsive by default.",
5
5
  "license": "MIT",
6
6
  "author": "Yohahn Ribeiro",
@@ -35,8 +35,13 @@
35
35
  },
36
36
  "main": "./dist/index.js",
37
37
  "types": "./dist/index.d.ts",
38
+ "bin": {
39
+ "pitchkit": "./bin/pitchkit.mjs"
40
+ },
38
41
  "files": [
39
- "dist"
42
+ "dist",
43
+ "bin",
44
+ "skills"
40
45
  ],
41
46
  "scripts": {
42
47
  "build": "tsup && node scripts/add-use-client.mjs",
@@ -0,0 +1,384 @@
1
+ ---
2
+ name: pitchkit
3
+ description: Builds football (soccer) pitch visualisations for the web with PitchKit, the @pitchkit/react and @pitchkit/core packages. Use when the request involves a shot map, pass map, pass network, pass flow, touch map, heatmap, hexbin, KDE surface, Voronoi, convex hull, or any other chart drawn on a football pitch in React or Next.js; when the user names PitchKit, @pitchkit/react, @pitchkit/core or the Pitch component; when they mention StatsBomb, Opta or UEFA pitch coordinates; or when they ask for mplsoccer's behaviour on the web.
4
+ license: MIT
5
+ ---
6
+
7
+ # PitchKit
8
+
9
+ PitchKit draws football pitches and the marks on them. It is mplsoccer's feature set
10
+ rebuilt for React, not a port of matplotlib.
11
+
12
+ This skill ships inside the installed `@pitchkit/react` tarball, so it describes the
13
+ exact version in the consuming project's `node_modules`. Check
14
+ `node_modules/@pitchkit/react/package.json` for that version before assuming any API
15
+ described here is present. Full docs: <https://pitchkitjs.com>.
16
+
17
+ ## Never guess the API
18
+
19
+ There is no PitchKit in any model's training data. Anything recalled about it is
20
+ invented — usually mplsoccer's Python API in JSX clothing. Every component, prop and
21
+ export a PitchKit answer uses must come from this file, from
22
+ [references/api.md](references/api.md), or from the package's own `.d.ts`. If something
23
+ needed is not in those, say so rather than inventing it.
24
+
25
+ Things that do **not** exist, however plausible: a `<PassMap>` / `<ShotMap>` /
26
+ `<PassNetwork>` component, a `theme` prop or JS theme object, a `type="wyscout"` (or
27
+ `"tracab"`, `"skillcorner"`, `"custom"`) pitch, a `responsive` prop, a `<Pitch>`
28
+ `onClick` handler that hands back pitch coordinates.
29
+
30
+ ## Package split
31
+
32
+ | Package | What it is | When it's imported from |
33
+ | ----------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
34
+ | `@pitchkit/react` | The rendering surface: `<Pitch>` + layer components + `usePitch()` | Almost always |
35
+ | `@pitchkit/core` | Zero-dependency maths: pitch dimensions, transforms, geometry, binning | Only for helpers like `cropForHalf`, `getPitchDimensions`, `createStandardizeTransform` |
36
+
37
+ `@pitchkit/react` is the **only supported rendering surface**. `@pitchkit/core` exports
38
+ `svgRenderer` / `renderSceneToSVGElement`; those are internal building blocks for the
39
+ repo's own dev harness and must never appear in consumer code.
40
+
41
+ ```bash
42
+ npm install @pitchkit/react
43
+ # add @pitchkit/core explicitly only when importing its helpers directly:
44
+ npm install @pitchkit/react @pitchkit/core
45
+ ```
46
+
47
+ `@pitchkit/core` arrives transitively as a dependency of `@pitchkit/react`, but importing
48
+ from it without declaring it is a phantom dependency — declare it when it's imported.
49
+
50
+ ## The five rules
51
+
52
+ 1. **Data goes in in its provider's own units.** Never pre-scale coordinates. Set `type`
53
+ on `<Pitch>` and pass raw StatsBomb / Opta / UEFA numbers straight through.
54
+ 2. **Every visual prop is an accessor:** a constant (`r={5}`) or a function of the datum
55
+ (`r={(s) => 3 + s.xg * 9}`). Same prop name for both forms.
56
+ 3. **Layer components must be descendants of `<Pitch>`.** They read the pixel transform
57
+ from context and throw if rendered outside one.
58
+ 4. **Responsive is the default.** A `<Pitch>` fills its container and keeps the pitch
59
+ aspect ratio, so sizing a pitch means sizing its parent. Passing `width` _and_
60
+ `height` is the opt-out — pass both or neither.
61
+ 5. **Theming is CSS variables only.** There is no theme object and no colour props on
62
+ `<Pitch>`.
63
+
64
+ ## Pitch types
65
+
66
+ | `type` | Extent | Origin | y direction | Notes |
67
+ | ------------- | --------- | ----------- | ----------- | -------------------------- |
68
+ | `"statsbomb"` | 120 × 80 | top-left | down | Abstract units |
69
+ | `"opta"` | 100 × 100 | bottom-left | up | Normalised percentage grid |
70
+ | `"uefa"` | 105 × 68 | bottom-left | up | Real metres |
71
+
72
+ Those three are the whole list. For a provider that isn't one of them, standardise the
73
+ data first and render in the target grid:
74
+
75
+ ```tsx
76
+ import { createStandardizeTransform, getPitchDimensions } from "@pitchkit/core";
77
+
78
+ const optaToStatsBomb = createStandardizeTransform(
79
+ getPitchDimensions("opta"),
80
+ getPitchDimensions("statsbomb"),
81
+ );
82
+ const [x, y] = optaToStatsBomb([50, 50]); // -> [60, 40]
83
+ ```
84
+
85
+ ## Components
86
+
87
+ SVG layers — server-renderable, one element per datum, all accept `className` and a
88
+ `tooltip` accessor:
89
+
90
+ `<Scatter>` `<Annotate>` `<Arrows>` `<Comet>` `<Polygon>` `<ConvexHull>` `<Voronoi>`
91
+ `<GoalAngle>` `<Flow>`
92
+
93
+ Canvas density layers — client-only, accept `className` and `style` but **no** `tooltip`:
94
+
95
+ `<Heatmap>` `<PositionalHeatmap>` `<Hexbin>` `<KDE>`
96
+
97
+ Roots: `<Pitch>`, and `<VerticalPitch>` (exactly `<Pitch orientation="vertical">`).
98
+ Escape hatch: `usePitch()` returns `{ dimensions, viewport, transform }` for custom marks.
99
+
100
+ Full prop tables for every component are in [references/api.md](references/api.md) — read
101
+ it before writing props not shown in the recipes below.
102
+
103
+ ## Composite charts are compositions, not components
104
+
105
+ A shot map is `<VerticalPitch>` + a crop + `<Scatter>`. A pass network is `<Arrows>` +
106
+ `<Scatter>` + `<Annotate>` over data the caller aggregated. PitchKit ships primitives; the
107
+ aggregation is ordinary JavaScript. Build these from the recipes below.
108
+
109
+ (The project intends to distribute such compositions as shadcn registry items later. That
110
+ registry does not exist yet — do not tell a user to run `npx shadcn add pass-map`.)
111
+
112
+ ## Gotchas that break builds
113
+
114
+ **Next.js App Router: originate the tree in a `"use client"` component.** Accessors are
115
+ functions, and React Server Components cannot pass functions to client components. A
116
+ `<Pitch>` tree written directly in a server page fails `next build` with _"Functions
117
+ cannot be passed directly to Client Components"_. Compose it in a `"use client"` component
118
+ and render that from the page — it is still fully server-rendered into the initial HTML.
119
+
120
+ **Density layers need a fixed-pixel pitch.** `<Heatmap>` / `<PositionalHeatmap>` /
121
+ `<Hexbin>` / `<KDE>` paint to a `<canvas>`, which needs real pixel dimensions and cannot
122
+ use the SVG's responsive viewBox. Give `<Pitch>` explicit `width` and `height` — measure
123
+ the container with a `ResizeObserver` to stay responsive (recipe 3 below).
124
+
125
+ **Density layers hide the pitch markings.** They fill opaquely from `colorMin` to
126
+ `colorMax`, including empty bins. Pass `appearance={{ linesOnTop: true }}` so the markings
127
+ paint above them (mplsoccer's `line_zorder`), and/or drop the layer's opacity via `style`.
128
+
129
+ **`className` turns the themed colour default off.** Colour defaults are applied as inline
130
+ styles, which would otherwise beat a utility class at the same property. So passing
131
+ `className` to an SVG layer means it owns `fill` / `stroke` — either style them there, or
132
+ pass `fill` / `stroke` explicitly.
133
+
134
+ **Canvas layers render nothing on the server.** That is by design; they paint after
135
+ hydration. The pitch and the SVG layers around them still SSR.
136
+
137
+ ## Theming
138
+
139
+ Set CSS custom properties anywhere in the cascade — globally, on `.dark`, or on a wrapper
140
+ around a single chart. Every variable has a built-in fallback, so none is required.
141
+
142
+ | Variable | Controls | Default |
143
+ | ------------------------ | ------------------------------- | --------------------------- |
144
+ | `--pitch-surface` | Grass fill | `#1a472a` |
145
+ | `--pitch-stripe` | Mow-stripe overlay | `rgba(255, 255, 255, 0.04)` |
146
+ | `--pitch-lines` | Markings, and `<Annotate>` text | `rgba(255, 255, 255, 0.8)` |
147
+ | `--pitch-line-width` | Marking stroke width | `1.5` |
148
+ | `--pitch-marker-primary` | Default mark colour | `#3b82f6` |
149
+ | `--pitch-tooltip-bg` | Tooltip background | `rgba(17, 17, 17, 0.92)` |
150
+ | `--pitch-tooltip-color` | Tooltip text | `#fff` |
151
+
152
+ `@pitchkit/core` exports `pitchTokens` as a typo-safe map of those names; the values
153
+ always live in CSS.
154
+
155
+ `appearance` is structure, never colour: `{ stripes, goalType, linesOnTop }`.
156
+
157
+ ```tsx
158
+ <div style={{ "--pitch-surface": "#101418" } as React.CSSProperties}>
159
+ <Pitch type="statsbomb" appearance={{ stripes: true, goalType: "box" }} />
160
+ </div>
161
+ ```
162
+
163
+ ## Recipe 1 — shot map
164
+
165
+ Attacking half, vertical framing, markers sized by xG and coloured by outcome.
166
+
167
+ ```tsx
168
+ "use client";
169
+
170
+ import { cropForHalf, getPitchDimensions } from "@pitchkit/core";
171
+ import { Scatter, VerticalPitch } from "@pitchkit/react";
172
+
173
+ // StatsBomb coordinates: 120 x 80, origin top-left, y increasing downward.
174
+ const shots = [
175
+ { x: 112, y: 39, xg: 0.76, outcome: "goal" },
176
+ { x: 105, y: 44, xg: 0.31, outcome: "saved" },
177
+ { x: 99, y: 47, xg: 0.13, outcome: "off target" },
178
+ { x: 91, y: 29, xg: 0.06, outcome: "off target" },
179
+ ];
180
+
181
+ const dimensions = getPitchDimensions("statsbomb");
182
+
183
+ export function ShotMap() {
184
+ return (
185
+ <VerticalPitch type="statsbomb" crop={cropForHalf(dimensions)}>
186
+ <Scatter
187
+ data={shots}
188
+ x={(s) => s.x}
189
+ y={(s) => s.y}
190
+ r={(s) => 3 + s.xg * 9}
191
+ fill={(s) => (s.outcome === "goal" ? "#fb923c" : "#38bdf8")}
192
+ fillOpacity={(s) => (s.outcome === "goal" ? 0.95 : 0.65)}
193
+ stroke="white"
194
+ strokeWidth={(s) => (s.outcome === "goal" ? 2 : 1)}
195
+ tooltip={(s) => `${s.outcome} · xG ${s.xg.toFixed(2)}`}
196
+ />
197
+ </VerticalPitch>
198
+ );
199
+ }
200
+ ```
201
+
202
+ `crop` takes any `{ x0, y0, x1, y1 }` window in provider units; `cropForHalf` is the
203
+ convenience for the attacking half. The container's aspect ratio follows the crop, so a
204
+ half-pitch crop reserves space for half a pitch.
205
+
206
+ ## Recipe 2 — pass map
207
+
208
+ Individual passes, tapered from origin to destination and coloured by completion.
209
+
210
+ ```tsx
211
+ "use client";
212
+
213
+ import { Comet, Pitch } from "@pitchkit/react";
214
+
215
+ const passes = [
216
+ { x: 22, y: 30, x2: 48, y2: 18, completed: true },
217
+ { x: 48, y: 18, x2: 71, y2: 26, completed: true },
218
+ { x: 71, y: 26, x2: 96, y2: 40, completed: false },
219
+ { x: 35, y: 55, x2: 62, y2: 62, completed: true },
220
+ ];
221
+
222
+ export function PassMap() {
223
+ return (
224
+ <Pitch type="statsbomb">
225
+ <Comet
226
+ data={passes}
227
+ x={(p) => p.x}
228
+ y={(p) => p.y}
229
+ x2={(p) => p.x2}
230
+ y2={(p) => p.y2}
231
+ color={(p) => (p.completed ? "#38bdf8" : "#f87171")}
232
+ startWidth={0.5}
233
+ endWidth={4}
234
+ gradient
235
+ tooltip={(p) => (p.completed ? "Completed" : "Incomplete")}
236
+ />
237
+ </Pitch>
238
+ );
239
+ }
240
+ ```
241
+
242
+ Swap `<Comet>` for `<Arrows>` when a flat line with an arrowhead reads better; `<Arrows>`
243
+ takes `strokeWidth` / `strokeOpacity` / `headSize` instead of the taper widths. For passes
244
+ aggregated by starting zone into one arrow per zone (mplsoccer's `flow`), use `<Flow>` with
245
+ `binsX` / `binsY` — its `tooltip` receives a `FlowBin` (`{ x, y, x2, y2, count }`), not a
246
+ raw datum.
247
+
248
+ ## Recipe 3 — heatmap (the responsive-canvas pattern)
249
+
250
+ This is the pattern for **all four** density layers. Copy it whenever one is used.
251
+
252
+ ```tsx
253
+ "use client";
254
+
255
+ import { useEffect, useRef, useState } from "react";
256
+ import { Heatmap, Pitch } from "@pitchkit/react";
257
+
258
+ const events = [
259
+ { x: 52, y: 22 },
260
+ { x: 55, y: 18 },
261
+ { x: 61, y: 20 },
262
+ { x: 66, y: 23 },
263
+ { x: 74, y: 22 },
264
+ { x: 59, y: 43 },
265
+ { x: 47, y: 52 },
266
+ { x: 82, y: 24 },
267
+ ];
268
+
269
+ const PITCH_ASPECT = 120 / 80; // statsbomb length / width
270
+
271
+ export function PressureHeatmap() {
272
+ const containerRef = useRef<HTMLDivElement>(null);
273
+ const [width, setWidth] = useState(480);
274
+
275
+ useEffect(() => {
276
+ const el = containerRef.current;
277
+ if (!el) return;
278
+ const observer = new ResizeObserver((entries) => {
279
+ const entry = entries[0];
280
+ if (entry) setWidth(entry.contentRect.width);
281
+ });
282
+ observer.observe(el);
283
+ return () => observer.disconnect();
284
+ }, []);
285
+
286
+ return (
287
+ <div ref={containerRef}>
288
+ <Pitch
289
+ type="statsbomb"
290
+ width={width}
291
+ height={Math.round(width / PITCH_ASPECT)}
292
+ appearance={{ linesOnTop: true }}
293
+ >
294
+ <Heatmap
295
+ data={events}
296
+ x={(e) => e.x}
297
+ y={(e) => e.y}
298
+ binsX={12}
299
+ binsY={8}
300
+ colorMin="#0f3d24"
301
+ colorMax="#38bdf8"
302
+ style={{ opacity: 0.85 }}
303
+ />
304
+ </Pitch>
305
+ </div>
306
+ );
307
+ }
308
+ ```
309
+
310
+ Same shape for the variants: `<PositionalHeatmap layout="full" />` for Juego de Posición
311
+ zones, `<Hexbin binsX={14} />` for a hex lattice, `<KDE resolution={64} maxOpacity={0.8} />`
312
+ for a smooth surface. Every one of them takes an optional `weight` accessor — omit it to
313
+ count points per bin, provide it to sum a value (total xG per zone, say).
314
+
315
+ ## Recipe 4 — pass network
316
+
317
+ Node size = touches, edge width = passes between the pair. The aggregation is plain data
318
+ prep; the pitch just draws the result.
319
+
320
+ ```tsx
321
+ "use client";
322
+
323
+ import { Annotate, Arrows, Pitch, Scatter } from "@pitchkit/react";
324
+
325
+ const players = [
326
+ { id: "GK", x: 10, y: 40, touches: 42 },
327
+ { id: "LCB", x: 26, y: 30, touches: 71 },
328
+ { id: "RCB", x: 26, y: 50, touches: 66 },
329
+ { id: "DM", x: 44, y: 40, touches: 88 },
330
+ { id: "ST", x: 92, y: 40, touches: 38 },
331
+ ];
332
+
333
+ const byId = Object.fromEntries(players.map((p) => [p.id, p]));
334
+ const node = (id: string) => {
335
+ const player = byId[id];
336
+ if (!player) throw new Error(`Unknown player id: ${id}`);
337
+ return player;
338
+ };
339
+
340
+ const passes = [
341
+ { from: "GK", to: "LCB", count: 18 },
342
+ { from: "GK", to: "RCB", count: 15 },
343
+ { from: "LCB", to: "DM", count: 24 },
344
+ { from: "DM", to: "ST", count: 9 },
345
+ ];
346
+
347
+ export function PassNetwork() {
348
+ return (
349
+ <Pitch type="statsbomb">
350
+ <Arrows
351
+ data={passes}
352
+ x={(p) => node(p.from).x}
353
+ y={(p) => node(p.from).y}
354
+ x2={(p) => node(p.to).x}
355
+ y2={(p) => node(p.to).y}
356
+ strokeWidth={(p) => 0.5 + p.count / 6}
357
+ strokeOpacity={(p) => 0.3 + Math.min(p.count / 30, 0.6)}
358
+ headSize={0}
359
+ tooltip={(p) => `${p.from} → ${p.to}: ${p.count} passes`}
360
+ />
361
+ <Scatter
362
+ data={players}
363
+ x={(p) => p.x}
364
+ y={(p) => p.y}
365
+ r={(p) => 4 + p.touches / 12}
366
+ stroke="white"
367
+ strokeWidth={1.5}
368
+ tooltip={(p) => `${p.id} · ${p.touches} touches`}
369
+ />
370
+ <Annotate data={players} x={(p) => p.x} y={(p) => p.y} label={(p) => p.id} offsetY={-14} />
371
+ </Pitch>
372
+ );
373
+ }
374
+ ```
375
+
376
+ Layer order is paint order: arrows first, then nodes, then labels on top.
377
+
378
+ ## Where to look next
379
+
380
+ - [references/api.md](references/api.md) — every component's full prop list, plus the
381
+ `@pitchkit/core` exports worth calling directly.
382
+ - <https://pitchkitjs.com/docs> — narrative guides.
383
+ - <https://pitchkitjs.com/gallery> — worked examples with source.
384
+ - The installed package's `dist/index.d.ts` — the authoritative types.
@@ -0,0 +1,235 @@
1
+ # PitchKit API reference
2
+
3
+ Everything `@pitchkit/react` and `@pitchkit/core` export, at the version of
4
+ `@pitchkit/react` this file shipped inside. `dist/index.d.ts` in the installed package is
5
+ the authoritative source if the two ever disagree.
6
+
7
+ Throughout: **`Accessor<T, V>`** means `V | ((datum: T, index: number) => V)` — pass a
8
+ constant or a function of the datum. Props marked _(accessor)_ take either form; props
9
+ without the marker are plain static values.
10
+
11
+ ---
12
+
13
+ ## Roots
14
+
15
+ ### `<Pitch>`
16
+
17
+ | Prop | Type | Default | Notes |
18
+ | ------------- | --------------------------------- | -------------- | -------------------------------------------------------------- |
19
+ | `type` | `"statsbomb" \| "opta" \| "uefa"` | — | Required. The provider coordinate system. |
20
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Display concern only; never changes the data's units. |
21
+ | `width` | `number` | — | Fixed pixel width. Pass with `height` or not at all. |
22
+ | `height` | `number` | — | Fixed pixel height. |
23
+ | `crop` | `{ x0, y0, x1, y1 }` | — | Window in provider units. Drives the container's aspect ratio. |
24
+ | `padding` | `{ top, right, bottom, left }` | zero | Pixel padding inside the viewport. |
25
+ | `appearance` | `PitchAppearance` | — | `{ stripes?, goalType?, linesOnTop? }` — see below. |
26
+ | `className` | `string` | — | On the wrapper `<div>`, not the `<svg>`. |
27
+ | `style` | `CSSProperties` | — | Merged into the wrapper's own positioning styles. |
28
+ | `children` | `ReactNode` | — | Layer components. |
29
+
30
+ `PitchAppearance`:
31
+
32
+ | Field | Type | Default | Meaning |
33
+ | ------------ | ------------------- | -------- | ---------------------------------------------------------------------------------------- |
34
+ | `stripes` | `boolean \| number` | `false` | Mow stripes; a number sets the count, `true` picks a default. |
35
+ | `goalType` | `"line" \| "box"` | `"line"` | How goals are drawn. |
36
+ | `linesOnTop` | `boolean` | `false` | Paint markings above the layers (mplsoccer's `line_zorder`). Turn on for density layers. |
37
+
38
+ Omitting `width`/`height` makes the pitch fill its container via `ResizeObserver`. Before
39
+ the first measurement (server render included) it renders at a nominal size with the
40
+ correct aspect ratio, so SSR output is never distorted.
41
+
42
+ ### `<VerticalPitch>`
43
+
44
+ `Omit<PitchProps, "orientation">`. Identical to `<Pitch orientation="vertical">`; named to
45
+ match mplsoccer.
46
+
47
+ ### `usePitch()`
48
+
49
+ Returns `{ dimensions, viewport, transform }` from the enclosing `<Pitch>`. Throws if
50
+ called outside one.
51
+
52
+ - `dimensions: PitchDimensions` — `{ pitchType, length, width, origin, yDirection, normalized, realLengthMeters, realWidthMeters, markings }`
53
+ - `viewport: Viewport` — `{ width, height, orientation, crop?, padding? }` in pixels
54
+ - `transform: PixelTransform` — `toPixel([x, y])`, `toProvider([px, py])`, `scale`
55
+
56
+ `toProvider` is the inverse needed to turn a pointer position back into pitch
57
+ coordinates.
58
+
59
+ ---
60
+
61
+ ## SVG layers
62
+
63
+ Every one of these accepts `className?: string` and `tooltip`, and must be a descendant of
64
+ `<Pitch>`. `tooltip` is `(d: T, i: number) => ReactNode` unless noted. Layer order in JSX
65
+ is paint order.
66
+
67
+ ### `<Scatter>` — one `<circle>` per datum
68
+
69
+ | Prop | Type | Default |
70
+ | ------------- | --------------------- | -------------------------------------- |
71
+ | `data` | `readonly T[]` | — |
72
+ | `x`, `y` | _(accessor)_ `number` | — |
73
+ | `r` | _(accessor)_ `number` | `4` |
74
+ | `fill` | _(accessor)_ `string` | `var(--pitch-marker-primary, #3b82f6)` |
75
+ | `fillOpacity` | _(accessor)_ `number` | — |
76
+ | `stroke` | _(accessor)_ `string` | `"none"` |
77
+ | `strokeWidth` | _(accessor)_ `number` | — |
78
+
79
+ ### `<Annotate>` — one `<text>` per datum
80
+
81
+ | Prop | Type | Default |
82
+ | -------------------- | --------------------- | ------- |
83
+ | `data` | `readonly T[]` | — |
84
+ | `x`, `y` | _(accessor)_ `number` | — |
85
+ | `label` | _(accessor)_ `string` | — |
86
+ | `offsetX`, `offsetY` | _(accessor)_ `number` | `0` |
87
+
88
+ Offsets are in pixels, applied after the coordinate transform. Text is 10px,
89
+ middle-anchored, filled with `var(--pitch-lines, …)`.
90
+
91
+ ### `<Arrows>` — one `<line>` + arrowhead `<polygon>` per datum
92
+
93
+ | Prop | Type | Default |
94
+ | --------------- | --------------------- | -------------------------------------- |
95
+ | `data` | `readonly T[]` | — |
96
+ | `x`, `y` | _(accessor)_ `number` | — start point |
97
+ | `x2`, `y2` | _(accessor)_ `number` | — end point |
98
+ | `stroke` | _(accessor)_ `string` | `var(--pitch-marker-primary, #3b82f6)` |
99
+ | `strokeWidth` | _(accessor)_ `number` | `1.5` |
100
+ | `strokeOpacity` | _(accessor)_ `number` | — |
101
+ | `headSize` | _(accessor)_ `number` | `6` |
102
+
103
+ `headSize={0}` gives a plain line — the usual choice for pass-network edges.
104
+
105
+ ### `<Comet>` — one tapered `<polygon>` per datum
106
+
107
+ | Prop | Type | Default |
108
+ | -------------------- | --------------------- | ---------------------------------------------- |
109
+ | `data` | `readonly T[]` | — |
110
+ | `x`, `y`, `x2`, `y2` | _(accessor)_ `number` | — |
111
+ | `color` | _(accessor)_ `string` | `var(--pitch-marker-primary, #3b82f6)` |
112
+ | `startWidth` | _(accessor)_ `number` | `0.5` |
113
+ | `endWidth` | _(accessor)_ `number` | `4` |
114
+ | `gradient` | `boolean` | `false` — fades opacity 0 → 1 along the length |
115
+
116
+ SVG cannot vary a line's stroke width along its length, so this is a filled quad.
117
+
118
+ ### `<Polygon>` — one `<polygon>` per datum, from explicit vertices
119
+
120
+ | Prop | Type | Default |
121
+ | ---------------------------- | ------------------------------------------------------- | ------- |
122
+ | `data` | `readonly T[]` | — |
123
+ | `points` | _(accessor)_ `ReadonlyArray<readonly [number, number]>` | — |
124
+ | `fill`, `stroke` | _(accessor)_ `string` | — |
125
+ | `fillOpacity`, `strokeWidth` | _(accessor)_ `number` | — |
126
+
127
+ ### `<ConvexHull>` — one polygon for the whole dataset
128
+
129
+ | Prop | Type | Notes |
130
+ | ---------------------------- | --------------------- | ---------------------------------------------- |
131
+ | `data` | `readonly T[]` | — |
132
+ | `x`, `y` | _(accessor)_ `number` | — |
133
+ | `fill`, `stroke` | `string` | **Static, not accessors** — there is one shape |
134
+ | `fillOpacity`, `strokeWidth` | `number` | Static |
135
+ | `tooltip` | `ReactNode` | **Static**, not a function |
136
+
137
+ ### `<Voronoi>` — one cell per datum, clipped to the pitch
138
+
139
+ | Prop | Type |
140
+ | ---------------------------- | --------------------- |
141
+ | `data` | `readonly T[]` |
142
+ | `x`, `y` | _(accessor)_ `number` |
143
+ | `fill`, `stroke` | _(accessor)_ `string` |
144
+ | `fillOpacity`, `strokeWidth` | _(accessor)_ `number` |
145
+
146
+ ### `<GoalAngle>` — a wedge from each point to both goalposts
147
+
148
+ | Prop | Type | Default |
149
+ | ---------------------------- | --------------------------------------------- | ----------- |
150
+ | `data` | `readonly T[]` | — |
151
+ | `x`, `y` | _(accessor)_ `number` | — |
152
+ | `goal` | _(accessor)_ `"left" \| "right" \| "nearest"` | `"nearest"` |
153
+ | `fill`, `stroke` | _(accessor)_ `string` | — |
154
+ | `fillOpacity`, `strokeWidth` | _(accessor)_ `number` | — |
155
+
156
+ ### `<Flow>` — one aggregate arrow per occupied grid bin
157
+
158
+ | Prop | Type | Default |
159
+ | ---------------------------------- | ---------------------------------------- | ------------------------------------------ |
160
+ | `data` | `readonly T[]` | — |
161
+ | `x`, `y` | _(accessor)_ `number` | — start point, which decides the bin |
162
+ | `x2`, `y2` | _(accessor)_ `number` | — end point |
163
+ | `binsX`, `binsY` | `number` | — grid resolution |
164
+ | `colorMin`, `colorMax` | `string` | — colour at the lowest / highest bin count |
165
+ | `strokeWidthMin`, `strokeWidthMax` | `number` | — width at the lowest / highest bin count |
166
+ | `tooltip` | `(bin: FlowBin, i: number) => ReactNode` | — **per bin, not per datum** |
167
+
168
+ `FlowBin` is `{ x, y, x2, y2, count }` in provider coordinates.
169
+
170
+ ---
171
+
172
+ ## Canvas density layers
173
+
174
+ Client-only: they paint to a `<canvas>` inside a `<foreignObject>` after hydration, and
175
+ render empty on the server. They take `className` and `style` but **no `tooltip`**, and
176
+ they need a `<Pitch>` with explicit `width` and `height` — see the responsive-canvas recipe
177
+ in SKILL.md. Pair them with `appearance={{ linesOnTop: true }}` so the markings stay
178
+ visible.
179
+
180
+ All four take `data`, `x`, `y`, an optional `weight` _(accessor)_ `number` (omitted =
181
+ count points per bin; provided = sum this per bin), and `colorMin` / `colorMax`.
182
+
183
+ | Component | Extra props |
184
+ | --------------------- | ---------------------------------------------------------------------------------------------------- |
185
+ | `<Heatmap>` | `binsX`, `binsY` |
186
+ | `<PositionalHeatmap>` | `layout?: "full" \| "horizontal" \| "vertical"` (default `"full"`), `stroke?`, `strokeWidth?` |
187
+ | `<Hexbin>` | `binsX` (hex columns; cell size follows), `stroke?`, `strokeWidth?` |
188
+ | `<KDE>` | `resolution?` (grid cells per axis), `bandwidth?` (provider units; default Silverman), `maxOpacity?` |
189
+
190
+ `<PositionalHeatmap>` bins into Juego de Posición zones derived from the pitch markings
191
+ rather than a uniform grid — mplsoccer's `bin_statistic_positional`. Unlike the binned
192
+ layers, `<KDE>` fades to transparent at low density instead of filling with `colorMin`.
193
+
194
+ ---
195
+
196
+ ## `@pitchkit/core` exports worth calling directly
197
+
198
+ **Dimensions**
199
+
200
+ - `getPitchDimensions(type)` → `PitchDimensions`; `PITCH_DIMENSIONS` is the record of all three.
201
+ - `PitchDimensions.markings` → `{ penaltyAreaLength, penaltyAreaWidth, sixYardLength, sixYardWidth, centerCircleRadius, penaltySpotDistance, cornerArcRadius, goalWidth }`, in provider units.
202
+
203
+ **Transforms**
204
+
205
+ - `cropForHalf(dimensions)` → the attacking-half `CropWindow`.
206
+ - `createStandardizeTransform(from, to)` → `(point) => point`, mplsoccer's `Standardizer`.
207
+ - `createPixelTransform(dimensions, viewport)` → `PixelTransform`. `<Pitch>` builds this
208
+ itself; call it directly only outside React.
209
+
210
+ **Aggregation** (the maths behind the density and geometry layers, usable standalone —
211
+ e.g. to compute a legend's domain, or to render a table alongside the chart)
212
+
213
+ - `computeHeatmapBins`, `computePositionalZones` / `computePositionalBins`,
214
+ `computeHexBins` / `hexCorners`, `computeKdeGrid` / `silvermanBandwidth`
215
+ - `computeFlowBins`, `computeConvexHull`, `computeVoronoiCells`, `computePolygonCentroid`,
216
+ `computeGoalAngle` / `selectGoal`
217
+ - `createColorScale` — the interpolator the density painters use
218
+
219
+ **Theming**
220
+
221
+ - `pitchTokens` — `{ surface, stripe, lines, lineWidth, markerPrimary, markerGoal, markerMiss }`,
222
+ mapping to the CSS variable names. Values live in CSS, never here.
223
+
224
+ **Types** — `PitchTypeId`, `PitchDimensions`, `PitchMarkings`, `Point`, `Orientation`,
225
+ `CropWindow`, `ViewportPadding`, `Viewport`, `PixelTransform`, `Scene`, `Layer`,
226
+ `Accessor`, `PitchAppearance`, and one `*Layer<T>` interface per mark. The React
227
+ components' props are `Omit<XLayer<T>, "type">` plus `tooltip`, so the layer interfaces are
228
+ the canonical prop definitions.
229
+
230
+ ### Not for consumer code
231
+
232
+ `svgRenderer`, `renderSceneToSVGElement`, `renderDensityLayersToCanvas`,
233
+ `renderHeatmapLayersToCanvas`, `canvasRenderer` and the `paint*` helpers are internal
234
+ rendering machinery, exported only for the repo's own dev harness. Render through
235
+ `@pitchkit/react`.