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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-soccer-territory-flow.jsonPrompt
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.
Soccer xG Race
A four-state expected-goals race replayed on the match clock — cumulative xG staircases that grow only as far as the playhead, with real transport buttons, a running scoreline and goal chips that jump the clock.
Soccer Shot Quality
A four-state soccer shot map that replays a match shot by shot — marks placed on the attacking half, sized by xG, shaped by outcome, driven by a real play / pause / step / scrub transport on the football clock.