Charts

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.

Preview in your theme

Loading preview…

"use client"

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

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-hockey-rink-shots.json

Prompt

Build a React + TypeScript + Tailwind "ChartHockeyRinkShots" chart — a
full-sheet ice-hockey shot map with period-and-clock playback, 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";
    title: string;
    subject?: string;
    teams: { home: string; away: string };
    attempts: { team: "home" | "away";
                period: number;          // 1-3 regulation, 4+ overtime
                clock: string;           // REMAINING in the period, "MM:SS"
                x: number; y: number;    // NHL feet from centre ice
                result: "goal" | "saved" | "missed" | "blocked";
                strength?: "even" | "powerplay" | "shorthanded" | "empty-net";
                shooter?: string; detail?: string }[] }.
- Props = z.infer of the schema plus the transport triple
  (frame / defaultFrame / onFrameChange and playing / defaultPlaying /
  onPlayingChange), accumulate ("game" | "period"), count
  ("corsi" | "fenwick" | "on-goal"), frameMs, onZoneSelect, onRetry and
  className. No hand-written parallel interface.
- An attempt does NOT carry its own danger zone: on a sheet of ice the danger
  of a shot is a property of the spot it was taken from, so the geometry
  decides it. A feed shipping its own label could disagree with the picture.
- Coordinates arrive normalised so the home side always attacks the right-hand
  net; teams change ends every period and a feed that forgets to flip the
  second draws a team shooting at its own goaltender. Say so in the JSDoc.

Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton in the
  ready silhouette — figures, sheet, chips, transport, rail — so the card keeps
  its height), empty (the sheet with its two slots, waiting; reused whenever a
  ready payload has nothing drawable), error (message plus "Try again" only
  when onRetry exists), ready.
- The playhead is an injected frame index over the attempts sorted into game
  order, NEVER a wall clock: frame N means the first N attempts have happened.
  Controlled (frame + onFrameChange) and uncontrolled (defaultFrame) both work;
  the uncontrolled default is the final horn, so a screenshot, an SSR paint or
  a docs thumbnail is already the whole game and nothing autoplays. A paused
  card at frame N renders byte-identical every time.
- Sorting is by period, then descending game clock, then payload order, so the
  playhead walks the game forward whatever order the feed arrived in.
- Playback is ONE window.setTimeout owned by one effect, cleared on every
  re-render — pause, scrub, speed change, unmount alike — plus a
  visibilitychange listener that pauses when the tab hides, because a throttled
  timer means coming back to a chart that has drifted half a period.
- Transport is real UI, never a gesture-only scrubber: buttons for back to the
  opening faceoff / previous period / step back / play-pause-replay / step
  forward / next period / final horn, a 0.5x-1x-2x speed radiogroup, and a
  role="slider" rail. Never native `disabled` on a focusable control — use
  aria-disabled plus a handler guard, so a button at the last frame does not
  vanish under the finger and drop focus onto body.
- The rail is the timeline AND the shot-attempt differential: a step area of
  the running home-minus-away count, above the even line when the home side
  leads it, the played part solid and the rest faint. Pointer drag with pointer
  capture (and a buttons === 0 bail-out for a swallowed pointerup), plus
  arrows / Page Up / Page Down (a period) / Home / End.
- `count` decides which attempts are drawn and counted — all attempts (Corsi),
  unblocked (Fenwick) or shots on goal. Attempts a mode leaves out stay on the
  timeline, so the clock still walks past them, and their number is printed in
  a note. `accumulate="period"` resets the window at each period start; the
  rail always runs from the opening faceoff, and the readout says so.
- Ingest repairs instead of trusting, and counts every repair out loud in a
  visible note: a non-finite coordinate cannot be placed and is dropped, an
  off-ice coordinate is pulled onto the nearest legal spot RADIALLY inside the
  28 ft corner arcs (a square clamp would invent a corner the rink does not
  have), and an unreadable clock keeps its attempt at the end of its own period
  and prints as --:--.
- Accessibility: the sheet is one role="img" figure with a sentence-long
  aria-label; the three zone chips below are a role="toolbar" with a roving
  tabindex (arrows / Home / End move focus, Enter or click fires onZoneSelect),
  and hovering the ice or focusing a chip rings the same zone at BOTH ends. A
  polite sr-only live region announces the clock, the running counts and the
  last attempt on every frame change; an sr-only table repeats the zone
  arithmetic. Every long label elides with a title rather than overflowing.

Rendering & styling
- Rink geometry in feet, from the NHL sheet and never from pixel guesses:
  200 by 85 with 28 ft corner arcs, goal lines 11 ft off the end boards
  (x = ±89), blue lines at ±25, faceoff circles of radius 15 with end-zone dots
  20 ft up ice and 22 ft off centre, neutral-zone dots 5 ft off each blue line,
  a crease of 6 ft radius cut off 4 ft either side of the net, and nets 6 ft
  wide by 40 inches deep. One exported feet-to-user-units map that every line,
  zone and mark goes through.
