【MapLibre】オフラインで動作する Web 地図を実装する
PWA を利用して、オフラインで動作する Web 地図を MapLibre で実装します。
はじめに
ブラウザで表示される Web 地図はインターネットに接続されている必要があります。これは地図を表示するために、タイルやフォントファイルといった地図データに加え、そもそも Web ページを構成する HTML 要素をサーバーから取得する必要があるためです。
逆に言うとこれらの要素を使用する端末上にキャッシュしさえすれば、オフラインでも動作する Web 地図を実現できます。その正攻法は PWA (Progressive Web Apps) の活用でしょう。PWA とは Google が提案した Web ページをスマホアプリのように動作させる技術で、通常の Web ページよりもキャッシュ戦略の幅が広がります。
そもそも管理人がオフラインで動作する Web 地図をつくろうと思ったきっかけは、Wi-Fi マップを趣味で作っていた時のことでした。ネットを求めて Wi-Fi を探すのに、オフラインで動作しないのでは本末転倒だと思ったのです。
そのため、本記事で紹介するソースコードは以下の Wi-Fi Speed Map に実装してあるものになっています。細かい実装はこれを読み解いてください。以下のリンクの Web ページに遷移し、「ホーム画面に追加」から PWA インストールすれば、オフラインでも地図が閲覧できるはずです。

