Cricket Wagon Wheel
A playable cricket wagon wheel in hand-rolled SVG — every scoring shot drawn from the striker's stumps out to where the ball finished, coloured and shaped by the runs it brought, with the eight region totals riding the rope and a ball-by-ball transport clocked in overs.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-cricket-wagon-wheel.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCricketWagonWheel" chart — a
playable cricket wagon wheel in hand-rolled SVG (no chart library, no animation
library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
batter: { name: string; hand: "left" | "right" };
innings?: string;
shots: { over: number; // 1-based over number
ball: number; // 1..6, the legal delivery within that over
runs: 1 | 2 | 3 | 4 | 6;
angle: number; // degrees clockwise from straight
distance: number; // metres from the striker's stumps
shot?: string }[] }.
- Props = z.infer of the schema plus title?, value?, defaultValue?,
onValueChange?, onRetry?, emptyState? and className. No hand-written
parallel interface.
- A wagon wheel plots SCORING shots only: a dot ball is simply absent, extras
belong on the scorecard, and five-run overthrows are out of scope. `runs` is
a literal union so every branch that colours or shapes a mark is exhaustive.
- Angles are measured at the striker's stumps: 0 is dead straight past the
bowler, 90 square on the clockwise side, 180 straight behind the keeper.
Sector edges are half-open, so a ball hit dead straight books to the first
sector.
- Handedness renames the ground, it never moves a ball: for a right-hander the
clockwise half is the leg side, for a left-hander the anticlockwise half. Mirror
the region NAMES, draw the shot where it was actually hit.
Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
mirrors the ready silhouette — stat line, the ground itself, readout,
transport, ball rail — so the card keeps its height), empty (a faint ground
plus copy, reused with a stated reason when a ready payload has nothing
drawable), error (message plus "Try again" only when onRetry exists), ready.
- Ingest repairs instead of trusting, and counts every repair in a visible
line: shots with no readable over, ball, line, length or run value are
dropped; a delivery numbered outside the over is snapped back into 1..6; a
distance past the rope on its own line is clamped onto the rope; angles are
normalised (lossless); the feed is sorted by (over - 1) * 6 + ball and says so
when it arrived out of order. A wagon wheel that quietly loses or moves a
shot is claiming an innings that was never played.
- ONE playhead — how many recorded scoring shots have been played — is the only
playback state, and it supports the full triple: `value` (controlled),
`defaultValue` (uncontrolled) and `onValueChange`, which fires once per ball
from the transport, the scrubber, the ball rail and the loop alike. Everything
on the card derives from it: runs, boundary share, leg/off split, region
totals, the clock, the scrubber thumb and the sr-only table.
- NO autoplay, and no wall clock anywhere in the first render path. The
uncontrolled playhead starts parked at the last ball, so a screenshot, an SSR
paint or a docs thumbnail shows the complete wheel; pressing play from there
rewinds to 0.0 overs. A card paused at ball N renders identically every time.
- Playback is one requestAnimationFrame loop that advances by the frame's
timestamp delta times the selected speed (1x / 2x / 4x), against a per-ball
dwell derived from how far the ball travelled (about 12 ms per metre, clamped
420–1100 ms) — so a six hangs and a push for one is over quickly. Leftover
milliseconds carry across balls in a ref, so 2x is exactly twice 1x. Cancel
the loop on pause, on every seek, on unmount, and pause playback on
visibilitychange when the document hides.
- The transport is real UI, all keyboard operable with focus-visible rings:
back-to-start, step back one ball, play/pause (aria-pressed with a
state-specific aria-label), step forward one ball, a labelled native
<input type="range"> scrubber with one step per ball and an aria-valuetext
that speaks the overs, and a 1x/2x/4x speed radiogroup. Buttons at the ends of
the innings use aria-disabled plus a handler guard, NEVER the native disabled
attribute — a control that stops taking focus vanishes under the finger using
it.
- The clock reads in cricket's own units: overs bowled as over.ball, so 12
completed overs and 3 balls is "12.3 ov". Frame 0 is "0.0 ov", the start of
the innings.
- A ball-by-ball rail under the transport is the keyboard equivalent of the
lines on the ground: one button per recorded shot showing its runs, roving
tabindex (arrows / Home / End move focus, Enter jumps the replay there),
aria-current on the playhead ball. Every ball keeps its button whatever the
playhead is doing, so a rewind can never drop focus onto <body>.
- prefers-reduced-motion (via matchMedia, subscribed with cleanup —
useSyncExternalStore fits): playback still works, but the ball in flight is
never drawn, so each shot simply appears whole when its dwell is up. CSS
transitions carry motion-reduce: variants.
- Hover on a line and focus on a rail button share ONE readout row under the
wheel — runs, region, stroke name, overs, metres — which shows the playhead's
own ball when nothing is being inspected, so the card never grows or shrinks.
- A sr-only aria-live region announces on pause, on every step or seek and on
reaching the last ball; it is emptied while the loop runs, so playback never
spams a screen reader.
Rendering & styling
- Ground geometry in metres, from the laws and never from pixel guesses: a 70 m
boundary measured from the CENTRE of the 22-yard (20.12 m) pitch, the 30-yard
(27.43 m) fielding circle as two semicircles about the middle stumps joined by
straight lines, a 10-foot-wide pitch with popping creases 4 feet in front of
each set of stumps. Because the origin is the striker's stumps and the ground
centre is half a pitch up-field, the straight boundary is ~80 m and the square
boundary ~69 m — the reason a straight six is the biggest hit on the ground.
- Export the maths as pure functions beside the component so a test can print
the same numbers the picture is made of: angle normalisation, rope distance
along a line (t = halfPitch·cos θ + √(R² − halfPitch²·sin²θ)), polar-to-screen,
region index for a hand, the rope arc between two lines, overs formatting,
ingest and region totals.
- Runs carry three channels, never colour alone: var(--chart-1..5) by run value,
a stroke width that grows with the runs, and an end mark whose SHAPE is the
category — disc for a run (radius grows 1 to 3), square for a four, hexagon
for a six. The legend repeats shape plus colour plus a count.
- Region totals ride the rope: an arc per sector whose stroke opacity is that
region's share of the runs so far (foreground ink, so it never competes with
the run scale), with the region's cricket name and its running total set
outside it, text-anchored by the sector's own direction so nothing overflows.
- The outfield is a bg-muted wash inside a muted-foreground rope, the fielding
circle and creases are border strokes, the pitch is background, and the
striker's stumps are a muted-foreground hub every line radiates from.
- The figure is role="img" with a one-sentence aria-label; its marks are pointer
affordances only (a transparent fat stroke over each line, starting a quarter
of the way out so the crowded hub is not one big target), and the accessible
equivalents are the ball rail plus an sr-only summary and a table of every
ball with overs, runs, region, distance, stroke and whether the replay has
reached it.
- Colour only ever comes from semantic tokens (border, muted, card, background,
popover, primary for the scrubber accent, destructive for the error branch,
var(--chart-*) for runs); ramps are opacity or color-mix on those tokens; no
hex, rgb() or oklch() anywhere, ground markings included. cn() merges
className and the root spreads remaining props with data-status.
Customization levers
- Ground size: BOUNDARY_R is the only number that decides how big the ground
reads — drop it to 62 m for a small city ground and the straight/square
asymmetry follows automatically. PITCH_LENGTH, INNER_R and CREASE are the
laws; leave them alone unless you are drawing a different format.
- Tempo: MS_PER_METRE plus the 420–1100 ms clamp set how an innings feels, and
SPEEDS is just an array — swap in 0.5x for coaching study, or drop the
radiogroup entirely for an embedded card.
- Encoding: RUN_TOKEN, RUN_WIDTH and RUN_MARK are three independent maps.
Re-key RUN_TOKEN to team colours, flatten RUN_WIDTH for a hairline wheel, or
give twos and threes their own shapes if your audience needs them apart.
- Regions: WAGON_REGIONS is an eight-name array driving sectors, labels, splits
and the mirror. Six or twelve sectors work as long as 360 divides evenly;
rename them for a broadcast partner's vocabulary.
- Blocks: the stat header, the boundary/side line, the readout row, the rail,
the legend and the ground footnote are independent siblings — drop any of
them for a compact card, or keep only the wheel plus the transport.
- Playhead: pass `value` and own time from the parent (jump to the first six,
drive it from a video player's currentTime); pass `defaultValue` for a card
that opens paused mid-innings; pass neither for the complete wheel.Concepts
- Wagon wheel — the cricket picture this chart is: every run off the bat drawn as a line from the striker's stumps to where the ball finished, so the shape of an innings (all through cover, or everything square on the leg side) is legible before a single number is read.
- The clock is over.ball, not seconds — cricket measures time in deliveries, so the transport reads "12.3 ov": twelve completed overs and three balls. The playhead is a ball index the component is handed, never a wall clock, which is what makes a paused card render identically on every machine and every reload.
- Parked complete, never autoplaying — the first render shows the whole wheel with its region totals, so a screenshot or an SSR paint is already the full innings; play from there means rewind and watch. Controlled and uncontrolled playback are the same code path, so a parent can own the playhead without losing the transport.
- Reduced motion changes the tween, not the feature — with motion off the ball in flight is never drawn: each shot appears whole when its dwell is up. Play, step, scrub, speed and the announcements all keep working, and no mark depends on an in-flight animation to become readable.
- The rope is not a circle around the batter — the boundary is 70 m from the centre of the pitch, so from the striker it is about 80 m straight and 69 m square. Clamping shots to that curve, rather than to a constant radius, is what stops a lofted straight drive being drawn shorter than a square cut of the same carry.
- Repairs are counted out loud — unreadable shots dropped, deliveries snapped back into a six-ball over, carries past the rope drawn at it, out-of-order feeds re-sorted: each count is printed under the chart, because a silent repair turns a scorecard into fiction.
Hockey Rink Shots
A four-state full-sheet hockey shot map in hand-rolled SVG — danger zones from the slot outward, marks that carry the team in colour and the outcome in shape, period-and-clock playback, and a shot-attempt differential rail under the ice.
Cricket Manhattan
A four-state cricket Manhattan chart in hand-rolled SVG — runs off every over as a skyline, wickets as drops under the axis, a cumulative worm on a second scale, two innings on one shared over axis, and a real transport that plays the match forward over by over.