Player Percentile Pizza
A scouting percentile pizza in hand-rolled SVG — one wedge per stat, radius as cohort percentile rank, wedges grouped and coloured by skill category, with guide rings at 25/50/75.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-player-pizza.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPlayerPizza" scouting widget in
hand-rolled SVG (no chart library) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
player: { name; position?; team?; season?; cohort?; minutes? >= 0 };
groups: { label; stats: { label; percentile 0-100; value?: string }[] }[] }.
- Component props = z.infer of the schema, plus padAngle? (degrees between
wedges, clamped 0-4), showLegend?, onRetry?: () => void, emptyState?, and
the usual div props (cn-merged className, rest spread on the root,
forwardRef). No hand-written parallel interface.
Behavior
- Flatten groups into one ring of N stats. Every stat owns an equal TAU / N
sweep starting at twelve o'clock; the inter-wedge gap comes out of the
paint, never out of the allocation, so hit regions tile the whole disc.
- A wedge's outer radius maps its percentile linearly from a small hub (18
units) to the rim (130 units in a 460-unit box). Dashed guide rings mark
the 25th / 50th / 75th and a solid ring the 100th; a near-zero percentile
still draws a dashed floor stub at the hub so the stat visibly exists.
- Each wedge prints its rounded rank in a badge at its tip — a card-filled
circle ringed in the group tone. The badge centre is clamped inside the
rim at 100, and its floor rises with the stat count — at least
(badge radius + 1.5) / sin(PI / N) from the centre, since adjacent badge
centres at radius r sit 2 * r * sin(PI / N) apart — so two neighbouring
bottom-percentile badges always clear a full diameter instead of printing
on top of each other.
- Hover or keyboard (roving tabindex on the wedge hit paths; Left / Right
wrap around the ring, Home / End jump) makes one stat active: its slot is
traced with a card-under-foreground double stroke, the other wedges dim,
and one shared readout line prints the full sentence (label, ordinal
percentile, raw value, group). Blur and pointer-leave clear it.
- Four first-class status branches. Error renders Try again only when
onRetry exists; a ready payload whose groups hold zero stats falls into
the empty branch; a group that declares no stats is skipped and the skip
is stated in the footnote, never swallowed.
Rendering & styling
- Group g colours var(--chart-{(g % 5) + 1}) for the wedge fill, the rim
band over the group's arc and the legend dot — one formula, three
consumers, so the grouping can never drift apart and re-themes with the
host's chart tokens.
- Panel: rounded-xl border bg-card. Grid strokes stroke-border; rim and
ring labels fill-muted-foreground with a var(--card) halo
(paint-order: stroke); wedges separated by a var(--card) stroke plus the
pad gap. No raw colors anywhere.
- Rim labels are placed radially outward and anchored away from the centre,
split onto two lines when long, then truncated against a per-label
character budget computed from the room left to the edge of the box;
every shortened or dropped name is counted in the footnote and printed in
full in the readout and the sr-only table.
- Dimming transitions carry motion-reduce:transition-none and the loading
pulse carries motion-reduce:animate-none. The svg is named by the card
heading (aria-labelledby) and described by an sr-only summary (strongest
stat, weakest stat, group medians, minutes caveat, arrow-key
instructions); with the full table they make the picture screen-reader
complete, and percentiles are read as ordinals.
Customization levers
- Ring geometry: the HUB / RIM constants set how much of the disc the data
owns; RINGS ([25, 50, 75]) is the guide-ring ladder — quartiles by
default, swap for deciles when the cohort is large enough to care.
- Wedge gap: padAngle 0 draws a seamless pie, 3-4 reads as separated petals.
- Badge style: card-filled with a tone ring by default; flip to tone-filled
with knockout text only if your tokens guarantee contrast for the number.
Shrink BADGE_RADIUS and the anti-collision floor relaxes in step — the
two are one formula, so resizing badges never reintroduces overlap.
- Legend density: drop the per-group median, or pass showLegend={false} and
lean on the rim bands alone for a thumbnail embed.
- Palette: groups cycle five chart tokens; pin fixed tokens per category
(attack = chart-1, defence = chart-3) by replacing toneOf.
- Stat count: 8-16 wedges read well; past that the rim labels thin out —
aggregate metrics upstream rather than shrinking the font.Concepts
- Equal angle, ranked radius — every stat owns the same sweep and only the radius carries data, so the silhouette is the player: spikes are strengths, bites are weaknesses, and no metric can steal angular importance from another.
- Percentile as the common axis — the chart never mixes raw units; everything arrives as a 0–100 rank against a named cohort, which is what makes goals, carries and tackles commensurable on one disc — and why the footnote insists a long slice means "few peers rank higher", not "a big number".
- One tone formula, three consumers — the wedge fill, the rim band over the group's arc and the legend dot all read
var(--chart-N)from the same group index, so category colours can never drift apart and the whole disc re-themes with the host's chart tokens. - Guide rings as the whole axis — dashed rings at the 25th/50th/75th replace an axis line: any wedge can be read against the quartiles from anywhere on the disc, and the badge at each tip prints the exact rank so nothing depends on eyeballing a radius.
- One readout channel — pointer and keyboard converge on the same active slice: a traced slot, dimmed neighbours and a single sentence line, while screen readers get the sr-only summary and the full table instead of a hover-only story.
- Stated, never swallowed — bottom-percentile stats keep a dashed floor stub, shortened rim labels are counted in the footnote, and groups with no stats are named rather than silently dropped.
Form Guide
A four-state team form guide — ranked rows of W/D/L result chips, a rolling points-per-game sparkline and season totals, driven by one contract.
Power Curve
A four-state cycling mean-maximal power curve on a log time axis — current period vs all-time best, an optional dashed critical-power asymptote, and a scan that reads watts at any duration.