Charts

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.

Preview in your theme

Loading preview…

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildPictogramLayout,
  pictogramIconPosition,
  pictogramViewBox,
  PICTOGRAM_CELL,
  PICTOGRAM_PALETTE_SIZE,
  PICTOGRAM_STEP,
  type ChartPictogramData,
  type PictogramGlyph,
  type PictogramOrder,
  type PictogramPartial,

Installation

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

Prompt

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.

On This Page