Tile Grid Map for Equal-Area Geographic Comparison — MUI X Charts

A tile grid map represents geographic regions (states, countries, provinces) as equally-sized tiles — squares or hexagons — arranged to approximate their real-world geographic positions. Unlike choropleth maps where large-area regions dominate visually, every region receives identical visual weight, making tile grid maps ideal for per-capita or per-region comparisons where the statistic matters more than physical area. Tiles are colored by a data variable and labeled with region abbreviations for identification.

Tile Grid Map for Equal-Area Geographic Comparison rendered with MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// map-tilegrid: Tile Grid Map for Equal-Area Geographic Comparison
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-09-05

import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { ScatterChart } from "@mui/x-charts/ScatterChart";
import { ContinuousColorLegend } from "@mui/x-charts/ChartsLegend";

const tokens = window.ANYPLOT_TOKENS;

// --- Data: renewable electricity share (%) by European country, placed on an
// approximate compass tile grid (row 0 = north, col 0 = west). Every country
// gets one equally-sized tile, so a small nation (Estonia) reads with the
// same visual weight as a large one (France) — the point of a tile grid. ----
const countries = [
  { abbr: "IS", name: "Iceland", row: 0, col: 0, value: 85 },
  { abbr: "NO", name: "Norway", row: 0, col: 3, value: 98 },
  { abbr: "SE", name: "Sweden", row: 0, col: 5, value: 65 },
  { abbr: "FI", name: "Finland", row: 0, col: 7, value: 55 },
  { abbr: "IE", name: "Ireland", row: 1, col: 0, value: 40 },
  { abbr: "GB", name: "United Kingdom", row: 1, col: 1, value: 43 },
  { abbr: "DK", name: "Denmark", row: 1, col: 4, value: 62 },
  { abbr: "EE", name: "Estonia", row: 1, col: 8, value: 32 },
  { abbr: "NL", name: "Netherlands", row: 2, col: 2, value: 37 },
  { abbr: "DE", name: "Germany", row: 2, col: 4, value: 46 },
  { abbr: "PL", name: "Poland", row: 2, col: 6, value: 18 },
  { abbr: "LT", name: "Lithuania", row: 2, col: 8, value: 28 },
  { abbr: "BE", name: "Belgium", row: 3, col: 1, value: 24 },
  { abbr: "CZ", name: "Czechia", row: 3, col: 4, value: 17 },
  { abbr: "SK", name: "Slovakia", row: 3, col: 5, value: 23 },
  { abbr: "UA", name: "Ukraine", row: 3, col: 8, value: 9 },
  { abbr: "FR", name: "France", row: 4, col: 1, value: 25 },
  { abbr: "CH", name: "Switzerland", row: 4, col: 3, value: 72 },
  { abbr: "AT", name: "Austria", row: 4, col: 4, value: 78 },
  { abbr: "HU", name: "Hungary", row: 4, col: 5, value: 14 },
  { abbr: "RO", name: "Romania", row: 4, col: 7, value: 43 },
  { abbr: "PT", name: "Portugal", row: 5, col: 0, value: 61 },
  { abbr: "ES", name: "Spain", row: 5, col: 1, value: 46 },
  { abbr: "IT", name: "Italy", row: 5, col: 4, value: 35 },
  { abbr: "HR", name: "Croatia", row: 5, col: 5, value: 55 },
  { abbr: "RS", name: "Serbia", row: 5, col: 6, value: 30 },
  { abbr: "BG", name: "Bulgaria", row: 5, col: 7, value: 19 },
  { abbr: "GR", name: "Greece", row: 6, col: 6, value: 40 },
];

const N_COLS = Math.max(...countries.map((c) => c.col)) + 1;
const N_ROWS = Math.max(...countries.map((c) => c.row)) + 1;
const colCategories = Array.from({ length: N_COLS }, (_, i) => String(i));
const rowCategories = Array.from({ length: N_ROWS }, (_, i) => String(i));

const points = countries.map((c) => ({
  id: c.abbr,
  x: String(c.col),
  y: String(c.row),
  z: c.value,
}));

const shares = countries.map((c) => c.value);
const minShare = Math.min(...shares);
const maxShare = Math.max(...shares);

// Perceived luminance of the tile fill decides whether the abbreviation reads
// better in dark or light ink — the fill itself always comes from the
// continuous Imprint scale, never a custom hex.
function textColorFor(hex) {
  const n = parseInt(hex.slice(1), 16);
  const r = (n >> 16) & 255;
  const g = (n >> 8) & 255;
  const b = n & 255;
  const toLinear = (c) => {
    const cs = c / 255;
    return cs <= 0.03928 ? cs / 12.92 : ((cs + 0.055) / 1.055) ** 2.4;
  };
  const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
  return luminance > 0.45 ? "#1A1A17" : "#F0EFE8";
}

// Custom scatter mark: opaque square tiles (instead of the default circles)
// sized from the band scales' own bandwidth, so every tile is identical in
// area regardless of how few or many neighbors share its row/column.
function CountryTile(props) {
  const { series, xScale, yScale, colorGetter, color } = props;
  const cellWidth = xScale.bandwidth();
  const cellHeight = yScale.bandwidth();
  const tileSize = Math.min(cellWidth, cellHeight);
  const fontSize = tileSize * 0.32;

  return (
    <g>
      {series.data.map((point, i) => {
        const cx = (xScale(point.x) ?? 0) + cellWidth / 2;
        const cy = (yScale(point.y) ?? 0) + cellHeight / 2;
        const fill = colorGetter ? colorGetter(i) : color;
        return (
          <g key={point.id}>
            <rect
              x={cx - tileSize / 2}
              y={cy - tileSize / 2}
              width={tileSize}
              height={tileSize}
              rx={tileSize * 0.1}
              fill={fill}
              stroke={tokens.pageBg}
              strokeWidth={3}
            />
            <text
              x={cx}
              y={cy}
              textAnchor="middle"
              dominantBaseline="central"
              fontSize={fontSize}
              fontWeight={600}
              fill={textColorFor(fill)}
            >
              {point.id}
            </text>
          </g>
        );
      })}
    </g>
  );
}

