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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-waffle.jsonPrompt
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 × rowsor 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.
Control Chart
A four-state SPC control chart that estimates its own limits from the moving range and flags every rule violation with the rule it broke.
Arc Diagram
A four-state arc diagram in plain SVG — nodes on one sequence-preserving baseline, half-ellipse arcs sized by weight, back-edges below the axis, hover to raise a node's arcs, and a keyboard walk over an sr-only node and arc table.