Charts

Soccer Territory Flow

A four-state replay of which third of the pitch a match was played in, minute by minute — a stacked territory band, a field-tilt momentum lane, jump-to-event markers and a play / step / scrub transport on the match clock.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, Pause, Play, RotateCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/registry/hooks/use-controllable-state"
import type {
  ChartSoccerTerritoryFlowData,
  ChartSoccerTerritoryFlowEvent,
  ChartSoccerTerritoryFlowMinute,
} from "./chart-soccer-territory-flow.contract"

/** How the three thirds are stacked. See `layoutTerritory`. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-soccer-territory-flow.json

Prompt

Build a React + TypeScript + Tailwind "ChartSoccerTerritoryFlow" card with zod
and lucide-react (Play, Pause, RotateCcw, ChevronLeft, ChevronRight). No charting
library: the plot is hand-built SVG paths, because the subject is a MATCH BEING
REPLAYED — the band has to grow one minute at a time and break wherever the feed
stopped tracking, and a chart library will happily interpolate straight through
both.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string; home: string; away: string; kickoff?: string;
    minutes: { minute: number; stoppage?: number; half: 1 | 2;
               own: number; middle: number; final: number;
               tilt?: number;
               event?: { kind: "goal" | "red-card" | "substitution";
                         team: "home" | "away"; label: string } }[] }
- own / middle / final are shares of that minute's ball time in each third FROM
  THE HOME TEAM'S POINT OF VIEW, normalised per minute by the component: 0-1,
  0-100 and raw touch counts all read the same, only the ratio is used.
- All three at zero is a TRACKING GAP, not a goalless minute. It is a different
  statement and it is drawn differently — as a break in the band.
- tilt is field tilt: the home team's share of that minute's final-third touches,
  0-1. Omit it and the momentum lane simply has nothing to draw for that minute.
- kickoff is printed VERBATIM and never parsed. A date parsed on the client
  renders in the reader's zone and in the server's on the server, which is a
  hydration mismatch on a card that must be byte-identical at every frame.
- Props = z.infer of the schema plus: baseline ("goal-line" | "halfway"), the
  playhead triple (frame / defaultFrame / onFrameChange), frameMs, onRetry,
  className, ref and the native div props.

Geometry, in exported pure functions beside the component
- Export one geometry table (view width/height, plot box, lane box) and make
  every function return numbers in that space, so a test can print exactly what
  the SVG draws.
- formatMatchClock(minute, stoppage) -> "67'", "45+2'", "90+4'", or null when the
  minute is unreadable. Never print "NaN'".
- normalizeShares(own, middle, final) -> shares that add to 1 plus a `clamped`
  flag; negative and non-finite values clamp to zero, all-zero returns null.
- layoutTerritory(minutes, baseline) -> per-minute columns (slot centre x, slot
  width, clock, band edges, tilt, lane bar, event) plus half-time break positions
  and three counters: untracked, clamped, unclocked. One minute owns one SLOT of
  width plotWidth / minutes.length and sits at its centre, so the first and last
  minute are as wide as any other.
- Baselines: "goal-line" stacks own -> middle -> final from unit 0 (home goal
  line) to unit 1, filling the box. "halfway" shifts every boundary by
  0.5 - (own + middle/2) so the middle third straddles the centre; that offset
  lives in +/-0.5, so open the drawing domain to [-0.5, 1.5] and NOTHING is ever
  clipped. The band then uses half the box, and the empty half is the room it
  drifts in — which is the point of that baseline.
- territoryBandPaths(columns, band, upTo) -> one `d` per contiguous run of
  tracked minutes, each run drawn from its first slot's left edge to its last
  slot's right edge so a single surviving minute is a visible column, not a
  zero-width polygon.
- fieldTiltThrough(columns, upTo) and territoryAxisTicks(count) for the running
  figure and the axis.

Behavior
- The playhead is DERIVED, never a wall clock: an index into `minutes`, clamped
  on read (`clamp(round(frame), 0, last)`), never stored clamped. A shorter feed
  or a caller passing 999 must not leave the card pointing past full time.
- Controlled and uncontrolled at once via a value/default/onChange triple —
  `frame`, `defaultFrame`, `onFrameChange` on one useControllableState-style
  hook. Under control the parent may decline a move; then the index does not
  change, the playback effect does not re-run and playback stops. That is what
  controlled means, and it should be a comment in the code.
- Transport is REAL UI: play/pause, previous minute, next minute, a native
  <input type="range"> scrubber (accent-primary, aria-valuetext = the match
  clock) and a "n/96" read-out. Never a gesture-only scrubber, and it starts
  PAUSED — an autoplay you cannot stop is the bug this avoids.
- Playback is one self-rescheduling setTimeout owned by one effect and cleared in
  that effect's cleanup, so no loop outlives the card. Reaching full time is
  DERIVED (`running = playing && canPlay && !atEnd`), never a setState inside an
  effect; at the end the play button becomes a replay button and says so.
- A visibilitychange listener pauses when the tab hides: a replay nobody is
  looking at is a timer burning battery to redraw pixels nobody will see.
- The band grows: paths are rebuilt for minutes 0..playhead, with the whole match
  behind it at ~22% opacity. A paused frame is therefore a finished picture, not
  a half-run animation — screenshot-stable and identical on the server.
- Event markers are a roving-tabindex toolbar of real HTML buttons positioned
  over the plot by percentage. A goal is a circle, a red card is a card-shaped
  rect, a substitution is a triangle — shape first, team colour second.
- Keyboard map: Tab reaches the marker strip as ONE stop, then Left/Right (and
  Up/Down) walk the markers, Home/End jump to the first and last, Enter or Space
  seeks the playhead to that minute. Tab again for the transport buttons, then
  the range, where the browser's own Left/Right, Home/End and PageUp/PageDown all
  scrub for free — which is most of why the scrubber is a native input.
- prefers-reduced-motion: the ONLY tween is the playhead's transform, gated with
  `motion-safe:`. With motion off the line jumps a minute at a time, the band
  still grows, every figure is still correct, and nothing needs an in-flight
  animation to become readable.
- Never the native `disabled` attribute on a focusable control: the step buttons
  use aria-disabled plus a handler guard, because a browser blurs a node it
  disables and the caret would land on <body> the moment the match ran out under
  the reader's finger.
- Degenerate data must not break the geometry, and nothing is dropped quietly:
  untracked minutes break the band into runs, out-of-range shares and tilts are
  clamped, unreadable clocks fall back to "Minute n" — and all three are counted
  in a visible note under the title ("2 minutes carry no tracking data — drawn as
  gaps in the band"). Long names elide with a title attribute, never overflow.

Rendering & styling
- Semantic tokens only: bg-card panel, var(--chart-1) for the home team's final
  third, var(--chart-2) for their own third, muted-foreground for the middle
  third, fill-muted/40 for the plot box, stroke-border for reference lines,
  foreground for the playhead and the half-time divider. No hex, no rgb().
- Colour is never the only channel: the own third is drawn with a diagonal SVG
  <pattern> (stripes in the card colour) while the final third is solid, every
  band is directly labelled with its live percentage in the legend, and the
  momentum lane encodes the team as DIRECTION (above the centre line = home,
  below = away) as well as hue.
- Axis labels are HTML positioned by percentage over the same box, not SVG text:
  a viewBox that scales to the card would scale the type with it and a 9px tick
  is unreadable. Edge labels shift so they stay inside the card.
- Accessibility: the plot is one role="img" with a one-sentence aria-label; the
  transport announces through a polite live region that carries the full frame
  sentence when paused, stepped or seeked and a single constant "Replay running."
  while playing, so it never talks over the match; every minute, share, tilt and
  event is repeated in an sr-only table whose WRAPPER is a div (a bare sr-only
  table keeps auto layout, ignores width:1px and drags the page sideways).
- cn() merges className, forwardRef passes the ref through and the rest of the
  native div props spread onto the card.

Customization levers
- Baseline: "goal-line" for boundary reading, "halfway" for drift reading. The
  domain table is two lines — add a third baseline (e.g. clamp the drift to
  +/-0.25 for a tighter river) by adding one entry and one offset expression.
- Pace: frameMs is one match minute in wall-clock ms, clamped 60-4000. Drop it to
  90ms for a flick-through, raise it to 800ms for a presentation.
- Sampling: nothing assumes 90 minutes. Feed five-minute buckets, a 120-minute
  match with extra time, or one row per phase of play — the slot width divides
  the plot by whatever it is given.
- Bands: the three-band stack is a Record keyed by band name; swap the middle
  third's paint for a fourth chart token if you want three team-coloured zones,
  but keep one channel that is not colour.
- Momentum lane: laneHeight 0 removes it; swap fieldTiltThrough for a rolling
  window (last 5 minutes) if you want momentum rather than a match-to-date mean.
- Events: the kind union drives the glyph switch — add "yellow-card",
  "penalty-award" or "VAR review" as one more case plus one more label.
- Transport: to autoplay a hero card, fire the play state from an intersection
  observer — and keep the pause button, because auto-updating content needs a
  stop.

Concepts

  • Derived playhead — the card never reads a wall clock. It takes an instant (a frame index), clamps it on read and rebuilds the picture from it, so the same index always renders the same bytes: safe to server-render, safe to screenshot, and safe to hand a value from a URL or a scroll position.
  • Grow, don’t reveal — the band is rebuilt for minutes zero through the playhead rather than being unmasked, and the rest of the match sits behind it, ghosted. A paused frame is a finished drawing instead of a stalled animation, which is what makes “pause and read it” an honest instruction.
  • Tracking gap is not zero — a minute with nothing in it breaks the band into a new run instead of joining two tracked minutes with a straight line. Interpolating across missing data invents territory nobody recorded, and the counted note under the title names the price out loud.
  • Two baselines, one contract — stacking from the goal line fills the box and asks you to read two moving boundaries; centring the middle third on the halfway line lets the whole band float, and the drift becomes the shape. Same data, same DOM, one offset term.
  • Field tilt as direction — the momentum lane encodes which team is winning the final-third exchange as which side of the centre line the bar leaves from, not only as hue. Direction survives greyscale, a colour-blind reader and a projector.
  • Events as chapters — goals, red cards and substitutions are focusable buttons in a roving-tabindex strip, and activating one seeks the playhead. That turns “what changed the game?” into a single keypress instead of a hunt along a scrubber.

On This Page