# Hexbin

Source: https://www.pitchkitjs.com/docs/overlays/hexbin

> Hexagonal density binning, for dense touch and event maps.

```tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { Hexbin, Pitch } from "@pitchkit/react";

/**
 * 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)}
      >
        <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.

```tsx
<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:

```tsx
<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.