PWA とは
前述の通り PWA とは Progressive Web Apps の略で、Web ページをスマホアプリのように動作させる技術です。Google が提案した技術なのですが、Apple は PWA に否定的なのか、Safari のプッシュ通知対応は 2023 年とつい最近で、カメラ機能が使えるようになったのも 2020 年頃になってからでした。
通常の Web サイトを PWA 化するのに最低限必要なファイルは以下の 2 つです。
- manifest.json (iOS では一部のプロパティを無視)
- Service Worker が記述された JavaScript ファイル
manifest.json は PWA におけるアプリ名やアイコン、起動 URLなどを定義しています。 Service Worker はブラウザの主動作と独立しバックグラウンドで動作する JavaScript で、ネットワークリクエストを横取りし、キャッシュや独自ロジックで応答できる仕組みです。以下これらのファイルについて説明します。
補足:PWA にはどの Web ページが PWA として動作するかを決める「scope」という概念があります。scope はディレクトリ単位となっており、特定ディレクトリにのみ PWA を設定することも可能です。しかし、scope を用いても複数の PWA を単一サイトで分けることは難しく、当サイトも Wi-Fi Speed Map のみをサブドメインとして、ドメインごと分けています。
manifest.json
Wi-Fi Speed Map の manifest.json を例として示します。
{
"id":"wifi-speed-map",
"name":"Wi-Fi Speed Map",
"short_name":"WiFi Map",
"description":"A web page for sharing Wi-Fi speeds in the style of an internet forum. Once downloaded, this map can be used even without an internet connection.",
"icons":[
{
"src":"画像ファイルパス",
"sizes":"192x192",
"type":"image\/png",
"purpose":"any"
},
{
"src":"画像ファイルパス",
"sizes":"512x512",
"type":"image\/png",
"purpose":"any"
}
],
"screenshots":[
{
"src":"画像ファイルパス",
"sizes":"640x360",
"type":"image\/png",
"label":"Homescreen of wifiPWA App",
"form_factor":"wide"
},{
"src":"画像ファイルパス",
"sizes":"360x640",
"type":"image\/png",
"form_factor":"narrow",
"label":"Homescreen of wifiPWA App"
}
],
"background_color":"#cccccc",
"theme_color":"#d5e0eb",
"display":"standalone",
"dir":"ltr",
"orientation":"portrait",
"start_url": "\/?utm_source=wifiPWA&utm_medium=wifiPWA&utm_campaign=wifiPWA",
"categories":[
"travel"
],
"scope": "\/",
"shortcuts":[
{
"name":"Wi-Fi Speed Map",
"description":"A web page for sharing Wi-Fi speeds in the style of an internet forum. Once downloaded, this map can be used even without an internet connection.",
"url": "\/wifi-speed-map\/",
"icons":[
{
"src":"画像ファイルパス",
"sizes":"192x192"
}
]
}
],
"launch_handler":{
"client_mode":"auto"
},
"handle_links":"preferred"
}
概ねプロパティから中身は予測できるかと思いますが、何点か補足します。
”display”:”standalone” はアプリの表示形式を指定しています。minimal-ui にするとブラウザに近い見た目に、fullscreen にすると表示エリア全体に広がります。
”dir”:”ltr” はアプリ内でのテキストの書字方向(Left to Right)を指定しています。
”launch_handler” や “handle_links” はアプリ起動時・リンククリック時のウィンドウや PWA の開き方を指定しています。
上記 manifest.json を読み込むため、以下のコードを HTML のヘッダーに書きます。
<link rel="manifest" href="https://wifi.pancake-trail.site/wifipwa-manifest.json">
Service Worker
Wi-Fi Speed Map の Service Worker を例として示します。コードの前に Wi-Fi Speed Map の説明をさせてください。
Wi-Fi Speed Map では背景地図に OSMFJ サーバーの OpenMapTiles を使用しています。これを表示するための style.json は他サイトのサーバー上にあると PWA でキャッシュできないため、コピーを自分のサイトのサーバーに保存します。デザインを変えたければ適宜カスタムしましょう。またこの style.json には planet/takeshima/hoppo という別の style.json を含んでいるため、これももれなく自分のサイトのサーバーに保存します。
Wi-Fi Speed Map では、経緯度分割した GeoJSON と、全世界のクラスター用の GeoJSON の二つを参照しています。これらもキャッシュする必要があるため、cacheJsonMesh() という関数と、cacheJsonCluster() で管理しています。地図表示する JavaScript 側には GeoJSON の取得時に最新のデータを参照するよう日時パラメータを付加しており、オフライン時のみキャッシュが参照されるようにしています。
PWA 上でのキャッシュはベクトルタイル 100 MB、経緯度分割したメッシュ GeoJSON を10 個までに制限しています。ベクトルタイルのキャッシュサイズは getCacheSize() という関数を使って管理をしています。
const STATIC_CACHE = 'wifi-static-v1';
const TILE_CACHE = 'wifi-tiles-v1';
const JSON_CACHE = 'wifi-json-v1';
const MESH_CACHE = 'wifi-mesh-v1';
const MESH_LIMIT = 10;
const STATIC_ASSETS = [
'/wp-content/themes/pancake-trail/pancake-trail_json/style-en.json',
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE).then(cache => {
return cache.addAll([
'/',
...STATIC_ASSETS
]);
})
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys
.filter(key =>
key !== STATIC_CACHE &&
key !== TILE_CACHE &&
key !== JSON_CACHE &&
key !== MESH_CACHE
)
.map(key => caches.delete(key))
);
})
);
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const pathname = url.pathname;
if (pathname.includes('wifi_cluster_latitude_and_longitude.json')) {
event.respondWith(cacheJsonCluster(event.request));
return;
}
if (pathname.includes('pancake-trail_wifijson/wifi_')) {
event.respondWith(cacheJsonMesh(event.request));
return;
}
if (pathname.startsWith('/wp-content/themes/pancake-trail/')) {
event.respondWith(cacheFirstStatic(event.request));
return;
}
if (event.request.url.includes('pancake-trail_json/style-en.json')) {
event.respondWith(cacheFirstStatic(event.request));
return;
}
if (event.request.url.endsWith('.json') && event.request.url.includes('/data/')) {
event.respondWith(cacheFirstStatic(event.request));
return;
}
if (event.request.url.includes('/styles/osm-bright-ja/sprite')) {
if (event.request.url.endsWith('.png') || event.request.url.endsWith('.json')) {
event.respondWith(cacheFirstStatic(event.request));
return;
}
}
if (event.request.url.match(/\/fonts\/.*\.pbf$/)) {
event.respondWith(cacheFirstStatic(event.request));
return;
}
if (event.request.url.endsWith('.pbf') || event.request.url.endsWith('.mvt')) {
event.respondWith(cacheFirstTile(event.request));
return;
}
if ((event.request.url.endsWith('.jpg') || event.request.url.endsWith('.png')) && !event.request.url.includes('/styles/osm-bright-ja/sprite')) {
event.respondWith(cacheFirstTile(event.request));
return;
}
if (pathname.startsWith('/wp-admin/') || pathname.startsWith('/wp-login.php')) {
return;
}
if (event.request.method !== 'GET') {
return;
}
if (pathname === '/' || pathname === '/index.html' || pathname === '/index.php') {
const CANONICAL_URL = '/';
event.respondWith(
(async () => {
try {
const requestForHtml = new Request(CANONICAL_URL, {
method: 'GET',
headers: event.request.headers,
mode: 'same-origin',
credentials: 'include'
});
const networkResponse = await fetch(requestForHtml);
const clone = networkResponse.clone();
const cache = await caches.open(STATIC_CACHE);
await cache.put(CANONICAL_URL, clone);
return networkResponse;
} catch (e) {
const cached = await caches.match(CANONICAL_URL);
if (cached) return cached;
return new Response('Offline', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
})()
);
return;
}
});
async function cacheFirstStatic(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
cache.put(request, response.clone());
return response;
}
async function cacheFirstTile(request) {
const cache = await caches.open(TILE_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
cache.put(request, response.clone());
await enforceTileCacheLimit(cache, 100 * 1024 * 1024);
return response;
}
async function getCacheSize(cache) {
let total = 0;
const keys = await cache.keys();
for (const key of keys) {
const res = await cache.match(key);
if (!res) continue;
const buf = await res.clone().arrayBuffer();
total += buf.byteLength;
}
return total;
}
async function enforceTileCacheLimit(cache, limitBytes) {
let size = await getCacheSize(cache);
if (size <= limitBytes) return;
const keys = await cache.keys();
for (const key of keys) {
await cache.delete(key);
size = await getCacheSize(cache);
if (size <= limitBytes) break;
}
}
async function cacheJsonCluster(request) {
const cache = await caches.open(JSON_CACHE);
const canonical = request.url.split('?')[0];
try {
const res = await fetch(request);
cache.put(canonical, res.clone());
return res;
} catch (e) {
const cached = await cache.match(canonical);
if (cached) return cached;
return new Response('{}', {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
async function cacheJsonMesh(request) {
const cache = await caches.open(MESH_CACHE);
const canonical = request.url.split('?')[0];
try {
const res = await fetch(request);
await cache.put(canonical, res.clone());
await enforceMeshLimit(cache, MESH_LIMIT);
return res;
} catch (e) {
const cached = await cache.match(canonical);
if (cached) return cached;
return new Response('{}', {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
async function enforceMeshLimit(cache, limit) {
const keys = await cache.keys();
if (keys.length <= limit) return;
const excess = keys.length - limit;
for (let i = 0; i < excess; i++) {
await cache.delete(keys[i]);
}
}
コードを見れば多くのことが分かると思うので細かい説明は割愛しますが、オフライン地図という観点だと pbf/mvt ファイル(ラスタタイルだと jpg/png ファイル)を明示的にキャッシュすることがポイントです。その他フォントなども忘れずにキャッシュが必要です。キャッシュを GET に限定していますが、POST などが下手に残らないようにするのも重要です。
上記の Service Worker を登録するために、以下のコードを HTML のヘッダーに書きます。
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('https://wifi.pancake-trail.site/wifimappwa-sw.js');
}
</script>
以上で PWA を使った Web 地図のオフライン対応は完了です。
ちょっと宣伝
当サイトは地図を使った海外旅ブログ/YouTube まとめサイトとなっています。トップページに地図がありますが、地名を押すと画像が開き、画像を押すと関連記事一覧(クリック数順)が開きます。記事数が多い国ほど赤く、地名もその国で記事が多い都市の文字が大きくなるようにしています。ぜひ覗いてみてください。
当サイトで海外旅ブログを執筆することも可能です(無料)! また既にブログ/ YouTube をお持ちの方も、当サイトからリンクを貼ることができるようになっています。気になる方は「当サイトについて」をご覧ください。
パントレ管理部