ヒートマップで47都道府県を一望する
「秋田の高齢化が深刻」「沖縄は若い」——なんとなく知ってはいる。でも 47 都道府県を 同じ画面で同じスケールで比べた経験 は、意外と少ないのではないでしょうか。棒グラフは順位を見るには最適ですが、47 本も並ぶと縦に長くなりすぎて、全体傾向が頭に入ってきません。
そこで今回は ヒートマップ の出番です。47 行 × N 列の格子状にデータを並べ、セルを色の濃淡で塗り分ける。1 枚で「どの県が」「どの年代が」「どれくらい高い/低いか」を直感的に把握できるビジュアルが完成します。
本シリーズの過去回では、データ取得(Part 1〜2)と棒グラフ(Part 3)を扱ってきました。今回の Part 4 では、棒グラフ 1 本では扱いきれなかった 「2 軸データ」 をヒートマップで表現します。具体的には、47 都道府県 × 5 年代の高齢化率推移を 1 枚にまとめる、というのがゴールです。
そしてもうひとつのテーマが「配色を Claude Code に任せる」こと。ヒートマップで一番悩むのが色なんですよね。d3-scale-chromatic には 40 種類以上のカラースキームが用意されていますが、「Viridis でいいのか、Oranges でいいのか、それとも RdYlBu の発散系か」——この判断、毎回迷う方は多いはずです。Claude Code に 「何を伝えたいか」を自然言語で投げて、配色を提案してもらう ワークフローを紹介します。
この記事のゴールは次の 3 点です。
- e-Stat の人口推計データから 47 都道府県 × 年代の行列を構築する
- D3 +
d3-scale-chromaticで連続スケールヒートマップを描く - 色覚バリアフリーを考慮した配色を Claude Code に決めさせる
それでは始めましょう。
使うデータ: 高齢化率(65 歳以上人口比率)
今回扱うのは「高齢化率」、つまり総人口に占める 65 歳以上人口の割合(%)です。日本で最もよく引用される統計のひとつで、e-Stat にも複数の収載先があります。
主な取得元候補は次のとおりです(statsDataId は例示なので、実行時は最新のものを e-Stat 検索で確認してください)。
| 統計表 | statsDataId(例) | 特徴 |
|---|---|---|
| 人口推計(年次) | 0003448237 | 毎年 10 月 1 日時点。47 都道府県 × 年齢 5 歳階級。最も粒度が細かい |
| 国勢調査(5 年ごと) | 0003410379 系列 | 完全悉皆調査。10 月 1 日時点。最新は 2025 年 |
| 住民基本台帳人口 | 別系列 | 1 月 1 日時点。外国人を含む / 含まない選択あり |
注記: statsDataId は e-Stat 側の改訂で変わることがあります。Part 2 で紹介した
/search-estatスキルで「人口推計 高齢化率」と打って最新の ID を確認するのが確実です。
今回は 人口推計(年次) を使い、2005 / 2010 / 2015 / 2020 / 2025 年の 5 時点を切り出します。「47 県 × 5 年代 = 235 セル」のヒートマップが完成形のイメージです。
なお、e-Stat の人口推計は「65 歳以上人口」と「総人口」が別行で配信されているため、高齢化率は取得後にこちらで (65歳以上 / 総人口) * 100 を計算する必要があります。Claude Code に頼むときは、この計算ステップを明示しておくと事故が減ります。
Step 1: Claude Code に「高齢化率データ取得」を頼む
stats47 のリポジトリには /fetch-estat-data というスキルが入っていて、e-Stat API の認証・キャッシュ・retry・年度フィルタを全部巻き取ってくれます。Part 1〜2 で扱った道具立てを、ここでも素直に使い回します。
Claude Code に投げるプロンプトはこんな感じです。
/fetch-estat-data を使って、人口推計(statsDataId=0003448237)から
47都道府県 × 5年代(2005, 2010, 2015, 2020, 2025)の高齢化率を取得し、
JSON で保存してください。
要件:
- 65歳以上人口 / 総人口 × 100 を都道府県・年代ごとに計算する
- 出力フォーマットは { areaCode, areaName, year, agingRate } の配列
- 保存先: /tmp/aging-rate-47x5.json
- 都道府県は areaCode 01000〜47000 の5桁固定
ポイントは 3 つあります。
- e-Stat 取得は
/fetch-estat-dataに任せる。年度フィルタや 47 都道府県の取得はスキル側でやってくれるので、こちらは「最終形の JSON 構造」だけ指示すればよい。 - 計算ステップを明示する。「分子と分母の API レスポンス行は別」という前提を共有しておかないと、間違えて 65 歳以上人口の絶対値を率と勘違いされることがある。
- areaCode は 5 桁固定。
.claude/rules/estat-api.mdにも書かれているプロジェクト規約。01(2 桁)と01000(5 桁)が混在するとマージ時に詰む。
実行すると、こんな JSON ができあがります。
[
{ "areaCode": "01000", "areaName": "北海道", "year": 2005, "agingRate": 21.5 },
{ "areaCode": "01000", "areaName": "北海道", "year": 2010, "agingRate": 24.7 },
{ "areaCode": "01000", "areaName": "北海道", "year": 2015, "agingRate": 29.1 },
{ "areaCode": "01000", "areaName": "北海道", "year": 2020, "agingRate": 32.1 },
{ "areaCode": "01000", "areaName": "北海道", "year": 2025, "agingRate": 33.8 },
{ "areaCode": "02000", "areaName": "青森県", "year": 2005, "agingRate": 22.7 }
]
「47 行 × 5 年代 = 235 件」のフラットな配列。これがヒートマップの素材になります。
値は説明用の例示で、最新の確定値ではありません。実行時は API の戻り値をそのまま使ってください。
Step 2: データを 47 行 × N 列の行列に整形
ヒートマップは内部的には 2 次元配列 で扱うのが定石です。フラット配列のままでも D3 で描けますが、「行 = 県」「列 = 年代」と明示的に整理しておくと、後段のスケール設定と凡例実装が格段に楽になります。
整形コードはこんな感じ。Node.js で書いていますが、Claude Code に投げる場合も同じロジックを書いてもらえばよいです。
import fs from "node:fs";
const raw = JSON.parse(
fs.readFileSync("/tmp/aging-rate-47x5.json", "utf-8")
);
const years = [2005, 2010, 2015, 2020, 2025];
// 都道府県ごとにグループ化
const byArea = new Map();
for (const row of raw) {
if (!byArea.has(row.areaCode)) {
byArea.set(row.areaCode, {
areaCode: row.areaCode,
areaName: row.areaName,
values: new Array(years.length).fill(null),
});
}
const idx = years.indexOf(row.year);
if (idx >= 0) {
byArea.get(row.areaCode).values[idx] = row.agingRate;
}
}
// 行列に変換(areaCode 昇順 = 北海道 → 沖縄)
const matrix = Array.from(byArea.values()).sort((a, b) =>
a.areaCode.localeCompare(b.areaCode)
);
console.log(JSON.stringify({ years, matrix }, null, 2).slice(0, 500));
出力構造はこうなります。
{
"years": [2005, 2010, 2015, 2020, 2025],
"matrix": [
{
"areaCode": "01000",
"areaName": "北海道",
"values": [21.5, 24.7, 29.1, 32.1, 33.8]
},
{
"areaCode": "02000",
"areaName": "青森県",
"values": [22.7, 25.8, 30.1, 33.7, 35.2]
}
]
}
行(県)の並び順は 議論の余地あり です。
- 北→南順(areaCode 昇順): 地理感覚と一致して直感的。ただし「どこが高いか」は色で読み取る必要がある
- 値で降順ソート: 「秋田・島根・高知が一番濃い」が一発で分かる。ただし地理感覚は失われる
- クラスタリング: 似た推移パターンの県をまとめる。高度だが解釈は最高
今回は素直に areaCode 昇順を採用します。読み手の頭に「秋田は東北だな」というメンタルマップがあるので、地理順のほうがストーリーを語りやすいのです。
Step 3: D3 でヒートマップ — rect グリッド + カラースケール
データができたら、いよいよ描画です。React のコンポーネントとして書いていますが、ロジック自体は素の D3 で完結します。
"use client";
import { useMemo } from "react";
import * as d3 from "d3";
import {
interpolateOranges,
interpolateRdYlBu,
interpolateViridis,
} from "d3-scale-chromatic";
type CellDatum = {
areaCode: string;
areaName: string;
year: number;
value: number;
};
type Props = {
data: { areaCode: string; areaName: string; values: number[] }[];
years: number[];
scheme?: "oranges" | "rdylbu" | "viridis";
};
const INTERPOLATORS = {
oranges: interpolateOranges,
rdylbu: interpolateRdYlBu,
viridis: interpolateViridis,
};
export function AgingHeatmap({ data, years, scheme = "oranges" }: Props) {
const cells = useMemo<CellDatum[]>(() => {
const out: CellDatum[] = [];
for (const row of data) {
row.values.forEach((value, i) => {
if (value == null) return;
out.push({
areaCode: row.areaCode,
areaName: row.areaName,
year: years[i],
value,
});
});
}
return out;
}, [data, years]);
const [vMin, vMax] = useMemo(() => {
const arr = cells.map((c) => c.value);
return [d3.min(arr) ?? 0, d3.max(arr) ?? 1];
}, [cells]);
const color = useMemo(
() => d3.scaleSequential(INTERPOLATORS[scheme]).domain([vMin, vMax]),
[scheme, vMin, vMax]
);
const cellW = 56;
const cellH = 18;
const padL = 80;
const padT = 32;
const width = padL + cellW * years.length + 24;
const height = padT + cellH * data.length + 8;
return (
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="47県×5年代の高齢化率ヒートマップ">
<g transform={`translate(${padL}, ${padT - 8})`}>
{years.map((y, i) => (
<text key={y} x={cellW * i + cellW / 2} y={0} textAnchor="middle" fontSize={11}>
{y}
</text>
))}
</g>
<g transform={`translate(${padL - 6}, ${padT})`}>
{data.map((row, i) => (
<text
key={row.areaCode}
x={0}
y={cellH * i + cellH * 0.7}
textAnchor="end"
fontSize={10}
>
{row.areaName}
</text>
))}
</g>
<g transform={`translate(${padL}, ${padT})`}>
{cells.map((c) => {
const row = data.findIndex((r) => r.areaCode === c.areaCode);
const col = years.indexOf(c.year);
return (
<rect
key={`${c.areaCode}-${c.year}`}
x={col * cellW}
y={row * cellH}
width={cellW - 1}
height={cellH - 1}
fill={color(c.value)}
>
<title>{`${c.areaName} ${c.year}: ${c.value.toFixed(1)}%`}</title>
</rect>
);
})}
</g>
</svg>
);
}
ポイントは 3 つです。
d3.scaleSequentialを使う。連続値を 1 つのカラーランプにマッピングするときの定番。scaleLinear<string>+interpolateRgbを手書きするより安全<title>で簡易ツールチップ。SVG ネイティブの<title>要素は、マウスホバーで OS のツールチップを出す。実装ゼロでアクセシブルrole="img"+aria-label。ヒートマップ全体の意味をスクリーンリーダーに伝える
ライブラリの追加はこれだけ。
npm install d3 d3-scale-chromatic
npm install --save-dev @types/d3 @types/d3-scale-chromatic
d3-scale-chromatic は D3 本体とは独立した別パッケージなので、忘れず明示インストールしてください。
Step 4: 配色を AI に決めさせる
ここからが本記事の本題です。「配色を Claude Code に任せる」というのは、雰囲気で良さげな色を選んでもらうということではありません。目的と制約を言語化してプロンプトに含め、複数候補の比較を出力させる という具体的なワークフローです。
たとえば、こんなプロンプトを投げてみます。
このヒートマップは「47都道府県の高齢化率 (2005-2025)」を表示します。
配色を提案してください。要件:
1. 連続スケール(順序のある値)であること
2. 色覚バリアフリー(P型・D型)に配慮
3. 高齢化率が高い県を「目立たせたい」(暖色寄り or 暗色寄り)
4. d3-scale-chromatic の interpolator 名で答えてください
5. 候補を3つ挙げて、それぞれのメリット・デメリットを比較
なお、この記事は印刷される可能性もあるので、グレースケール印刷時にも順序が
残るスキームを優先してください。
実際に返ってくる回答(要約)。
| 候補 | スキーム名 | 強み | 弱み |
|---|---|---|---|
| 1 | interpolateViridis | 色覚バリアフリーで世界標準。グレースケールでも順序が残る | 「高齢化=オレンジ」の文化的連想が薄い。やや学術的 |
| 2 | interpolateOranges | 高齢化を「暖色=注意喚起」で表現でき、直感的 | 単色グラデのため値の差が分かりにくい。低値が薄すぎる |
| 3 | interpolatePlasma | 暗→明のコントラストが強く、最大値の県が刺さる | 紫から黄への変化が派手で、報告書には向かない場面も |
実は d3-scale-chromatic には 40 種類以上のスキームがあり、用途別に整理されています。代表的なものを並べると以下のとおり。
| 種別 | スキーム例 | 使いどころ |
|---|---|---|
| Sequential (single hue) | Blues, Oranges, Greens, Purples | 「0 から最大値」の単方向データ。直感的だが幅が狭い |
| Sequential (multi hue) | Viridis, Plasma, Magma, Inferno, Cividis | 色覚バリアフリーの本命。研究論文での標準 |
| Diverging | RdYlBu, RdBu, BrBG, PiYG | 中央値(例: 全国平均)から上下に発散させたいとき |
| Cyclical | Rainbow, Sinebow | 時刻・角度など循環するデータ専用 |
高齢化率は「単方向データ(高いほど課題が深刻)」なので Sequential が適切。色覚バリアフリーを最優先するなら Viridis、ストーリーを「警告色」で語りたいなら Oranges、というのが定石です。
私の結論はこうでした。
- 記事のメイン画像:
interpolateOranges(読者の直感に合わせる) - 論文・レポート用の代替版:
interpolateViridis(同じデータで色だけ差し替えた画像を別に書き出す) - 「全国平均からの偏差」を見せる派生版:
interpolateRdYlBu(reverse=true で「赤=高齢化高い」に)
Claude Code に頼むメリットは、自分が無意識に避けていた候補を提示してくれる こと。私は Cividis を知らなかったのですが、提案されてから常に Viridis と一緒に比較するようになりました。
Step 5: 凡例(legend)を追加
ヒートマップは凡例なしでは読めません。「この色が何 % か」が分からないと、相対比較しかできないからです。
D3 の連続スケール用 legend は、実は標準ヘルパーがありません。自分で linearGradient を仕込んで横長の矩形に塗る のが定石です。
function HeatmapLegend({
color,
vMin,
vMax,
width = 240,
height = 12,
}: {
color: d3.ScaleSequential<string>;
vMin: number;
vMax: number;
width?: number;
height?: number;
}) {
const stops = d3.range(0, 1.0001, 0.1).map((t) => ({
offset: `${t * 100}%`,
color: color(vMin + (vMax - vMin) * t),
}));
return (
<svg width={width} height={height + 18} role="img" aria-label="凡例">
<defs>
<linearGradient id="heatmap-legend">
{stops.map((s, i) => (
<stop key={i} offset={s.offset} stopColor={s.color} />
))}
</linearGradient>
</defs>
<rect x={0} y={0} width={width} height={height} fill="url(#heatmap-legend)" />
<text x={0} y={height + 12} fontSize={10} textAnchor="start">
{vMin.toFixed(1)}%
</text>
<text x={width} y={height + 12} fontSize={10} textAnchor="end">
{vMax.toFixed(1)}%
</text>
</svg>
);
}
<defs> と <linearGradient> の組み合わせがコツです。stops を 10 等分に切って color() でサンプリングし、SVG ネイティブの線形グラデーションを生成しています。カラースケール本体と完全に同期する ので、scheme を切り替えても凡例側を書き直す必要がありません。
凡例の位置は、ヒートマップ本体の右側に縦長で配置するパターンと、上部に横長で配置するパターンの 2 通りがあります。47 県が縦に並ぶ今回のレイアウトでは、上部に横長 のほうがバランスがよいです。
Step 6: アクセシビリティ — ARIA とツールチップ
ヒートマップは「色だけで情報を伝える」ビジュアルなので、色が見えないユーザーへの代替手段 が必須です。
最低限こなしたいのは次の 3 点。
role="img"とaria-label: チャート全体の意味を 1 文で説明する<title>要素: 各セルにホバーで詳細値を表示- タブ可能なフォールバック: スクリーンリーダーで全データを順に読める表を提供
3 番目が手抜きされがちです。<details> でデータテーブルを折りたたんで併置するのが、実装コストとアクセシビリティのバランスが良い解です。
<details>
<summary>テキストでデータを見る</summary>
<table>
<thead>
<tr>
<th>都道府県</th>
{years.map((y) => (
<th key={y}>{y}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.areaCode}>
<th scope="row">{row.areaName}</th>
{row.values.map((v, i) => (
<td key={i}>{v?.toFixed(1) ?? "—"}</td>
))}
</tr>
))}
</tbody>
</table>
</details>
「テキストで見る」というラベルを付けておけば、晴眼者でも「正確な値を引用したい」ときに開いて使えるので、二度おいしい実装です。
つまずきポイントまとめ
実装してみると、「あれ?」となる場面がいくつかあります。代表的なものを列挙しておきます。
カラーランプの向きが逆になる
interpolateRdYlBu は「赤 → 黄 → 青」の順なので、そのまま使うと「赤=低値・青=高値」 になります。高齢化率を表したいときは「赤=高齢化進行」のほうが直感的なので、ドメインを反転させましょう。
const color = d3
.scaleSequential(d3.interpolateRdYlBu)
.domain([vMax, vMin]); // ← 反転
color.range().reverse() で interpolator 側を反転させる方法もありますが、d3.scaleSequential の場合はドメイン反転のほうが意図が明確です。
ゼロ値・null セルの扱い
データが欠損している都道府県・年代があると、color(null) は "#000000" になってしまいます。これは「最低値」と誤読されかねないので、欠損は別色(薄いグレー)で塗るのが安全。
fill={c.value == null ? "#f1f5f9" : color(c.value)}
印刷時にグレースケールになる
社内資料が白黒コピーされる前提なら、グレースケール印刷でも順序が保たれるか を確認しておきましょう。Viridis 系は明度の単調変化が設計されているので問題ありませんが、RdYlBu のような発散系は中央が灰色になって順序が読めなくなります。
Chrome の DevTools には Rendering タブに「Emulate vision deficiencies」と「Print preview」があるので、完成後に必ず両方で目視確認 することをおすすめします。
セル幅と県名ラベルのバランス
47 県の県名ラベルは縦に 47 行並びます。フォントを小さくしすぎると老眼の読者がつらいですが、大きくしすぎると 1 画面に収まりません。12px 程度、line-height 1.4 が経験的な妥協点でした。スマホ向けには横スクロール許容で書き出すのが現実的です。
次回予告
Part 5 では、いよいよ コロプレス地図 に進みます。ヒートマップは「県の並び順」を恣意的に決める必要がありましたが、コロプレス地図なら 地理空間そのものを軸にできる ので、地域クラスタの可視化に強い武器になります。
GeoJSON の取得、d3-geo の geoPath と geoMercator の使い分け、トポロジーの簡略化(topojson-simplify)、配色の流用、ホバーインタラクション——テーマは盛りだくさんです。本記事で作ったカラースケール設計の知見が、ほぼそのままコロプレス地図に転用できます。お楽しみに。
関連ランキング・記事
実データで遊んでみたい方は、stats47.jp の以下のランキングを参考にどうぞ。
- 都道府県別 高齢化率ランキング — 本記事で作ったヒートマップの元データそのまま
- 都道府県別 65 歳以上人口ランキング — 率ではなく絶対数で比較したい場合
- 都道府県別 平均寿命ランキング — 高齢化率と相関の高い指標
シリーズの他記事もぜひ。
- Part 3: 都道府県別人口を Claude Code × D3 で棒グラフに
- Part 2: e-Stat 統計表検索を Claude Code スキル化する
- Part 5(次回・準備中): コロプレス地図で47都道府県を地理的に描く
それでは、よい AI コーディングライフを。