Charts

Net Points

A four-state tennis net-approach composition card — one horizontal 100% stacked bar per way of coming in (won at net / passed / lobbed / error, largest-remainder shares), per-group n and success rate, a totals bar, segment tooltips, and a muted hairline for ways in that were never used.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartNetPointsData, ChartNetPointsGroup } from "./chart-net-points.contract"

export interface ChartNetPointsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartNetPointsData {
  /** Card heading — what the chart is. Whose net game it is comes from `meta`. */
  title?: string
  /** Omit to hide the retry affordance in the error branch entirely. */
  onRetry?: () => void

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartNetPoints" chart — a tennis
net-approach composition card in plain HTML/CSS bars (no chart library), with
zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    groups: { label: string;
              outcomes: { wonAtNet: int >= 0; passed: int >= 0;
                          lobbed: int >= 0; error: int >= 0 } }[];
    meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and
  className. No hand-written parallel interface.
- Already aggregated: one row per way of coming in. wonAtNet is the only
  winning outcome — success = wonAtNet / (all four summed) — and a group whose
  outcomes are all zero is still a row: "never came in behind the backhand" is
  a finding, not a missing record.

Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
  keeps the ready silhouette — headline, labelled bars, legend), empty
  (outlined tracks + copy, reused when a ready payload holds only zeros, with
  the reason printed), error (message + "Try again" only when onRetry exists),
  ready.
- Model building is a pure exported function: counts repaired to whole
  non-negative numbers (repairs reported on the card), per-group n, and shares
  apportioned by largest remainder so every bar's four whole percents add to
  exactly 100 — rounding each share alone prints 99 or 101 for some inputs and
  reads as a data error. One share set sizes the segments, fills the tooltip
  and populates the table.
- One 100% stacked bar per group in a fixed segment order — won at net,
  passed, lobbed, error — plus a totals bar labelled "All approaches",
  separated by a border. Per-group header prints n and the won-at-net share as
  the success rate. Groups with n = 0 render a muted hairline and the words
  "no approaches" instead of a bar.
- Hover a segment → tooltip with the outcome name, count of n and share,
  anchored over the segment's centre and clamped inside the card. Each bar is
  a roving-tabindex listbox: one tab stop, ArrowLeft/ArrowRight/Home/End walk
  the rendered segments, every segment speaks a full sentence, focus-visible
  draws an inset ring. The tooltip is aria-hidden; a sr-only role="status"
  region mirrors the pointer readout.
- A count too small for a whole percent prints "under 1%", never "0%"; a
  segment whose share apportions to 0 keeps a 2px sliver so the point is not
  visually lost.

Rendering & styling
- Colour from tokens only, one per outcome everywhere it appears (segment,
  legend chip, tooltip swatch): wonAtNet var(--chart-2), passed var(--chart-5),
  lobbed var(--chart-4), error var(--chart-3). Neighbouring segments are
  separated by a 1px border-card seam so two mid-lightness tokens stay two
  segments in the dark theme. All text wears text tokens, never a series
  colour.
- Bars are plain divs: a flex track h-4 rounded-sm overflow-hidden, segment
  widths from the apportioned percents. Panel rounded-xl border bg-card p-4,
  numbers tabular-nums, cn() merges className, the root spreads remaining
  props and carries data-status. Skeleton pulses honour prefers-reduced-motion
  via motion-reduce:animate-none. A sr-only table repeats every count and
  share.

Customization levers
- Track height: the h-4 on the bar and the skeleton is the one density knob;
  h-3 reads as a sparkline row, h-6 as a dashboard block.
- Outcomes: the four OUTCOME_* arrays (keys, labels, phrases, inks) are the
  single place to relabel, retint or extend — a fifth outcome (e.g. "forced
  weak reply") is one entry in each array plus a contract field.
- Rows: drop the totals bar by slicing it off allRows; reorder groups upstream
  — the chart draws them as sent.
- Callouts: the header's success figure is the wonAtNet share; swap it for
  points-per-approach or add a best/worst group verdict from the model.
- Domain: nothing but the labels is tennis — the same contract reads as
  "how did each pick-and-roll finish" or "how did each sales channel convert"
  by renaming groups and outcomes.

Concepts

  • Composition, not volume — every bar is 100% of its own group, so a group with 8 approaches and one with 30 are directly comparable as proportions; the n printed beside each label is what carries the volume, and the footnote says so instead of letting equal-length bars imply equal evidence.
  • Fixed segment order is part of the encoding — won at net always leftmost, the approacher's own error always rightmost, so a bar reads "what I earned → what the opponent did → what I gave away" by position alone, before colour, and still reads that way in greyscale.
  • Largest-remainder apportionment — the four shares of one bar are rounded together, leftover points going to the largest fractional parts: 14/5/3/4 of 26 always prints a set that sums to exactly 100, and the same set sizes the segments, fills the tooltip and populates the table, so no two surfaces can disagree.
  • The zero row is drawn — a way of coming in that was never used renders as a muted hairline reading "no approaches" rather than vanishing: the coach's finding may be precisely that the player never chips and charges.
  • Success is one of the shares — the per-group success rate is the bar's own won-at-net share, not an independently rounded ratio, so the number beside the bar can never disagree with the segment it summarises.
  • Every pointer readout has a keyboard twin — each bar is one roving-tabindex listbox: arrows walk the segments, each speaks its outcome, count and share, the visual tooltip stays aria-hidden, and a status region voices what the pointer touches.

On This Page