0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

47 都道府県の choropleth を SVG パス無しで作る — タイルグリッドマップで最低賃金を可視化する

0
Posted at

都道府県別のデータ可視化 (choropleth / 階級区分図) を作ろうとすると、最初の壁が「日本地図の SVG パスをどこから持ってくるか」。Natural Earth 由来の GeoJSON は最低 50KB、簡略化しても各県のパスは数百点。しかも小さい県 (香川・大阪) は色が見えないほど潰れる。タイルグリッドマップ (1 県 = 1 正方形タイル) なら、データは県あたり (col, row) の整数 2 つ、合計 1KB 以下。地理的正確さを捨てる代わりに、全県が同じ面積で比較でき、ラベルも数値も直接書ける。令和6年度の地域別最低賃金 (厚労省公表) を題材に実装した。

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

スクリーンショット

実地図 choropleth の問題

都道府県 choropleth を実地図でやると:

  1. パスデータが重い — 47 県の GeoJSON は簡略化しても 50KB+。このアプリの JS 全体より大きい
  2. 小さい県が見えない — 大阪・香川・東京は数ピクセル。一番見たい東京の色が見えない
  3. ラベルが置けない — 県名 + 数値を地図上に書くと重なって崩壊する
  4. 面積が意味を持ってしまう — 北海道が視覚的に支配するが、データ上の重要度は面積と無関係

「地理的な位置関係はおおまかに保ちつつ、各県を等面積にする」解がタイルグリッドマップ。アメリカの州別可視化 (NPR の state grid) で広く使われている手法の日本版。

データ構造: 県 = タイル座標 1 個

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

タイル配置は「北海道が右上、沖縄が左下、東北は縦に、瀬戸内は横に」という日本列島の トポロジー をおおまかに保つ。配置の正解は無いので、隣接関係がだいたい合っていれば OK。

レンダリングは rect を 47 個置くだけ:

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

タイルが 52px あるので、県名と金額を直接タイルに書ける。実地図では不可能なこと。

タイル配置をテストで守る

タイル座標は手書きなので、ありがちなミスは「2 つの県を同じマスに置く」。Set で検証:

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");
});

test("exactly 47 prefectures", () => {
  assert.equal(PREFECTURES.length, 47);
});

test("codes are 1..47 unique", () => {
  const codes = PREFECTURES.map((p) => p.code).sort((a, b) => a - b);
  assert.deepEqual(codes, Array.from({ length: 47 }, (_, i) => i + 1));
});

tile.join(",")[11, 8]"11,8" に潰して Set へ。配列は参照比較なので直接 Set に入れても重複検出できない、という JS あるあるを避ける。

Choropleth の階級区分: equal-interval

色分けは最小〜最大を 5 等分する equal-interval 方式:

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));
}

Math.min(buckets - 1, ...) がポイント。value === max のとき t = 1.0floor(1.0 * 5) = 5 になり、0..4 の範囲を溢れる。最大値は最後のバケツに含めるという境界処理。

equal-interval の代わりに quantile (各バケツの県数を揃える) という選択肢もあるが、最低賃金データは「東京・神奈川だけ飛び抜けて高い」分布なので、equal-interval の方が「上位 2 県だけ赤い」という実態が見える。

凡例も同じ式から生成:

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);
  }
});

データそのものをテストする

データは厚労省の令和6年度確定値をハードコード。データ整合性テストを書く:

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 < NATIONAL_AVG_HISTORY.length; i++) {
    assert.ok(NATIONAL_AVG_HISTORY[i].avg >= NATIONAL_AVG_HISTORY[i - 1].avg);
  }
});

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

test("2024 row matches prefecture data", () => {
  const h2024 = NATIONAL_AVG_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)));
});

最後のテストが地味に重要: 2 つの独立したデータテーブル (県別 / 年別) の整合性を機械的に検証する。手で 2 箇所にデータを書くと必ずズレるので。

推移チャートも素の SVG

2004〜2024 の 3 系列 (東京 / 全国加重平均 / 最低県) を折れ線で。チャートライブラリは使わず、スケール関数だけ自作:

export function chartScale(history, width, height, pad = 30) {
  const years = history.map((h) => h.year);
  const allValues = history.flatMap((h) => [h.avg, h.tokyo, h.lowest]);
  const minVal = Math.floor(Math.min(...allValues) / 100) * 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,
  };
}

Math.floor(min / 100) * 100 で軸の下端を 100 円単位に丸める (600 円から始まる軸)。グリッド線も 100 円刻みで引けば読みやすい。

データから見えること:

  • 2004 年: 全国平均 665 円 → 2024 年: 1,055 円 (20 年で 1.59 倍)
  • 2020 年 (コロナ) だけ +1 円でほぼ凍結。それ以外は加速していて、2023 年 +43 円、2024 年 +51 円
  • 最高と最低の絶対差は拡大 (2004: 104 円 → 2024: 212 円)。比率はほぼ横ばい

YoY のテストも書ける:

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

設計

data.js  ← 47 県 + 21 年推移 (厚労省公表値)
core.js  ← bucketing, stats, tile/chart 数学 (DOM-free, 34 tests)
app.js   ← SVG render

試してみる

タイルにマウスを乗せると全国加重平均との差が出ます。関東圏と地方の「色の断層」が一目で見えるのがタイルマップの良さ。

まとめ

  • 都道府県 choropleth はタイルグリッドマップにすると、パスデータ 50KB → 座標 1KB になり、全県等面積で、ラベルも直接書ける。
  • タイル配置は手書きデータなので Set による重複検証をテストに入れる。
  • equal-interval bucketing は Math.min(buckets - 1, floor(t * buckets))最大値の境界を処理する。
  • 公的データのハードコードには整合性テスト (単調増加・系列間の不等式・複数テーブルの相互一致) をセットで書く。
  • 軸スケールは floor(min/100)*100 で丸めるだけでチャートライブラリ無しでも読みやすくなる。

これは SEN 合同会社の OSS ポートフォリオ #260 です。https://sen.ltd/portfolio/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?