@pitchkit/react 0.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/install-skill.mjs +223 -0
- package/bin/pitchkit.mjs +15 -0
- package/dist/index.d.ts +52 -19
- package/dist/index.js +97 -15
- package/dist/index.js.map +1 -1
- package/package.json +29 -2
- package/skills/pitchkit/SKILL.md +384 -0
- package/skills/pitchkit/references/api.md +235 -0
|
@@ -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`.
|