MapLibre GL JS で PMTiles のサーバーレス地図タイルを配信する
タイルサーバーを立てずに、静的ホスティングへ置いた 1 個の .pmtiles ファイルだけでベクター地図を配信するパターン。PMTiles の仕組み(HTTP Range request)、pmtiles protocol の登録、tippecanoe での生成、CORS / キャッシュ設定、React との組み合わせまで、都道府県ポリゴンを 126KB で配信する触れる demo 付きで整理する実装メモ。
検証日: 2026-07-26
使用バージョン:
maplibre-gl@6.0.0/pmtiles@4.4.1/ tippecanoe 2.79.0対象: 「地図を出したいがタイルサーバーは運用したくない」「外部タイル API の利用規約・課金・レート制限から自由になりたい」人
タイルサーバーなしでベクター地図を配信する構成を作る。地図タイル一式を 1 個の .pmtiles ファイルに固めて静的ホスティングに置き、MapLibre GL JS が必要なバイト範囲だけを HTTP Range request(ファイルの一部だけを要求する HTTP の仕組み)で取りに行く。この記事では都道府県ポリゴン 47 件を 126KB の 1 ファイルで配信する。動く demo 付き。
触って試す
このページと同じ静的ホスティングに置いた japan-prefectures.pmtiles(126KB)だけで描画している。ズーム 0〜8 のタイルピラミッド全部が 1 ファイルに入っており、Range 対応ホストなら必要なタイルの分だけ部分取得される。demo 上部に、いまどちらの配信モード(Range / 全体フェッチ fallback)で動いているかが表示される — ホストによって Range 対応が分かれる話は §4 で扱う。
1. PMTiles とは何か
従来のベクタータイル配信は /tiles/{z}/{x}/{y}.pbf 形式の大量の小ファイル(数万〜数百万個)か、タイルサーバー(PostGIS + Martin / tileserver-gl 等)が必要だった。PMTiles はこれを 1 ファイルにまとめるアーカイブ形式:
| 方式 | サーバー | ファイル数 | 更新 |
|---|---|---|---|
| タイルサーバー(Martin 等) | 常駐プロセス必要 | — | 動的、DB 更新が即反映 |
{z}/{x}/{y} 静的展開 | 不要 | 数万〜数百万 | アップロードが苦行 |
| PMTiles | 不要 | 1 個 | 1 ファイル差し替え |
クライアント側は「ファイル内のどこに何のタイルがあるか」の索引(ヘッダ部分)を最初に読み、以降は必要なタイルのバイト範囲だけを Range: bytes=... ヘッダ付きで要求する。サーバーは Range request に応答できる静的ホスティングなら何でもよい(Cloudflare Pages / S3 / GitHub Pages 等)。
2. 最小サンプル(インストール → 表示まで 3 ステップ)
MapLibre は PMTiles を直接は読めないので、pmtiles パッケージで protocol handler(pmtiles:// という URL スキームの処理係)を登録する。
pnpm add maplibre-gl@6.0.0 pmtiles@4.4.1
次のコードは protocol を登録し、.pmtiles ファイルをソースにした地図を表示する:
// v6 は default export 廃止。namespace import で読む(v5 でも動く書き方)
import * as maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";
import "maplibre-gl/dist/maplibre-gl.css";
// アプリ起動時に 1 回だけ登録する
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
japan: {
type: "vector",
// pmtiles:// + ファイルの https URL(または同一オリジンの絶対 URL)
url: "pmtiles://https://example.com/maps/japan-prefectures.pmtiles",
},
},
layers: [
{ id: "bg", type: "background", paint: { "background-color": "#b9cfdf" } },
{
id: "pref-fill",
type: "fill",
source: "japan",
"source-layer": "prefectures", // ← ファイル内のレイヤー名
paint: { "fill-color": "#7c9fd4" },
},
],
},
center: [137.0, 38.0],
zoom: 4,
});
ポイント:
addProtocolはアプリ全体で 1 回。React の component 内で毎回呼ぶとリークするので、module スコープか root の effect で登録するurlはpmtiles://+ ファイルへの URL。source-layerは生成時に付けたレイヤー名(後述の tippecanoe--layer)- ラベル(文字)を出す場合は style に
glyphsの指定が別途必要。ポリゴンと線だけなら不要
3. tippecanoe で GeoJSON から PMTiles を作る
生成側は tippecanoe(Mapbox 発の tile 生成 CLI、現在は felt がメンテ)が定番。v2.17 以降は .pmtiles を直接出力できる。
次のコマンドは GeoJSON をズーム 0〜8 のベクタータイルに変換する:
brew install tippecanoe # macOS
tippecanoe -o japan-prefectures.pmtiles \
-Z0 -z8 \
--layer=prefectures \
--detect-shared-borders \
--simplification=4 \
japan_prefectures.geojson
ポイント:
-z8(最大ズーム)がファイルサイズを決める。今回の都道府県ポリゴンは z8 で 126KB、z12 まで上げると数 MB になる。用途に必要な最小ズームで止める--detect-shared-borders: 隣接ポリゴンの共有境界を同じ形で単純化する(県境の隙間・重なりを防ぐ)--layerが MapLibre 側のsource-layerになる- 中身の確認は
pmtilesCLI(brew install pmtiles)でpmtiles show file.pmtiles(ズーム範囲・タイル数・メタデータが出る)
4. ホスティング: Range request と CORS が要件
置き場所の条件は 2 つだけ — HTTP Range request に応答できること、別オリジンから読むなら CORS ヘッダが出ること。
| ホスト | Range | 備考 |
|---|---|---|
| Cloudflare Pages(classic) | ✅ | 25MB/file 制限に注意(大きい tile は R2 へ) |
| Cloudflare Workers Static Assets | ✖ | 2026-07 実測: Range を無視して 200 で全体を返す(下記) |
| Cloudflare R2 | ✅ | CORS 設定を bucket に追加。egress 無料 |
| GitHub Pages | ✅ | 100MB/file(repo 制限) |
| S3 + CloudFront | ✅ | CORS 設定必要 |
確認方法: 本番 URL に対して curl -I -H "Range: bytes=0-16383" <url> で 206 Partial Content が返れば Range が効いている。200 で全体を返すホストでは、pmtiles ライブラリが Server returned no content-length header ... Check that your storage backend supports HTTP Byte Serving. を投げてタイルが 1 枚も出ない(このサイトの配信基盤 = Workers Static Assets で実際に踏んだ)。
Range 非対応ホストでの選択肢は 2 つ:
- ファイルが小さい(〜数 MB): 全体を 1 回 fetch して
ArrayBufferから切り出すカスタム Source をprotocol.add()する(この demo の fallback 実装。ページの JS から見れば挙動は同一) - ファイルが大きい(basemap 等): Range 対応のストレージ(R2 / S3)に置き、CORS を設定して別オリジンから読む
次のコードは fallback の全体像 — Range に 206 で応えるかを 1 リクエストで判定し、非対応なら全体を読み込んでメモリから切り出す:
import { PMTiles, Protocol } from "pmtiles";
class InMemorySource {
constructor(private url: string, private buf: ArrayBuffer) {}
getKey() { return this.url; }
async getBytes(offset: number, length: number) {
return { data: this.buf.slice(offset, offset + length) };
}
}
const probe = await fetch(url, { headers: { Range: "bytes=0-0" } });
if (probe.status !== 206) {
// Range 無視で全体が返っている → その body をそのまま使う
const buf = await probe.arrayBuffer();
protocol.add(new PMTiles(new InMemorySource(url, buf)));
}
// style 側は同じ url: "pmtiles://<url>" のままでよい
キャッシュはファイル単位で効く。immutable な運用(更新時はファイル名を変える)なら Cache-Control: public, max-age=31536000 を付けると CDN・ブラウザ両方で Range 応答がキャッシュされる。
5. React との組み合わせ
React では「protocol 登録は 1 回だけ」「Map インスタンスは effect で作って cleanup で破棄」の 2 点を守る。次のコードは demo と同じ構成の骨格:
import { useEffect, useRef } from "react";
import * as maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";
import "maplibre-gl/dist/maplibre-gl.css";
let registered = false;
function ensureProtocol() {
if (registered) return;
maplibregl.addProtocol("pmtiles", new Protocol().tile);
registered = true;
}
export function PrefMap() {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ref.current) return;
ensureProtocol();
const map = new maplibregl.Map({ container: ref.current, style: /* §2 と同じ */ {} as never });
return () => map.remove(); // ← これを忘れると WebGL context を食い潰す
}, []);
return <div ref={ref} style={{ height: 400 }} />;
}
Astro / Next.js のような SSR 環境では、maplibre-gl が window を触るので クライアント専用で読み込む(Astro なら client:only="react"、Next.js なら dynamic(() => import(...), { ssr: false }))。
hover で属性を出す・クリックで絞り込むといった操作は map.on("mousemove", "レイヤーid", handler) + map.setFilter() の組み合わせで書ける(demo のソースがこの形)。
6. Leaflet とどう使い分けるか
labs には Leaflet の実装ガイドもあるので位置づけを整理しておく:
- Leaflet: ラスタータイル(画像)ベース。DOM/Canvas 描画。学習コストが低く、マーカー中心の用途に強い
- MapLibre GL: ベクタータイル + WebGL。ズームしても文字・線がにじまない、回転・傾き、クライアント側でのスタイル変更(色分けの切り替え等)ができる
- PMTiles が効くのは MapLibre 側: ベクタータイルの「配信が面倒」という弱点を静的 1 ファイルで消せる。Leaflet + ラスターで同じことをするとタイル画像を大量に静的展開することになる
つまずいたポイント
- 地図が真っ白 + console にエラーなし:
source-layerの名前間違いが定番。タイルは届いているがレイヤー名が一致せず何も描かれない。pmtiles show file.pmtiles --metadataでvector_layersの実名を確認する Failed to fetchで全タイル失敗: 別オリジンに置いた.pmtilesの CORS 未設定。Access-Control-Allow-Originに加えて、Range を使うのでAccess-Control-Allow-Headers: Rangeも必要- ローカル preview では動くのに本番でタイルが 1 枚も出ない: 本番ホスト(Workers Static Assets)が Range 非対応で、pmtiles が
Check that your storage backend supports HTTP Byte Serving.を投げていた。ローカルの preview サーバーは206を返すので開発中は気づけない。§4 の curl 確認は必ず本番 URL で行い、非対応なら fallback(全体フェッチ Source)か R2 に切り替える import maplibregl from "maplibre-gl"が build で"default" is not exportedになる: v6 で default export が廃止された。import * as maplibreglに書き換える(v5 でも同じ書き方で動く)- protocol 登録を component の effect 内で実行してしまう: mount ごとに
Protocolインスタンスが増え、古い方への参照が残る。module スコープのフラグで登録を 1 回に制限する(§5 のensureProtocol) - 県境に白い筋が出る: tippecanoe の単純化で隣接ポリゴンの境界が別々に単純化されたのが原因。
--detect-shared-bordersで解消
向くケース / 向かないケース
| 判定 | |
|---|---|
| 主題データ(行政界・路線・ポイント群)を地図に重ねたい | ◎ 本命 |
| 背景地図ごと自前配信したい(外部タイル API 依存をゼロに) | ◎ Protomaps 配布の basemap をそのまま置く |
| データが毎分更新される(車両位置等) | ✖ タイル再生成が追いつかない。GeoJSON ソースか動的サーバー |
| PostGIS に入った巨大データを都度クエリしたい | ✖ Martin 等のタイルサーバーが適切 |
関連書籍
Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
フロントエンドの知識地図ーー 一冊でHTML/CSS/JavaScriptの開発技術が学べる本 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。