Charts

Net Clearance

A four-state side elevation of shot quality — height over the net cord against landing depth, with the coached window banded, netted balls stacked on a to-scale net and median crosshairs.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartNetClearanceData, ChartNetClearanceShot } from "./chart-net-clearance.contract"

export interface ChartNetClearanceProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartNetClearanceData {
  /** Card heading — what the chart is. Whose shots they are comes from `meta`. */
  title?: string
  /** Plot height in pixels, excluding the axis labels (clamped 170–420, default 240). */
  height?: number

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartNetClearance" chart in hand-rolled
SVG (no chart library) with zod: a side elevation of one player's shots.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    shots: { clearanceCm: number; depthMetres: number;
             wing?: "forehand" | "backhand"; netted?: boolean }[];
    targetBand: { fromCm: number; toCm: number };   // toCm above fromCm, both >= 0
    meta: { player: string; context?: string; shotLabel?: string } }.
- Component props = z.infer of the schema, plus title, height (plot pixels,
  clamped 170-420, default 240), onRetry?, emptyState? and the native div
  props, spread on the root. forwardRef. No hand-written parallel interface.
- clearanceCm is signed height above the top of the net cord as the ball
  crossed the net plane; negative means it struck the net that far below the
  cord. depthMetres is landing depth measured from the net; a netted ball never
  landed, so its depth is ignored.

Court geometry (from the rules, not from pixels)
- Court length 23.77 m, so the receiving half is 11.885 m deep: that is the
  baseline reference line. The service line sits 6.40 m from the net.
- Net height 0.914 m at the centre strap and 1.07 m at the posts; taking the
  cord as straight between them, it is about 1.03 m over a singles sideline
  (8.23 m singles width, posts 5.485 m from centre). The elevation can only
  draw one cord, so it draws the centre strap and says so on the card.
- The y domain therefore runs from -91.4 cm (the court surface at the net, the
  foot of the frame) up past the tallest mark; the x domain from 0 (the net) to
  at least the baseline plus a margin, extended when a ball landed long.
- Depth zones are read off those numbers: 0-6.40 m inside the service box,
  6.40-11.885 m past the service line, over 11.885 m long.

Behavior
- Four first-class branches in one bg-card panel: loading (skeleton with the
  same silhouette: two stat figures, a framed plot, a net strip), empty (a
  small net drawing plus copy), error (message plus a "Try again" button only
  when onRetry exists), ready.
- One classification rule: a shot is netted when clearanceCm is below 0. The
  optional netted flag is only a cross-check, so a mark's colour, the side of
  the cord it sits on and the "into the net" readout can never disagree; count
  the flags that contradict the measurement and report them under the plot.
- Drop rows that cannot be placed, by reason, and report the counts: a
  non-finite number, a clearance below the court surface, a ball that cleared
  the net but landed behind it. Never drop silently.
- Netted marks have no landing depth, so they stack on the net strip in a few
  lanes, positioned in y by their true clearance. Say so on the card, because
  their x carries no measurement.
- Medians are taken over the shots that crossed only, and drawn as a crosshair:
  a horizontal line at the median clearance and a vertical one at the median
  depth, both labelled in place.
- Readouts: share inside the target window and share into the net as two large
  figures (denominator is every drawable shot, netted included), plus median
  clearance, median depth and the share that landed in the deep band, named
  with its range (6.40-11.885 m) so it cannot be read as also counting the
  balls that went long.
- A wing filter (radiogroup, roving tabIndex, arrow keys move selection and
  focus together) re-runs every readout and median; it only appears when the
  payload actually carries wings, and unlabelled shots drop out while a single
  wing is selected. Derive the active wing so a reload without backhands cannot
  leave the card filtered to a wing that no longer exists.
- The plot is a listbox: one option per mark, delegated pointer handler via a
  data attribute, arrow keys walking the marks netted-first then by depth,
  Home/End to the ends, aria-activedescendant on the focused cursor. Only
  keyboard focus arms the cursor (:focus-visible), so a mouse click does not
  pin a readout with no ring to explain it. An echo line under the plot repeats
  the active mark, and an sr-only table breaks the shots into the four depth
  zones with counts, shares, median clearance and window hits. The four zone
  shares are apportioned by largest remainder so they add to exactly 100 under
  every wing filter, and the tiles print those same apportioned figures.
