Next.js × deck.glで作る1kmメッシュ人流可視化アプリの裏側
こんにちは!
こんにちは、株式会社ビットライトのモリタカです。
今回はこちらのG空間情報データ様の全国の人流オープンデータ(1kmメッシュ、市区町村単位発地別)のデータを拝借しまして、関東圏の人流データを可視化しました。
超ざっくり説明すると、可視化の方法としては総人流が多いと高くて、夜より昼間の方が多いと赤くなる。という風に作りました。


こちらから実際に操作できるのでぜひお試しください ソースコードはこちらです。
可視化してみて面白かったのが、この期間がちょうどコロナの過渡期で、2020年4月にがくっと人流が減るんですよね。
確かコロナがちょうど日本に来てしまったのが1月くらいだと記憶してるんですが、みんな自粛し始めたのは4月くらいなんですね。そういえばそうだったなーと今となっては懐かしく思います。
宣伝
弊社ビットライトはGIS、データ可視化・分析・基盤構築、モダンWeb開発を得意とする開発会社です。お仕事のご相談などは弊社の問い合わせ窓口か、代表の私モリタカのXのDMよりください!
以下はソースコードをCodexに読み込ませて解説を書いてもらいました。(今度ちゃんと清書します ^^; )
はじめに
東京圏の人流データはCSVの塊として配布されていますが、実際に街の変化を読み解くには「いつ・どこで・どれくらい」起きているのかを俯瞰できる地図表現が欠かせません。このリポジトリでは、東京都市圏(1都6県)の1kmメッシュと日中/夜間の推計人口を組み合わせ、Webブラウザ上で直感的に比較できるNext.js製のビジュアライザを実装しています。本記事ではデータ加工から可視化UI、GitHub Pages配信までのアーキテクチャをZenn向けに整理しました。
- 目的: 平日/休日や月ごとの人流変化を地図で素早く読み取れるようにする
- 最終成果物: deck.gl + MapLibreによる3Dメッシュ表示と、Chakra UIで構築した操作パネル
- 技術スタック: Next.js 15 (App Router)、TypeScript、deck.gl 9、MapLibre GL、Chakra UI
実際の動作イメージを頭に浮かべつつ、順番に中身を見ていきましょう。
データセットと前処理パイプライン
扱っているデータは国土交通省が公開している1kmメッシュ属性 (attribute_mesh1km_2020.csv) と、人流の月次推計 (monthly_mdp_mesh1km*.csv) です。生データのままでは以下の課題があります。
- メッシュ境界や中心座標が別ファイルに分かれている
- 月単位・平日/休日・時間帯の値が巨大なCSVに広がっている
- 地図で扱うにはGeoJSONやJSONに整形した方が効率的
そこで script/process_mesh_population.mjs をNode.jsで実装し、ZIPアーカイブを解凍しながらストリーミング処理で集約しています。
node script/process_mesh_population.mjs
スクリプトのポイントは以下の通りです。
-
unzip -pを組み合わせ、巨大ZIPをディスクに展開せずストリーム処理 - メッシュ属性を
mesh1kmidをキーにMapへ格納し、人口CSVと突き合わせ -
periods(YYYY-MM)やdayflags(0:平日,1:休日,2:祝前後など)をメタデータとして抽出し、可視化側でドロップダウンに流用 - 集計結果を
public/data/mesh_population_aggregated.jsonとして出力
生成されたJSONには約2.8万メッシュが含まれ、features 配列の各要素に境界・中心座標・平日/休日別人口がまとまっています。ローカルでのデータ前処理で重い計算を済ませておくことで、フロントエンドは単一のJSONをfetchするだけで済みます。
Next.js App Routerと静的エクスポート
このプロジェクトはNext.js App Routerを使い、トップページ (src/app/page.tsx) を「クライアントコンポーネント」として実装しています。理由はdeck.glがWebGL依存であるためSSRが難しく、ブラウザでの状態管理が中心になるためです。
データ読み込みは単純な fetch:
useEffect(() => {
fetch(DATA_URL)
.then(async (response) => {
if (!response.ok) throw new Error(`Failed to fetch data: ${response.status}`);
const json = (await response.json()) as Dataset;
setDataset(json);
setSelectedDayflag(json.metadata.dayflags[0] ?? "0");
setSelectedPeriodIndex(Math.max((json.metadata.periods?.length ?? 1) - 1, 0));
})
.catch((error) => setError(error instanceof Error ? error.message : String(error)));
}, []);
App Routerの静的書き出し (next.config.ts の output: "export") と組み合わせるため、環境変数 NEXT_PUBLIC_BASE_PATH を基に assetPrefix と basePath を設定しています。GitHub Pagesにデプロイする際は NEXT_PUBLIC_BASE_PATH=/people-flow-data-visualization を指定し、ビルドした静的ファイルを gh-pages ブランチに配信するワークフローです。
この戦略の利点は以下の通りです。
- APIサーバを用意せずに巨大JSONを静的ホスティング可能
-
ViewStateやフィルタ状態をフロントエンドだけで完結できる - GitHub Pagesの無料枠で公開でき、再現性も高くなる
deck.gl × MapLibreで1kmメッシュを表現する
主役である PopulationMap コンポーネントは、deck.glの PolygonLayer を使ってメッシュを押し出し表示しています。ポイントは「色」と「高さ」に異なる情報を載せている点です。
const ratioDomain = ratioReference > 0 ? ratioReference : 0.05;
const neutralColor: RGBAColor = [200, 200, 200, 120];
const elevationScale = rawMax ? 0.02 : 0;
new PolygonLayer<MeshData>({
id: "population-mesh",
data,
extruded: true,
elevationScale,
getPolygon: (d) => [
[d.bounds.lonMin, d.bounds.latMin],
[d.bounds.lonMax, d.bounds.latMin],
[d.bounds.lonMax, d.bounds.latMax],
[d.bounds.lonMin, d.bounds.latMax],
],
getElevation: (d) => d.total,
getFillColor: (d) => {
const ratioNorm = clamp(d.ratio / ratioDomain, -1, 1);
const intensity = totalReference
? clamp(Math.log10(Math.max(d.total, 1)) / Math.log10(Math.max(totalReference, 10)), 0, 1)
: clamp(Math.log10(Math.max(d.total, 1)) / 4, 0, 1);
const baseColor = turboColor((ratioNorm + 1) / 2);
const [r, g, b] = blendColor(baseColor, neutralColor, intensity);
const alpha = Math.round(60 + 140 * intensity);
return [r, g, b, alpha];
},
});
- 高さ: そのメッシュの総人流(選択した平日/休日と時間帯の合計)を対数スケールでスムージング
- 色: 夜間人口と日中人口の差分比率(-1〜1)をGoogleのTurboカラーマップで着色し、ボリュームが小さい地域ほど彩度を抑えてノイズを減らす
-
ツールチップ: ホバー時に
総人流(人)と昼夜差(%)を即座に参照できる
@deck.gl/react と react-map-gl/maplibre は動的にimportし、初期表示をブロッキングしない工夫も入っています。WebGL非対応ブラウザではメッセージを返すフォールバックも実装済みです。
UI/UXを支えるコントロール群
データを読むためには操作性も重要です。Chakra UIを用いた PopulationControls が司るのは以下のUI。
- 左上に平日/休日トグルボタン(
dayflag0と1のみ抽出) - 画面下部の期間スライダーで2019-01〜2021-12の範囲を移動
- 選択中の年月を即時表示
const filteredFlags = dayflags.filter((flag) => flag === "0" || flag === "1");
const handleSliderChange = ({ value }: SliderValueChangeDetails) => {
onPeriodChange(value[0] ?? selectedPeriodIndex);
};
加えて、タッチデバイスでのピンチ操作を改善するために MobileTouchMapController を自作し、multipan イベントを有効化しています。1本指ドラッグを回転、2本指をパンに割り当てることで、スマートフォンでも快適に俯瞰できるようになりました。
デプロイとメンテナンスの戦略
ビルドは next export を利用し、GitHub Actionsで out/ を gh-pages ブランチへ自動デプロイします。NEXT_PUBLIC_BASE_PATH を環境変数として渡す点に注意すれば、ローカル開発 (npm run dev) と本番ホスティングの両立が可能です。
今後の改善余地として、リポジトリの docs/README.md では以下をTODOに挙げています。
- カラーバーや凡例の追加で定量的な読み取りを補助
- データ量削減のためのベクトルタイル化や圧縮
- 主要シナリオのE2Eテスト計画
- 月次アニメーションやリプレイ機能
静的ホスティングでも、deck.glのレイヤ構成とデータ前処理を工夫することでリッチな体験を提供できる点がこのプロジェクトの面白さです。
おわりに
1kmメッシュという粒度で都市の動きを可視化するには、ただ地図に色を塗るだけでは伝わりません。データ前処理で平日/休日・昼夜の差を抽出し、deck.glで高さと色を分離して表現することで、街のリズムが立体的に浮かび上がります。
本記事が、人流データやメッシュ統計を活用したい方の参考になれば嬉しいです。Forkして独自のデータセットに差し替えるだけで他地域にも応用できるので、ぜひ試してみてください。
Discussion