はんけトケ

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

『じぱんぐライク』の画面

サービスワーカーを活用した、クライアントサイド動的地図タイルサーバーの作り方を紹介します。

目次

  1. 概要
  2. なぜ作ったか
  3. なぜサービスワーカーか
  4. からくり
  5. XYZ形式とは?
  6. コードのひな型
  7. サービスワーカー内での画像生成
  8. 不意のリソース解放に注意
  9. 展望
  10. 用語・注釈
  11. 補遺

概要

はんけトケにヒートマップ表示機能を追加しました。このヒートマップは地図をズームレベルに応じて倍々分割した画像、すなわち「タイル」で出来ており、必要な部分だけがオンデマンドに表示されます。

しかし、このためのタイル配信サーバーはどこにも存在しません。クライアント(ブラウザー)がタイルを自前で生成し、自身のタイル要求に自ら“応答”しているのです。

左様にサーバーを装えるクライアント側の実装といえば、ウェブ開発に詳しいかたならピンと来るでしょう。そう、サービスワーカーです。

なぜ作ったか

ヒートマップは、典型的には人口の多寡などを、行政区画やメッシュ単位で色分けして表します。実はそのため程度の情報なら、日本全国分まとめても量はたかが知れています。

「全国の市区町村×人口」データなぞ、たとえテキスト形式でもそう大きくないことは想像がつきますね。メッシュのヒートマップだって、日本全域を3千キロメートル四方とすれば、画像に例えると3,000×3,000ピクセル(3次地域メッシュの場合)にしかすぎません。しかもその大半は海であり、無情報の塊として省く工夫も可能です。

ならば、すべての情報を一括でクライアントにロードして適当に可視化し、日本全国を覆う(多少は分割するにしても)レイヤーとして表示してしまえば手っ取り早い。

ところが、ロードは確かに一括で済むものの、表示のほうはこの方法ではあまりうまくいきません。ぼやけたり動作がぎこちなくなったり、何かと問題が生じるのです。

ではタイル化してサーバー配信すればいいかというと、技術的には可能ですが、とてつもなく馬鹿らしい。クライアント側に難なく一括ロードできる程度の小さな情報を、なぜわざわざ膨大な画像群にしつらえ直し(静的であれ動的であれ)、そのためのサーバーを用意して都度配信せねばならないのか。ごく常識的な感覚として許しがたい非効率です。

クライアントは情報を一括ロードできる。その情報のタイルをオンデマンドで表示したい。が、サーバー配信は御免こうむる。かくなるうえは、タイルをクライアント自ら生成してしまえばいい、と考えるのは自然でしょう。

なぜサービスワーカーか

はんけトケは地図の表示にMapLibre GL JSを使っています。考えられる方法のひとつは、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'
});

サービスワーカー側

サービスワーカーは、フェッチイベント(クライアントからサーバーへのリクエストを送信前に捉える)を受け取ったらそのリクエスト先URLを調べます。

上記の仮想URLでない、タイルに無関係のリクエストなら、そのまま素通しするなり応答をキャッシュするなり通常どおり振る舞います。

しかしもし上記の仮想URLだったら、タイル画像を生成し、あたかもサーバーからのレスポンスのごとくクライアントに返します。この仮想URLリクエストを発したのはさっき設定したMapLibreであり、ここでタイルを送り返してやるわけです。

XYZ形式とは?

案外簡単に出来そうですが、まだ肝心の問題が残っています。要求されたタイルは、地図のいったいどこからどこの部分にあたるのでしょうか。

タイルの位置と範囲(画角)を指し示すのがURLパス末尾のX・Y・Zです。しかし、どう見ても経緯度でない奇妙な体系の数値です。

調べてみると、国土地理院に説明がありました。理屈はわかったとして、では具体的にどう変換すれば経緯度を得られるのか。答えは、オープンストリートマップのウィキにありました。数式や疑似コードだけでなく、各言語向けの具体例までそろっていてありがたい。

例をまとめると、次のコードでXYZ値を経緯度に変換できます。

または

この経緯度が指すのは、タイルの中央ではなく左上(北西)隅とのこと。式の変形があるのは、使える関数が処理系により異なるためでしょうか。ちなみにウィキには、経緯度から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/WebGPUWebAssemblyを駆使すれば軽減できるかもしれません。

蛇足

「なぜ作ったか」、「なぜサービスワーカーか」……。最後に打ち明けると、思いつきのこの手法が挑戦的でおもしろそうだったから、が本音です。なぜもへったくれもありません。

はんけトケは、地元のわずかな人口と日本全国1億2千万人とが地続きにつながっている感覚を生み出す「挑戦」でした。一方で、地図のどこに何人いるかはわたしの関心事ではありません。人口ヒートマップなどいらないのです。

