GIS開発研究所 > PostGISからベクトルタイルを配信する方法|ST_AsMVTとMapLibre
2026年7月22日
•約7分で読めます
PostGISのST_AsMVTとST_AsMVTGeomを使い、MapLibreへMVTを配信する構成を解説します。z/x/yからタイル範囲を作るSQL、座標変換、GiSTでの空間絞り込み、APIレスポンス、キャッシュ、本番運用の注意点まで紹介します。
PostGISに保存した設備、道路、行政界などをWeb地図へ表示するとき、GeoJSONで全件を返す構成はデータ増加に伴って重くなります。PostGISのST_AsMVTを使うと、現在のズームと表示範囲に必要な地物をMVTとして生成できます。
この記事では、z/x/yを受け取るタイルAPIを想定し、PostGISでMVTを生成してMapLibre GL JSへ表示するまでの基本構成を説明します。
実装では、次の4点を分けて考えます。
ST_TileEnvelopeで対象タイルの範囲を作るST_AsMVTGeomでタイル座標へ変換するST_AsMVTでバイナリのMVTを生成する表示用の座標系はWebメルカトルのEPSG:3857を使います。元データがEPSG:4326の場合は、比較用の範囲とMVT生成用の形状を適切に変換します。
MapLibre GL JS
↓ GET /tiles/facilities/{z}/{x}/{y}.mvt
タイルAPI
↓ z/x/yをSQLパラメータとして渡す
PostGIS
↓ bytea形式のMVT
タイルAPI
↓ Content-Typeを付けて返す
MapLibre GL JS
SQL文字列へz/x/yを直接連結せず、整数として検証したうえでプレースホルダーへ渡します。想定外のズームや座標を拒否することで、SQLインジェクションだけでなく過大な処理も防げます。
次は、facilitiesテーブルからMVTを生成する例です。geomはEPSG:4326を想定しています。
WITH bounds AS (
SELECT ST_TileEnvelope($1, $2, $3) AS geom
),
mvtgeom AS (
SELECT
f.id,
f.name,
f.category,
ST_AsMVTGeom(
ST_Transform(f.geom, 3857),
bounds.geom,
extent => 4096,
buffer => 64
) AS geom
FROM facilities AS f
CROSS JOIN bounds
WHERE f.geom && ST_Transform(
ST_TileEnvelope($1, $2, $3, margin => 64.0 / 4096),
ST_SRID(f.geom)
)
)
SELECT ST_AsMVT(mvtgeom.*, 'facilities', 4096, 'geom', 'id')
FROM mvtgeom;
$1、$2、$3には順にz、x、yを渡します。
ST_TileEnvelope(z, x, y)は、指定したXYZタイルの範囲をEPSG:3857で返します。marginを付けると、ラベルや線がタイル境界で切れにくいよう、検索範囲を少し広げられます。
ST_AsMVTGeomは、地物の座標をMVTのタイル座標へ変換します。
extent: タイル内の座標解像度。一般的には4096buffer: タイル外側へ含める余白clip_geom: 既定ではタイル範囲へクリップ元データがEPSG:4326の場合、ST_Transform(f.geom, 3857)でWebメルカトルへ変換してから渡します。
ST_AsMVTは行の集合をMVTのバイナリへ変換します。第2引数のfacilitiesはレイヤー名です。MapLibre側のsource-layerと一致させます。
タイル範囲で地物を絞る列には、GiST空間インデックスを作成します。
CREATE INDEX facilities_geom_gix
ON facilities
USING GIST (geom);
ANALYZE facilities;
実行計画では、対象タイルの地物数が少ないときにインデックスが使われているか確認します。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM facilities
WHERE geom && ST_Transform(
ST_TileEnvelope(12, 3637, 1612),
ST_SRID(geom)
);
全件をST_Transformしてから比較する書き方は、元のgeomに作ったインデックスを使いにくくします。検索範囲を元データのSRIDへ変換し、インデックス列はそのまま比較するのが基本です。
SQLの結果はbyteaです。APIではテキストやJSONへ変換せず、そのままバイナリとして返します。
Content-Type: application/vnd.mapbox-vector-tile
Cache-Control: public, max-age=3600
地物がないタイルは、空のレスポンスになることがあります。APIフレームワークがnullをJSONへ変換しないように確認します。
タイルのURLが同じでもデータが更新される場合は、キャッシュ期限を短くするか、更新時にキャッシュを無効化します。日次更新なら長め、リアルタイム更新なら短めなど、レイヤーごとに方針を分けます。
MapLibre GL JSでは、vector sourceにタイルURLを指定します。
map.addSource("facilities", {
type: "vector",
tiles: [
"https://example.com/tiles/facilities/{z}/{x}/{y}.mvt",
],
minzoom: 5,
maxzoom: 16,
});
map.addLayer({
id: "facility-points",
type: "circle",
source: "facilities",
"source-layer": "facilities",
paint: {
"circle-radius": 5,
"circle-color": "#1f5b45",
},
});
何も表示されない場合は、最初にsource-layerがSQLのレイヤー名と一致しているか確認します。次に、タイルAPIが200を返しているか、レスポンスサイズが0バイトではないかを確認します。
ST_TileEnvelopeはEPSG:3857です。ST_AsMVTGeomへ渡す形状もEPSG:3857にそろえます。一方、絞り込み条件はインデックスを使うため、タイル範囲を元データのSRIDへ変換します。
SELECT ST_SRID(geom), COUNT(*)
FROM facilities
GROUP BY ST_SRID(geom);
SRIDが0や混在している場合は、タイル実装より先にデータを整えます。
URLルーティングとSQLパラメータの順番がずれると、別の地域のタイルを生成します。サーバーログへz/x/yと返却バイト数を残すと発見しやすくなります。
説明文、内部メモ、更新履歴などをすべてタイルへ入れると、タイルが大きくなります。色分けやラベルに必要な属性だけを含め、詳細はIDを使って別APIから取得します。
日本全体を表示するズームで設備をすべて返すと、MVTでも重くなります。低ズームでは集計結果や代表点を返し、拡大後に個別地物を返します。
WHERE
($1 >= 12)
AND f.geom && ST_Transform(
ST_TileEnvelope($1, $2, $3),
ST_SRID(f.geom)
)
z/x/yの範囲をAPIで検証するMVT化は通信量を減らすだけではありません。低ズームの集約、表示属性、権限、キャッシュまで含めて設計すると、データが増えても運用しやすい地図になります。
BitLightでは、既存GeoJSONの負荷調査、MVT用SQL、タイルAPI、MapLibreのレイヤー設計まで支援しています。
現在の地図を止めずに、重いレイヤーから段階的にMVTへ移行することも可能です。GIS開発を無料相談する
千葉県出身。10歳の頃からプログラミングを始め、ゲーム、Webサイト、ロボット、スマホアプリなどを制作。大阪大学基礎工学部情報科学科で情報工学と統計学を学び、大学時代はAIを研究。大学在学中にWeb広告代理店でのインターンや人材系Webサービスの立ち上げを経験し、卒業後はフリーランスエンジニアとしてGISシステム、データ基盤構築、Webシステムの開発に従事。10年以上のWebアプリ開発・データ分析経験を基に、2023年9月に株式会社ビットライトを設立し、現場業務の仕組み化からデータ基盤構築、データ活用支援までを一気通貫で支援。