Tile Grid Map
A grid cartogram where every region is an equal-size tile at a hand-supplied grid position, classed by quantile, equal interval or manual breaks — choropleth readability with no projection and no geographic data.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-tile-map.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTileMap" card — a grid cartogram
drawn as plain SVG (no map library, no projection, no topology) with zod.
Contract
- One zod schema is the source of truth, and the component props are its
z.infer plus a few presentation-only extras:
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string;
regions: { id: string; label: string; row: int; column: int;
abbr?: string }[];
values: { id: string; value: number | null }[];
scale: { method: "quantile" | "equal-interval" | "manual";
classes?: int; breaks?: number[]; unit?: string;
decimals?: int } }
Extras: showValues (boolean | "auto", default "auto"), ramp (1-5, default
1), locale (default "en-US"), onSelectRegion, onRetry, emptyState,
className, and the native div props via forwardRef.
- LAYOUT AND VALUES ARE TWO ARRAYS ON PURPOSE. The layout is a static asset
that ships with the component; the values are what actually loads. That
split is what lets the loading state pulse the real map instead of a
generic block, and it is why a region missing from `values` and a region
whose value is null render identically — both mean "not measured".
- Ship two layouts as plain data next to the schema: a 51-tile US grid
(50 states plus DC on 8 x 12) and a 30-tile Europe grid (8 x 8). They are
opinions about geography, not facts, so the whole point is that a caller
can pass their own array — counties, sales districts, data-centre regions.
- Ship the maths as pure functions beside the schema so the card holds no
derived state: normalizeTileGrid(regions), inspectTileMapLayout(regions,
scale) returning an issue or null, tileMapClassOf(value, breaks), and
buildTileMapView(regions, values, scale) returning tiles, class bands,
breaks, min/max/median, highest/lowest, and counts for measured, missing,
duplicate and unknown ids.
Behavior
- NORMALISE THE LAYOUT to its own bounding box and sort it into reading
order (row, then column). Any integers are legal, negatives included, so
adding a column to the west costs one edit instead of re-indexing every
entry. Reading order is not cosmetic: it is the arrow-key order and the
order of the accessible table.
- REFUSE TWO LAYOUT FAULTS instead of quietly repairing them, because both
are invisible once painted: a duplicate region id (one region can never be
given a value) and two regions on the same cell (one tile is painted over
the other, so the reader counts one region fewer than the data has). Name
both offenders in the message. A "manual" scale with no breaks, or breaks
that do not ascend strictly, is the third refusal.
- CLASSIFY, do not ramp. A reader cannot recover a number from a shade, so
the scale is a small number of classes with a legend, chosen by method:
quantile puts equal counts in each class (best for skewed data, breaks
move with the data so two maps are never comparable); equal-interval gives
every class the same width (what rates and percentages want; one outlier
can empty four of five classes, which is information); manual takes your
breaks and is the only method that makes two renders comparable or can put
a break exactly on 0. Bounds are lower-inclusive everywhere — paint,
legend and table — so a value sitting on a break belongs to the class
above it.
- COLLAPSE, DO NOT FAKE, CLASSES. Derived breaks are filtered to those
strictly above the minimum and at or below the maximum. Without that, a
quantile scale over fewer distinct values than classes emits its own floor
as a break and opens the map with a class nothing can land in, and an
all-equal dataset emits k-1 copies of one number and paints every region
at the top of the ramp. When fewer classes survive than were asked for,
say so in the footnote.
- MISSING IS NOT ZERO. On a classed scale 0 is a real measurement in the
lowest class, so null is never coerced. Unmeasured tiles get no fill at
all: a hatch, plus a line in the legend that says "no data — not 0".
- OWN UP TO WHAT IS NOT DRAWN. Value ids that match no tile (territories on
a 50-state grid) are counted and named in the footnote, never dropped
silently. Ids listed twice keep the last figure and are counted too.
- FOUR FIRST-CLASS BRANCHES. loading: the real layout, pulsing, so nothing
moves when the numbers land. empty: the real layout, hatched, because "we
know these 51 places and none has a number yet" is a different sentence
from "we have nothing" — and status "ready" with zero measurements lands
here too. error: the transport message or the specific layout issue, plus
a retry button only when onRetry exists. ready: the map.
- INTERACTION: the SVG is one tab stop with role="listbox"; each tile is a
role="option" with an id, and the cursor is published through
aria-activedescendant rather than by focusing SVG children. Arrow keys
walk the grid and skip the holes — step in the direction until an occupied
cell turns up, because a tile layout is mostly holes. Home/End jump to the
ends of the row, Ctrl/Cmd+Home/End to the first/last region. Enter or
Space calls onSelectRegion, and only takes the key when that prop exists,
so Space still scrolls an inert chart. Escape clears a pinned class, and
only swallows the key when there is a pin to clear. Pointer hover and the
keyboard cursor feed one readout line; hover never moves the cursor.
- CLEANUP: one delegated pointer/click listener on the SVG rather than one
per tile; one ResizeObserver attached through a callback ref (the plot
only exists in one branch, so an effect would run against a skeleton and
never see the plot); its state update hops through requestAnimationFrame
to break the "ResizeObserver loop completed with undelivered
notifications" cycle; observer disconnected and frame cancelled on
replace and on unmount.
- DEGENERATE CASES, each handled deliberately: zero regions (an empty card
saying the layout is what is missing), zero measurements (the hatched
map), one region (single class, mid-ramp fill), all values equal (one
class, and the footnote explains why), negative values (fine — a manual
break at 0 is how you keep decline and growth in different classes), and
numbers too long for a tile (below).
Rendering & styling
- GEOMETRY: one user-unit system where a tile is 10 x 10 with a 1.2 gap,
handed over as viewBox="0 0 (cols*11.2-1.2) (rows*11.2-1.2)" with
preserveAspectRatio="xMidYMid meet" and class "block h-auto w-full". That
is the whole responsive story: the aspect ratio is fixed by the data, so
the card never collapses in a flex parent and no tile can ever be a
different size from its neighbour — the one promise a tile map makes. Cap
the SVG at 60px per column so a wide card does not blow tiles up into
postage stamps.
- COLOUR: the ramp is ONE palette token mixed toward --card, from 14% at the
lowest class to 78% at the highest — never var(--chart-1..5) in sequence.
Those five tokens are a categorical palette (five hues of deliberately
similar weight), so walking them would say "different kind of thing" five
times where the data says "more of the same thing". Mixing one token
toward the card travels away from the surface in both themes (darker on
the light card, lighter on the dark one), so the classes stay monotone in
lightness and survive greyscale and colour vision deficiency, which hue
would not. The ceiling stops short of a full-strength fill so
--foreground stays legible on the deepest class.
- COLOUR IS NEVER THE ONLY CHANNEL: every tile is labelled with its code,
the legend is ordered low to high and prints each class's range and count,
the readout names the class and the rank, missing data is a hatch rather
than a shade, and the sr-only table carries every number.
- LABEL TIERS, decided once for the whole grid from the measured tile size,
never per tile: under 26px nothing is printed (the readout, the tooltip
and the table carry the values); from 26px the region code at 38% of the
tile side; from 40px the value under it at 26%, so neither line ever falls
below about 10px. showValues={false} pins it to the code, true forces the
value whenever any text fits. Before the first measurement assume the
middle tier — because the geometry comes from the viewBox and not from the
measurement, changing tier afterwards moves no pixel of the map.
- TEXT THAT DOES NOT FIT: SVG has no text-overflow, so a long value drops to
compact notation (39,538,223 is ten characters in a box that fits six;
"39.5M" is five) and anything still too wide is squeezed with textLength
plus lengthAdjust="spacingAndGlyphs". Squeezed glyphs are ugly; a label
crossing into the next region is a lie about which tile it belongs to.
- A hover or cursor ring is drawn as a rect inset into the gap, so a
highlight never bleeds onto the neighbouring tile; the ring only appears
while the plot itself holds focus.
- Legend entries are buttons with aria-pressed: hover or focus lights a
class, click pins it, and the tiles outside it fade by mixing their own
fill further toward the card rather than by dropping opacity — opacity
would fade the focus ring too, exactly when it has to be at full strength.
- The plot's accessible name is short and orienting (what it is, how big it
is, how to move); the findings live in a visible paragraph under it and
every figure lives in an sr-only table of region, value, class, rank and
grid position, sorted highest first. Put sr-only on the WRAPPER DIV, 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.
- Motion is one pulse in the loading branch and one colour transition on the
tiles, both dropped under motion-reduce; nothing about the map's function
depends on either.
Customization levers
- Layout: the single biggest lever. Pass your own regions array to move a
state, add territories, or replace the map entirely with counties,
warehouses or AWS regions. Nothing in the component knows what a country
is; it only knows integers.
- scale.method: quantile to keep every class populated on skewed data,
equal-interval when a class has to mean the same thing at both ends,
manual when two renders must be comparable or a threshold has to sit on a
break. scale.classes 3-5 for a card, up to 7 for a full-page map.
- ramp: 1-5 picks which palette token the ramp is built from; the tiles, the
legend swatches and the pinned-class highlight follow together. Widen or
narrow the 14%-78% mix span to flatten or steepen the ramp.
- Density: tile size, gap and corner radius are three constants; drop the
gap to zero for a solid mosaic, raise the 60px cap for a hero map, lower
the 26/40px label thresholds if your codes are one character.
- Content: the legend, the footnote, the readout and the table are separate
blocks — drop any of them for a compact tile, keep the table always.
- Interaction: wire onSelectRegion to drill into a region; without it the
tiles stay readable and inert rather than pretending to be buttons.Concepts
- Grid cartogram — geography is spent, not measured. Every region gets one equal tile at a position you hand-authored, so a two-pixel district is as clickable as a continent and nothing needs a projection, a topology file or a map library. The price is stated on the card: a tile's area means nothing at all.
- Layout as a static asset — the arrangement ships with the component and only the numbers load. That is why the loading state can pulse the real map and the empty state can hatch it: both are honest pictures of "we know these places, we do not know their numbers yet", and nothing shifts when the values arrive.
- Classed scale — a small number of named classes instead of a continuous ramp, because nobody reads a number back out of a shade. Which method you pick is a claim about the data: quantile balances the counts, equal interval balances the widths, manual freezes the breaks so two maps can be compared or a threshold can sit exactly on 0.
- Class collapse — asking for five classes does not create five. Breaks that fall on the minimum, or repeat each other, would open the map with a class nothing can reach or paint an all-equal dataset at full strength; they are dropped, the map draws with fewer classes, and the footnote says how many were lost.
- Missing is not zero — an unmeasured region is hatched rather than tinted, because on a classed scale zero is a real measurement that belongs in the lowest class. Absent from the data and explicitly null render identically; the legend spells the difference out.
- Roving cursor in a listbox — one tab stop on the SVG,
aria-activedescendantnaming the current tile, arrow keys stepping through the holes until they find the next occupied cell. It is what makes every region keyboard-reachable without minting 51 tab stops or asking a browser to focus SVG children.
Radial Bar Chart
Concentric arc bars — one ring per category on one shared ceiling, with a per-ring target tick, capped overflow, anticlockwise negatives and an sr-only value table.
Chart Burndown
A four-state sprint burndown: remaining work against an ideal that only burns on working days, scope changes drawn as steps, and a projected finish from a burn rate a descope cannot fake.