tech-book-labs
外部統合 · 最終検証 2026-07-26 · maplibre-gl 6.0.0 · 初公開 2026-07-26

MapLibre GL JS で PMTiles のサーバーレス地図タイルを配信する

タイルサーバーを立てずに、静的ホスティングへ置いた 1 個の .pmtiles ファイルだけでベクター地図を配信するパターン。PMTiles の仕組み(HTTP Range request)、pmtiles protocol の登録、tippecanoe での生成、CORS / キャッシュ設定、React との組み合わせまで、都道府県ポリゴンを 126KB で配信する触れる demo 付きで整理する実装メモ。

maplibre pmtiles vector-tiles map serverless

検証日: 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 で登録する
  • urlpmtiles:// + ファイルへの 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 になる
  • 中身の確認は pmtiles CLI(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 Assets2026-07 実測: Range を無視して 200 で全体を返す(下記)
Cloudflare R2CORS 設定を bucket に追加。egress 無料
GitHub Pages100MB/file(repo 制限)
S3 + CloudFrontCORS 設定必要

確認方法: 本番 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-glwindow を触るので クライアント専用で読み込む(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 --metadatavector_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 等のタイルサーバーが適切

関連書籍

tech-book.net /books/9784873118086

Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net

Kyran Dale/嶋田 健志/木下 哲也

関連テーマ・学習ロードマップから次の一冊へ。

詳細を tech-book.net で見る
tech-book.net /books/9784297138714

フロントエンドの知識地図ーー 一冊でHTML/CSS/JavaScriptの開発技術が学べる本 | tech-book.net

株式会社ICS 池田 泰延/西原 翼/松本 ゆき

関連テーマ・学習ロードマップから次の一冊へ。

詳細を tech-book.net で見る