DEV Community

SEN LLC
SEN LLC

Posted on

A Choropleth Map Without Map Paths — Visualizing Japan's Minimum Wages with a Tile Grid

The first wall you hit building a prefecture-level choropleth of Japan (or any country) is: where do the map paths come from? GeoJSON from Natural Earth starts at ~50KB simplified — bigger than the entire app. And the smallest prefectures (Tokyo! Osaka!) are a few pixels wide, exactly where the most interesting data lives. A tile grid map — one square tile per prefecture, positioned to roughly preserve the country's shape — replaces 50KB of paths with two integers per prefecture, gives every region equal visual area, and leaves room to label each tile with its name AND value. I built one for Japan's FY2024 statutory minimum wages (data: Ministry of Health, Labour and Welfare), in 500 lines of vanilla JS.

🌐 Demo: https://sen.ltd/portfolio/min-wage-jp/
📦 GitHub: https://github.com/sen-ltd/min-wage-jp

Screenshot

Why real-geography choropleths fail at this

  1. Path data is heavy — 47 prefecture outlines cost 50KB+ even simplified.
  2. Small regions vanish — Tokyo and Osaka render at a few pixels. The highest-wage prefectures are the least visible.
  3. No room for labels — name + value on a real map collide and overlap.
  4. Area lies — Hokkaido dominates the canvas visually, but its data importance has nothing to do with its land area.

