Charts

Badminton Shot Mix

A four-state badminton composition chart — one horizontal 100% stacked bar per game across five fixed strokes (clear, drop, smash, drive, net), largest-remainder shares, an attack-share tick (smash + drive) under every bar, per-game totals at the row end and a pooled match row.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartBadmintonShotCounts,
  ChartBadmintonShotMixData,
  ChartBadmintonShotMixGame,
} from "./chart-badminton-shot-mix.contract"

export interface ChartBadmintonShotMixProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartBadmintonShotMixData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-badminton-shot-mix.json

Prompt

Build a React + TypeScript + Tailwind "ChartBadmintonShotMix" chart — a
per-game 100% stacked composition bar in plain divs (no chart library), with
zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    games: { label: string;
             counts: { clear: int >= 0; drop: int >= 0; smash: int >= 0;
                       drive: int >= 0; net: int >= 0 } }[];
    meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, barHeight?, onRetry?,
  emptyState? and className. No hand-written parallel interface.
- The five strokes are a fixed taxonomy, not a free category list — the
  stacking order and the colours never re-derive from the data. Games are
  drawn in the order sent and never renamed.

Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
  piece for piece — stat line, legend, one full-width bar per game with its
  attack tick, a divided totals row; a 100% stack is always full width, so
  the skeleton silhouette is exact), empty (outlined bars + copy), error
  (message + a "Try again" button only when onRetry exists), ready.
- The model is a pure exported function: counts that arrive negative,
  fractional or unreadable are repaired to whole shots and the repairs
  reported in a visible line — a mix whose segments were quietly invented
  reads as tactics that never happened.
- Every bar is 100% of its own game, so mixes compare shape for shape across
  games of different lengths; the per-game total prints at the row end in
  text tokens, because the bar normalised the volume away.
- Each game's five shares are apportioned together by largest remainder so
  they add to exactly 100 — never rounded one at a time. Segment WIDTHS use
  the exact shares (position never comes from a rounded value); only the
  printed numbers are apportioned.
- The attack tick under each bar marks smash % + drive % on the same 0–100%
  axis the bar fills, computed as the sum of the two apportioned shares so a
  reader adding the printed segment labels lands exactly on the tick. The
  label side-flips past 80% so it never leaves the track.
- A totals row pools the whole match under a divider and gets the same bar,
  tick and treatment. A game with 0 shots keeps its row as a muted hairline
  — a retirement is a finding, not a missing row — with no tick, since an
  attack share of nothing is not 0%.
- Hover or keyboard focus on a segment raises a tooltip (count + share of
  that game) and dims the row's other segments; every segment is a
  focus-visible tab stop with a full aria-label, the pointer-leave reset is
  guarded so crossing segments never blinks, and the tooltip is aria-hidden
  because the label already says it all. An sr-only summary + table repeat
  every count, share and attack share.

Rendering & styling
- Colour from tokens only, fixed order: clear var(--chart-1), drop
  var(--chart-2), smash var(--chart-3), drive var(--chart-4), net
  var(--chart-5). Between segments a 2px surface gap — a card-coloured
  border INSIDE each non-first segment, so the geometry stays exact instead
  of gaps eating width. The attack tick is foreground ink at reduced
  strength (color-mix), so it reads over any of the five fills without
  joining them.
- Legend in the same fixed order with per-stroke match totals, plus a tick
  chip explaining the attack marker.
- Labels and figures wear text tokens, numbers tabular-nums; panel
  rounded-xl border bg-card p-4, cn() merges className, the root spreads
  remaining props and carries data-status; opacity transitions respect
  prefers-reduced-motion; bars, ticks and hairlines are aria-hidden.

Customization levers
- barHeight (14–32 px) trades density for presence; the label column sizes
  itself to the longest game label (capped at 12ch).
- TYPE_INK / TYPE_LABEL are the single home of the taxonomy — retheme by
  token swap, or adapt to another racket sport (tennis: serve / forehand /
  backhand / volley / overhead) by renaming the five keys; the attack pair
  is two index constants.
- The attack tick derives from SMASH_INDEX + DRIVE_INDEX; redefine "attack"
  (add net kills, drop drives) by changing which apportioned shares sum
  into it, or drop the tick row entirely for a plain composition read.
- The totals row is one extra renderBarRow call — remove it for a
  games-only card, or feed a single game for a one-bar mix.
- The tooltip anchors at each segment's centre in track percentages;
  replace it with an always-on count row if the card never gets hover.

Concepts

  • 100% bars trade volume for shape — normalising each game to 100 makes a 47-shot game and a 62-shot game comparable stroke for stroke, so the volume has to be printed back at the row end; a composition chart without its n hides how much evidence each bar stands on.
  • Fixed taxonomy, fixed ink — the five strokes stack in one order with one colour each, never re-derived from the data, so smash is var(--chart-3) in every row of every match and a game with no clears never silently recolours the rest.
  • The attack tick is a checkable sum — it marks smash + drive as the sum of the apportioned shares, on the same 0–100% axis the bar fills; a reader adding the two printed labels lands exactly on the tick, which is what makes it an annotation rather than a second data series.
  • Widths are exact, labels are apportioned — segment geometry uses the raw shares (position must never come from a rounded value), while every printed percent comes from one largest-remainder pass per game so the five always add to exactly 100.
  • Surface gaps that cannot lie — the 2px gap between segments is a card-coloured border inside each following segment, so the separation is visual only and the bar still spans exactly 100%; real gaps would eat width and shrink every share they touch.
  • A hairline is a measured nothing — a game with 0 shots keeps its slot as a muted hairline with no attack tick, because "this game was never played" and "this game is missing from the feed" are different claims, and an attack share of nothing is not 0%.

On This Page