- Measure the plot with a ResizeObserver, disconnected on unmount; drop the
  in-place labels under a narrow plot instead of letting them collide.

Rendering & styling
- Semantic tokens only. Marks: forehand var(--chart-1) disc, backhand
  var(--chart-2) square, unlabelled var(--chart-3) diamond, netted a
  var(--destructive) cross — shape carries the wing so the card survives
  greyscale. Every filled mark gets a stroke-card hairline so a pile still
  reads as a pile.
- Target band fill-primary/10 dark:fill-primary/20 with stroke-primary/40
  dashed edges: a tenth of primary lands within a few sRGB steps of a dark card
  and would vanish, so the dark wash is doubled.
- Court paint: stroke-border gridlines, stroke-muted-foreground dotted
  landmarks, fill-muted net strip, a stroke-foreground/70 tape on top of it and
  a stroke-foreground/35 cord line across the plot. Every piece of text — the
  ticks, the landmark names, the median labels — is painted after the marks and
  gets paintOrder="stroke" with stroke var(--card), so a label is never drawn
  under a dot.
- cn() merges className, numbers are tabular-nums, animations carry
  motion-reduce:animate-none, buttons a focus-visible ring.

Customization levers
- Plot density: `height` sets the plot box; the y tick ladder follows it (about
  one tick per 30px), so a taller card gets a finer axis without new code.
- Sub-blocks: the wing filter, the legend, the echo line and the caption are
  independent — drop any of them for a compact tile; keep the sr-only table.
- The window: `targetBand` is a coaching cue, not a percentile. Swap it per
  player (flat drivers 40-90 cm, heavy topspin 90-180 cm), or feed a second
  band by rendering two instances side by side.
- Palette: wing colours come from one wingInk function over var(--chart-1..3);
  re-map it to fixed brand tokens per stroke, or collapse to a single token and
  let shape alone carry the wing.
- Zones: SERVICE_LINE_M and HALF_COURT_M are the only depth cuts; replace them
  with a coach's own targets (for example a 3 m "deep third" band) and the
  landmark lines, the caption and the sr-only table all follow.
- Sport swap: the same skeleton reads any "margin over an obstacle against
  distance" story — volleyball over the net, golf carry over a hazard — by
  renaming the axis titles and the two constants.

Concepts

  • One value, one mark — the y coordinate, the "into the net" verdict and the mark's colour all come from clearanceCm alone, so a destructive cross can never appear above the cord and a mark counted inside the coached window can never be drawn outside the band. The optional netted flag is a cross-check whose disagreements are printed, never a second source of truth.
  • Geometry before pixels — the reference lines are the court: the service line 6.40 m from the net and the baseline at half of 23.77 m, with the foot of the frame exactly 91.4 cm under the cord because that is how tall the net is at the centre strap. Nothing on the axis is a guessed offset.
  • Absence has a place — a netted ball has no landing depth, so it is stacked on the to-scale net at the height it struck rather than dropped or parked at zero depth, and the card says its horizontal position carries no measurement.
  • Filter that owns the whole card — the wing radiogroup re-runs the medians, the crosshair, both headline shares and the table, so a forehand-only reading is never a filtered picture beside unfiltered numbers. The active wing is derived, so a payload that arrives without backhands cannot strand the card on an empty filter.
  • Two ways to read one mark — pointing and the arrow keys drive the same cursor: the plot is a listbox, each mark an option carrying its own sentence, with an echo line under the plot for sighted readers and aria-activedescendant for screen readers. Only keyboard focus arms the cursor, so a stray click never pins a readout.
  • Coached window, not a percentile — the band is what the player was told to hit through, so it stays put when the data improves; that is what makes "43 percent inside the window" a target to chase rather than a restatement of the median.

On This Page