クライアントサイド地図タイルサーバー

サービスワーカーを活用した、クライアントサイド動的地図タイルサーバーの作り方を紹介します。
目次
概要
はんけトケにヒートマップ表示機能を追加しました。 このヒートマップは地図をズームレベルに応じて倍々分割した画像、すなわち「タイル」で出来ており、必要な部分だけがオンデマンドに表示されます。
しかし、このためのタイル配信サーバーはどこにも存在しません。 クライアントがタイルを自前で生成し、自身のタイル要求に自ら“応答”しているのです。
左様にサーバーを装えるクライアント側の実装といえば、ウェブ開発に詳しいかたならピンと来るでしょう。 そう、サービスワーカーです。
なぜ作ったか
ヒートマップは、典型的には人口の多寡などを、行政区画やメッシュ単位で色分けして表します。 実はそのため程度の情報なら、日本全国分まとめても量はたかが知れています。
「全国の市区町村×人口」データなぞ、たとえテキスト形式でもそう大きくないことは想像がつきますね。 メッシュのヒートマップだって、日本全域を3千キロメートル四方とすれば、画像に例えると3,000×3,000ピクセル(3次地域メッシュの場合)にしかすぎません。 しかもその大半は海であり、無情報の塊として省く工夫も可能です。
ならば、すべての情報を一括でクライアントにロードして適当に可視化し、日本全国を覆う(多少は分割するにしても)レイヤーとして表示してしまえば手っ取り早い。
ところが、ロードは確かに一括で済むものの、表示のほうはこの方法ではあまりうまくいきません。 ぼやけたり動作がぎこちなくなったり、何かと問題が生じるのです。
ではタイル化してサーバー配信すればいいかというと、技術的には可能ですが、とてつもなく馬鹿らしい。 クライアントに難なく一括ロードできる程度の小さな情報を、なぜわざわざ膨大な画像群にしつらえ直し(静的であれ動的であれ)、そのためのサーバーを用意して都度配信せねばならないのか。 ごく常識的な感覚として許しがたい非効率です。
クライアントは情報を一括ロードできる。 その情報のタイルをオンデマンドで表示したい。 が、サーバー配信は御免こうむる。 かくなるうえは、タイルをクライアント自ら生成してしまえばいい、と考えるのは自然でしょう。
なぜサービスワーカーか
はんけトケは地図の表示にMapLibreを使っています。 考えられる方法のひとつは、MapLibreに適合する地図情報取得コンポーネント(カスタムSourceクラス?)を作り、その中でタイルを生成すること。 しかしこの方法は、相手の設計の理解から始めねばならず面倒なうえ、せっかく作ってもMapLibreにしか使えません。
より簡単かつ汎用的な方法は、一般的な地図情報源そのものに成りすますことです。 一般的な地図情報源とはすなわち、いわゆるXYZ形式のリクエストに応えるタイル配信サーバーです。 クライアント内で、つまりJavaScriptでそうしたサーバーを装える仕組みは、サービスワーカーをおいて他にありません。
都合のいいことに、はんけトケはサービスワーカーをPWA実現のためすでに導入しています。 ヒートマップの元になる統計ファイル(もともと範囲円内の人口を求めるためのもの)も、オフライン動作のためキャッシュ済み。 この構造に便乗すれば、手間を省けるばかりでなく、ヒートマップもオフライン表示できることになるわけです。
からくり
基本的な動作を説明します。 まず、クライアントがタイルを取得するための、タイル配信サーバー風URLを決めておきます。 仮想のURLなので、サーバーに実在する必要はありません(むしろ実在のURLと被らないようにする)。
ここでは簡単に、「自オリジン/tile/{Z}/{X}/{Y}」とします。
取得できるタイルは、256ピクセル四方のラスター(ビットマップ)画像とします。
MapLibre側
MapLibreに、上記の仮想URLをタイル取得先として設定します(Leafletほかのウェブ地図ライブラリーでも同様)。
map.addSource('serviceworker-tileservermodoki', {
'tiles': ['/tile/{z}/{x}/{y}'],
'tileSize': 256,
'type': 'raster'
});
サービスワーカー側
サービスワーカーは、フェッチイベント(サーバーへのHTTPリクエストを送信直前に捉える)を受け取ったらそのリクエスト先URLを調べます。
上記の仮想URLでない、タイルに無関係のリクエストなら、そのまま素通しするなり応答をキャッシュするなり通常どおり振る舞います。
しかしもし上記の仮想URLだったら、タイル画像を生成し、あたかもサーバーからのレスポンスのごとくクライアントに返します。 この仮想URLリクエストを発したのはさっき設定したMapLibreであり、ここでタイルを送り返してやるわけです。
XYZ形式とは?
案外簡単に出来そうですが、まだ肝心の問題が残っています。 要求されたタイルは、地図のいったいどこからどこの部分にあたるのでしょうか。
タイルの位置と範囲(画角)を指し示すのがURLパス末尾のX・Y・Zです。 しかし、どう見ても経緯度でない奇妙な体系の数値です。
調べてみると、国土地理院に説明がありました。 理屈はわかったとして、では具体的にどう変換すれば経緯度を得られるのか。 答えは、オープンストリートマップのウィキにありました。 数式や疑似コードだけでなく、各言語向けのコード見本までそろっていてありがたい。
見本をまとめると、次のコードでXYZ値を経緯度に変換できます。
N = PI - 2 * PI * Y / pow(2, Z)緯度 = atan(0.5 * (exp(N) - exp(-N))) * 180 / PI経度 = X / pow(2, Z) * 360 - 180
または、
N = pow(2, Z)緯度 = atan(sinh(PI * (1 - 2 * Y / N))) * 180 / PI経度 = X / N * 360 - 180
この経緯度が指すのは、タイルの中央ではなく左上(北西)隅とのこと。 式の変形があるのは、使える関数が処理系により異なるためでしょうか。 ちなみにウィキには、経緯度からXYZ値を求める逆変換の式も示されています。
コードのひな型
以上から、サービスワーカー内でタイル取得リクエスト検出からタイルの経緯度を求めるまでを素直にコーディングすると、次の例のようになります。 このまま動くわけではありませんが、流れはわかるでしょう。
// フェッチイベントリスナー
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// リクエストURLパスを調べる
if (/^\/tile\/[0-9]+\/[0-9]+\/[0-9]+$/.test(url.pathname)) {
// もしタイル取得パスだったらタイル画像を生成して返す
event.respondWith(makeTile(url.pathname));
} else {
// 無関係のURLなら通常の処理
...
}
})
// タイル画像を生成しHTTPレスポンスとして返す
const makeTile = async (path) => {
// URLパスからXYZの値を得る
const xyz = pathToXyz(path);
// XYZを経緯度に変換する
const pos1 = xyzToCoords(xyz);
// XとYを1単位ずらした経緯度も得る
const pos2 = xyzToCoords({x: xyz.x + 1, y: xyz.y + 1, z: xyz.z});
// pos1からpos2までの矩形がタイルの範囲!
// タイル画像を作る
...
// HTTPレスポンスとして返す
return new Response(hoge);
}
// URLパス(/tile/{z}/{x}/{y})からXYZの値を得る
const pathToXyz = (path) => {
const params = path.split('/');
let len = params.length;
const y = parseInt(params[--len]);
const x = parseInt(params[--len]);
const z = parseInt(params[--len]);
return {x: x, y: y, z: z};
}
// XYZの値から経緯度を得る
const xyzToCoords = (xyz) => {
const n = Math.pow(2, xyz.z);
const lat = 180 / Math.PI * Math.atan(Math.sinh(Math.PI * (1 - 2 * xyz.y / n)));
const lng = xyz.x / n * 360 - 180;
return {lat: lat, lng: lng};
}
サービスワーカー内での画像生成
サービスワーカー内でビットマップ画像を作るには、オフスクリーンキャンバスを使います。 キャンバスのコンテキストにタイルの内容(ヒートマップならメッシュの色など)を各種メソッドやピクセル配列を使って描画し、最後に画像ファイル形式にして返してやればいいだけ。 先の例に加えるなら、コードは次のようになります。
// タイル画像を生成しHTTPレスポンスとして返す
const makeTile = async (path) => {
// XYZの値を得て経緯度に変換うんぬん
...
// タイル画像を作る
// 256×256ピクセルのオフスクリーンキャンバスを用意
// (ここで毎度作るのはよろしくないが例として…)
const canvas = new OffscreenCanvas(256, 256);
const context = canvas.getContext('2d');
// キャンバスコンテキストにタイル内容を描画する
// 試しに文字列を描いてみる
context.fillText('X: ' + xyz.x, 0, 8);
context.fillText('Y: ' + xyz.y, 0, 20);
context.fillText('Z: ' + xyz.z, 0, 32);
context.fillText('Lat: ' + pos1.lat, 0, 44);
context.fillText('Lng: ' + pos1.lng, 0, 56);
// キャンバスを画像ファイル形式に変換
const blob = await canvas.convertToBlob();
// HTTPレスポンスとして返す
return new Response(blob);
}
注意すべきは、第一に、タイルのリクエストは同時(並列)に複数回呼ばれ得ること。 地図画面を埋め尽くすにはたくさんのタイルが必要なので当然です。 ネイティブのスレッド処理ほど厳格ではありませんが、並列に実行されてもおおむね問題なさそうか確かめながらプログラムすることが大切です。 さもないとおかしな挙動に悩まされかねません。
第二に、処理に時間をかけ過ぎないこと。 コードがあまりに複雑だったり非効率だったりして時間がかかると、地図の表示更新が目に見えて遅くなってしまいます。 「こんなに遅いならサーバー配信のほうがマシ」と言われないよう努めます。 もしどんな技巧をもってしても改善しないなら、そのタイルは本質的に動的生成向きではないのかもしれません。
すでに実用十分な速度に思えても、さらにコードを磨く価値はあります。 より効率化することで消費電力を抑えられ、モバイル(と地球)に優しくなるからです。 キャッシュを活用するのも良い方法ですが、厄介な面もあるため慎重に。
不意のリソース解放に注意
重大な注意がもうひとつあります。 ブラウザーは節約のため、開いているウェブページが非アクティブ(タブ非表示など)になると、そのページが持っているリソースも非アクティブ化したり解放したりしようとします。
そのロジックは、共通の指針はあれどブラウザーの実装次第でしょうか。 いずれにせよ経験則からいうと、サービスワーカーの持つリソースはどうやら標的になりやすい模様。
わたしは効率化のため、キャンバスをタイルリクエストのたびに作らず1つだけ作って使い回していますが、ページを非アクティブにしてしばらく放置していると、それが不意に使えなくなってしまうことがあります。
この現象に対するベストプラクティスはわかりません。 仕方ないので単純に、もしキャンバスコンテキストを失ったらそれをメインスレッドに伝え、ヒートマップ表示処理をやり直す(キャンバスを作り直すほか、MapLibreのソースとレイヤーも作り直す)ことにしました。 そのほか予防措置らしき処理も一応加えているものの、効果のほどは不明です。
展望
クライアントサイド地図タイルサーバーの説明は以上です。 サービスワーカーさえ知っていれば仕組みは単純で、ごく少ないコードで実現できることがおわかりになったかと思います。 もっとも、そのサービスワーカーが曲者で意外な挙動の数々に悩まされたりするのですが、それはまた別の話。
今のところヒートマップにしか使っていませんが、この手法はさまざまに応用できるはず。 初回の起動時間さえいとわなければ、大きなファイルをロードして(IndexedDBに蓄えておけばいい)、もっと複雑なタイルを生成することもできます。 描画の負荷は、WebGL/WebGPUやWebAssemblyを駆使すれば軽減できるかもしれません。
蛇足
「なぜ作ったか」、「なぜサービスワーカーか」……。 最後に打ち明けると、思いつきのこの手法が挑戦的でおもしろそうだったから、が本音です。 なぜもへったくれもありません。
はんけトケは、地元のわずかな人口と日本全国1億2千万人とが地続きにつながっている感覚を生み出す「挑戦」でした。 一方で、地図のどこに何人いるかはわたしの関心事ではありません。 人口ヒートマップなどいらないのです。
「クライアントサイド地図タイルサーバー」の思いつきは技術的に興味深く、可能性にワクワクします。 そう斬新とはいえずとも(サービスワーカーに通じていればトリッキーでも何でもない)、実際実用に足るものか、どこまで高度化できそうか、己の手で試してみたいと思いました。 いらないはずの人口ヒートマップを作ったのは、その実験、第一歩にちょうどよかったからなのです。 そもそもMapLibreに限れば、わざわざこんな実装をしなくてもヒートマップを効率よく表示するレイヤーが実はちゃんとあったりします。
ともあれ、今回の成果には手応えを感じています。
これを使ってできそうなことをいくつか考えつきました。
いずれ試してみたいと思います。
(追記: 作りました。)
用語・注釈
- タイルについて
- この記事ではラスター画像としたが、リクエスト元(MapLibreなどのウェブ地図ライブラリー)に理解できればタイルの形式は何でもよい。
テキスト形式のベクターデータならキャンバスを使わずに済む。
ただし、リクエスト元がそれをパース・レンダリングする二度手間が生じることに注意。 それを言うならラスター画像にもコーデックの手間は生じるが(無圧縮形式にすればよいとも一概にいいにくい)、負荷はボトルネックになるほどではなく、内容の複雑さによる変動も比較的小さい。 - タイル配信サーバーについて
- たとえタイルの元情報が小さくても、ライセンスの都合等でクライアントにまるごと渡すことができず、サーバーでタイル配信せざるを得ないこともあり得る。 とはいえ多くの場合、データベースとしての二次利用が不可能なまでに情報を加工すればクライアントに渡しても問題ないはず。
- ワーカー(ウェブワーカー)
- JavaScriptにおけるバックグラウンド実行スレッド。
- サービスワーカー
- サイト固有のローカルプロキシとして働く特殊なワーカー。 オフラインキャッシュに用いられるのが典型。
- XYZ形式について
- グーグルマップ・オープンストリートマップ・地理院タイルがXYZ形式によるタイル配信の代表例。 なお、タイル指定形式はXYZ以外にもある。 ところでXYZ形式→経緯度変換の式を、GISや地理タイルの代表者格たるサイトが軒並み公にしてくれていないのはなぜなのか。
- タイルの並列リクエストについて
- MapLibreなど、ウェブ地図ライブラリーによってはタイルの並列リクエスト数を開発者が制御できる。
活用すべき一方で、サービスワーカーの一般的な作法としてはやはり並列リクエストに備えて然るべきだろう。
なお「ネイティブのスレッド処理ほど厳格」でないというのは、ワーカーはもともとスレッドセーフな仕組みだから。 それでも、並列処理を意識しておかないと期待を裏切られるシナリオはあり得る。 - MapLibreの地図情報取得コンポーネントについて
- MapLibreには、カスタムSourceクラスの実装よりずっと手軽な「プロトコル追加(addProtocol)」機能もある。 ただし、サービスワーカーに似た処理をメインスレッドで実行するため効率に劣り、内容によっては露骨にカクカクするので注意(ワーカー化する手段もあるようだが現時点では実験的)。 いずれにしても、MapLibreに依存し過ぎるのはやはり避けたい。
補遺
サービスワーカーの活性化
サービスワーカーは通常、サイトの初回訪問時にブラウザーに登録されます。 しかし、その時点ではまだ(少なくとも十全には)機能しません。 実際に働くのは次回のページ表示以降です。
サービスワーカーを前提とする仕組みにおいては、これでは困ってしまいます。 初めからすぐ働いてくれるよう細工せねばなりません。
その方法はといえば、何のことはない、もしサービスワーカーが使える状態になければページをリロードすればいいのです。 やや泥縄的ですが、強制的に「次回のページ表示」してやるわけですね。 サービスワーカーの状態は、次の例のようにcontroller値で判定できます。
if ('serviceWorker' in navigator) {
// サービスワーカーを登録する
navigator.serviceWorker.register(...).then(() => {
// 使えるか?
if (!navigator.serviceWorker.controller) {
// 見込みがない(controller==null)ならリロード
window.location.reload();
}
});
}
メインスレッドとサービスワーカーとの連絡
サービスワーカーを高度に使いこなすには、メインスレッドとの連絡が欠かせません。 JavaScriptのスレッド間通信には一般に、postMessageメソッドを用います。 サービスワーカーにも専用のpostMessageが用意されており、メインスレッドからサービスワーカーへの連絡にはそれを使います。
一方、サービスワーカーからメインスレッドへの連絡は少々面倒です。 サービスワーカーは同じサイトの複数のウインドウから共有され得るため、メインスレッド(「クライアント」と呼ぶ)がひとつに定まらないのです。 よってまず、相手のスレッドをクライアントIDで特定せねばなりません。 具体的な方法は次のとおり。
// サービスワーカー内
// フェッチイベントリスナーの場合
self.addEventListener('fetch', (event) => {
// リクエスト発行元のクライアントID
const clientId = event.clientId;
// IDからクライアントオブジェクトを得てメッセージを送り返す
self.clients.get(clientId).then((client) => {
client.postMessage('Hello!');
});
})
// メッセージイベントリスナーの場合
self.addEventListener('message', (event) => {
// メッセージ発行元のクライアントID
const clientId = event.source.id;
// IDからクライアントオブジェクトを得てメッセージを送り返す
self.clients.get(clientId).then((client) => {
client.postMessage('Hello!');
});
})
ほかの手段
- 全クライアントが受信してよければブロードキャストチャンネルも使えます。 複数開かれたウインドウに同じ情報を伝えて一貫性を保つなら、むしろこちらが適しています。
- タイル生成と同様に、特別なURLによるリクエスト/レスポンスで情報をやり取りする手も考えられます。 が、そんな非効率な真似をあえてする意味は普通はないでしょう。
- 共有メモリーはサービスワーカーと共有できません。
(最終更新:2026-02-13)