Charts

Partner Chemistry

A four-state doubles pairing grid — one roster on both axes, the lower triangle carrying each partnership's win rate over its match count, samples under the bar hatched out of every read, each player's own average on the diagonal to compare against, and a load strip whose widths are largest-remainder shares of the season.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartPartnerChemistryData,
  ChartPartnerChemistryPair,
  ChartPartnerChemistryPlayer,
} from "./chart-partner-chemistry.contract"

export interface ChartPartnerChemistryProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartPartnerChemistryData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartPartnerChemistry" card — a doubles
partnership grid drawn as a plain HTML table (no chart library), with zod for
the contract.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    players: { id; label; soloWinPct?: 0-100 }[];
    pairs:   { a; b; wins: int >= 0; losses: int >= 0 }[];
    minMatches: int >= 0 }
- Props = z.infer of that schema plus title?, description?, defaultPlayerId?,
  onRetry?, emptyState?, className. No hand-written parallel interface.
  forwardRef + spread the rest onto the root.
- A pairing is UNORDERED: {a,b} and {b,a} are the same two people, so they fold
  into one cell keyed by roster position and repeated rows are summed — a season
  handed over in blocks (autumn, spring) is one record. Racket sports have no
  draws, so wins + losses IS the sample; never carry a separate match count that
  could disagree with it.
- soloWinPct is the player's own win rate across every match they played,
  whoever they partnered. Omit it and derive one from the pairings listed,
  weighted by matches, and mark those diagonals — a baseline that silently means
  "only what is on screen" turns every delta into the card comparing itself.

Behavior
- Four first-class branches in one bg-card panel: loading (skeleton keeping the
  two verdict tiles, a triangle of cells and the load strip), empty (a doubles
  court glyph + copy), error (message + a Try again button only when onRetry
  exists), ready. A ready payload whose rows all got dropped falls back to the
  zero state and says how many and why.
- Grid: roster on both axes, LOWER TRIANGLE ONLY — the upper half is the same
  partnerships read backwards, so it is left out rather than mirrored. Column
  headers are the roster number (full name in an sr-only span and in title),
  row headers carry the number plus the name in a fixed-width label column —
  horizontal, so they never collide or need rotating the way column labels
  would, and a name too long for that column truncates to an ellipsis while
  the full string stays in title and in the accessible name.
- Diagonal = that player's own win rate, which is what every cell in their row
  and column is read against. It is a different measurement from a pairing, so
  it gets a different treatment (muted tile, "solo" microlabel, an asterisk when
  it was derived) rather than another step of the ramp.
- Sample gate: pairings with fewer than minMatches matches are hatched, keep
  their numbers, and are excluded from the colour ramp and from the best /
  weakest verdict. Print the arithmetic that justifies the bar: at
  minMatches − 1 matches one result moves the rate by 100/(minMatches − 1)
  points.
- Verdict tiles name the best and the weakest QUALIFIED pairing with its rate,
  its record, its match count and its chemistry delta (win rate minus the mean
  of the two solo averages). Ties break by matches, then by roster order — a
  total order, so the same grid always crowns the same pair.
- Player lens: each row header is an aria-pressed toggle. Selecting a player
  outlines their row-and-column hook, and re-derives the verdict, the load strip
  and every share against their matches only. Click again or press Escape to
  clear. The lens deliberately does NOT touch the colour ramp: a cell that
  changes colour without its number changing is a lie about the data.
- Load strip: one segment per pairing, heaviest first, width = its share of the
  view's matches. Apportion those shares by largest remainder — floor, then hand
  the leftover points to the largest fractions — so they add to exactly 100 in
  every lens state; rounding each on its own prints 99 or 101 and leaves the
  strip visibly short. Colour each segment with the same fill its cell got, so
  the strip answers "is the load going to the pairings that win?". Lensed on a
  player with no partnership logged there is nothing to apportion, so the strip
  and its caption drop out rather than printing that promise over an empty
  track.
- Unknown ids, a player paired with themselves, unreadable counts and duplicate
  players are dropped, counted by reason and reported under the card — a
  silently dropped record lowers the denominator every percentage divides by.
- No effects, observers, timers or animation frames: the whole card is derived
  in two memos from props, so there is nothing to clean up on unmount.