The tile grid map (popularized by NPR's US state grid) trades geographic fidelity for equal-area comparability. Each prefecture is one square; the squares are arranged to roughly echo Japan's shape — Hokkaido top-right, Okinawa bottom-left, Tohoku stacked vertically.

The data structure: two integers per prefecture

export const PREFECTURES = [
  { code: 1,  name: "北海道", wage: 1010, region: "北海道", tile: [12, 0] },
  { code: 13, name: "東京",   wage: 1163, region: "関東",   tile: [11, 8] },
  { code: 47, name: "沖縄",   wage: 952,  region: "九州",   tile: [0, 11] },
  // ... 47 total
];
Enter fullscreen mode Exit fullscreen mode

Rendering is 47 <rect> elements:

export function tileRect(tile, size = 52, gap = 4) {
  const [col, row] = tile;
  return { x: col * (size + gap), y: row * (size + gap), size };
}
Enter fullscreen mode Exit fullscreen mode

At 52px per tile, each tile carries its prefecture name and wage value directly — impossible on a real map.

Guard hand-written tile positions with a test

Tile coordinates are hand-authored, and the classic mistake is assigning two prefectures the same cell:

test("tile positions are unique (no overlapping tiles)", () => {
  const positions = new Set(PREFECTURES.map((p) => p.tile.join(",")));
  assert.equal(positions.size, 47, "two prefectures share a tile");
});
Enter fullscreen mode Exit fullscreen mode

Note tile.join(",") — arrays are reference-compared in JS, so putting them straight into a Set wouldn't detect duplicates.

Equal-interval bucketing, with the max-value edge

export function bucketIndex(value, min, max, buckets) {
  if (buckets <= 0) throw new Error("buckets must be ≥ 1");
  if (max === min) return 0;
  const t = (value - min) / (max - min);
  return Math.min(buckets - 1, Math.floor(t * buckets));
}
Enter fullscreen mode Exit fullscreen mode

The Math.min(buckets - 1, ...) matters: at value === max, t = 1.0 and floor(1.0 * 5) = 5, overflowing the 0..4 range. The max value belongs in the last bucket.

Why equal-interval instead of quantile (equal-count) buckets? Japan's wage distribution has a long right tail — Tokyo (¥1,163) and Kanagawa (¥1,162) are far above everyone else. Equal-interval makes that visible: only two tiles glow red. Quantile bucketing would flatten the story.

The legend derives from the same math:

export function legendRanges(min, max, buckets) {
  const step = (max - min) / buckets;
  return Array.from({ length: buckets }, (_, i) => ({
    from: Math.round(min + step * i),
    to: Math.round(min + step * (i + 1)),
  }));
}

test("contiguous ranges", () => {
  const r = legendRanges(951, 1163, 5);
  for (let i = 1; i < r.length; i++) assert.equal(r[i].from, r[i - 1].to);
});
Enter fullscreen mode Exit fullscreen mode

Test the data itself

The dataset is hardcoded government figures (MHLW, FY2024). Hardcoded data deserves integrity tests:

test("Tokyo is the highest", () => {
  const max = Math.max(...PREFECTURES.map((p) => p.wage));
  assert.equal(PREFECTURES.find((p) => p.name === "東京").wage, max);
});

test("avg is monotonically non-decreasing (min wage never went down)", () => {
  for (let i = 1; i < HISTORY.length; i++) {
    assert.ok(HISTORY[i].avg >= HISTORY[i - 1].avg);
  }
});

test("tokyo ≥ avg ≥ lowest for every year", () => {
  for (const h of HISTORY) assert.ok(h.tokyo >= h.avg && h.avg >= h.lowest);
});

test("2024 row matches prefecture data", () => {
  const h2024 = HISTORY.find((h) => h.year === 2024);
  assert.equal(h2024.tokyo, PREFECTURES.find((p) => p.name === "東京").wage);
  assert.equal(h2024.lowest, Math.min(...PREFECTURES.map((p) => p.wage)));
});
Enter fullscreen mode Exit fullscreen mode

That last one is the quiet hero: the app has two independent data tables (per-prefecture FY2024, and a 21-year national history). When the same fact lives in two places, a cross-consistency test catches the inevitable drift.

The trend chart, also library-free

Three series (Tokyo / national weighted average / lowest prefecture), 2004–2024, as plain SVG paths. The only "infrastructure" is a scale function:

export function chartScale(history, width, height, pad = 30) {
  const allValues = history.flatMap((h) => [h.avg, h.tokyo, h.lowest]);
  const minVal = Math.floor(Math.min(...allValues) / 100) * 100; // round to ¥100
  const maxVal = Math.ceil(Math.max(...allValues) / 100) * 100;
  return {
    x: (year) => pad + ((year - minYear) / (maxYear - minYear)) * (width - pad * 2),
    y: (val) => height - pad - ((val - minVal) / (maxVal - minVal)) * (height - pad * 2),
    minVal, maxVal,
  };
}
Enter fullscreen mode Exit fullscreen mode

Rounding the axis bounds to ¥100 (floor(min/100)*100) means the gridlines land on round numbers. That one trick is most of what a chart library would buy you here.

What the data shows:

  • National weighted average: ¥665 (2004) → ¥1,055 (2024) — 1.59× in 20 years
  • 2020 (COVID year) froze at +¥1; recent years accelerate: +¥43 (2023), +¥51 (2024)
  • The absolute Tokyo-vs-lowest gap widened (¥104 → ¥212) while the ratio held steady

Even the historical shape is testable:

test("2020 (corona year) has the smallest increase", () => {
  const min = yoyDeltas(HISTORY).reduce((a, b) => (b.delta < a.delta ? b : a));
  assert.equal(min.year, 2020);
  assert.equal(min.delta, 1);
});
Enter fullscreen mode Exit fullscreen mode

Architecture

data.js  ← 47 prefectures + 21-year history (MHLW published figures)
core.js  ← bucketing, stats, tile/chart math (DOM-free, 34 tests)
app.js   ← SVG render
Enter fullscreen mode Exit fullscreen mode

Try it

Hover any tile for the gap vs. the national average. The color cliff between greater Tokyo and everywhere else is the whole story, visible in one glance — which is what a choropleth is for.

Takeaways

  • Tile grid maps turn 50KB of geo paths into 2 integers per region, equalize visual area, and create room for on-tile labels.
  • Hand-authored positions need a uniqueness testnew Set(tiles.map(t => t.join(","))).
  • Equal-interval bucketing needs the min(buckets-1, ...) clamp for the max value, and beats quantile bucketing when the distribution has a meaningful tail.
  • Hardcoded public data deserves integrity tests: monotonicity, inter-series inequalities, and cross-table consistency.
  • A scale function with round-number axis bounds replaces a chart library for simple line charts.

This is OSS portfolio #260 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)