0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

国土数値情報 土砂災害警戒区域など を DuckDB Spatial + FastAPI + OpenLayers でベクトルタイルを表示する

0
Last updated at Posted at 2026-06-23

国土数値情報 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 表示はタイル単位で配信することです。


全体構成

全体構成図は 以下のイメージです。

ベクトルタイル配信処理フロー
ChatGPT Image 2026年6月10日 11_22_44.png


システム構成

A33 / A46 ベクトルタイル配信システム構成図
ChatGPT Image 2026年6月10日 11_36_06.png


プロジェクト名

今回のプロジェクト名です。

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つです。

  1. 元座標系の geom_4326 ではなく、表示用の geom_3857 を作っておく
  2. x_min, y_min, x_max, y_max の bbox 列を持たせる

bbox 列を持たせることで、タイル範囲に関係する Feature を先に粗く絞り込めます。


取り込み処理の考え方

A33 / A46 データ取り込みシーケンス図
ChatGPT Image 2026年6月10日 11_50_45.png

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 では VectorTileMVT を使います。

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のイメージ図です。

A33/A46 WebMap画面UI設計図
ChatGPT Image 2026年6月10日 13_05_37.png


起動手順

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 静的配信フロー図
ChatGPT Image 2026年6月10日 13_10_27.png

静的な防災系データは更新頻度が高くないため、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 を使うと、ローカル環境でも取り込み・変換・タイル生成まで扱いやすく、試作から本番構成まで段階的に発展させやすいです。


参考リンク


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?