GeoParquetとPMTilesで全国1kmメッシュの防災Web地図を作る ― 5段階に分けて実装 ―
日本全国の1kmメッシュを土台にして、洪水、土砂災害、津波、内水、地震などの防災データを重ねるWeb地図を作りたいと考えています。大量データを高速に表示する方法も考えています。
最初は、データ変換からWeb表示までを1つのDocker Composeにまとめるつもりでした。ただ、全国データを扱う処理では、どこかで失敗したときの切り分けが難しくなります。Shapefileの読み込みに失敗したのか、空間集計に時間がかかっているのか、Tippecanoeの設定が合っていないのか、PMTilesの配信設定が悪いのかを、エラーの切り分けができずに最初からデバッグする必要があるためです。
そこで、実装を次の5段階に分けることにしました。
実装の手順
- 初回 全体構成と、Step間で受け渡すファイルを決める
- Step 1 e-Statの全国1kmメッシュをGeoParquetへ変換する
- Step 2 防災区域を正規化し、1kmメッシュへ集計する
- Step 3 TippecanoeでPMTilesを作る
- Step 4 NginxとOpenLayersで静的表示する
- Step 5 DuckDBとFastAPIで詳細・統計APIを追加する
- 最終回 Step 1~5の成果物を1つのDocker Compose環境へまとめる
この記事は初回です。まだ全国データそのものは変換しません。先に、各Stepがどこまで担当するか、前後のStepへ何を渡すか、どの時点で検査するかを決めます。
最終的に作る構成
全体は、データを作る処理と、普段動かしておくサービスに分けます。
それぞれの役割は次のとおりです。
| 役割 | 使用するもの | 主な用途 |
|---|---|---|
| 原本・中間成果物 | GeoParquet | 再集計、属性確認、別形式への変換 |
| 空間集計・API用DB | DuckDB Spatial | 詳細検索、統計、FastAPIからの参照 |
| 固定レイヤーの表示 | Tippecanoe / PMTiles | 全国1kmメッシュ、浸水想定区域などの配信 |
| 静的配信 | Nginx | OpenLayersとPMTilesのRange配信 |
| 詳細・統計API | FastAPI | メッシュ詳細、データセット情報、集計値 |
| Web地図 | OpenLayers | 背景地図、凡例、レイヤー切り替え、クリック |
全国1kmメッシュや浸水想定区域のように、頻繁には変わらないレイヤーはPMTilesにして静的配信します。地図を移動するたびにFastAPIでMVTを生成する構成にはしません。
一方で、メッシュの詳しい属性、データの出典、基準日、集計値などはFastAPIから返します。雨量、水位、警報のように更新頻度が高い情報も、将来はFastAPI側へ追加します。
1kmメッシュは共通の索引として使う
防災データは、すべて同じ形ではありません。
洪水浸水想定区域 ポリゴン
土砂災害警戒区域 ポリゴン
避難施設 ポイント
J-SHIS地震ハザード 250mメッシュ
DEM ラスター
雨量・水位 時系列
これらを最初からすべて1kmメッシュだけにしてしまうと、道路沿いや斜面沿いの細かな区域が見えなくなります。そのため、1kmメッシュは全国を横断して検索・集計するための共通索引として使い、元の形状も別に残します。
元データの形状
詳細表示、再集計、検証に使用
1kmメッシュ集計
全国表示、検索、統計、データセット間の比較に使用
たとえば洪水データなら、次の2種類を作ります。
flood_native.parquet
元の浸水想定区域ポリゴン
flood_summary_1km.parquet
1kmメッシュごとの最大区分、影響面積、面積率
Stepごとに分ける理由
各Stepは、できるだけ単独で実行できる小さなプロジェクトにします。
ここでいう「単独で実行できる」は、前段の成果物が不要という意味ではありません。必要なファイルを所定の場所へ置けば、前段のコンテナやPythonソースを起動しなくても処理できる、という意味です。
たとえばStep 2は、Step 1のPythonモジュールをimportしません。読み込むのは、Step 1が作った次のファイルだけです。
また、機能を追加して、表示するデータが増えてもstepを増やせば良いので、特に問題はありません。
例えば、
workspace/artifacts/step1/mesh_master_1km.parquet
workspace/artifacts/step1/manifest.json
workspace/artifacts/step1/validation_report.json
この形にしておくと、次の作業がしやすくなります。
- Step 1だけをやり直してメッシュ件数を比較する
- Step 2で洪水だけ、土砂災害だけを処理する
- Step 3のズーム設定だけを変えてPMTilesを作り直す
- Step 4を架空のPMTilesで先に確認する
- Step 5をフロントなしでテストする
- 最終統合で、どの成果物に問題があるかを追う
フォルダー構成
シリーズ全体は、次の構成で進めます。
japan_disaster_pmtiles_series_overview_r002/
├─ README.md
├─ Makefile
├─ .env.example
│
├─ 00_overview/
│ ├─ README.md
│ ├─ qiita_overview_r002.md
│ ├─ architecture.md
│ ├─ data_contracts.md
│ ├─ development_rules.md
│ └─ execution_plan.md
│
├─ 01_step1_mesh_geoparquet/
│ ├─ README.md
│ ├─ config/
│ ├─ docker/
│ ├─ scripts/
│ ├─ src/
│ └─ tests/
│
├─ 02_step2_hazard_aggregation/
│ ├─ README.md
│ ├─ config/
│ ├─ docker/
│ ├─ scripts/
│ ├─ src/
│ └─ tests/
│
├─ 03_step3_pmtiles/
│ ├─ README.md
│ ├─ config/
│ ├─ docker/
│ ├─ scripts/
│ ├─ src/
│ └─ tests/
│
├─ 04_step4_static_web/
│ ├─ README.md
│ ├─ frontend/
│ ├─ nginx/
│ ├─ scripts/
│ └─ tests/
│
├─ 05_step5_fastapi_duckdb/
│ ├─ README.md
│ ├─ backend/
│ ├─ config/
│ ├─ docker/
│ ├─ scripts/
│ └─ tests/
│
├─ 99_final_integration/
│ ├─ README.md
│ ├─ backend/
│ ├─ frontend/
│ ├─ nginx/
│ ├─ scripts/
│ └─ tests/
│
├─ contracts/
│ ├─ artifact_manifest.schema.json
│ ├─ dataset_catalog.schema.json
│ └─ examples/
│
├─ workspace/
│ ├─ raw/
│ │ ├─ mesh/
│ │ └─ hazards/
│ ├─ artifacts/
│ │ ├─ step1/
│ │ ├─ step2/
│ │ ├─ step3/
│ │ ├─ step4/
│ │ └─ step5/
│ ├─ published/
│ │ └─ tiles/
│ ├─ runtime/
│ └─ logs/
│
└─ scripts/
├─ show_pipeline.py
├─ validate_structure.py
└─ check_overview.sh
01_から05_までは、記事もDocker環境も個別に作ります。99_final_integrationでは、各Stepの処理を書き直すのではなく、検査済みの成果物を組み合わせます。
入力データと成果物をソースから分ける
元データや生成ファイルは、各Stepのソースフォルダーへ置きません。トップレベルのworkspaceへまとめます。
workspace/raw
e-Statや国土数値情報から取得した原本。
展開後も内容は変更しない。
workspace/artifacts/step1~step5
各Stepが作った検査中の成果物。
workspace/published
表示確認と検査が終わったPMTilesやcatalog.json。
workspace/runtime
FastAPIが読み取るDuckDB。
workspace/logs
実行ログ、件数比較、検査結果。
コンテナへマウントするときも、前段の成果物は読み取り専用にします。
Step 2の例
workspace/artifacts/step1 : read only
workspace/raw/hazards : read only
workspace/artifacts/step2 : read / write
処理ミスでStep 1の成果物を書き換えないためです。
Step間で受け渡すファイル
Step 1
workspace/artifacts/step1/
├─ mesh_master_1km.parquet
├─ manifest.json
└─ validation_report.json
mesh_master_1km.parquetには、少なくとも次の列を持たせます。
| 列 | 内容 |
|---|---|
feature_id |
Web表示や内部処理で使う数値ID |
mesh_code |
8桁の3次メッシュコード |
mesh1_code |
1次メッシュコード |
mesh2_code |
2次メッシュコード |
center_lon |
メッシュ中心経度 |
center_lat |
メッシュ中心緯度 |
geometry |
標準地域メッシュのポリゴン |
bbox |
BBOX部分読み込み用の範囲列 |
メッシュコードは計算対象ではなく識別コードなので、整数ではなく文字列で保存します。
Step 2
workspace/artifacts/step2/
├─ native/
│ └─ {dataset_id}.parquet
├─ summary_1km/
│ └─ {dataset_id}__{scenario_id}.parquet
├─ dataset_catalog_fragment.json
├─ manifest.json
└─ validation_report.json
1km集計には次のような列を持たせます。
| 列 | 内容 |
|---|---|
mesh_code |
Step 1のメッシュと結合する8桁コード |
dataset_id |
データセットの識別子 |
scenario_id |
計画規模、想定最大規模などの条件 |
has_hazard |
対象区域と交差するか |
max_class |
メッシュ内の最大区分 |
affected_area_m2 |
重複を除いた影響面積 |
affected_area_ratio |
メッシュ面積に対する影響面積率 |
feature_count |
交差した元地物数 |
Step 3
workspace/artifacts/step3/
├─ tiles/
│ └─ {dataset_id}__{scenario_id}__{version}.pmtiles
├─ pmtiles_catalog_fragment.json
├─ manifest.json
└─ validation_report.json
PMTilesへ入れる属性は、地図表示とクリックに必要なものだけに絞ります。
feature_id
mesh_code
class_id
value
source_layer
長い説明文、利用条件、複数年分の統計値はPMTilesへ詰め込まず、Step 5のAPIから返します。
Step 4
workspace/artifacts/step4/
├─ frontend_dist/
├─ nginx.conf
├─ range_check_report.json
├─ browser_smoke_report.json
└─ manifest.json
Step 4はPMTilesを作りません。Step 3の成果物をNginxから配信し、OpenLayersで問題なく読めるかを確認します。
Step 5
workspace/artifacts/step5/
├─ disaster.duckdb
├─ openapi.json
├─ api_test_report.xml
├─ manifest.json
└─ validation_report.json
DuckDBには、メッシュ詳細、データセット情報、集計値を登録します。PMTilesへ含めなかった属性を、地物をクリックしたときに取得するためのデータベースです。
manifest.jsonを残す
生成物だけが残っていても、どの入力ファイルと設定から作ったのか分からなければ再現できません。各Stepでは、成果物と一緒にmanifest.jsonを出力します。
{
"schema_version": "1.0",
"step": "step2",
"artifact_version": "2026-08-04_r001",
"created_at": "2026-08-04T10:00:00+09:00",
"inputs": [
{
"role": "mesh_master",
"path": "workspace/artifacts/step1/mesh_master_1km.parquet",
"sha256": "..."
}
],
"outputs": [
{
"role": "hazard_summary_1km",
"path": "workspace/artifacts/step2/summary_1km/flood__maximum.parquet",
"sha256": "..."
}
],
"parameters": {
"dataset_id": "flood",
"scenario_id": "maximum"
},
"validation": {
"status": "passed",
"report": "workspace/artifacts/step2/validation_report.json"
}
}
最終統合では、validation.statusがpassedになっている成果物だけを公開対象にします。JSON Schemaはcontracts/へ置き、各Stepで同じ形式を使います。
Step 1:全国1kmメッシュをGeoParquetへ変換する
最初に作るのは、後続処理の基準になる全国1kmメッシュです。
入力には、e-Statの3次メッシュ境界Shapefileを使います。全国分のファイルを読み込み、KEY_CODEを8桁のmesh_codeへ統一し、GeoParquetへまとめます。
この段階で行う処理は次のとおりです。
Shapefileの再帰検索
必須ファイルとCRSの確認
メッシュコードの文字列化
全国分の結合
重複コードの確認
欠損・不正geometryの確認
GeoParquet 1.1への保存
manifestと検査レポートの作成
GeoParquetは、互換性を優先して次の条件から始めます。
GeoParquet 1.1
geometry encoding : WKB
compression : ZSTD
covering bbox : あり
Step 1では、防災区域の集計やPMTiles作成までは行いません。まず共通メッシュだけを安定して作れるようにします。
終わりの目安は次のとおりです。
メッシュ件数が0件ではない
mesh_codeが8桁の数字である
mesh_codeに重複がない
geometryに欠損がない
geometryが有効である
CRSが記録されている
出力したGeoParquetを再読込できる
Step 2:防災データを正規化して1kmメッシュへ集計する
防災データは、提供元やデータ種別によって属性名が異なります。Step 2では設定JSONを用意し、元の列名を共通の列へ置き換えます。
元の属性名
↓ 設定JSONで対応付け
共通列
dataset_id
scenario_id
hazard_class
source_name
source_version
reference_date
元ポリゴンはGeoParquetとして残し、別に1kmメッシュ集計を作ります。集計では、最大区分、影響面積、面積率、交差した地物数を計算します。
防災データを扱うときは、次の点を外さないようにします。
元形状を捨てない
計画規模と想定最大規模を混ぜない
区域外を「安全」と記録しない
EPSG:3857で面積を計算しない
重複ポリゴンの面積を二重計上しない
面積計算に使うCRSは対象地域に合わせて決めます。全国を一度に処理する場合でも、Web表示用のEPSG:3857を面積算定に流用しません。
終わりの目安は次のとおりです。
元形状GeoParquetを再読込できる
dataset_idとscenario_idが空でない
面積率が0以上1以下である
最大区分が設定した範囲内である
対象外・区域外・データなしを区別できる
出典と版がcatalog断片へ記録される
Step 3:TippecanoeでPMTilesを作る
Step 2のGeoParquetから、表示に必要な列だけを取り出してPMTilesを作ります。
GeoParquet
↓ ogr2ogr
FlatGeobuf
↓ Tippecanoe
PMTiles
TippecanoeへGeoParquetを直接渡すのではなく、FlatGeobufを中間形式にします。変換時に列を絞ることで、タイルへ不要な属性が入るのを防ぎます。
ズームレベルは、最初から全国1kmメッシュだけで押し切らず、表示縮尺に合わせて分けます。
Zoom 4~6 都道府県または広域集計
Zoom 7~9 10km・20km集計
Zoom 10~14 1kmメッシュ
Zoom 12以上 元の詳細区域
防災メッシュでは、タイルサイズを小さくするための自動間引きを安易に使いません。地物が落ちると、「危険度が低い」のではなく「タイル作成時に消えた」状態になるためです。低ズームには、あらかじめ粗く集計した別レイヤーを用意します。
終わりの目安は次のとおりです。
PMTilesの構造検査に通る
想定したsource layerが存在する
必須属性がタイル内に残っている
代表地点のタイルを取得できる
ファイル名にデータ版が含まれている
公開中の同名ファイルを上書きしていない
Step 4:NginxとOpenLayersで静的表示する
Step 4では、FastAPIを使わずにPMTilesを表示します。これにより、表示側の問題とAPI側の問題を分けて確認できます。
構成は次のとおりです。
PMTiles
↓ HTTP Range Request
Nginx
↓
ol-pmtiles
↓
OpenLayers
Nginxでは、PMTilesのMIME Type、CORS、長期キャッシュ、Range Requestを設定します。PMTilesファイルへHTTPレベルのgzip圧縮は重ねません。
確認する応答は次のとおりです。
206 Partial Content
Accept-Ranges: bytes
Content-Range
Content-Length
ETag
OpenLayers側では、背景地図切り替え、凡例、レイヤーON/OFF、地物クリックまで作ります。クリック時にはmesh_codeやfeature_idを取得できるようにしておきます。
終わりの目安は次のとおりです。
Web画面を表示できる
Range Requestが206を返す
地図移動時に必要部分だけが取得される
凡例と表示色が一致する
クリックした地物からmesh_codeを取得できる
FastAPIが停止していても固定レイヤーは表示できる
Step 5:DuckDBとFastAPIを追加する
最後に、詳細情報と統計を返すAPIを追加します。
FastAPIはPMTiles本体を配信しません。固定地図はNginxへ任せ、APIはJSONの取得に絞ります。
最初に用意するエンドポイントは次のとおりです。
GET /api/v1/health
GET /api/v1/datasets
GET /api/v1/datasets/{dataset_id}/stats
GET /api/v1/meshes/{mesh_code}?dataset_id=...
DuckDBには、Step 1のメッシュ属性、Step 2の集計値、データセット情報を登録します。APIからは読み取り専用で開き、データ更新処理と参照処理を分けます。
終わりの目安は次のとおりです。
DuckDBを読み取り専用で開ける
health APIが200を返す
datasets APIがcatalogと一致する
mesh_codeで詳細を1件取得できる
存在しないmesh_codeが404になる
統計件数がStep 2の検査結果と一致する
pytestが通る
最終回:検査済みの成果物をまとめる
最終統合では、各Stepの処理を1つの巨大なPythonファイルへまとめません。検査済みの成果物を公開領域へ移し、WebとAPIを起動します。
個別実行用のスクリプトは、次の名前へそろえます。
./scripts/run_step1.sh
./scripts/run_step2.sh
./scripts/run_step3.sh
./scripts/run_step4.sh
./scripts/run_step5.sh
./scripts/run_final.sh
全工程を順番に確認するために、別途build_all.shも用意します。
./scripts/build_all.sh
build_all.shは、単に各スクリプトを呼ぶだけにはしません。各Stepのmanifest.jsonと検査結果を確認し、失敗した時点で止めます。
Dockerコンテナの分け方
データ構築用のコンテナは常駐させません。
| 段階 | コンテナ | 通常運用で常駐 |
|---|---|---|
| Step 1 | mesh-builder | しない |
| Step 2 | hazard-builder | しない |
| Step 3 | tile-builder | しない |
| Step 4 | web | する |
| Step 5 | api | する |
| 最終統合 | web + api | する |
データを更新するときだけbuilderを起動し、普段はwebとapiだけを動かします。
ソースコードの書き方
このシリーズのPythonソースは、次の方針でそろえます。
Python 3.13
文字コード UTF-8
改行 LF
型ヒント 公開関数と主要な内部関数へ付ける
docstring 日本語でParameters / Returns / Raisesを記載
パス pathlib.Pathを使用
ログ loggingを使用
設定 JSON、環境変数、CLI引数へ分離
終了コード 成功は0、検査失敗は非0
テスト pytest
コメントは、コードを日本語に言い換えるだけではなく、処理の理由が後から分かるように書きます。
# EPSG:3857はWeb表示には便利ですが、全国の面積計算には向きません。
# 影響面積を求める処理では、対象地域に合った投影座標系へ変換します。
# メッシュコードは算術計算に使う数値ではなく識別子です。
# 先頭ゼロや桁数を保つため、整数型へ変換せず文字列として保存します。
すべての関数へ長い説明を書くのではなく、入出力、例外条件、GIS処理で間違えやすい点を優先してdocstringとコメントへ残します。
公開ファイルは版を付ける
PMTilesは同じ名前で上書きしません。データ条件と版をファイル名へ含めます。
{dataset_id}__{scenario_id}__{source_version}__{build_revision}.pmtiles
例です。
flood__maximum__2025__r001.pmtiles
landslide__warning_zone__2026__r001.pmtiles
mesh_1km__base__estat__r001.pmtiles
更新時は、新しいファイルを作成して検査し、catalog.jsonの参照先を切り替えます。公開中のファイルを直接置き換えないため、問題があった場合は前の版へ戻せます。
防災データとして気を付けること
区域外を「安全」と表示しない
浸水想定区域や警戒区域に含まれないことと、災害が起きないことは同じではありません。画面では「区域外」「対象データなし」「未整備」を分けて表示し、「安全」という表現は使いません。
出典と基準日を表示する
少なくとも次の情報は、データセット情報またはポップアップから確認できるようにします。
データ名称
作成機関
基準日・基準年度
取得日
シナリオ
原典URL
利用条件
処理版
固定データとリアルタイムデータを混ぜない
PMTilesへ向くのは、更新頻度が低い境界や想定区域です。雨量、水位、地震情報、警報、避難情報はFastAPI側へ追加します。10分ごとに変わる値まで、その都度全国PMTilesへ作り直す構成にはしません。
今回の骨格に含めた確認用ソース
全体構成版には、GIS処理の代わりに、実装順とフォルダー構成を確認する軽量スクリプトを入れています。
python3 scripts/show_pipeline.py
python3 scripts/validate_structure.py
まとめて実行する場合は次です。
make check
show_pipeline.pyは各Stepの入力と出力を表示します。validate_structure.pyは、必須パスが存在するかだけでなく、想定したファイルまたはディレクトリになっているかも確認します。
この段階ではGeoPandas、DuckDB、Dockerは不要です。Step 1以降で、それぞれの処理に必要な環境を追加します。
これからの記事
次回からは、次の順番で実装します。
まずはStep 1で、e-Statの3次メッシュShapefileを全国分まとめ、GeoParquetとして再利用できる状態まで作ります。
長丁場になりますが、頑張って作成して行きましょう。
第1回
e-Stat Shapefileの配置
Docker環境
全国ファイルの読み込み
mesh_codeの正規化
GeoParquet出力
件数・CRS・geometry検査
第2回
防災データ設定JSON
元形状の正規化
CRSとgeometryの確認・修復
1kmメッシュとの空間結合
影響面積率の計算
出典と版の管理
第3回
GeoParquetからFlatGeobufへ変換
Tippecanoeの設定
ズーム別レイヤー
PMTiles作成
pmtiles verify
第4回
Vite + OpenLayers
ol-pmtiles
Nginx Range Request
凡例とレイヤー切り替え
クリック選択
第5回
DuckDB構築
FastAPI
メッシュ詳細API
統計API
pytest
OpenLayersとの連携
最終回
成果物の昇格
Docker Compose統合
一括構築スクリプト
スモークテスト
更新・ロールバック手順
参考資料
- e-Stat 統計地理情報システム データダウンロード
- GeoParquet
- GDAL GeoParquetドライバー
- GDAL FlatGeobufドライバー
- Tippecanoe
- PMTiles
- PMTiles for OpenLayers
- DuckDB Spatial
- FastAPI
- OpenLayers

