Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, MoveRight } from "lucide-react"

import { cn } from "@/lib/utils"
import type { ChartPitchHeatmapCell, ChartPitchHeatmapData } from "./chart-pitch-heatmap.contract"

export interface ChartPitchHeatmapProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartPitchHeatmapData {
  /** Card heading, e.g. "Touch map — LW #11". */
  title?: string
  /** Second header line — put the measure, the match and the period here. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-pitch-heatmap.json

Prompt

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 over bg-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.

On This Page