Pitch Heatmap
A hand-rolled SVG soccer pitch whose zone grid shades by event count — one chart token at stepped opacity, printed legend bounds, a direction-of-play arrow, and every zone readable by pointer, keyboard or screen reader.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-pitch-heatmap.jsonPrompt
Build a React + TypeScript + Tailwind "Pitch Heatmap" component (zod, lucide-react, a cn() class
merger). It draws a soccer pitch as hand-rolled SVG and shades a zone grid by event count — touch
density, pressures, recoveries — with a stepped-intensity legend and a direction-of-play arrow.
Contract
- zod schema, one source of truth:
status: "loading" | "empty" | "error" | "ready"
gridWidth: int 2-40 // zone columns along the pitch length
gridHeight: int 2-30 // zone rows across the pitch width
cells: { x: int >= 0; y: int >= 0; count: number >= 0 }[] // long form, sparse
- Orientation is part of the contract: always attacking left → right. x = 0 touches the own goal
line, y = 0 is the top row = the attacking team's left flank. Normalise both halves upstream;
the component never flips anything.
- Props on top of the contract: title, description, steps (2-7, default 5), unit ("touches"),
directionLabel, onRetry, emptyState, className, ...div props. forwardRef onto the card.
Pitch geometry (hand-rolled SVG, metre units, viewBox "-3 -3 111 74")
- A 105 x 68 pitch: outline, halfway line, centre circle r 9.15 with spot, penalty boxes
16.5 x 40.32, six-yard boxes 5.5 x 18.32, penalty spots at x = 11 / 94, penalty arcs (the slice
of the 9.15 circle outside each box, meeting the box edge 7.31 above and below the spot),
corner arcs r 1, and a 2m-deep goal behind each goal line.
- All markings live in ONE aria-hidden group: fill-none stroke-border, strokeWidth 0.35,
pointer-events none so the zones underneath stay hoverable. Spots are fill-border circles.
- Zone rects are (105/gridWidth) x (68/gridHeight), drawn UNDER the markings layer with
shape-rendering crispEdges so adjacent cells never leave antialiasing seams.
Behavior
- Ingest (pure, exported, testable): long-form cells → one row-major slot per zone. Repeated
coordinates are SUMMED — two partial counts of one zone are one aggregation, not a conflict.
Entries outside the grid or with a negative / non-finite count are dropped, counted by reason,
and printed in a visible note under the pitch, never skipped in silence. A zone absent from
cells is a measured 0.
- The ladder: levelOf(count) = floor(count / max * steps), clamped onto the top step, cut on
(0, max]. Zero is EXCLUDED on purpose — "never went there" gets no shading at all, not the
palest shade of "went there". When every occupied zone holds the same count the ladder
collapses to one mid-ramp step: nothing there is hotter, so nothing gets to look hotter.
- Legend: a "0" swatch (border only, spoken as "measured 0 — the grid covers the whole pitch")
plus one swatch per step at its exact fillOpacity with its printed lower bound and a spoken
range, so the cut is never decided off-screen. The spoken range mirrors levelOf's half-open
band — lower bound included (except 0), upper bound excluded ("12 up to but not including
16") — so screen readers hear the same inclusion rule the printed "≥ low" states.
- Direction of play: a visible label + arrow above the pitch, AND "attacking left to right"
spoken in the SVG's aria-label summary — which also gives the total, the busiest zone in
football terms ("final third · left flank") and the share per third of the pitch.
- Hover: ONE delegated pointerover handler on the svg (locate via a data-z attribute), never a
closure per zone — a 20 x 14 tracking grid is 280 rects. Every zone carries a native <title>,
an aria-hidden readout line under the pitch echoes the active zone, and an sr-only table lists
every occupied zone busiest-first with count and share: pointer, tooltip, keyboard and screen
reader all read the same sentence.
- Keyboard: the svg is ONE tab stop (role listbox, tabIndex 0, focus-visible outline) whose
zones are role option rects driven by aria-activedescendant. Arrow keys walk the row-major
grid clamped at the pitch edges, Home/End jump to the row's ends, and a stroke-ring rect drawn
over the markings marks the active zone — hover and cursor feed the same readout line, so the
exact count is never pointer-only.
- Degenerate input must not break the geometry: an unreadable grid pair renders the zero state
naming the two values; ready with zero events still draws the whole unshaded pitch (the grid is
a decision, not a consequence of the data), reports 0 in the meta line, and the caption drops
the ladder wording ("no zone was reached, so no intensity steps are drawn") because no step
swatches render when max is 0.
- Four states are first-class branches: loading is a pitch-shaped skeleton (outline + pulsing
patches, motion-reduce:animate-none) behind an sr-only status; empty keeps the pitch
silhouette; error shows the retry button only when onRetry exists.
Rendering & styling
- Semantic tokens only. Density is ONE chart token at stepped opacity: fill var(--chart-1),
fillOpacity ramping 0.14 → 0.9 across the steps, composited over bg-card so the ramp always
travels away from the surface — in both themes. Markings stroke-border, card is rounded-xl
border bg-card p-4, meta text text-muted-foreground text-xs, all counts tabular-nums.
- Colour is never the only channel: the legend prints bounds, every zone titles itself, the
readout line and the sr-only table repeat the numbers, the drop note names what was refused.
- cn() merges className; the root spreads remaining div props and forwards its ref; the retry
button and the pitch itself carry focus-visible rings; the only animation is the skeleton
pulse.
Customization levers
- Colour: swap var(--chart-1) for any other single chart token (a second instance on --chart-2
makes an us-vs-them pair of cards). Keep ONE token per pitch — a multi-hue ramp stops reading
as density.
- Ramp: the 0.14 / 0.9 opacity floor and ceiling; raise the floor for projector decks, lower the
ceiling when markings must dominate. steps 3 reads as cold/warm/hot, 7 as a near-gradient.
- Grid: 6 x 4 for thirds-and-channels tactics talks, 12 x 8 for touch maps, 18 x 12 and up for
raw tracking bins (give the card more width first).
- Vocabulary: unit renames the event ("pressures", "duels won"); directionLabel localises the
arrow; the thirds/flank wording lives in one zoneName function.
- Trim: replace the zero state via emptyState, drop the meta row for a thumbnail embed — the
contract and the ladder stay untouched.
- Vertical pitch: rotate the geometry constants and swap the two grid axes, but keep x pointing
at the attacked goal so upstream data never has to flip.Concepts
- One token, stepped opacity — density is a single
--chart-*colour whose fill opacity climbs the ladder, composited overbg-card. The ramp re-themes with the host's tokens and always travels away from the surface, so the picture cannot invert its meaning in dark mode. - Unshaded is a measured zero — the grid covers the whole pitch, so a blank zone means "tracked, never went there", not "no data". Zero is excluded from the ladder on purpose: it gets no shading at all rather than the palest shade of presence.
- Orientation is contract, not styling — every payload is normalised to attack left → right before it arrives, the arrow says so visually and the summary says so out loud. A heatmap whose direction is ambiguous is a heatmap that can be read exactly backwards.
- Football words over coordinates — the busiest zone is announced as "final third · left flank", and the meta line splits the total across the three thirds, because that is the sentence a coach actually asks for; the raw column/row pair is still printed beside it.
- Ladder honesty — steps are cut linearly up to the busiest zone's count and the legend prints every bound; when all occupied zones tie, the ladder collapses to one mid-ramp step instead of painting a uniform field as if something in it were hotter.
- Nothing dropped in silence — entries outside the grid or with unreadable counts are refused, counted by reason, and reported in a visible note; repeats of the same zone are summed because partial counts of one zone are one aggregation.
Shot Map
A four-state basketball half-court shot chart in hand-rolled SVG, with made/missed dots, a per-shot hover tooltip and an FG% header.
Pass Network
A four-state soccer pass network in hand-rolled SVG — players at their average positions on a pitch, marker area by touches, edges by pass count above a threshold, hover to light one player's links.