都道府県別のデータ可視化 (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 を実地図でやると:
- パスデータが重い — 47 県の GeoJSON は簡略化しても 50KB+。このアプリの JS 全体より大きい
- 小さい県が見えない — 大阪・香川・東京は数ピクセル。一番見たい東京の色が見えない
- ラベルが置けない — 県名 + 数値を地図上に書くと重なって崩壊する
- 面積が意味を持ってしまう — 北海道が視覚的に支配するが、データ上の重要度は面積と無関係
「地理的な位置関係はおおまかに保ちつつ、各県を等面積にする」解がタイルグリッドマップ。アメリカの州別可視化 (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.0 で floor(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/
