Charts

Waffle Chart

A four-state waffle (square pie) that apportions categories onto a countable grid with largest-remainder rounding, reserves a cell for shares worth less than one, and keeps the palette readable past five categories.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildWaffleLayout,
  waffleCellPosition,
  waffleGrid,
  WAFFLE_PALETTE_SIZE,
  type ChartWaffleData,
  type WaffleBand,
  type WaffleFlow,
  type WaffleOrigin,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartWaffle" card — a square pie — with
zod. No charting library: the picture is one SVG grid, and every number in it
comes out of two small pure functions.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    description?: string; unit?: string;
    items: { id: string; label: string; value: number >= 0 }[];
    total?: number }.
  value is non-negative on purpose. A waffle encodes quantity as AREA and
  there is no arrangement of cells that means "less than none"; a signed
  series (a delta, a net change) is not a part-to-whole and belongs in a
  chart with a baseline. Reject it at parse time instead of drawing its
  absolute value.
  total is the denominator. Omit it and the parts ARE the whole, so the shares
  always add to 100%. Give it when the whole is known independently of the
  parts — licensed seats, rack capacity, a survey's sample size — and the
  leftover becomes an explicit unallocated band instead of being silently
  redistributed among the categories. That is the difference between "we sold
  62% of the seats" and "of the seats we sold, all of them are sold".
- Component props = z.infer of the schema plus columns (10) and rows (10, both
  clamped 1-30), flow: "row" | "column" ("row"), origin: "top-left" |
  "bottom-left" ("top-left"), smallShare: "reserve" | "omit" ("reserve"),
  order: "contract" | "descending" ("contract"), remainderLabel
  ("Unallocated"), maxCellSize (28, clamped 8-64), locale ("en-US"), onRetry,
  className, a forwarded ref and the remaining div props.
- Ship a pure module beside the schema: inspectWaffleParts() for the structural
  pass, buildWaffleLayout() returning { ok: true, layout } or { ok: false,
  issue }, waffleGrid() for the empty frame and waffleCellPosition() for the
  geometry. The layout carries, per band: share, quota (the exact unrounded
  entitlement), cells, start (its first cell in fill order), rounding
  ("up" | "down" | "exact"), reserved, invisible, paletteIndex and tier; plus
  cellCount, total, cellValue, allocated, and the reserved / invisible lists.

Behavior
- APPORTIONMENT is the whole point. The promise a waffle makes is that shares
  are COUNTABLE, which means the painted cells must add up to exactly
  columns * rows — no more, no fewer. Rounding each share on its own does not
  do that: five shares of 19.9% each floor to 19 cells and leave 5 cells with
  nothing to say. Use the Hare quota with largest remainders: quota =
  share * cellCount, floor every quota, then hand the leftover cells one each
  to the largest fractional parts, ties broken by fill order so two renders of
  the same data are identical. The leftover can never exceed (band count - 1),
  so one pass is enough.
- SHARES UNDER ONE CELL are the honest hard case. A 0.25% category on a
  100-cell grid earns a quarter of a cell, and both available answers are
  lies of different sizes. smallShare="reserve" (default) borrows one cell
  from the largest band that can still spare one (a donor needs 2 cells to
  give 1), marks the receiver reserved and draws that cell with a dashed inset
  — it is present but no longer proportional. smallShare="omit" keeps the grid
  strictly proportional and names the category in a footnote and in the table
  as "worth 0.25 cells — too little to paint". Never silently drop it, and
  never silently inflate it without a mark. If the grid runs out of donors,
  stop reserving and let the rest fall through to the footnote: a 3x3 grid
  cannot hold twelve categories and should say so rather than pretend.
- THE REMAINDER BAND. When total exceeds the sum of the parts, synthesise one
  extra band worth (total - sum) with an id that cannot collide with a real
  one, put it last in fill order, and let it take part in the apportionment
  like any other band — that is what keeps the grid exactly full. It gets no
  hue: it is the absence of a category, not one more of them.
- REFUSALS, before any layout: two categories sharing an id, a negative or
  non-finite value, and a stated total below the sum of the parts (compare
  with a relative slack of 1e-9 * max(|total|, sum, 1); summing floats is not
  associative, so parts built to match their total can still land 1e-12 over
  it). Each refusal returns a code plus a sentence naming the offending
  numbers, and the component renders it in the error branch. Run the same
  structural pass inside the schema AND inside the layout builder: props are
  only z.infer of the schema, so a caller who never calls parse() still gets
  an error state instead of a grid of NaN rectangles.
