はじめに
OpenStreetMap(OSM)のデータを使って、サーバーレス・オフライン対応のReact地図アプリを作りました。
地図データの配信には PMTiles を採用し、ServiceWorkerとIndexedDBを組み合わせることで、一度オンラインでデータを取得すれば以降はオフラインでも地図を表示できる仕組みを実装しています。
アプリの詳細記事はこちら
https://qiita.com/shun123/items/91a65bfbe41aea8dbbda
本記事では、OSMからPMTilesを生成する変換フローから、Supabase Storageへの保管、オフライン対応の実装ポイントまでを解説します。
技術スタック
| 役割 | 技術 |
|---|---|
| 地図タイル形式 | PMTiles |
| データソース | OpenStreetMap (OSM) |
| 変換ツール | osmtogeojson / tippecanoe |
| ストレージ | Supabase Storage |
| フロントエンド | React |
| オフライン対応 | ServiceWorker + IndexedDB |
1. 配信サーバーが不要
従来のベクタータイル配信では、TileServerGLやMartiniなどのタイルサーバーを別途立てる必要がありました。PMTilesは単一の.pmtilesファイルにすべてのタイルが格納されており、HTTP Range Requestsを使って必要なタイルだけを取得できます。
2. ファイル容量を削減できる
PMTilesはタイルデータを効率的に圧縮して格納しているため、個別タイルファイルを大量に用意する.pbfや従来方式と比べてトータルの容量を抑えられます。ズームレベルや対象エリアを絞ることでさらに軽量化が可能です。
OSMのエクスポート
OpenStreetmapのエクスポートからosmファイルを取得しました。
https://www.openstreetmap.org/#map=19/35.630647/139.879764&layers=N
OSM → PMTiles 変換フロー
変換には以下の2つのツールを使います。
- osmtogeojson:OSMのXMLデータをGeoJSONに変換
- tippecanoe:GeoJSONをPMTilesに変換(Mapboxが開発したツール)
① OSM → GeoJSON
OSMからエクスポートした.osmファイル(XML形式)をosmtogeojsonでGeoJSONに変換します。
osmtogeojson data.osm > data.geojson
② GeoJSON → PMTiles
tippecanoeを使ってGeoJSONをPMTilesに変換します。
tippecanoe data.geojson -o data.pmtiles
Tips:ズームレベルを指定することでファイルサイズをコントロールできます。
例:-z14(最大ズームレベル14)、-Z10(最小ズームレベル10)
インストールから実際の作業までは次の通りです
(すでにosmファイルを取得している前提です)
npm install -g osmtogeojson
# 必要なパッケージをインストール
sudo apt update
sudo apt install -y build-essential libsqlite3-dev zlib1g-dev git
# tippecanoeをクローン
git clone https://github.com/felt/tippecanoe.git
cd tippecanoe
# ビルドとインストール
make -j
sudo make install
# インストール確認
tippecanoe --version
# osmを配置しているフォルダに移動
# osm→geojson
osmtogeojson data.osm > data.geojson
# geojson→pmtiles
tippecanoe data.geojson -o data.pmtiles
Supabase StorageにPMTilesを保管
生成した.pmtilesファイルはSupabase Storageにアップロードして配信します。
Supabase StorageはHTTP Range Requestsに対応しているため、PMTilesのPartial Content取得と相性が良いです。
オフライン対応の実装
オンライン時に一度PMTilesをダウンロードし、IndexedDBにキャッシュすることでオフラインでも地図を表示できるようにします。全体の流れは以下のとおりです。
初回アクセス(オンライン)
└─ ServiceWorker が PMTiles を Supabase Storage から取得
└─ IndexedDB にキャッシュ保存
└─ 以降はオフラインでも IndexedDB から読み込み
ポイント① Partial Content(Range Request)で取得
PMTilesは必要なタイルだけをHTTP Range Requestsで取得する設計になっています。ServiceWorkerでこのリクエストをインターセプトし、IndexedDBにキャッシュ済みの場合はそこから返す、未キャッシュの場合はネットワークから取得してキャッシュするという流れを実装します。
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
const params = url.searchParams;
const method = event.request.method;
// pmtilesファイルの取得
const isPMTilesQuery = url.pathname.endsWith(".pmtiles");
if (isPMTilesQuery) {
const response = handlePMTilesRequest(event, url);
event.respondWith(response);
return;
}
});
async function handlePMTilesRequest(event: FetchEvent, url: URL) {
const regex = /(?<version>version\d+)\/(?<area>[^\/]+)\.pmtiles$/;
const matchGroups = url.pathname?.match(regex)?.groups;
if (!matchGroups) {
return new Response(
"PMTilesのURLフォーマットが不正です。期待するパス形式: version{数字}/{エリア名}.pmtiles",
{
status: 400,
},
);
}
const { area, version } = matchGroups;
const resDB = await fetchPMTiles([area, version]).catch(() => null);
if (resDB && resDB.pmtiles) {
// キャッシュヒットの場合、indexedDBに保持しているpmtilesをpartial contentで返却
// range取得している
const rangeHeader = event.request.headers.get("Range");
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
// 206 Partial Contentで返却
const start = parseInt(match[1], 10);
const end = match[2]
? parseInt(match[2], 10)
: resDB.pmtiles.byteLength - 1;
const slicedBuffer = resDB.pmtiles.slice(start, end + 1);
const partialRes = new Response(slicedBuffer, {
status: 206,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": `${slicedBuffer.byteLength}`,
"Content-Range": `bytes ${start}-${end}/${resDB.pmtiles.byteLength}`,
"Accept-Ranges": "bytes",
},
});
return partialRes;
}
}
// rangeヘッダーにbytesの指定が無い場合、全量のpmtilesを返却
return new Response(resDB.pmtiles, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": `${resDB.pmtiles.byteLength}`,
"Accept-Ranges": "bytes",
},
});
}
// キャッシュミス
const orgRes = await fetch(event.request);
if (!orgRes.ok) return orgRes;
// indexedDBにpmtiles全量を保存
event.waitUntil(
(async () => {
// rangeヘッダーを使わず素のfetchでpmtilesファイル(全量)を取得
const fullRange = await fetch(url.href);
const pmtiles = await fullRange.arrayBuffer();
await savePMTiles({ area, version, pmtiles }).catch((error) =>
console.error(error),
);
})(),
);
return orgRes;
}
ポイント② IndexedDBのオブジェクトにバージョン情報を保持
IndexedDBに保存するオブジェクトには、以下の構造でデータを持たせています。
| フィールド | 内容 |
|---|---|
area |
エリア名(例:tokyo_shinjuku) |
version |
バージョン名(例:v1.0.0) |
pmtilesData |
PMTilesのバイナリデータ(ArrayBuffer) |
バージョン情報を持たせることで、PMTilesファイルが更新された際に古いキャッシュを検出して再取得する仕組みを実現しています。アプリ起動時にサーバー側の最新バージョンとIndexedDB内のバージョンを比較し、差異があれば再ダウンロードします。
// IndexedDB に保存するオブジェクトのイメージ
{
area: "shinjuku",
version: "v1.2.0",
pmtilesData: ArrayBuffer // PMTilesのバイナリ
}
ポイント③ スタイル情報はソースコードに埋め込み
MapLibre GL JSなどで地図を表示する際に必要なスタイル情報(レイヤーの色・太さ・表示ズームレベルなど)は、現時点ではソースコードに直接埋め込んでいます。
緯度・経度の初期表示位置もスタイル情報に含めて管理しており、エリアごとの設定をまとめて一元管理しています。
// スタイル情報の埋め込みイメージ
const mapStyle = {
version: 8,
center: [139.6917, 35.6895], // 緯度経度もここで管理
zoom: 14,
sources: {
// ...
},
layers: [
// ...
]
};
スタイルをあてる際にどこにどんなフィーチャがあるかを以下サイトを使って確認しました。
(pmtilsファイルをアップするだけなので大変重宝しました)
https://pmtiles.io/
今後の課題:スタイル情報とエリア設定はDBから取得できるよう改善予定です。エリアの追加・変更をコード修正なしに行えるようにします。
まとめ
| 課題 | 解決策 |
|---|---|
| タイルサーバーの運用コスト | PMTiles + 静的ファイルホスティング |
| ファイル容量の肥大化 | tippecanoeによる圧縮・ズーム最適化 |
| オフライン利用 | ServiceWorker + IndexedDB |
| キャッシュの鮮度管理 | IndexedDBにバージョン情報を保持 |
PMTilesはサーバーレス構成と非常に相性が良く、Supabase Storageのような静的ホスティングと組み合わせるだけで本格的な地図配信インフラを構築できます。ServiceWorkerとIndexedDBによるオフライン対応も、Range Requestとの組み合わせで効率的に実現できました。
今後はスタイル情報のDB管理や複数エリア対応を進めていく予定です。参考になれば幸いです!
参考リンク
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
