KDE
A smooth kernel density surface, for pressure and territory maps.
"use client";import { useEffect, useRef, useState } from "react";import { KDE, Pitch, Scatter } from "@pitchkit/react";import { docsDensityAppearance } from "./docs-appearance";// StatsBomb coordinates (120 x 80). Defensive pressure events from one// half: a high press concentrated around the opposition's left channel,// with a second, looser cluster in front of the defensive line.const pressures: { x: number; y: number }[] = [ { x: 88, y: 22 }, { x: 92, y: 26 }, { x: 85, y: 19 }, { x: 90, y: 31 }, { x: 96, y: 24 }, { x: 83, y: 28 }, { x: 94, y: 18 }, { x: 87, y: 33 }, { x: 99, y: 29 }, { x: 91, y: 15 }, { x: 45, y: 44 }, { x: 51, y: 38 }, { x: 48, y: 52 }, { x: 55, y: 47 }, { x: 42, y: 49 }, { x: 58, y: 41 }, { x: 50, y: 58 }, { x: 62, y: 51 }, { x: 70, y: 62 }, { x: 75, y: 55 },];const PITCH_ASPECT = 120 / 80;const FALLBACK_WIDTH = 480;/** * `<KDE>` paints to a canvas, which needs a fixed pixel size up front, so * this example measures its own container (the same technique `<Pitch>` * uses internally) rather than leaning on <Pitch>'s responsive mode. */export function KdeBasic() { 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={docsDensityAppearance} > <KDE data={pressures} x={(p) => p.x} y={(p) => p.y} bandwidth={7} colorMin="#facc15" colorMax="#b91c1c" maxOpacity={0.85} /> <Scatter data={pressures} x={(p) => p.x} y={(p) => p.y} r={1.6} fill="white" fillOpacity={0.65} /> </Pitch> </div> );}Usage
<KDE> estimates a continuous density surface from point data via a 2D Gaussian kernel — mplsoccer's
kdeplot. Where <Heatmap> is a hard histogram, each point here spreads influence over its
neighbourhood, so a handful of events reads as a cloud rather than a grid of cells.
<Pitch type="statsbomb" width={480} height={320}>
<KDE data={pressures} x={(p) => p.x} y={(p) => p.y} />
</Pitch>Bandwidth
bandwidth is the smoothing radius in provider units (7 StatsBomb units ≈ 7 yards). Leave it
unset and each axis gets its own bandwidth from Silverman's rule of thumb — σ · n^(-1/6) — computed
from the data's own spread, which is the same default seaborn's kdeplot starts from. Set it
explicitly when the data's spread isn't the right smoothing scale, e.g. a small sample you want to
read tightly.
<KDE data={pressures} x={(p) => p.x} y={(p) => p.y} bandwidth={7} />Opacity and resolution
Density is drawn with a per-cell opacity ramp: zero density is fully transparent and the peak sits at
maxOpacity (default 0.9), so the pitch stays visible under the tail of the surface rather than
being washed in colorMin.
resolution is the grid the estimate is sampled on, per axis (default 64). Raise it for a smoother
surface, lower it for speed.
Like every Canvas layer, this needs a fixed-size <Pitch> (width/height).
Keeping the pitch markings visible
A filled density layer paints over the lines underneath it. Set linesOnTop on the pitch's
appearance — mplsoccer's line_zorder — to paint the markings above the layers instead. Only the
markings move; the grass surface and stripes stay underneath either way.
<Pitch type="statsbomb" width={480} height={320} appearance={{ linesOnTop: true }}>
{/* ... */}
</Pitch>