- FILL ORDER is an encoding. Each band owns one CONTIGUOUS run of cells, so
  the reading order of the grid is the order of the legend, and "the first
  block is the biggest" is legible without reading a single number. flow
  "row" reads like text (left to right, then down); flow "column" with origin
  "bottom-left" is the container metaphor, a tank filling up.
- FOUR STATES are first-class branches of one bg-card panel: a pulsing
  skeleton drawn on the SAME grid frame the ready state will use (so the
  picture does not jump on arrival), an empty state, an error state carrying
  either the transport message or the specific refusal plus a "Try again"
  button only when onRetry exists, and ready. A READY payload whose parts are
  all zero is EMPTY, not broken — every category can legitimately measure zero
  on a quiet day — and it gets its own wording ("all 3 categories measured
  zero") distinct from "no data at all".
- INTERACTION: pointing at any cell or at any legend entry highlights that
  band (the others drop to 25% opacity) and writes the full line into a
  readout under the grid. Clicking a legend entry PINS it, which is the only
  way a touch device can isolate a band; a hover preview outranks the pin so
  pointing at another band still previews it. Both listeners are DELEGATED,
  one per event on the grid and one per event on the legend: a 30x30 grid
  would otherwise mount 900 closures and rebuild them on every render.
- KEYBOARD: the legend is a role="group" with ONE tab stop (roving tabIndex).
  Left/Up move to the previous band, Right/Down to the next, both wrapping;
  Home and End jump to the ends; Enter or Space pins and unpins; Escape clears
  the pin. Movement calls .focus() on the target synchronously and lets the
  state update ride along on the focus event, so a held arrow key never waits
  for a render. Focus follows the arrow keys, and blur only clears the readout
  when it actually leaves the group (moving between two entries fires blur
  then focus).
- DEGENERATE DATA, each handled deliberately: zero items and all-zero values
  give the zero-state; one item at 100% paints every cell one colour and the
  summary says so instead of quoting "100%"; equal values hit the tie-break
  (100/3 = 33.33 each, one leftover cell, given to the first band in contract
  order); a category worth exactly zero keeps its legend row and prints 0
  cells; labels longer than the legend lane truncate with the full text living
  in aria-label and in the table; more categories than the palette hold wrap
  onto the glyph tiers; more categories than cells run the reservation out of
  donors.
- CLEANUP: there is nothing to unsubscribe. No timers, no requestAnimationFrame,
  no ResizeObserver — responsiveness is a viewBox, and both interaction
  surfaces are delegated React handlers that unmount with their container.
  Indices held in state (pinned, hovered, focused) are range-checked ON READ
  against the current band count rather than reset from an effect, so new data
  arriving under a pinned index repaints in the same frame instead of one
  frame late.

Rendering & styling
- GEOMETRY: one cell is 10 units with a 2-unit gap in a viewBox of
  (columns * 12 - 2) x (rows * 12 - 2), preserveAspectRatio="xMidYMid meet",
  the svg "block h-auto w-full". Responsiveness is therefore free and the
  chart cannot collapse to zero height in a flex parent. Cap the container
  with maxWidth = viewWidth / 10 * maxCellSize so a 10x10 grid in a full-width
  card does not become a wall of squares. waffleCellPosition(index) is the
  only geometry: column = index % columns, row = floor(index / columns) for
  flow "row", swapped for flow "column", and mirrored (rows - 1 - row) for
  origin "bottom-left".
- COLOUR is var(--chart-1..5) and nothing else, assigned by band position and
  cycling. Because it cycles, colour cannot be the only channel: every wrap of
  the palette adds a TEXTURE TIER, a glyph struck into the cell in var(--card)
  — a slash for tier 1, a dot for tier 2, a cross for tier 3 — so the sixth
  category is never mistaken for the first. That survives greyscale printing,
  every kind of colour blindness and a bad projector. Four tiers times five
  hues is 20 distinguishable bands; past that, say so rather than pretend.
  Ordering, the legend, the cell counts and the readout are the other channels.
- A RESERVED cell is drawn with a dashed inset in var(--card), matching the
  dashed swatch in the footnote: "not earned" is a visual property, not a
  footnote-only one. The unallocated band is fill-muted with no hue.
  Highlighting a band adds a var(--foreground) hairline ring to its cells —
  again shape, not just opacity.
- ACCESSIBILITY: the grid is a SINGLE FIGURE. Wrap the svg in a div with
  role="img" and an aria-label that states the finding, not the geometry:
  grid size, fill order, what one cell is worth, the largest and smallest
  categories with their cell counts, the unallocated share, which categories
  were given a cell they had not earned, and which were too small to paint.
  role="img" is children-presentational, which is exactly why the grid must
  not contain focusable nodes: 100 tab stops is 100 ways to lose your place in
  a picture you read by counting. The keyboard interaction lives in the legend
  instead, where each button carries the whole line as its aria-label. Below
  the figure, an sr-only WRAPPER DIV holds a real table (category, value,
  share, cells, rounding). Put sr-only on the wrapper, never on the table: CSS
  width is only a lower bound for a table box, so width:1px does not hold one
  back and a narrow viewport picks up hundreds of px of horizontal scroll. The
  visible readout is aria-hidden — the focused legend entry already announces
  it, and a live region would say everything twice.
- Percentages are formatted with precision that follows magnitude (one decimal
  at or above 1%, more digits below), because a waffle routinely puts 34% next
  to 0.25% and one fixed setting either rounds the tail away to "0%" or pads
  the headline with meaningless zeros. Numbers are Intl-formatted against an
  explicit locale: Intl.NumberFormat(undefined) desyncs SSR from the visitor.
  Only semantic tokens: bg-card, text-card-foreground, bg-muted / fill-muted,
  text-muted-foreground, text-destructive, border, ring, and var(--chart-1..5)
  for the bands. Transitions are opacity only and carry
  motion-reduce:transition-none; the skeleton pulse carries
  motion-reduce:animate-none. Nothing about the chart depends on motion.

Customization levers
- Grid shape: columns x rows is the resolution of the whole picture. 10x10
  makes one cell exactly 1% and is the default for a reason; 5x20 or 4x25
  suits a narrow column; 5x5 is a coarse "quarter of it" summary and 20x20 is
  as fine as this gets before counting stops being possible. Keep the product
  a round number and the arithmetic stays readable.
- flow / origin: "row" + "top-left" for a ranking that reads like text,
  "column" + "bottom-left" for the tank metaphor in a capacity widget.
- smallShare: "reserve" when every category must be visible (a legend the
  reader can point at), "omit" when the grid is a measurement and must stay
  strictly proportional. The choice is a policy, so it is a prop, not a
  constant.
- order: "contract" respects a meaningful order (severity, tier, time);
  "descending" turns the grid into a ranking whose blocks shrink left to
  right. Sorting upstream is equally fine — this is only a shortcut.
- total: omit for a pure composition, supply for a capacity picture, and
  rename its band with remainderLabel ("Unsold", "Idle", "Unassigned").
- Density: maxCellSize is the whole visual weight dial. Drop it to 16 for a
  KPI-strip waffle, raise it to 40 for a hero card; the gap-to-cell ratio
  (2:10) is the other dial — set the gap to 0 for a solid mosaic, or to 4 for
  a beaded look.
- Palette: repoint the band fill formula at a different token set and the
  legend swatches follow, because both read the same function. The glyph tiers
  are independent of the hue, so a monochrome build (one token, tiers only)
  works without touching anything else.
- Trimming the tail: this component never groups categories for you. If a feed
  routinely has 30 categories, sum the tail into an explicit "Other" upstream —
  a grouping decision belongs to the data layer, where it can be labelled and
  audited, not to a renderer.

Concepts

  • Largest-remainder apportionment — the cells must add up to exactly columns × rows or the chart stops being countable, so shares are floored and the leftover cells handed to the largest fractional parts, ties broken by fill order. It is the same arithmetic that assigns parliamentary seats, and for the same reason: whole units, no residue.
  • Reserved cell — a category worth less than one cell borrows one from the largest band so it does not vanish. The borrowed cell is dashed, because it is the one square on the grid that is no longer proportional; smallShare="omit" takes the other side of that trade and keeps the grid exact.
  • Unallocated remainder — when the denominator is known independently of the parts, the difference is a real band with its own muted cells. Without it, 298 assigned seats out of 480 would paint as a full grid and quietly report 100%.
  • Palette wrap glyph — five hue tokens, more categories than five: every wrap adds a slash, a dot or a cross struck into the cell in the card colour. Colour is never the only channel, so the picture still works in greyscale, in every kind of colour blindness, and on a projector.
  • Fill order as encoding — each band is one contiguous run starting at the origin corner, so position carries the ranking and the grid reads in the same order as the legend. Scattering the cells would look livelier and destroy the only reason to count them.
  • One figure, one tab stop — the grid is a single role="img" with a summary and a table, not a hundred focusable squares; all keyboard interaction lives in the legend, where arrows move, Enter pins a band and Escape lets it go.

On This Page