はんけトケのロゴタイプ
これは何?
開発ノート

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

概要

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

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

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

なぜ作ったか

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

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

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

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

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

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

なぜサービスワーカーか

はんけトケは地図の表示にMapLibreを使っています。 考えられる方法のひとつは、MapLibreに適合する地図情報取得コンポーネント(カスタムSourceクラス?)を作り、その中でタイルを生成すること。 しかしこの方法は、相手の設計の理解から始めねばならず面倒なうえ、せっかく作ってもMapLibreにしか使えません。

より簡単かつ汎用的な方法は、一般的な地図情報源そのものに成りすますことです。 一般的な地図情報源とはすなわち、いわゆるXYZ形式のリクエストに応えるタイル配信サーバーです。 クライアント内で、つまりJavaScriptでそうしたサーバーを装える仕組みは、サービスワーカーをおいて他にありません。

都合のいいことに、はんけトケはサービスワーカーをPWA実現のためすでに導入しています。 ヒートマップの元になる統計ファイル(もともと範囲円内の人口を求めるためのもの)も、オフライン動作のためキャッシュ済み。 この構造に便乗すれば、手間を省けるばかりでなく、ヒートマップもオフライン表示できることになるわけです。

からくり

基本的な動作を説明します。 まず、クライアントがタイルを取得するための、タイル配信サーバー風URLを決めておきます。 仮想のURLなので、サーバーマシンに実在する必要はありません(むしろ実在のURLと被らないようにする)。

ここでは簡単に、「自オリジン/tile/{Z}/{X}/{Y}」とします。 取得できるタイルは、256×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値を求める逆変換の式も示されています。

ところでこの式を、GISや地理タイルの代表者格たるサイトが軒並み公にしてくれていないのはなぜなのでしょう。 敬意を表して記しておくと、ウェブ上には苦心して式を導いた先達がちらほら見られ(形がいろいろで問題の厄介さがうかがわれる)、くだんのウィキもそうした有志たちの成果のようです。

コードのひな型

以上から、サービスワーカー内でタイル取得リクエスト検出からタイルの経緯度を求めるまでを素直にコーディングすると、次の例のようになります。 このまま動くわけではありませんが、流れはわかるでしょう。

// フェッチイベントリスナー
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({type: 'image/png'});

	// HTTPレスポンスとして返す
	return new Response(blob);
}

注意すべきは、第一に、タイルのリクエストは同時(並列)に複数回呼ばれ得ること。 地図画面を埋め尽くすにはたくさんのタイルが必要なので当然です。 ネイティブのスレッド処理ほど厳格ではありませんが、並列に実行されてもおおむね問題なさそうか確かめながらプログラムすることが大切です。 さもないとおかしな挙動に悩まされかねません。

第二に、処理に時間をかけ過ぎないこと。 コードがあまりに複雑だったり非効率だったりして時間がかかると、地図の表示更新が目に見えて遅くなってしまいます。 「こんなに遅いならサーバー配信のほうがマシ」と言われないよう努めます。 もしどんな技巧をもってしても改善しないなら、そのタイルは本質的に動的生成向きではないのかもしれません。

すでに実用十分な早さに思えても、さらにコードを磨く価値はあります。 より効率化することで消費電力を抑えられ、モバイル(と地球)に優しくなるからです。 キャッシュを活用するのも良い方法ですが、厄介な面もあるため慎重に。

不意のリソース解放に注意

重大な注意がもうひとつあります。 ブラウザーは節約のため、開いているウェブページが非アクティブ(タブ非表示など)になると、そのページが持っているリソースも非アクティブ化したり解放したりしようとします。

そのロジックは、共通の指針はあれどおそらくブラウザーの実装次第でしょう。 ただ経験則からいうと、サービスワーカーの持つリソースはどうやら標的になりやすい模様。

わたしは効率化のため、キャンバスをタイルリクエストのたびに作らず1つだけ作って使い回していますが、ページを非アクティブにしてしばらく放置していると、それが不意に使えなくなってしまうことがあります。

この現象に対するベストプラクティスはわかりません。 仕方ないので単純に、もしキャンバスコンテキストを失ったらそれを主スレッドに伝え、ヒートマップ表示処理を初期化からやり直すことにしました。 そのほか予防措置らしき処理も一応加えているものの、効果のほどは不明です。

ヒートマップの色彩

