国土数値情報 A33/A46 を DuckDB Spatial + FastAPI + OpenLayers でベクトルタイルを表示する
はじめに
国土数値情報の A33(土砂災害警戒区域) や A46(地すべり防止区域) は、全国規模で見るとかなり重いデータです。
そのまま GeoJSON として OpenLayers に読み込むと、以下のような問題が出ます。
問題点
- 初期表示が遅い
- ズーム・パンのたびにブラウザが重い
- 全国表示では Feature 数・頂点数が多すぎる
- GML を都度読むとパース処理が重い
- 属性付きポリゴンを一括転送すると通信量が大きい
そこで今回は、ローカル環境の Docker Compose 上で、以下の構成を使って A33/A46 をベクトルタイル化して高速表示する構成を整理します。
DuckDB Spatial + FastAPI + OpenLayers + MVT(Vector Tile)
この記事では、まずローカルサイドの検証用構成として、DuckDB から動的に MVT を生成して OpenLayers で表示する方法をまとめます。
対象データ
| データ | 内容 | 主な形状 | 用途 |
|---|---|---|---|
| A33 | 土砂災害警戒区域・土砂災害特別警戒区域 | 面・線 | イエローゾーン、レッドゾーンの表示 |
| A46 | 地すべり防止区域 | 面・点 | 地すべり防止区域の表示 |
A33 は以下の区域を持ちます。
- 土砂災害警戒区域(イエローゾーン)
- 土砂災害特別警戒区域(レッドゾーン)
現象種別は以下です。
- 急傾斜地の崩壊
- 土石流
- 地すべり
A46 は、地すべり防止区域を所管省庁別に分類したデータです。
- 国土交通省
- 農林水産省農村振興局
- 林野庁
なぜベクトルタイルにするのか
GeoJSON 一括表示とベクトルタイル表示を比較すると、全国表示では差が大きく出ます。
| 項目 | GeoJSON一括表示 | MVTベクトルタイル |
|---|---|---|
| 初期表示 | 遅い | 速い |
| 全国表示 | 重い | 必要タイルだけ取得 |
| ズーム時の表示 | 全体再描画で重い | 表示範囲だけ再取得 |
| ブラウザ負荷 | 高い | 低い |
| 属性表示 | 可能 | 可能 |
| スタイル変更 | 可能 | 可能 |
| キャッシュ | しにくい | しやすい |
| 本番公開 | 不向き | 向いている |
今回のポイントは、元データは DuckDB に入れ、WebMap 表示はタイル単位で配信することです。
全体構成
全体構成図は 以下のイメージです。
システム構成
プロジェクト名
今回のプロジェクト名です。
sabo_hazard_ol_fastapi_duckdb_mvt_r001
フォルダー構成
Dockerでの実装を基本としてフォルダー構成を決めています。
参考のためにZIPファイルを置いておきますので、ダウンロードして活用してください。
sabo_hazard_ol_fastapi_duckdb_mvt_r001/
├─ .env
├─ docker-compose.yml
├─ README.md
├─ data/
│ ├─ raw/
│ │ ├─ A33/
│ │ └─ A46/
│ ├─ extracted/
│ │ ├─ A33/
│ │ └─ A46/
│ ├─ duckdb/
│ │ └─ sabo_hazard.duckdb
│ └─ tile_cache/
│ ├─ a33/
│ └─ a46/
├─ api/
│ ├─ Dockerfile
│ ├─ requirements.txt
│ └─ app/
│ ├─ main.py
│ ├─ settings.py
│ ├─ db.py
│ ├─ ingest.py
│ ├─ mvt.py
│ └─ utils.py
└─ web/
├─ Dockerfile
└─ html/
├─ index.html
├─ main.js
└─ style.css
.env
環境変数などを.envにまとめています。
API_PORT=8000
WEB_PORT=8080
DUCKDB_PATH=/data/duckdb/sabo_hazard.duckdb
A33_RAW_DIR=/data/raw/A33
A46_RAW_DIR=/data/raw/A46
A33_EXTRACTED_DIR=/data/extracted/A33
A46_EXTRACTED_DIR=/data/extracted/A46
TILE_CACHE_DIR=/data/tile_cache
ENABLE_TILE_CACHE=true
SOURCE_CRS=EPSG:6668
WEB_CRS=EPSG:3857
MVT_EXTENT=4096
MVT_BUFFER=64
MVT_MIN_ZOOM=4
MVT_MAX_ZOOM=16
DEFAULT_CENTER_LON=137.0
DEFAULT_CENTER_LAT=36.0
DEFAULT_ZOOM=6
A33/A46 は JGD2011 の緯度経度データなので、SOURCE_CRS=EPSG:6668 としておきます。WebMap 表示用には Web メルカトルの EPSG:3857 に変換します。
docker-compose.yml
containerを複数動かすので、Docker composeで動かすため、ymlを書きます。
services:
api:
build: ./api
container_name: sabo_hazard_api
ports:
- "${API_PORT:-8000}:8000"
volumes:
- ./data:/data
environment:
- DUCKDB_PATH=${DUCKDB_PATH:-/data/duckdb/sabo_hazard.duckdb}
- A33_RAW_DIR=${A33_RAW_DIR:-/data/raw/A33}
- A46_RAW_DIR=${A46_RAW_DIR:-/data/raw/A46}
- A33_EXTRACTED_DIR=${A33_EXTRACTED_DIR:-/data/extracted/A33}
- A46_EXTRACTED_DIR=${A46_EXTRACTED_DIR:-/data/extracted/A46}
- TILE_CACHE_DIR=${TILE_CACHE_DIR:-/data/tile_cache}
- ENABLE_TILE_CACHE=${ENABLE_TILE_CACHE:-true}
- SOURCE_CRS=${SOURCE_CRS:-EPSG:6668}
- WEB_CRS=${WEB_CRS:-EPSG:3857}
- MVT_EXTENT=${MVT_EXTENT:-4096}
- MVT_BUFFER=${MVT_BUFFER:-64}
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
web:
build: ./web
container_name: sabo_hazard_web
ports:
- "${WEB_PORT:-8080}:80"
volumes:
- ./web/html:/usr/share/nginx/html:ro
depends_on:
- api
API Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
unzip \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
EXPOSE 8000
api/requirements.txt
fastapi
uvicorn[standard]
duckdb
python-dotenv
Web Dockerfile
FROM nginx:alpine
COPY html /usr/share/nginx/html
EXPOSE 80
データ取得
A33 土砂災害警戒区域データ
国土数値情報の A33 ページから取得します。
まずは全国版ではなく、1県分から始めるのがおすすめです。
例:秋田県の場合
秋田 世界測地系 令和2年 15.1MB
data/raw/A33/A33-20_05_GML.zip
全国版を使う場合は以下です。
全国 世界測地系 令和2年 1310.00MB
data/raw/A33/A33-20_00_GML.zip
ただし、全国版はファイルサイズが大きいため、最初の検証では都道府県単位のデータを推奨します。
A46 地すべり防止区域データ
国土数値情報の A46 ページから取得します。
例:秋田県の場合
秋田 世界測地系 令和2年 286KB
data/raw/A46/A46-20_05_GML.zip
DuckDB Spatial の初期化
DuckDB では Spatial 拡張を読み込みます。
INSTALL spatial;
LOAD spatial;
Python 側では以下のように接続します。
import duckdb
con = duckdb.connect('/data/duckdb/sabo_hazard.duckdb')
con.execute('INSTALL spatial;')
con.execute('LOAD spatial;')
テーブル設計
A33 と A46 は別テーブルにします。
ポイントは次の2つです。
- 元座標系の
geom_4326ではなく、表示用のgeom_3857を作っておく -
x_min, y_min, x_max, y_maxの bbox 列を持たせる
bbox 列を持たせることで、タイル範囲に関係する Feature を先に粗く絞り込めます。
取り込み処理の考え方
Shapefile は ST_Read で読み込みます。
CREATE OR REPLACE TEMP TABLE tmp_a33 AS
SELECT *
FROM ST_Read('/data/extracted/A33/A33-20_14_GML/xxx.shp');
取り込み時に Web メルカトルへ変換します。
ST_Transform(geom, 'EPSG:6668', 'EPSG:3857', true) AS geom_3857
bbox も作成しておきます。
ST_XMin(geom_3857) AS x_min,
ST_YMin(geom_3857) AS y_min,
ST_XMax(geom_3857) AS x_max,
ST_YMax(geom_3857) AS y_max
MVT配信API
API は以下のようにします。
GET /api/health
POST /api/ingest
GET /api/counts
GET /api/a33/tiles/{z}/{x}/{y}.pbf
GET /api/a46/tiles/{z}/{x}/{y}.pbf
本命はこの2つです。
GET /api/a33/tiles/{z}/{x}/{y}.pbf
GET /api/a46/tiles/{z}/{x}/{y}.pbf
DuckDBでMVTを生成するSQL
A33 の例です。
WITH
tile AS (
SELECT ST_TileEnvelope(?, ?, ?) AS bounds
),
features AS (
SELECT
id,
hazard_type_name,
zone_name,
area_no,
area_name,
address,
ST_AsMVTGeom(
ST_SimplifyPreserveTopology(geom_3857, ?),
ST_Extent(bounds),
4096,
64,
true
) AS geom
FROM a33_hazard, tile
WHERE
x_max >= ST_XMin(bounds)
AND x_min <= ST_XMax(bounds)
AND y_max >= ST_YMin(bounds)
AND y_min <= ST_YMax(bounds)
AND ST_Intersects(geom_3857, bounds)
)
SELECT ST_AsMVT(features, 'a33', 4096, 'geom', 'id')
FROM features
WHERE geom IS NOT NULL;
A46 の場合は、レイヤ名を a46 にして、属性を変えます。
SELECT ST_AsMVT(features, 'a46', 4096, 'geom', 'id')
FROM features
WHERE geom IS NOT NULL;
ズーム別の簡略化
広域表示では形状を簡略化した方が軽くなります。
def zoom_to_tolerance(z: int) -> float:
if z <= 6:
return 200.0
if z <= 8:
return 80.0
if z <= 10:
return 30.0
if z <= 12:
return 10.0
return 0.0
詳細ズームでは 0.0 にして、元形状に近い表示にします。
FastAPIでMVTを返す
from fastapi import FastAPI, Response
app = FastAPI()
@app.get('/api/a33/tiles/{z}/{x}/{y}.pbf')
def a33_tile(z: int, x: int, y: int):
data = get_a33_mvt(z, x, y)
return Response(
content=data,
media_type='application/vnd.mapbox-vector-tile',
headers={'Cache-Control': 'public, max-age=86400'},
)
OpenLayers側
OpenLayers では VectorTile と MVT を使います。
const a33Layer = new ol.layer.VectorTile({
declutter: true,
source: new ol.source.VectorTile({
format: new ol.format.MVT(),
url: 'http://localhost:8000/api/a33/tiles/{z}/{x}/{y}.pbf',
maxZoom: 16
}),
style: function(feature) {
const zoneName = feature.get('zone_name') || '';
if (zoneName.includes('特別')) {
return new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(255, 0, 0, 0.45)' }),
stroke: new ol.style.Stroke({ color: 'rgba(180, 0, 0, 0.9)', width: 1 })
});
}
return new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(255, 220, 0, 0.45)' }),
stroke: new ol.style.Stroke({ color: 'rgba(180, 150, 0, 0.9)', width: 1 })
});
}
});
A46 は紫系で表示します。
const a46Layer = new ol.layer.VectorTile({
source: new ol.source.VectorTile({
format: new ol.format.MVT(),
url: 'http://localhost:8000/api/a46/tiles/{z}/{x}/{y}.pbf',
maxZoom: 16
}),
style: new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(140, 80, 255, 0.35)' }),
stroke: new ol.style.Stroke({ color: 'rgba(90, 40, 180, 0.9)', width: 1 })
})
});
画面イメージ
UIのイメージ図です。
起動手順
1. フォルダーを作成
mkdir sabo_hazard_ol_fastapi_duckdb_mvt_r001
cd sabo_hazard_ol_fastapi_duckdb_mvt_r001
2. データ配置
data/raw/A33/A33-20_14_GML.zip
data/raw/A46/A46-20_14_GML.zip
3. Docker Compose起動
docker compose up --build
4. API確認
別の PowerShell で確認します。
curl http://localhost:8000/api/health
以下のように返ればOKです。
{"status":"ok"}
5. データ取り込み
curl -X POST http://localhost:8000/api/ingest
6. 件数確認
curl http://localhost:8000/api/counts
例:
{
"a33_hazard": 123456,
"a46_landslide": 789
}
7. WebMapを開く
http://localhost:8080
タイルキャッシュ
MVT は以下に保存します。
data/tile_cache/a33/{z}/{x}/{y}.pbf
data/tile_cache/a46/{z}/{x}/{y}.pbf
2回目以降は DuckDB で再生成せず、キャッシュ済みの .pbf を返します。
データを入れ替えた場合はキャッシュを削除します。
rmdir /s /q data\tile_cache\a33
rmdir /s /q data\tile_cache\a46
mkdir data\tile_cache\a33
mkdir data\tile_cache\a46
実装時の注意点
1. 最初は全国版を使わない
A33 の全国版はかなり大きいので、最初は1県分で確認します。
おすすめの順番は以下です。
1. A46の1県分
2. A33の1県分
3. A33/A46の複数県
4. A33全国版
2. A46は属性名に注意
A46 は所管省庁により、属性名が次のように分かれる可能性があります。
A46-a_001
A46-b_001
A46-c_001
SQL で参照する場合、ハイフンを含む列名はダブルクォートで囲みます。
"A46-a_001"
3. Shapefileの文字コードに注意
属性文字列が文字化けする場合は、GeoJSON版を使うか、取り込み前に ogr2ogr 等で UTF-8 に変換します。
4. 防災データとしての注意
A33/A46 は防災上重要なデータですが、Web表示では縮尺や更新時点に注意が必要です。正確な区域確認や法的判断には、各都道府県や自治体の公式資料を確認してください。
将来の拡張事項
将来的に、以下の3ステップで拡張を行いたいと考えています。
r002
- A33 現象種別フィルタ
- 急傾斜地の崩壊
- 土石流
- 地すべり
- A33 区域区分フィルタ
- イエローゾーン
- レッドゾーン
- A46 所管省庁フィルタ
- 左側パネル追加
r003
- タイルキャッシュ管理画面
- 取り込みログ表示
- クリック属性の整形
- 住所・区域名検索
r004
- PMTiles出力
- 静的配信
- S3配信
- CloudFront配信
PMTiles化も有力
ローカル検証では、DuckDB + FastAPI の動的 MVT 配信で十分だと思います。
ただし、公開用・本番用としては、最終的に PMTiles へ変換する構成が良いです。
静的な防災系データは更新頻度が高くないため、PMTiles と相性が良いと考えられます。
これについては、別途検討して記事にします。
まとめ
国土数値情報 土砂災害警戒区域などのA33/A46 の全国表示では、GeoJSON一括表示ではなく、ベクトルタイル化するのが現実的です。
また、今後gitに公開予定です。
今回の構成では、以下の流れで高速表示を実現します。
高速表示のフロー
国土数値情報 ZIP
↓
DuckDB Spatial へ取り込み
↓
EPSG:3857 へ事前変換
↓
bbox列で高速絞り込み
↓
DuckDBでMVT生成
↓
FastAPIで配信
↓
OpenLayers VectorTileで表示
ローカルサイドの検証では、まず以下の構成がおすすめです。
Docker Compose + DuckDB Spatial + FastAPI + OpenLayers + MVT
将来的に本番公開する場合は、以下へ発展できます。
DuckDBで前処理 → PMTiles化 → 静的配信
A33/A46のような全国規模の防災GISデータは、表示用データを事前に整えることが重要です。DuckDB Spatial を使うと、ローカル環境でも取り込み・変換・タイル生成まで扱いやすく、試作から本番構成まで段階的に発展させやすいです。
参考リンク
-
国土数値情報 A33 土砂災害警戒区域データ
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-A33-v1_4.html -
国土数値情報 A46 地すべり防止区域データ
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-A46-v1_0.html -
DuckDB Spatial Functions
https://duckdb.org/docs/current/core_extensions/spatial/functions -
DuckDB Spatial Extension Overview
https://duckdb.org/docs/stable/core_extensions/spatial/overview.html -
OpenLayers VectorTile API
https://openlayers.org/en/latest/apidoc/module-ol_source_VectorTile-VectorTile.html -
OpenLayers MVT Example
https://openlayers.org/en/latest/examples/vector-tile-info.html -
Qiita Mermaid 紹介
https://qiita.com/Qiita/items/c07f3262d8f3b25f06c9