「クライアントサイド地図タイルサーバー」の思いつきは技術的に興味深く、可能性にワクワクします。そう斬新とはいえずとも(サービスワーカーに通じていればトリッキーでも何でもない)、実際実用に足るものか、どこまで高度化できそうか、己の手で試してみたいと思いました。いらないはずの人口ヒートマップを作ったのは、その実験、第一歩にちょうどよかったからなのです。そもそもMapLibreに限れば、わざわざこんな実装をしなくてもヒートマップを効率よく表示するレイヤーが実はちゃんとあったりします。

ともあれ、今回の成果には手応えを感じています。これを使ってできそうなことをいくつか考えつきました。いずれ試してみたいと思います。

用語・注釈

タイルについて
この記事ではラスター画像としたが、リクエスト元(MapLibreなどのウェブ地図ライブラリー)に理解できればタイルの形式は何でもよい。テキスト形式のベクターデータならキャンバスを使わずに済む。
ただし、リクエスト元がそれをパース・レンダリングする二度手間が生じることに注意。それを言うならラスター画像にもコーデックの手間は生じるが(無圧縮形式にすればよいとも一概にいいにくい)、負荷はボトルネックになるほどではなく、内容の複雑さによる変動も比較的小さい。
タイル配信サーバーについて
たとえタイルの元情報が小さくても、ライセンスの都合等でクライアントにまるごと渡すことができず、サーバーでタイル配信せざるを得ないこともあり得る。とはいえ多くの場合、データベースとしての二次利用が不可能なまでに情報を加工すればクライアントに渡しても問題ないはず。
ワーカー(ウェブワーカー)
JavaScriptにおけるバックグラウンド実行スレッド。
サービスワーカー
サイト固有のローカルプロキシとして働く特殊なワーカー。オフラインキャッシュに用いられるのが典型。ウェブビュー内では(今のところ)機能しないのが玉にきず。
なお2026年初めに、すべての主要ブラウザーがサービスワーカー内でのモジュールインポートに対応した模様。高度な機能を作りやすくなり、面白い使い方が続々生まれるかもしれない。
XYZ形式について
グーグルマップ・オープンストリートマップ・地理院タイルがXYZ形式によるタイル配信の代表例。なお、タイル指定形式はXYZ以外にもある。
ところで肝心のXYZ形式→経緯度の変換式を、GISや地理タイルの代表者格たるサイトが軒並み公にしてくれていないのはなぜなのか。
タイルの並列リクエストについて
MapLibreなど、ウェブ地図ライブラリーによってはタイルの並列リクエスト数を開発者が制御できる。活用すべき一方で、サービスワーカーの一般的な作法としてはやはり並列リクエストに備えて然るべきだろう。
なお「ネイティブのスレッド処理ほど厳格」でないというのは、ワーカーはもともとスレッドセーフな仕組みだから。それでも、並列処理を意識しておかないと期待を裏切られるシナリオはあり得る。
MapLibreの地図情報取得コンポーネントについて
MapLibreには、カスタムSourceクラスの実装よりずっと手軽な「プロトコル追加(addProtocol)」機能もある。ただし、サービスワーカーに似た処理をメインスレッドで実行するため効率に劣り、内容によっては露骨にカクカクするので注意(ワーカー化する手段もあるようだが現時点では実験的)。いずれにしても、MapLibreに依存し過ぎるのはやはり避けたい。
リソース解放について
アイドル時にサービスワーカーのリソースがやたら解放されがちなのは、気のせいではなく仕様らしい。思い返せばブラウザー拡張機能MV3のサービスワーカー解放が議論を呼んでいたけれど、サイトのサービスワーカーも同様ということか。裏技的な回避策もあるらしいが(?)、健全な環境保全のための仕様なら抗うべきではないだろう。幸い、復帰にそう時間がかかるわけではない。

補遺

サービスワーカーの活性化

サービスワーカーは通常、サイトの初回訪問時にブラウザーに登録されます。しかし、その時点ではまだ機能しません。実際に働くのは次回のページ表示以降です(あるいは何らかのトリガー?)。

サービスワーカーを前提とする仕組みにおいては、これでは困ってしまいます。初めからすぐ働いてくれるよう細工せねばなりません。

その方法はといえば、第一に、サービスワーカー内でインストール時にskipWaiting()を使うこと。

// サービスワーカー内

// インストールイベントリスナー
self.addEventListener('install', (event) => {
	self.skipWaiting(); // 直ちにアクティブ化を試みる
});

第二には何のことはない、もしサービスワーカーが使える状態になければページをリロードすればいいのです。つまり強制的に「次回のページ表示」してやるわけですね。サービスワーカーの状態は、次の例のように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');
	});
})

ほかの手段