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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-cricket-manhattan.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCricketManhattan" chart — a cricket
Manhattan in hand-rolled SVG (no chart library, no animation library), played
forward over by over, with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
innings: { team: string;
overs: { over: number; runs: number;
wickets?: number; note?: string }[];
target?: number }[];
maxOvers?: number; // 20 for a T20, 50 for an ODI
startedAt?: string } // ISO instant of the first ball
- Props = z.infer of the schema plus title?, variant?, over?, defaultOver?,
onOverChange?, onRetry?, emptyState? and className. No hand-written parallel
interface.
- An over is the atom: a Manhattan bar is "runs off the over", extras included,
so nothing is ever sliced finer than an over and no ball-by-ball detail is
invented to make an animation smoother.
- One or two innings share ONE over axis — that shared axis is what makes "who
was ahead at ten overs" answerable. A third innings and beyond is ignored and
counted.
- Ingest repairs instead of trusting, and prints every repair in a visible
line: non-finite over numbers or run figures drop the over; a repeated over
keeps the first reading; negative runs are pulled up to zero; wicket figures
outside 0–10, or a running total that would put an eleventh batter out, are
pulled into range; overs numbered past the scheduled allotment are drawn
anyway and the axis grows to fit; an unparseable startedAt is printed
verbatim and flagged. A chart that quietly renumbers overs is describing a
match nobody played.
Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
mirrors the ready silhouette — scoreline, gridded plot with pulsing bars,
legend, transport — so the card keeps its height), empty (the bare axis plus
copy, reused with a stated reason when a ready payload has nothing
drawable), error (message + "Try again" only when onRetry exists), ready.
- The playhead is COMPLETED OVERS and nothing else. It is derived, never a wall
clock: `over` / `defaultOver` / `onOverChange` are the controlled,
uncontrolled and reporting sides of the same number. Uncontrolled it is held
as a float so a bar can grow inside an over; controlled the parent owns whole
overs and the loop reports each one as it comes due. Paused at over N the
card renders byte-identically every time — that is what makes it
screenshot-stable and SSR-safe.
- NO autoplay, and no Date.now or Math.random in the first render path. The
first frame parks at the last over bowled, so a static preview shows the
finished match with both worms and every wicket. Play from there rewinds to
over 0.
- Transport is real UI, never a gesture-only scrubber: step back, play/pause
(aria-pressed), step forward, a labelled <input type="range"> with one step
per over and an aria-valuetext that speaks the clock, a 1×/2×/4× speed
radiogroup, and a "12.0 / 20.0 ov" readout in cricket's own notation —
overs.balls, with the ball slot honest at 0 because per-over data carries no
ball count. Step buttons are aria-disabled with a handler guard at each end,
NEVER natively disabled: a control that vanishes under the finger at the last
over is exactly the bug that avoids.
- One requestAnimationFrame loop advances the playhead by the frame's timestamp
delta × speed against a fixed ms-per-over, so a dropped frame skips forward
instead of slowing the match down. It is cancelled on pause, on every commit
and on unmount, and a visibilitychange listener pauses when the tab hides —
never leave a loop running off-screen. Keep the onChange callback in a ref so
an inline handler cannot restart the loop every frame and freeze it.
- prefers-reduced-motion (matchMedia, subscribed with cleanup — a
useSyncExternalStore fits): playback still works, but the rendered position
quantises to whole overs, so each bar appears complete instead of growing.
Gate every CSS transition with motion-safe/motion-reduce.
- A polite sr-only live region announces the transport, not the frames: on a
step, a seek, a pause and once at the finish, each time with the over and
both scorelines — never once per animation frame.
- Completed bars are a keyboard walk: the svg is role="listbox" with
aria-activedescendant (role="img" with a one-sentence label when nothing is
walkable yet), arrow keys / Home / End move a cursor over the bars in bowling
order across both innings, and only :focus-visible arms it so a stray click
does not pin the tooltip. Hover and cursor share one tooltip (over, runs,
wickets, score, note) anchored in viewBox percentages, with a transparent hit
rect no wider than the gap to the neighbouring bar.
Rendering & styling
- All plot maths lives in exported pure functions beside the component —
ingestCricketInnings, layoutCricketManhattan, cricketRunY, cricketTotalY,
cricketScoreAt, cricketWormPath, cricketOverNotation, cricketRunRate,
formatCricketScore — so a test can print the same numbers the picture is
made of.
- One fixed viewBox, so the card keeps its aspect at 6 overs or 50. Each over
owns a slot; the two innings split ~78% of it side by side; bar height is
runs off the over against a left axis rounded up to a multiple of six — one
gridline per six runs, the most one legal ball can yield.
- Exactly one horizontal rule per labelled runs tick. The cumulative axis on
the right gets ticks but NO gridlines: two scales cannot both own the rules
without one of them lying.
- Wickets hang under the axis as drops — a stem from the baseline into that
innings' lane, one mark per wicket that fell in the over, circles for the
first innings and squares for the second. A maiden (zero runs) is a hollow
diamond on the baseline, because a zero-height bar reads as "never bowled".
- Colour never carries meaning alone: the first innings is var(--chart-1)
solid, the second var(--chart-2) with a diagonal card-coloured hatch and a
dashed worm, every mark is repeated in the tooltip, the legend and an
sr-only table.
- Scorelines above the plot in the game's vocabulary: "176/7" (or "… all out"),
"12.0 ov", run rate to two decimals, and for a side with a target "Needs 62
off 48 balls · RRR 7.75" from the SCHEDULED allotment, not from the last over
the feed happens to carry.
- Semantic tokens only — bg-card, border, muted, muted-foreground, popover,
destructive, primary for the scrubber accent, var(--chart-1..2) for the
series. No hex, rgb() or oklch() anywhere. cn() merges className and the root
spreads remaining props with data-status.
- Long team names and notes elide with a title attribute or line-clamp; they
never widen the card.
Customization levers
- Tempo: OVER_MS sets how long an over takes at 1×; SPEEDS is a plain array —
add 0.5× for over-by-over analysis, or 8× for a fifty-over innings.
- Variant: `manhattan` (bars lead, thin worm), `worm` (worm leads, bars recede
to a silhouette), `bars` (no worm, no cumulative axis) — one prop, three
readings of the same data.
- Geometry: VIEW_W/VIEW_H, PAD_L/PAD_R, BASE_Y, LANE_Y/LANE_STEP and
GROUP_FILL control aspect, axis room, the wicket lanes and how fat the bars
are; drop the lanes entirely for a bars-only mini card.
- Scales: the runs axis steps in sixes (twelves for an avalanche) and the
cumulative axis in 25/50/100 — re-key niceCeil for Test-match totals.
- Tokens: INNINGS_TOKEN is a two-entry array — swap in the sides' own chart
tokens; the hatch and the dashed worm keep the pair legible without hue.
- Blocks: the scoreline row, legend, footnote, repair line and sr-only table
are independent siblings — drop any of them for an embedded mini chart.
- Domain: the same engine draws any per-period skyline with a cumulative
overlay — innings become periods, wickets become any discrete event — but
relabel every string if you move it off a cricket field.Concepts
- The playhead is completed overs, not a clock — one integer (a float only while a bar is growing) drives bars, drops, worm, scorelines, scrubber and announcements. Because it is injected rather than read from
Date.now, a card paused at over 11 paints the same pixels on the server, in the browser and in a screenshot. - Controlled and uncontrolled are the same playback —
over/defaultOver/onOverChangeare one value in three roles: hand the prop in and the parent owns the match, leave it out and the transport does, and either way playback reports every over it crosses. - Manhattan and worm are two readings of one number — the bar is what happened in the over, the worm is what it added up to. They share the plot but not the axis: only the runs scale owns the gridlines, because two scales that both claim the rules make every crossing a coincidence.
- Wickets are a drop, a shape and a sentence — a wicket hangs below the axis in the over it fell in, circle for the first innings and square for the second, and is repeated in the tooltip, the summary and the table. Nothing here is legible only in colour.
- Reduced motion changes the tween, not the feature — with motion off the same clock runs but each over appears complete instead of growing, and every transition carries a
motion-reducevariant. Playback, stepping, scrubbing and announcements all keep working. - Repairs are counted out loud — dropped overs, repeated over numbers, negative runs, an eleventh wicket, overs past the scheduled allotment and an unreadable start time each print their count under the chart. Silently fixing a scorecard turns a chart into a story about a match that was never played.
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.
Chat Sidebar
A conversation-history sidebar — calendar-day groups cut in the display time zone, in-place rename with rollback, delete confirmed by title, pinning, local search with its own no-match state, and real loading/empty/error branches.