export default function Chart() {
  const { width, height } = window.ANYPLOT_SIZE;
  const TITLE_BLOCK_HEIGHT = 100;
  const chartWidth = width;
  const chartHeight = height - TITLE_BLOCK_HEIGHT;

  // ContinuousColorLegend positions its bounding box (bar + both labels)
  // flush against the *full* width passed to ScatterChart — it ignores
  // margin.right entirely, so the tile grid's margin math can't push it
  // inward. Render the chart itself slightly narrower than the mount so a
  // safety strip of true background is always left between the legend's
  // rightmost label glyph and the physical canvas edge.
  const RIGHT_SAFETY = 12;
  const renderWidth = chartWidth - RIGHT_SAFETY;

  // Reserve a slice on the right for the continuous colorbar legend, then
  // fit the largest uniform tile pitch that keeps every tile the same size
  // (the "equal area" rule the spec calls out as the whole point of a tile
  // grid map) inside whatever space remains. The legend itself is only as
  // wide as its longest label (its bar and labels are stacked, not summed),
  // so the reserve only needs to cover that plus a small gap.
  const PAD = 28;
  const LEGEND_RESERVE = 64;
  const availW = renderWidth - PAD * 2 - LEGEND_RESERVE;
  const availH = chartHeight - PAD * 2;
  const cellPitch = Math.min(availW / N_COLS, availH / N_ROWS);
  const gridWidth = cellPitch * N_COLS;
  const gridHeight = cellPitch * N_ROWS;

  const marginLeft = PAD + (availW - gridWidth) / 2;
  const marginTop = PAD + (availH - gridHeight) / 2;
  const marginRight = renderWidth - marginLeft - gridWidth;
  const marginBottom = chartHeight - marginTop - gridHeight;

  return (
    <Box sx={{ width, height, bgcolor: tokens.pageBg, display: "flex", flexDirection: "column" }}>
      <Box
        sx={{
          height: TITLE_BLOCK_HEIGHT,
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          alignItems: "center",
        }}
      >
        <Typography sx={{ color: tokens.ink, fontSize: 22, fontWeight: 500, lineHeight: 1.2, fontFamily: "inherit" }}>
          map-tilegrid · javascript · muix · anyplot.ai
        </Typography>
        <Typography sx={{ color: tokens.inkSoft, fontSize: 13, lineHeight: 1.2, fontFamily: "inherit", pt: "4px" }}>
          Renewable electricity share by country — equal tile weight, not land area
        </Typography>
      </Box>
      <Box sx={{ flex: 1, display: "flex" }}>
        <ScatterChart
          width={renderWidth}
          height={chartHeight}
          skipAnimation
          disableVoronoi
          series={[
            {
              id: "renewables",
              type: "scatter",
              data: points,
              label: "Renewable electricity share",
              xAxisId: "col",
              yAxisId: "row",
              zAxisId: "share",
              valueFormatter: (value, { dataIndex }) =>
                `${countries[dataIndex].name}: ${value.z}% renewable`,
            },
          ]}
          xAxis={[{ id: "col", scaleType: "band", data: colCategories, categoryGapRatio: 0.12 }]}
          yAxis={[{ id: "row", scaleType: "band", data: rowCategories, categoryGapRatio: 0.12 }]}
          zAxis={[
            {
              id: "share",
              min: minShare,
              max: maxShare,
              colorMap: { type: "continuous", min: minShare, max: maxShare, color: [tokens.seq[0], tokens.seq[1]] },
            },
          ]}
          bottomAxis={null}
          leftAxis={null}
          margin={{ top: marginTop, right: marginRight, bottom: marginBottom, left: marginLeft }}
          slots={{ scatter: CountryTile }}
          slotProps={{ legend: { hidden: true } }}
        >
          <ContinuousColorLegend
            axisId="share"
            axisDirection="z"
            position={{ horizontal: "right", vertical: "middle" }}
            direction="column"
            length="55%"
            thickness={14}
            minLabel={({ formattedValue }) => `${formattedValue}%`}
            maxLabel={({ formattedValue }) => `${formattedValue}%`}
            // MUI X measures this label off-screen against `document.body`
            // (not `#container`), so "inherit" would pick up the browser's
            // default serif font instead of the sans-serif stack the label
            // actually renders in — an explicit family keeps the measured
            // and rendered widths in sync.
            labelStyle={{
              fontSize: 13,
              fill: tokens.inkSoft,
              fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
            }}
          />
        </ScatterChart>
      </Box>
    </Box>
  );
}

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/map-tilegrid/muix/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "map-tilegrid",
  "language": "javascript",
  "library": "muix",
  "page": "https://anyplot.ai/map-tilegrid/javascript/muix",
  "hub": "https://anyplot.ai/map-tilegrid",
  "code_json": "https://api.anyplot.ai/specs/map-tilegrid/muix/code",
  "spec_json": "https://api.anyplot.ai/specs/map-tilegrid",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/map-tilegrid/javascript/muix/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/map-tilegrid/javascript/muix/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/map-tilegrid/javascript/muix/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/map-tilegrid/javascript/muix/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Tile Grid Map for Equal-Area Geographic Comparison on anyplot.ai.

Other implementations