Court geometry (the zero state's glyph) — derive it, never guess pixels
- ITF doubles court: 23.77 m baseline to baseline, 10.97 m wide for doubles and
  8.23 m for singles, so each alley is (10.97 − 8.23) / 2 = 1.37 m and is what
  doubles adds; the service line sits 6.40 m either side of the net; the centre
  mark is 10 cm; the net posts stand 0.914 m outside the doubles sidelines.
- The viewBox is in metres, so the drawing cannot drift from the game.

Rendering & styling
- Semantic tokens only, zero hex / rgb / oklch: the ramp is
  color-mix(in oklab, var(--chart-2) N%, var(--card)) above an even split and
  var(--chart-5) below it, so it always travels AWAY from the surface and stays
  legible on a white and on a near-black card alike — a flat token at low alpha
  vanishes into exactly one of the two themes. Cap N at 50%: past that the
  strongest cells drop --foreground under 4.5:1 on the dark card.
- N is the distance from an even split over a ramp radius fitted to the widest
  qualified gap on the grid, floored at ±12 points so a roster where nobody
  strays past 52% does not get painted as if it had extremes. Print both ends of
  the ramp in the legend. An even split gets no ink at all — 50% is a neutral
  finding, not a weak one.
- Colour is never the only channel: every cell prints its own rate and sample,
  the sample gate is texture (a 45° repeating-linear-gradient of
  --muted-foreground) rather than a paler colour, and "never partnered" is a
  dashed empty cell, distinct from both.
- table-fixed + a declared colgroup and no w-full, so the width is a function of
  the roster size rather than of the longest name; the pane scrolls instead of
  the card growing. Cells are square, numbers tabular-nums.
- A11y: an sr-only summary referenced by aria-describedby, an sr-only caption,
  a full sentence in each cell's aria-label and title, real th scope="col" /
  scope="row" headers, focus-visible rings on the row-header toggles, and
  motion-reduce disabling the skeleton pulse and the button transitions.

Customization levers
- Sample bar: minMatches is the one knob that changes what the card is willing
  to claim. Six is a fair club bar, ten a tour one, 0 reads every cell.
- Ramp: the MAX_MIX ceiling (ink strength), the RAMP_FLOOR radius (how quickly
  a grid saturates) and the two tokens. Swap in one token plus a hatch for the
  low arm if the host palette has no second hue.
- Density: drop the load strip for a compact card, or the verdict tiles when the
  grid sits beside a table that already ranks pairings; CELL_PX and
  LABEL_COLUMN_REM set the grid's footprint.
- Diagonal: swap the solo average for the player's overall matches, their
  ranking, or leave it blank if the roster has no individual record.
- Verdict axis: rank by chemistry delta instead of raw win rate (best partner
  FOR each player rather than the best pair on the sheet) by changing the two
  comparisons in the view function — everything else follows.
- Lens: defaultPlayerId mounts the card already narrowed, which is what a player
  profile page wants; drop the toggles entirely for a static report.
- Domain: the shape fits any unordered pairing with a win-loss record — padel
  and pickleball squads, badminton doubles, relay teams, chess doubles — by
  changing only the labels and the bar.

Concepts

  • Unordered pair, single triangle — a partnership has no direction, so (a,b) and (b,a) fold into one cell keyed by roster position and repeated rows are summed. The upper half would be the same numbers read backwards, so it is never drawn; the blank half is the claim, not a gap.
  • Diagonal as the baseline — the diagonal is a different measurement to the cells around it: the player's own win rate across every match, given by the feed or derived from the grid and marked when derived. It is what makes a cell readable as chemistry rather than as form — a pairing is only interesting when it clears both of its own diagonals.
  • Sample gate before verdict — a pairing under minMatches keeps its numbers, loses its ink and is dropped from the ranking. At five matches one result moves a win rate by twenty points, so an ungated grid crowns whoever played twice and won both; the card prints that arithmetic instead of asking to be trusted.
  • Lens as re-derivation, not highlight — selecting a player re-runs the verdict, the denominators and every share against their matches alone, while the colour ramp stays pinned to the whole roster: a filter may change what is claimed, never what a cell looks like.
  • Largest-remainder load — the strip's segment widths are whole-percent shares apportioned by largest remainder, so they add to exactly 100 in every lens state and the bar is visibly full. Rounding each share on its own is how a "100%" strip stops short and reads as a rendering bug.
  • Colour is never alone — ink strength is the distance from an even split, mixed into the card surface so it travels away from the background in both themes, and every cell still prints its rate and its sample. "Too few matches" is texture and "never partnered" is a dashed empty cell, so the three states survive greyscale.

On This Page