Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartPlayerPizzaData } from "./chart-player-pizza.contract"

export interface ChartPlayerPizzaProps
  extends React.HTMLAttributes<HTMLDivElement>,
    ChartPlayerPizzaData {
  /**
   * Gap between neighbouring wedges, in degrees. Clamped 0–4. The gap comes
   * out of the paint, never out of a stat's allocation, so hit regions still
   * tile the whole disc.

Installation

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

Prompt

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.

On This Page