Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, LayoutGrid } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  buildTileMapView,
  inspectTileMapLayout,
  normalizeTileGrid,
  type ChartTileMapData,
  type TileMapClassBand,
  type TileMapGrid,
  type TileMapTile,

Installation

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

Prompt

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-activedescendant naming 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.

On This Page