Charts

Shot Map

A four-state basketball half-court shot chart in hand-rolled SVG, with made/missed dots, a per-shot hover tooltip and an FG% header.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartShotMapData, ChartShotMapShot } from "./chart-shot-map.contract"

export interface ChartShotMapProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartShotMapData {
  /** Omit to hide the retry affordance in the error branch entirely. */
  onRetry?: () => void
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-shot-map.json

Prompt

Build a React + TypeScript + Tailwind "ChartShotMap" basketball half-court
shot chart in hand-rolled SVG (no chart library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    player?: string; shots: { x: 0–50; y: 0–47; made: boolean;
    points?: 2 | 3; label?: string }[] }.
- Coordinates are half-court feet: x across the baseline from the left
  sideline, y from the baseline out toward half court, hoop centre at
  (25, 5.25). Shots arrive in game order and stay in it.
- Component props = z.infer of the schema, plus onRetry?: () => void and
  className; forwardRef the root div and spread remaining props on it.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: a skeleton that mirrors the ready layout (stat bar, court
    block with a faint paint/arc silhouette, legend bar), aria-hidden,
    with a sr-only role="status" "Loading shots".
  - empty: the court itself drawn faint with no dots + zero-data copy.
  - error: message + a "Try again" button rendered only when onRetry exists.
  - ready: FG% header + court + dots + tooltip + legend + sr-only table.
- The FG line (made/attempts, FG%) derives from the same shots array the
  dots are drawn from; add a 3PT line when any shot declares points === 3,
  and a PTS total only when every made shot declares its value — a total
  from a half-annotated feed would lie.
- One active index drives everything: pointer enter/leave on a dot, or
  focus plus ArrowLeft/ArrowRight/Home/End walking the shots in game order.
  The svg is a single tab stop (role="listbox" + aria-activedescendant);
  each shot is role="option" whose aria-label is a full sentence (result,
  value, distance from the hoop, note).
- The active shot gets a foreground halo ring and the others dim; an HTML
  tooltip (result · distance, plus the optional label) appears anchored to
  the dot, flips below it near the baseline and slides off-centre near the
  sidelines so the card never clips it.
- Guard rails: non-finite coordinates are dropped and counted out loud in a
  status line (and excluded from every figure); out-of-range ones are
  clamped onto the floor. No timers, observers or rAF — nothing to clean up.

Rendering & styling
- Geometry: one linear map (feet × 10 + padding) projects half-court feet
  into viewBox units (0 0 520 490); the svg is w-full h-auto so it scales
  with the card, and the tooltip is positioned in percentages of the same
  box so it stays glued to its dot at every width.
- Court in NBA feet: paint 16×19 with a muted wash, free-throw circle r=6
  (far half solid, key half dashed), restricted arc r=4, backboard at y=4,
  rim r=0.75, 3pt arc r=23.75 meeting corner lines 3 ft off each sideline,
  half-court circle r=6 — all stroke-border, with backboard and rim in
  stroke-muted-foreground so the target reads.
- Dots: made = filled var(--chart-1) with a hairline card stroke; missed =
  card-filled circle stroked muted-foreground (fill vs hollow, never a
  red/green pair). A transparent hit circle (r 14) sits over each dot so a
  7-unit mark is not the touch target.
- Tokens only (bg-card, border, bg-muted, text-muted-foreground, bg-popover,
  var(--chart-1)); cn() merges className; the skeleton pulse and the dim
  transition respect prefers-reduced-motion (motion-reduce:animate-none /
  transition-none); a sr-only table repeats every shot as text.

Customization levers
- Court dimensions: the constants are NBA feet — switch to FIBA by setting
  the arc to 22.15, the corner offset to 21.65 and the paint width to
  16.08; every line and dot re-projects through the same map.
- Result encoding: made/missed is fill-vs-hollow on one hue; re-map made to
  another chart token, or split twos vs threes across two tokens, at the
  single fill callsite.
- Dot size and density: DOT_R 7 suits ~20–60 shots; for a season's worth,
  shrink it or aggregate upstream into a hexbin — this chart is for a
  readable number of attempts.
- Header stats: the FG line is derived, not passed — add eFG% or per-zone
  splits from the same array without touching the drawing.
- Tooltip content: one small JSX block — add shot clock, assist or video
  links; keep it pointer-events-none so it never steals the hover.

Concepts

  • Court as coordinate system — shots arrive in half-court feet, and one linear map (feet × 10 + padding) projects every court line and every dot; swap the constants for FIBA dimensions and the whole picture re-projects itself.
  • Fill-vs-hollow result encoding — made and missed differ by fill on a single hue, not by a red/green pair, so the split survives greyscale and colour-blindness; the legend carries the counts.
  • Percent-anchored tooltip — the tooltip is HTML positioned in percentages of the same box the viewBox scales in, so it stays glued to its dot at every card width, and it flips below the dot near the baseline instead of clipping.
  • One array, two consumers — the dots and the FG% header both derive from the same shots array, so the headline can never disagree with the picture; the PTS total only appears when every made shot declares its value.
  • Keyboard walk in game order — the svg is one tab stop (listbox + aria-activedescendant); arrow keys step through the attempts in the order they happened, each announced as a full sentence with result, distance and note.
  • Honest drops — attempts with no usable location are counted out loud and excluded from every figure; out-of-range ones are clamped onto the floor, never silently discarded.

On This Page