Charts

Pressure Points

A four-state pressure-situation profile — one horizontal lollipop per situation stemming from the player's overall win-rate baseline, above in chart-2 and below in chart-5, "62% (n=34)" labels, and hollow dots for samples under a contract-supplied floor.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartPressurePointsData,
  ChartPressurePointsSituation,
  ChartPressurePointsTally,
} from "./chart-pressure-points.contract"

export interface ChartPressurePointsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartPressurePointsData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartPressurePoints" card — a tennis
pressure-situation profile drawn as horizontal lollipops against a baseline
rule, in hand-rolled CSS (no chart library) with zod for the contract.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    meta: { player, context? };
    overall: { points >= 0, won >= 0 };
    situations: { id, label, points >= 0, won >= 0 }[];
    minSample?: int 1-500 }
  with refines that won <= points on every tally and that situation ids are
  unique. Component props = z.infer of that schema plus title?, rowHeight?
  (22-48, default 30), onRetry?, emptyState? and className. No parallel
  hand-written interface.
- Semantics, stated in the schema's JSDoc: `overall` is EVERY point the
  player contested and is the baseline; situations may overlap (a break
  point inside a tiebreak lives in two rows) and are never summed. minSample
  (default 10) is the reliability floor, not a filter.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: a skeleton with the ready silhouette (headline, then rows of
    label + stem + value), aria-hidden, plus one sr-only role=status.
  - empty: a small baseline-and-two-lollipops glyph and copy explaining what
    will appear.
  - error: a destructive message and a "Try again" button rendered only when
    onRetry is supplied.
  - ready: baseline headline, legend, lollipop rows, percent axis, footnote.
  - A ready payload with no situations falls back to the zero state; one
    whose OVERALL tally holds no points also refuses to draw and says there
    is no baseline to measure against — stems with nothing to grow from.
- Profile maths live in one exported pure function returning rows (points,
  won, rate 0-100 or null, printed delta, side, reliable), the baseline, the
  above/below/level/small/unmeasured counts, the resolved floor and a repair
  count.
- Above/below is decided by CROSS-MULTIPLICATION on the exact tallies
  (won x overall.points vs overall.won x points), never on rounded percents:
  52.44% against a 52.34% baseline is above, even though both print as 52%.
- A situation with points === 0 is unmeasured: no stem, no dot, "— (n=0)",
  excluded from every count. A situation with points under the floor is
  drawn where it landed but HOLLOW, tagged "small sample", and kept out of
  the above/below counts — four set points split 3-1 is an anecdote.
- Counts that arrive negative, fractional, unreadable or winning more than
  they played are repaired and the repair is REPORTED, never swallowed.
- Interaction: rows form a roving-tabindex list — one tab stop, arrows/Home/
  End between rows — and hover or focus shows a floating tooltip (points,
  won, rate, signed delta vs baseline). The hit target is the whole row, far
  wider than the stem and dot. Pointer-leave hands the tooltip back to the
  focused row.

Rendering & styling
- Percent axis 0-100 with light var(--border) gridlines every 25 from two
  CSS gradients (zero extra DOM); printed labels only 0 / 50 / 100%.
- The baseline rule is a 1px vertical line at the overall win rate, drawn
  per-row across the row's full height so neighbouring rows read as one
  continuous rule, in color-mix(in oklab, var(--foreground) 55%, transparent)
  — a border token is too faint on a dark card for the line everything is
  measured from.
- Per row: a 3px stem with rounded ends from the baseline to the situation's
  rate, and a 10px dot AT the rate. Stems and dots above the baseline take
  var(--chart-2), below var(--chart-5) — two tokens that differ in hue and
  lightness — and a level row takes solid var(--muted-foreground). Hollow
  (low-sample) dots are bg-card with a 2px border in the same side token, so
  reliability is a shape channel, never a colour.
- Value labels "62% (n=34)" in text tokens (foreground rate, muted n),
  tabular-nums, fixed-width column; low-sample and unmeasured rates demote
  to muted. Text never wears a series colour.
- Tooltip: fixed-width bg-popover card floated above the track, clamped
  inside it with CSS clamp(); aria-hidden because each row's aria-label
  already says the same sentence.
- Accessibility: the list is role=group named by a full summary; each row is
  role=img with a one-sentence label including the small-sample caveat; an
  sr-only table repeats every number; focus-visible ring on rows; hover
  transitions carry motion-reduce:transition-none and skeleton pulses
  motion-reduce:animate-none.
- Semantic tokens only: bg-card, border, text-muted-foreground,
  text-destructive, bg-popover, var(--chart-2), var(--chart-5),
  var(--muted-foreground), color-mix on var(--foreground) for the baseline.
  cn() merges className; remaining props spread on the root.

Customization levers
- Situation vocabulary: rows are whatever the feed sends, in its order —
  swap tennis situations for serve types, opponents, or months of a season;
  the baseline stays "all points" whatever the rows are.
- Sample floor: minSample gates only the solid/hollow split and the counts,
  never the drawing; set 1 to trust everything, raise it for season data.
- Axis: the 0-100 domain is fixed on purpose (win rates live there); if
  every rate sits mid-range, zoom by changing the gridline pitch, not by
  clipping the domain — a stem needs the baseline in frame.
- Palette: above/below take var(--chart-2)/var(--chart-5) — remap to any two
  tokens that differ in lightness as well as hue; keep level on a solid
  neutral token.
- Density: rowHeight (22-48) sets the card's height; drop the value column
  and keep the tooltip for a compact variant, or drop the "small sample"
  tag line when every n is large.

Concepts

  • Baseline as the zero — the chart's reference is the player's own overall win rate, not 50%: a 46% tiebreak record is bad for a player who wins 52% of everything, and fine for one who wins 44%. Every stem measures the gap from "their normal", which is the question a coach is actually asking.
  • Stem length is the finding — the dot marks the rate, but the stem from the baseline IS the delta, drawn as a distance. A long stem left on "Break point" reads as "collapses when receiving with the game on offer" before any number is read.
  • Exact side, printed rounding — above/below is decided by cross-multiplying the raw tallies, never by comparing rounded percentages, so a stem visibly right of the line can never carry a "level" colour because both ends printed as the same integer.
  • Sample floor as a shape — situations under the floor draw hollow with a muted "small sample" tag: position is preserved (the data is not censored), but the read is visibly downgraded, and the headline counts exclude it. Reliability rides the shape channel, so it survives greyscale.
  • Overlapping situations, never summed — a point can be a break point inside a tiebreak; rows legitimately overlap and are never asked to add up to the overall tally, which is why there is no stacked or 100% variant of this chart.
  • No baseline, no chart — an overall tally with zero points makes every comparison undefined, so the ready branch falls back to the zero state and names the missing ingredient instead of drawing stems from an arbitrary line.

On This Page