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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-pressure-points.jsonPrompt
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.
Break Point Ledger
A four-state per-set break-point ledger — one track per player per set sized by chances earned, a filled overlay for chances converted, "3/7 · 43%" labels, a pooled match totals row on the same scale, and hover/keyboard tooltips.
Match Stats Mirror
A four-state broadcast-style head-to-head panel — mirrored bars from a centre label column, count rows split the pair's total while percent rows keep an honest 0–100 half-scale, the higher side at full fill and the lower faded, hairlines for zeros.