- Danger zones are pure exported geometry, so a test can print the same numbers
  the picture is made of: high danger is the home plate — the full 22 ft lane
  from the goal line to the faceoff dots, closing linearly to a point at the
  top of the circles; medium is the rest of the box the two circles sit in;
  low is everything else in that half — the point, the corners, below the goal
  line. They are exhaustive, so the three counts always add up.
- Zone regions are traced as two subpaths filled even-odd, so the hole is
  exactly the shape drawn on top of it and the pointer target is exactly the
  painted region. The whole zone group is clipped to the boards path, so no
  fill can square off a corner. Hit-test order runs low, medium, high — outward
  in — while reading order runs the other way.
- Never colour alone: the team is the colour (var(--chart-1) home,
  var(--chart-2) away) and the OUTCOME is the shape — filled disc in a ring for
  a goal, filled disc saved, hollow disc missed, cross blocked, with the same
  four glyphs drawn at chip size in the legend. Danger zones stay neutral
  (muted-foreground wash plus a diagonal hatch on the slot) so the colour
  channel is never asked to carry two things.
- Zone names are drawn on the ice, measured with an exported labelFits helper
  and elided to a shorter form rather than overflowing; text carries a --card
  paint-order stroke so it stays legible over marks and hatch alike.
- Every tween is gated with motion-safe: (the travelling ring on the newest
  attempt, the rail thumb) and every skeleton pulse with motion-reduce:none —
  playback still works with motion off, marks simply appear and disappear at
  each step.
- Colour only ever comes from semantic tokens — border, muted, muted-foreground,
  card, primary, ring, foreground, var(--chart-1..2) — including every rink
  marking; no hex, rgb() or oklch() anywhere. cn() merges className and the root
  spreads remaining props with data-status.

Customization levers
- Pace: frameMs (clamped 80-5000) and the SPEEDS array set how a game feels;
  drop in 0.25x for a video session that steps shift by shift.
- Clock model: periodSeconds returns 20 minutes for periods 1-3 and 5 for
  overtime — return 20 for period 4 as well and the same card replays playoff
  overtimes; periodLabel is where "P1 / OT / 2OT" is spelled.
- Danger definition: DOT_UP_ICE, DOT_LATERAL, SLOT_TOP and CIRCLE_OUTER are the
  only numbers behind the zones. Widen the plate for a junior sheet, or replace
  slotHalfWidth with an expected-goals threshold to redraw the same chart
  around xG bands.
- Counting: countsAttempt is the one place Corsi, Fenwick and shots on goal are
  defined — add a scoring-chance filter there and the header, the chips, the
  rail and the notes all follow.
- Tokens: HOME_TOKEN and AWAY_TOKEN are two constants — re-key them to the
  club colours the host app already owns.
- Blocks: the figures header, the repair notes, the readout line, the chip row,
  the legend, the geometry footnote and the rail are independent siblings —
  drop any of them for an embedded mini map, or replace the header with your
  own scoreline.
- Sheet: swap the rink-lines group and the zone paths to re-surface the same
  playback engine for ringette, sledge hockey or a smaller international sheet;
  the feet constants are the only NHL-specific thing in it.

Concepts

  • The playhead is derived, never a clock — the only playback state is a frame index over the sorted attempts, and frame / defaultFrame / onFrameChange make it controllable from outside. Paused at frame N the card renders byte-identical every time, which is what makes a replay screenshot-stable and safe to render on the server.
  • Danger is geometry, not a label — the home plate is the 22 ft lane from the goal line to the faceoff dots, closing to a point at the top of the circles; medium danger is the rest of the circles' box; low is the point, the corners and below the goal line. Because the zone is computed from the spot, the counts printed for a zone can never disagree with the marks drawn inside it.
  • Shape carries the outcome, colour carries the team — a goal is a filled disc in a ring, a save a filled disc, a miss a hollow disc, a block a cross. The two channels stay independent, so a greyscale print or a colour-blind reader loses neither, and the danger zones can stay neutral ink.
  • The rail is two instruments in one — dragging it seeks the game, and its shape is the running shot-attempt differential, above the even line when the home side leads it. Territory and time are the same axis in hockey, so they belong on the same control.
  • Corsi, Fenwick and shots on goal are one switchcount decides what is drawn and counted, and the attempts it drops stay on the timeline rather than vanishing: the clock still walks past a blocked shot, and a note says how many the mode left out.
  • Repairs are counted out loud — attempts logged past the boards are pulled back radially inside the 28 ft corner arcs, unplaceable coordinates are dropped, unreadable clocks keep their place at the end of their period, and every one of those counts is printed under the chart. A shot map that quietly moves the puck is describing a game nobody played.

On This Page