本筋からやや離れますが参考までに触れておくと、はんけトケのヒートマップタイルの生成処理は、高速な代わり極めて単純で乱暴です(あくまで「概算」を貫くポリシー)。 そのため、ズームレベルの変わり目でメッシュの境界が揺らぐなど、よく見ると粗があります。 が、あれくらいなら許容範囲でしょう。

そんなことより色について。 ヒートマップは、たとえばHSLの色相を回すなど、RGB以外の色空間でグラデーション表現されることが多い。 ただし、いちいちそんな計算をしていると遅くなってしまうため、事前計算テーブル(要はカラーパレット)を作っておいて効率化を図ります。

はんけトケの場合、そのパレットはあえて8階調分しか作らず色を量子化しています。 ヒートマップは目安にすぎないと考え、データを直に反映するより大小のつかみやすさを優先しました(気まぐれに変えるかも)。

また、設定メニューでヒートマップの色彩を選べるようにしました。 好みの問題ではなく、アクセシビリティーのためです。 デフォルトは赤系で、地図画像との対比で目立つようにとこしらえたもの。 けれども利用者の色覚特性によっては見づらい恐れがあるため、別の色彩も用意したのです。

「プラズマ」と「ビリディス」はデータ可視化において定番の色彩で、アクセシビリティーに優れ、白黒印刷しても見分けやすいとされます。 定番は他にもありますが、地図画像に重ねたとき見やすいとは限らず、選択肢が多すぎても混乱を招くため、とりあえずプラズマとビリディスだけを採用しました。

展望

今のところヒートマップにしか使っていませんが、この手法はさまざまに応用できるはず。 初回の起動時間さえいとわなければ、巨大なファイルをロードして(IndexedDBに蓄えておけばいい)、もっと複雑なタイルを生成することもできます。 描画の負荷は、WebGL/WebGPUやWebAssemblyを駆使すれば軽減できるかもしれません。

蛇足

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

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

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

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

用語・注釈

タイルについて
この記事ではラスター画像としたが、リクエスト元(MapLibreなどのウェブ地図ライブラリーやGIS)に理解できればタイルの形式は何でもよい。 SVGやJSONのようなテキスト形式ならキャンバスを使わずに済む。
ただし、リクエスト元がそれをパース・レンダリングする二度手間が生じることに注意。 それを言うならラスター画像にもコーデックの手間は生じるが(無圧縮形式にすればよいとも一概にいいにくい)、ボトルネックになるほどではないだろう。
タイル配信サーバーについて
たとえタイルの元情報が小さくても、ライセンスの都合等でクライアントにまるごと渡すことができず、サーバーでタイル配信せざるを得ないこともあり得る。 とはいえ多くの場合、データベースとしての二次利用が不可能なまでに情報を加工すればクライアントに渡しても問題ないはず。
ワーカー(ウェブワーカー)
JavaScriptにおけるバックグラウンド実行スレッド。
サービスワーカー
サイト固有のローカルプロキシとして働く特殊なワーカー。 オフラインキャッシュに用いられるのが典型。
XYZ形式について
グーグルマップ・オープンストリートマップ・地理院タイルがXYZ形式によるタイル配信の代表例。 なお、タイル指定形式はXYZ以外にもある。
タイルの並列リクエストについて
MapLibreなど、ウェブ地図ライブラリーによってはタイルの並列リクエスト数を開発者が制御できる。 便利に活用すべき一方で、サービスワーカーの一般的な作法としてはやはり並列リクエストに備えて然るべきだろう。
なお「ネイティブのスレッド処理ほど厳格」でないというのは、ワーカーはもともとスレッドセーフな仕組みだから。 それでも、並列処理を意識しておかないと期待を裏切られるシナリオはあり得る。
MapLibreの地図情報取得コンポーネントについて
MapLibreには、カスタムSourceクラスの実装よりずっと手軽な「プロトコル追加(addProtocol)」機能もある。 ただし、サービスワーカーに似た処理をメインスレッドで実行するため効率に劣り、内容によっては露骨にカクカクするので注意(ワーカー化する手段もあるようだが現時点では実験的)。 いずれにしても、MapLibreに依存し過ぎるのはやはり避けたい。
HSL色空間
色相・彩度・輝度の三要素で色を決定する体系。 何にせよ特別な色空間は、ディスプレーのネイティブ色空間であるRGBとの間に変換の手間が生じる。
ビリディス(viridis)
「(新鮮な)緑」を意味するラテン語。

 

(最終更新:2025.11.19)