PitchKit
Overlays

Hexbin

Hexagonal density binning, for dense touch and event maps.

"use client";import { useEffect, useMemo, useRef, useState } from "react";import { Hexbin, Pitch } from "@pitchkit/react";import { docsDensityAppearance } from "./docs-appearance";/** * A deterministic stand-in for a full match's touch data — hexbin only * earns its keep at densities where listing every point inline would be * unreadable. Seeded so the docs render identically on every build. */function generateTouches(count: number) {  let seed = 20260909;  const random = () => {    seed = (seed * 1103515245 + 12345) % 2147483648;    return seed / 2147483648;  };  // Box-Muller, so touches cluster around a centre-of-gravity in the  // attacking half rather than spreading uniformly across the pitch.  const normal = () =>    Math.sqrt(-2 * Math.log(random() || 1e-9)) * Math.cos(2 * Math.PI * random());  // Rounded to 2dp deliberately: `Math.log`/`Math.cos` are allowed to  // differ in the last bit between engines, so full-precision coordinates  // would differ between the server render and the browser.  const round = (value: number) => Math.round(value * 100) / 100;  return Array.from({ length: count }, () => ({    x: round(Math.min(119, Math.max(1, 72 + normal() * 22))),    y: round(Math.min(79, Math.max(1, 40 + normal() * 17))),  }));}const PITCH_ASPECT = 120 / 80;const FALLBACK_WIDTH = 480;/** * `<Hexbin>` 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 HexbinBasic() {  const containerRef = useRef<HTMLDivElement>(null);  const [width, setWidth] = useState(FALLBACK_WIDTH);  const touches = useMemo(() => generateTouches(600), []);  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}      >        <Hexbin          data={touches}          x={(t) => t.x}          y={(t) => t.y}          binsX={18}          colorMin="#0f3d24"          colorMax="#facc15"          stroke="rgba(0, 0, 0, 0.25)"          strokeWidth={0.5}          style={{ opacity: 0.9 }}        />      </Pitch>    </div>  );}

Usage

<Hexbin> bins data into a hexagonal lattice instead of a rectangular grid — mplsoccer's hexbin. Hexagons pack more evenly than squares (every neighbour is the same distance away), so dense event data reads with less of the axis-aligned banding a <Heatmap> shows at the same resolution.

<Pitch type="statsbomb" width={480} height={320}>
  <Hexbin data={touches} x={(t) => t.x} y={(t) => t.y} binsX={18} />
</Pitch>

Cell size

binsX is the number of hexagon columns across the pitch length; the cell size follows from it, so the same value looks right on a 120x80 StatsBomb pitch and a 100x100 Opta one. It defaults to 20.

Empty cells

Unlike <Heatmap>, hexagons with no data aren't drawn at all, so the pitch stays visible wherever there was no activity — no colorMin wash to opt out of. The lattice is clipped to the pitch outline, so edge hexagons don't spill past the touchlines.

Pass weight to sum a value per hexagon (e.g. xG) rather than counting points, and stroke to outline each cell. 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:

<Pitch type="statsbomb" width={480} height={320} appearance={{ linesOnTop: true }}>
  <Hexbin data={touches} x={(t) => t.x} y={(t) => t.y} />
</Pitch>

Only the markings move — the grass surface and stripes stay underneath either way. It's off by default so that discrete SVG marks (a scatter dot on the penalty spot) still sit on top of the lines, which is what you want everywhere except a density fill.

On this page