Pictogram
A four-state ISOTYPE unit chart: one row per category, icons repeated at a stated one-icon-equals-N scale, the last icon clipped to the leftover instead of shrunk, and unusable rows dropped and counted in view.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-pictogram.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPictogram" card — an ISOTYPE unit
chart — with zod. No charting library and no hooks: the picture is one SVG per
row, and every number in it comes out of three small pure functions.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
description?: string; unit?: string; unitValue?: number > 0;
glyph?: "person" | "building" | "drop" | "bolt" | "star" | "square";
items: { id: string; label: string; value: number >= 0;
of?: number >= value; glyph?: Glyph }[] }.
value is non-negative on purpose: a pictogram encodes quantity as a COUNT OF
ICONS and there is no arrangement of figures that means "minus three people".
A signed series belongs in a chart with a baseline.
unitValue is what ONE ICON STANDS FOR, and it is the only thing that makes two
of these comparable: the same unitValue on two cards means the same icon means
the same amount. Omit it and the component picks one — convenient for a single
card, wrong the moment two sit side by side, because each picks its own.
`of` is the row's OWN base, drawn as empty outlines after the filled icons:
"3 in 10", "38 of 50 seats". Bases are per row and are never summed, so two
rows may legitimately be "3 of 10" and "300 of 1,200".
- Props = z.infer of the schema plus maxIcons (20, clamped 4-40), partial:
"clip" | "round" ("clip"), order: "contract" | "descending" ("contract"),
iconSize (24 px, clamped 8-48), locale ("en-US"), showValues (true),
skeletonRows (4, clamped 1-12), onRetry, className, a forwarded ref and the
remaining div props.
- Ship a pure module beside the schema: inspectPictogramData() for the
structural pass, chooseUnitValue() for the scale, buildPictogramLayout()
returning { ok: true, layout } or { ok: false, issue }, plus
pictogramIconPosition() and pictogramViewBox() for the geometry. Per row the
layout carries icons (the exact unrounded entitlement), full, fraction, drawn,
slots, of, share, lines, rounding and baseIgnored; plus unitValue, unitChosen,
columns, widest, wraps, total and the dropped / partials / rounded /
baseIgnored lists. A test can print the same numbers the picture is made of.
Behavior
- THE SCALE IS THE CHART. When unitValue is omitted, pick the smallest step of
the 1 / 2 / 2.5 / 5 x 10^n ladder that keeps the longest row inside maxIcons
icons. Never use maxValue / budget directly: "one icon = 1,000 people" is a
number a reader can multiply in their head, "one icon = 617 people" is not.
When every value in the data is a whole number, clamp the chosen step to an
integer too — drawing three people at "one icon = 0.2 people" gives fifteen
figures for three humans, which is arithmetically correct and a lie about the
census. Print the chosen scale next to a sample glyph, and say whether it was
chosen or supplied, because a reader comparing two cards has to know.
- CLIP THE PART ICON, DO NOT SCALE IT. icons = value / unitValue; the whole part
is drawn solid and the leftover is the LEFT SLICE of one more icon at full
size. Shrinking that icon instead would encode the leftover as AREA, which
grows quadratically: a "half" icon drawn at half size covers a quarter of the
ink and reads as a quarter. Draw the clipped icon over its own faint outline
so the missing part of the unit is visible. partial="round" is the other
honest option for indivisible things (there is no 0.4 of a nurse): whole icons
only, a live row worth less than half an icon still gets one so it cannot be
deleted from the picture, and a footnote says how many rows were moved.
- REFUSE vs DROP-AND-COUNT is the split that matters. A pictogram is a LIST OF
INDEPENDENT ROWS, so one bad number must not blank the card the way a bad part
invalidates a part-to-whole. Row-local damage is repaired and COUNTED, never
swallowed: a NaN or negative value drops the row and the card prints "2 rows
carried no usable number and are not drawn: Delta (not a number), Echo
(negative)"; an `of` below its own value is ignored (clamping it would invent
a 100% share) and the row is drawn as a plain count with a note. Only three
faults make the whole picture wrong and come back as a refusal card: two rows
sharing an id, a unitValue of zero or less, and more icons than anyone will
count. Run the 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 explicit error state instead of NaN-wide icons.
- THE COUNTABILITY CEILING. This chart's one promise is that the reader can
count. Cap the whole card at ~900 icon boxes and refuse past it with a message
that names the cause that actually applies: with a supplied unitValue, suggest
the value that would fit; with a chosen one no single row can exceed the
budget, so the cause is row count and "raise unitValue" would be useless
advice. Rows longer than maxIcons wrap onto further lines at the SAME column
count, so lengths still compare — a two-line row is more than twice a
half-line row — and a note says so.
- FOUR STATES are first-class branches of one bg-card panel: a skeleton drawn on
the same three-column frame the ready state uses, so the fixed label column
keeps the plot starting at the same x when the data lands, with a descending
staircase of rows so it already reads as a ranking; 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 rows
are all zero is EMPTY, not broken — every row can legitimately measure zero on
a quiet day — and gets its own wording. The exception is rows with a base:
"0 of 480 seats" is a real picture and is drawn.
- DEGENERATE DATA, each handled deliberately: no rows; every row zero (one row
worth zero keeps its label and prints 0 with no icons); a row worth less than
one icon (a sliver, never rounded up to a whole one while clipping); a single
row (no "largest and smallest" in the summary, just the one); labels longer
than their column (truncated with the full string on `title` and in the
table); more rows than the five palette tokens (nothing breaks, because colour
never had to identify anything).
- NOTHING TO CLEAN UP. No state, no effects, no timers, no observers, no
Math.random and no Date.now — the picture is a pure function of the props, so
it renders identically on the server and on the client, and the module needs
no "use client". There is deliberately NO tooltip and no hover reveal: every
row already carries its own label and its own number as text, so a hover would
reveal what is already printed while owing keyboard users an equivalent.
Rendering & styling
- GEOMETRY: one icon box is 10 units with a 3-unit gap; a row's viewBox is
(columns * 13 - 3) x (lines * 13 - 3) with preserveAspectRatio="xMinYMin meet"
and the svg "block h-auto w-full". Every row on the card is drawn at the SAME
column count and the SAME max width, which is what keeps icons the same size
from row to row — the one property that lets a reader compare lengths. Cap the
plot with maxWidth = viewWidth / 10 * iconSize. Layout is
pictogramIconPosition(index): column = index % columns, row = floor(index /
columns), exactly like text.
- CLIPPING WITHOUT IDs: draw the part icon inside a NESTED svg whose viewport is
fraction * 10 wide with viewBox "0 0 10 10" and
preserveAspectRatio="xMinYMin slice". Slice scales by max(w/10, 10/10) = 1, so
the glyph stays at 1:1 and is simply cut off at the fraction. This needs no
clipPath id, therefore no useId and no chance of two charts on one page
colliding.
- GLYPHS are silhouettes in the same 10 x 10 box, so the grid, the legend swatch
and the clipped icon all read one definition. Filled, not outlined, because
the OUTLINE is already taken: it means an empty slot. A row may override the
chart glyph, which is how a card whose rows count different kinds of thing
works — then the glyph is the unit and there is no shared unit noun to print.
- COLOUR is var(--chart-1..5) by row index and it is decoration, not encoding:
every row carries a DIRECT LABEL next to its own icons and its own number at
the end of the line, so a palette wrap past five rows costs nothing and the
chart survives greyscale, every kind of colour blindness and a bad projector.
Nothing here is legend-only. Semantic tokens throughout: bg-card,
text-card-foreground, bg-muted / fill-muted, text-muted-foreground /
stroke-muted-foreground, text-destructive, border, ring.
- ACCESSIBILITY: the plot is a SINGLE FIGURE. role="img" on the rows container
with an aria-label that states the finding, not the geometry — row count,
glyph, what one icon is worth, the longest and shortest rows with their icon
counts, the total, and how many rows were dropped. role="img" is
children-presentational, which is safe precisely because nothing inside is
focusable. Below it, an sr-only WRAPPER DIV holds a real table (row, value,
base, share, icons, drawn as) including the dropped rows. 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.
- Numbers are Intl-formatted against an explicit locale: Intl.NumberFormat
(undefined) desyncs SSR from the visitor. The only animation is the skeleton
pulse, carrying motion-reduce:animate-none, and the chart is complete and
readable with animation off.
Customization levers
- Scale: unitValue is the headline dial. Supply it whenever two cards must be
read together, or when the unit is meaningful in itself ("one icon = one
classroom"). Leave it out for a standalone card. maxIcons is the countability
dial: 10-12 for a compact KPI strip, 20 (default) for a report card, 30+ only
if the audience really will count.
- Density: iconSize is the visual weight, 14-18 px for an inline strip and 32-40
for a hero figure. The gap-to-box ratio (3:10) is the other dial — drop it to
1 for a dense block, raise it to 5 for a poster.
- partial: "clip" for continuous quantities (litres, pounds, hours), "round" for
indivisible ones (nurses, buses, buildings). It is a policy, so it is a prop.
- Glyphs: add one by adding a path in the 10 x 10 box; the legend, the grid and
the clip all pick it up. Give rows their own glyph when they count different
things, and drop `unit` in that case — the figure is the noun.
- Layout: swap the three-column grid for label-above-icons when the labels are
long, or drop showValues for a poster where the icons are meant to be counted
rather than read. order="descending" turns the card into a ranking; sorting
upstream is equally fine.
- Colour: repoint the fill formula at a single token for a monochrome ISOTYPE
look — nothing depends on the rows differing in hue.
- Trimming the tail: this component never groups rows for you. A feed with 30
categories should sum its tail into an explicit "Other" upstream, where the
decision can be labelled and audited, not in a renderer.Concepts
- Unit chart (ISOTYPE) — quantity is a repeated countable mark, not a length or an angle, and the mark depicts the thing being counted. It trades resolution for legibility on purpose: a reader who cannot be trusted with an axis can be trusted to count five figures.
- One icon equals N — the scale is the whole contract with the reader, so it is printed beside a sample glyph and labelled as chosen or supplied. Two pictograms with different scales are not comparable however similar they look, which is why an automatic scale is a convenience and a supplied one is a commitment.
- Clip, do not scale — the leftover of a unit is drawn as a left slice of a full-size icon. Shrinking the icon instead would encode the leftover as area: a half drawn at half size covers a quarter of the ink and reads as a quarter.
- Empty slots as base — the outlines after the filled icons are the part of that row's own base that is not there yet. Bases belong to rows and are never summed, which is what keeps "3 of 10" and "300 of 1,200" on the same card without either of them lying.
- Countability ceiling — past a few hundred boxes nobody counts and the picture has quietly become a slow bar chart, so the component refuses and says which knob to turn. Rows longer than one line wrap at the same column count, so lengths still compare.
- Drop and count, not refuse — because rows are independent, an unusable number costs its own row and nothing else, and the card prints how many it lost and why. Only faults that make the whole picture wrong — a duplicate id, a scale of zero, too many icons — take the error branch.
Difference Area Chart
A four-state difference area: two series with the gap between them shaded, the fill and hatch flipping wherever the lead changes hands, and every crossing marked, counted and keyboard-reachable.
Q-Q Plot
A four-state quantile-quantile plot that ranks the sample itself — ppoints plotting positions, a resistant quartile reference line, a pointwise order-statistic band, and tail departures named on the plot instead of left to the eye.