Zipanguelike

惑星のつくりかた 実習編

リクツ編の続き。汎用のウェブ地図ライブラリーで表示可能な架空の惑星の世界地図を、プロシージャル生成技術で実際に作ってみます。(補習編に続く)

目次

  1. まずは成果から
  2. 構成と処理の流れ
  3. コード
  4. 解説
  5. おわりに
  6. 用語・注釈
  7. 補遺

まずは成果から

動くプログラムが上にすでに出ていますね(要JavaScript)。地図の表示・操作をつかさどっているのはおなじみMapLibre GL JSです。適当にさわってみてください。

「投影法切替」ボタンを押すと、地図の表示が球とメルカトル図法とに交互に替わります。メルカトル図法で最もズームアウトした状態で見える地図が、この記事で生成するデータの全貌です。地形はこのページをリロードするたびにランダムに変化します。

構成と処理の流れ

構成

基本的な仕組みはZipanguelikeと同じ。MapLibreが要求した地図タイルを、サービスワーカーが生成して返します。リモートの地図タイルサーバーは存在せず、すべての処理がクライアント(ブラウザー)内で完結します。

このサービスワーカーを用いたからくりについて詳しくは、『はんけトケ』の記事「クライアントサイド地図タイルサーバー」をお読みください。

メインスレッドの処理

  1. サービスワーカーを起動します。
  2. リクツ編の要領で地図メッシュをプロシージャル生成し、サービスワーカーに渡します。
  3. MapLibreを起動し、地図タイルのソースつまりリクエスト先として、サービスワーカーが受け付ける仮想のタイルURLを設定します。

サービスワーカーの処理

  1. メインスレッドからのメッセージを待ち受け、地図メッシュを受け取って保持します。
  2. 仮想のタイルURLへのHTTPリクエスト(フェッチイベント)を検知したら、地図メッシュに基づいてタイルをオフスクリーンキャンバスに描画し、その画像をHTTPレスポンスとして返します。

コード

動作するコードを示します。ファイルのパスは適宜書き換えてください。サービスワーカーはセキュアなウェブサーバー上でしか動作しないことに注意(ローカルファイルを直接ブラウザーで開いても動かない)。利用・改変は自由ですが、これらを用いた行為の責任を作者は一切負いません。
【追記】 MapLibre GL JS v6対応のため一部改修しました。

HTML: ./index.html

<html>
	<head>
		<!-- MapLibre GL JS用CSS -->
		<link href='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css' rel='stylesheet'/>

		<!-- 勾配ノイズライブラリー(https://github.com/josephg/noisejs)-->
		<script src='./perlin.js' defer></script>

		<!-- メインスレッド -->
		<script type="module" src='./planet.js'></script>
	</head>
	<body>
		<!-- 地図を入れる要素 -->
		<div id="map" style="width:100%; height:100%"></div>
	</body>
</html>

メインスレッド: ./planet.js

/*! planet.js v1.0.1 by Masato Taniguchi 2026 - MIT License */
'use strict';

// MapLibre GL JSをロード(v6.0で動作確認済)
import * as maplibregl from 'https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.mjs';


// サービスワーカー登録
if ('serviceWorker' in navigator) {
	navigator.serviceWorker.register(
		'./planet-sw.js',	// サービスワーカー
		{scope: './'} // スコープ(※本当は絶対パスにしたほうが安全)
	).then(() => {
		// いま登録したサービスワーカーが不活性ならリロード
		if (!navigator.serviceWorker.controller
			|| !navigator.serviceWorker.controller.scriptURL.match(/planet-sw/)
		) {
			window.location.reload();
		}
	});
}


// 初期化
document.addEventListener('DOMContentLoaded', async (event) => {
	const	ZOOMLEVEL = 9;  // 地図メッシュのズームレベル
	const	TILESIZE = 256; // ラスタータイルサイズ(ピクセル)

	// 勾配ノイズによる地図メッシュのプロシージャル生成
	{
		const	seed = Math.random(); // ノイズのシード値
		const	altitude = 7;         // 標高の最大値
		const	threshold = -0.1;     // 海と陸を分けるノイズ閾値
		const	freq = 2.5;           // 合成するノイズの周波数倍率
		const	amp = 0.5;            // 合成するノイズの振幅倍率

		const	mapSize = 1 << ZOOMLEVEL; // 地図メッシュの辺の長さ(=基準ズームレベルのタイル解像度)
		const	mapData = new Uint8Array(mapSize * mapSize); // 地図メッシュ配列
		noise.seed(seed);	// 勾配ノイズ生成器

		// 基準ズームレベルの全タイル座標を走査
		// ※わかりやすさを優先し、同じ計算を無駄に繰り返していることに注意
		for (let y = 0, i = 0; y < mapSize; y++) {
			for (let x = 0; x < mapSize; x++) {
				// タイル座標を経緯度(ラジアン)に変換
				const	n = Math.pow(2, ZOOMLEVEL);
				const	lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)));
				const	lng = x / n * 2 * Math.PI - Math.PI;

				// 経緯度を球面上の座標に変換
				const	cos_ = Math.cos(lat);
				const	posX = Math.cos(lng) * cos_;
				const	posY = Math.sin(lat);
				const	posZ = Math.sin(lng) * cos_;

				// 球面座標を入力として勾配ノイズを得る
				let	v = noise.simplex3(posX, posY, posZ);
				// さらに周波数・振幅を変えて合成
				v	+= amp * noise.simplex3(posX * freq, posY * freq, posZ * freq);

				// ノイズ値が閾値以下なら海(0)、でなければ陸の起伏
				v -= threshold;
				v = Math.max(0, Math.min(1, v * 0.85));
				v *= altitude;

				// 結果を地図メッシュ配列に格納
				mapData[i++] = v;
			}
		}

		// 生成した地図メッシュをサービスワーカーに送る
		const	registration = await navigator.serviceWorker.ready;
		registration.active.postMessage({
			mapData: mapData,
			zoomLevel: ZOOMLEVEL,
			tileSize: TILESIZE
		}, [mapData.buffer]);

		// サービスワーカーを眠らせにくくする?(効果は限定的)
		setInterval(async () => {
			if (registration.active) {
				registration.active.postMessage('keepalive');
			}
		}, 5000);
	}

	// MapLibreを起動
	const	map = new maplibregl.Map({
		container: 'map', // 地図を入れる要素のid
		maxZoom: ZOOMLEVEL,
		attributionControl: false
	});
	map.addSource('planet', {
		type: 'raster',
		tiles: ['/planet-tile/{z}/{x}/{y}'], // サービスワーカーが受け付けるタイルURL
		tileSize: TILESIZE
	});
	map.addLayer({
		id: 'layer-planet',
		type: 'raster',
		source: 'planet'
	});
	map.setProjection({type: 'globe'}); // 球投影にする(お好みで)
	// map.addControl(new maplibregl.NavigationControl()); // 操作ボタン
	// map.addControl(new maplibregl.ScaleControl({maxWidth: 80, unit: 'metric'})); // 縮尺目盛り
})

サービスワーカー: ./planet-sw.js

/*! planet-sw.js v1.0.1 by Masato Taniguchi 2026 - MIT License */
'use strict';

// タイル描画用データ
const	data = {
	mapData: null, // 地図メッシュ配列
	zoomLevel: 9,  // 地図メッシュのズームレベル
	tileSize: 256, // 地図タイルサイズ(ピクセル)
	canvas: null,  // オフスクリーンキャンバス
	context: null, // キャンバスコンテキスト
	pixels: null   // キャンバスピクセル配列
};


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


// メッセージイベントリスナー
self.addEventListener('message', async (event) => {
	// メインスレッドから地図メッシュ配列等を受け取る
	if (event.data.mapData) {
		data.mapData = event.data.mapData;
		data.zoomLevel = event.data.zoomLevel;
		data.tileSize = event.data.tileSize;

		// オフスクリーンキャンバスを用意
		data.canvas = new OffscreenCanvas(data.tileSize, data.tileSize);
		data.context = data.canvas.getContext(
			'2d', {colorSpace: 'srgb', colorType: 'unorm8'}
		);
		data.pixels = data.context.getImageData(
			0, 0, data.tileSize, data.tileSize,
			{colorSpace: 'srgb', pixelFormat: 'rgba-unorm8'}
		);
	}
});


// フェッチイベントリスナー
self.addEventListener('fetch', (event) => {
	const	url = new URL(event.request.url);

	// リクエスト先URLのパスを調べる
	if (/^\/planet-tile\/[0-9]+\/[0-9]+\/[0-9]+$/.test(url.pathname)) {
		// もしタイル取得パスだったらタイル画像を生成して返す
		event.respondWith(makeTile(url.pathname));
	}
})


// タイル画像を生成しHTTPレスポンスとして返す
const makeTile = async (path) => {
	// 画像生成できない状況なら404を返す
	// ※アイドル時のリソース解放の結果でもあり得るが、その場合の回復策は省略
	if (!data.mapData || !data.canvas) {
		return new Response(null, {
			status: 404,
			headers: {'Cache-Control': 'no-store,must-revalidate'}
		});
	}

	// URLパスからXYZの値を得る
	const	xyz = pathToXyz(path);

	// キャンバスコンテキストにタイルを描画する
	{
		// RGBパレット(標高昇順に8色、0番は海)
		const palette = [
			[115,240,228], [239,205,115], [164,170, 52], [ 36,105, 50],
			[255,137, 35], [169, 68,  0], [194,178,150], [247,237,231]
		];

		// ズームレベルに応じたスケールと範囲でピクセル描き込み
		const	buf = data.pixels.data;
		let	x, y, i, j, mul = Math.pow(2, data.zoomLevel - xyz.z);
		const	mapSize = 1 << data.zoomLevel;
		const	offset = Math.floor(xyz.y * mul) * mapSize + Math.floor(xyz.x * mul);
		mul /= data.tileSize;
		for (y = data.tileSize - 1; y >= 0 ; --y) {
			i = (y * data.tileSize) * 4;
			j = offset + Math.floor(y * mul) * mapSize;
			for (x = data.tileSize - 1; x >= 0 ; --x) {
				const	pal = palette[data.mapData[Math.floor(j)] || 0];
				buf[i++] = pal[0];
				buf[i++] = pal[1];
				buf[i++] = pal[2];
				buf[i++] = 0xff;
				j += mul;
			}
		}

		// キャンバスコンテキストに反映
		data.context.putImageData(data.pixels, 0, 0);
	}

	// キャンバスを画像ファイル形式に変換
	const	blob = await data.canvas.convertToBlob();

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


// URLパス(/{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};
}

勾配ノイズライブラリー: ./perlin.js

https://github.com/josephg/noisejs から「perlin.js」をダウンロードして同じパスに置いてください。あるいは、3次元パーリンノイズ/シンプレックスノイズさえ使えれば別のライブラリーでも自前の関数でもかまいません(呼び出しコードを適宜書き換えること)。

解説

短く、コメントもまめに入れているので、コードを読めば何をしているかわかるでしょう。肝である地図メッシュの生成周りについてだけ解説します。

地図メッシュとは

前々節で述べたとおり、メインスレッドの初期化時に「リクツ編の要領で地図メッシュをプロシージャル生成し」ています。

ここでいう地図メッシュとは、基準と定めたズームレベルにおけるXYZ形式タイル単位の格子に、0〜7まで8段階の大まかな標高を格納する、このプログラム独自のバイト配列です。要は素朴なハイトマップですね(他の情報も入れられるよう意図しているが)。紛らわしい呼び名ですみませんが、標準地域メッシュコードの付いた地理情報とも3Dモデルデータとも無関係。

基準のズームレベルは、とりあえず9としました。XYZ形式のタイルは、ズームレベル0の1枚のタイルから始まり、レベルが1上がるごとにタイルの縦横が半分に分割されていきます。したがってズームレベル9ならタイルは512×512枚、「X=0〜511,Y=0〜511」の座標をとります。

これらのタイルそれぞれについて、XY座標を経緯度に、経緯度を惑星表面(球面)の3次元座標に変換します。そしてそれを3次元パーリンノイズ(正確にはシンプレックスノイズ)関数に与え、結果を加工して地図メッシュ配列に格納するのです。

なお、例としてのわかりやすさを優先し、コードはあえて最適化していません。この程度のループならしてもしなくても大差ないでしょう。ただし、地図メッシュの解像度(基準ズームレベル)を上げる場合はご注意ください。

パーリンノイズから標高へ

ノイズ関数は一般に-1.0〜1.0の値を返します(とはいえ実装次第なので要確認)。地図メッシュに格納する際、これを0〜7の整数に加工します。ある閾値以下なら0(海)とし、それを上回るなら7までの整数にストレッチしてやるだけです。

ただし、球面の座標を素直に与えただけでは結果が単調すぎたため、周波数・振幅を変えたパーリンノイズをさらに合成(加算)しています。このへんは結果を見ながら適当に調整しただけで、とくに根拠はありません。

地図タイルの描画

出来上がった地図メッシュはサービスワーカーに渡します。

サービスワーカーは渡された地図メッシュを保持します。リクエストされた地図タイルを描くとき、この地図メッシュの値を参照して海や陸の起伏を塗り分けます。

なお、このコードでは手癖でなんとなくピクセル処理していますが、描画の手段はもちろん何でもかまいません。地図メッシュをあらかじめ画像化しておき、スケール付きで矩形領域コピーしたほうが簡単で速いかもしれません(そんなメソッドが2Dコンテキストにあったか定かでないが)。WebGL/WebGPUで描ければより良い。ただの塗り分けなら現状でいいですが、内容が複雑化したら高速化も考えねばならないでしょう。

既知の問題

地図タイルが欠けることがある

これを実行しているウインドウ(タブ)がしばらく隠れるなどすると、サービスワーカーが非アクティブになり、そのリソースすなわちタイル描画用のキャンバスが解放されてしまうことがあります。そうなると以降、サービスワーカーは地図タイルを作って返すことができなくなります。

スマートな回復方法はまだ模索中で、この記事の焦点もぼやけかねない(枝葉の処理を延々加えねばならない)ことから対策していません。もし地図のタイルが欠けたままになったら、リロードして再表示してください。

同時に複数開くとおかしくなる

同時に複数のウインドウ(タブ)でこれを開くと、描かれる地図タイルがちぐはぐになったりします。

サービスワーカーのスレッドはウインドウごとに専属に作られるわけではなく、最初に作られた1つが共有されます。したがって、ウインドウAのメインスレッドから渡されたデータが、ウインドウBのメインスレッドから渡されたデータで上書きされてしまうのです。

メインスレッドはIDで区別できるため、渡されたデータを丁寧に分けて管理することも可能ですが、このデモではそこまでしません。

おわりに

意義

ここでのプロシージャル生成処理は初歩も初歩、いわばビー玉にマーブル模様を描いてみたにすぎない原始的なもの。ではなぜわざわざ作って公開したか。汎用のウェブ地図ライブラリー上でも、地図タイルサーバー不要で手軽に地形のプロシージャル生成ができる・架空の世界を創造できる、と分野の可能性を示すことに意義があると考えたためです。

ウェブ地図の枠組みであれば、地形や生態系といった自然の美しさに加え、文化・歴史・政治・経済ほか幅広い社会情報・科学情報をもインタラクティブなレイヤーとして簡単に重ねることができます。それも、オープンかつ信頼性ある枯れた技術で。ユーザーも開発者もだれもが使い慣れたインターフェイスで。

そうしたレイヤーまでプロシージャルに作って示せれば完璧でした。が、さすがに大変すぎてデモの範疇を大きく超えてしまいます。

所感

たったいま適当にでっちあげた大義名分はさておき、これを作ってみて改めて気づいたどころか衝撃を受けたのは、メルカトル図法の大胆さです。

冒頭の地図をメルカトル投影にし、限界までズームアウトしてみてください。南北の両極へ、赤道からちょっと離れたあたりからの伸長ぶりたるや、常軌を逸していませんか。

左に球投影、右にメルカトル投影された世界地図
同じ世界地図の球投影とメルカトル投影との比較

わたしたちがふだん見慣れている世界地図もこうなっているはずです。でもたぶん生まれて初めて見る世界の姿がそれだったりして、むしろあれが大陸の本当の形だくらいに認識しているのではないでしょうか。知識としては地図がゆがんでいることくらい知っていながら、それと矛盾して平気なのがヒトの認識・刷り込みの不思議なところ。

が、見知らぬ惑星の世界地図として虚心坦懐に眺めると、やっぱりどうかしています。ゆがみが気になりすぎて大陸の形が目に入りません。これが最初に生成されたとき、バグかと思いました。16世紀、メルカトル氏が世界地図を初めて作ったときも、間違いではないかと何度も確かめたのではと想像します。見せられた人たち(ヴィルヘルム公とか)も二度見したことでしょう。

同時に感じたのは、たまたま赤道にわりと近い(?)日本は地図運に恵まれていたということ。北極圏の国々は、もちろん当地にふさわしい地図をふだん使っているのでしょうが、グローバルな世界地図とすり合わせるのがいちいち面倒なのでは。

技術的な観点では、タイルあたりの情報密度に何倍も差がつくことが気になります。メッシュの解像度は緯度により可変とすべきか。ただ、これはメッシュやラスター(ビットマップ)だから無駄が生じるのであって、ベクターなら極地のタイルは自然に情報が薄く広がってバランスがとれそうです。

用語・注釈

プロシージャル(手続き型)生成
データをアルゴリズムで生成する手法。手作業を省いたり、無数のバリエーションを生み出したりするために用いられる。
MapLibre GL JSについて
この仕組みはMapLibreに依存しておらず、他のウェブ地図ライブラリーでも実現できる。ただし、球投影に対応しているものは限られる。
メルカトル図法について
リクツ編でも触れたとおり、ここで使っているのは正確にはウェブメルカトル。違いはこの際無視するが、緯度が南北85度までに限られている点には要注意(極点は補完して表示される)。
サービスワーカー
サイト固有のローカルプロキシとして働く特殊なウェブワーカー(JavaScriptにおけるバックグラウンド実行スレッド)。オフラインキャッシュに用いられるのが典型。
勾配ノイズ
連続した値を入れると、なだらかに変化する乱数が出てくるノイズ関数。パーリンノイズはその代名詞的存在。自然界には勾配ノイズ状の現象が多々あるため、プロシージャル生成によく用いられる。
描画の手段について
サービスワーカーはモジュールインポートに対応しており、高度な描画ライブラリーも利用できる。……はずだが、ワーカーにはメインスレッドにない制約がいろいろあり、それが邪魔をするかもしれない。
「回復方法はまだ模索中」について
Zipanguelikeではキャンバスの回復処理をしているものの(たまにクルクル待たされるアレ)泥縄的で、まれに動作を痙攣的に繰り返してしまうなどベストな対策とはいえない。
大義名分について
これを作った真の理由は、サイトにブログ風の体裁の「ノート」をこしらえた以上、記事が初めの1本だけでは寂しかったから。
「メルカトル図法の大胆さ」について(1)
とはいえ経緯度に沿って、つまり東西南北にまっすぐ船を走らせて海岸線をプロットしていけば、自然ああいう図になる道理ではある。簡単にいえば、その「方位の一貫したまっすぐさ」がメルカトル図法の特長(方位を頼りに航海しやすい)。ただ、やはり疑う人もいてすんなりとは普及しなかったようだ。一方ではすでに地球儀が作られていたり、教会と揉めてみたり等々、大航海時代の地理的世界観が実際どんなものだったか興味深い。
「メルカトル図法の大胆さ」について(2)
メルカトル図法世界地図における国土面積のゆがみは国家の軽重に不当な先入観をもたらす、との申し立てが国連に近々なされるという。同様の提言は昔から種々あったらしく、メルカトル図法がわれわれの認識に及ぼす影響のほどを物語っている。
代わりに現在推奨されているのが、面積のゆがみを抑えた「イコールアース図法」。提灯型で地球儀を横から眺める感覚に近く、世界を把握するにはたしかに良さそうだ。コストのかからないところから差し替えてみてはいかがか。

補遺

本題とあまり関係のない枝葉です。

メルカトル図法の地図を得る

地図メッシュの生成コードを、メルカトル図法の世界地図画像の作成に転用したいと考えるかもしれません。できなくはないものの、それだけのためなら、わざわざXYZ形式タイル座標なんぞを基礎にしてややこしい変換をする必要はありません。

単純に、経度・緯度の値をX・Yと見なして描けば正距円筒図法(エクイレクタングラー。全天球カメラのパノラマ写真でおなじみかも)になります。メルカトル図法を得るには、このYにさらに「1/cos(緯度)」を掛けるだけ。1/cos(緯度)は緯度0度つまり赤道で1.0、45度で約1.4、85度で約11.5、90度つまり極点なら無限になり、まさにメルカトル図法のゆがみ方っぽいことがわかりますね。

球投影のクセ

MapLibre GL JSは、2024年末にリリースされたv5で球投影(グローブビュー)に正式対応しました。素敵な機能ですが、少々クセがあります。

北極・南極が見えるように球を縦方向に傾けると、タイルのズームレベルが自動的に落ちるのです。ビューとしてのズームレベルが勝手に変わるわけではなく、球の表面に貼り付けられるタイルのリクエストURL(XYZの値)が変わるということ。上のデモもよく見れば、球を縦に傾けたときタイルの解像度が変わるのがわかると思います。

これはどうやら、メルカトルと球とのいびつな対応に関係しているらしい。球投影における極地には多くのタイルが密集し、結果として赤道あたりに比べて無駄に詳細・超高解像度になります。つまりタイル枚数と解像度とのバランスが悪いため、最適なズームレベルに落とすことにしている模様。

最適化は結構だし一般的には意識しなくていいものの、地図タイルの内容によっては困ったことになります。タイルの内容にズームレベル間で著しい差があると、傾けただけで球の見え方が変わってしまうのです。

たとえば低ズームレベルでは衛星写真で、あるズームレベル以上だと図面になる、みたいなタイル群ならどうなるでしょう。ユーザー自身のズーム操作で両者が切り替わるぶんにはいいですが、球を傾けるだけで勝手に替わってしまったら違和感どころか、見ようとしているものを見られない事態にすら陥ってしまいます。

制御できる現象なのか知りませんが、いずれにせよ球投影を前提に地図を作る際には留意せねばなりません。

何の話?

こんな話をいったいなぜしているのか。この惑星のデモはもともと、Zipanguelikeのようにタイルをマップチップで描くつもりだったのです(『シムアース』みたいに)。

実際に作りました。Zipanguelikeと同様、ズームレベルに応じてマップチップがだんだん細かくなっていく惑星の様子はなかなかユニークで面白かったのですが、球を傾けてみたら上記のとおり。あまりに不自然だったため諦めて、ただの彩色に変えました。地図メッシュが妙に粗いのは、マップチップ表示を前提にしていた名残りです。

裏テーマ

実はこの記事、「クライアントサイド地図タイルサーバー」のフォロー記事でもあります。あちらの一般的な書きぶりに対し、こちらはそのまま動くサービスワーカーの具体的なコードを載せました。

メインスレッドから渡されたデータ(地図メッシュ)を基にタイルを描く仕組みも、あちらのヒートマップに寄せてそうしました。XYZ形式タイル座標系のプロシージャル生成データの代わりに、何かしらの標準地域メッシュデータをもらってタイルを描くよう改造すれば、はんけトケのヒートマップと同様のものができるはず。

もしこの裏テーマがなければ、メインスレッドで地図メッシュなど作らず、サービスワーカー内でタイル描画の際に地形を直接プロシージャル生成する仕組みにしていたかもしれません。それはそれでGPUなど使う羽目になって複雑になり、Zipanguelikeの記事とも重複してしまいそうですが。

ただ、そのほうがメモリーに制約されず、どこまでもズームインできる詳細な世界地図を作りやすかろうと思います。Zipanguelikeは現実の日本のメッシュ情報と整合させる必要がありましたが、徹頭徹尾アルゴリズムで地形生成するとなれば、自由度は当然ずっと高くなります。純粋な計算で作るのなら、ベクターにも3Dにもしやすいでしょう。
【追記】 けっきょく作りました。

サービスワーカーの重複

かなり特殊な事情でだれにも関係なさそうですが、備忘録として記しておきます。デモプログラムをここで動かすにあたって、ひとつ心配なことがありました。サービスワーカーについてです。

サービスワーカーには「スコープ」があります。スコープとは、サービスワーカーがプロキシとして働く範囲のこと。わたしの理解では、パスを指定すると、そのパスを含む配下のすべてのパスで同じサービスワーカーが働く仕組みになっています。

このサイトのルートパス(「/」)にはZipanguelikeがあり、そのサービスワーカーのスコープは無論ルートパスです。ということは、このサイトのすべてのパスでZipanguelikeのサービスワーカーが働くはずです。とすると、このデモ用のサービスワーカー(スコープはこのページのパス)はどうなるのでしょう。衝突したときの挙動が不明です。

案ずるより産むが易し。とにかく試しにやってみると、デモ用のサービスワーカーが無事に登録され働きました。ただし、ややこしい注意があります。

このページの初回の表示時には、まだルート(Zipanguelike)のサービスワーカーが働いています。デモ用のサービスワーカーが優先して働き始めるのは、このページの次回の表示以降らしいのです。

したがって要するに、このページの初回の表示時にはデモが動かないことになります。それでは困るので、次のコードで強制的にページをリロードすることにしました。本文で示したコードのこの部分です。

// サービスワーカー登録
if ('serviceWorker' in navigator) {
	navigator.serviceWorker.register(
		'./planet-sw.js',	// サービスワーカー
		{scope: './'} // スコープ(※本当は絶対パスにしたほうが安全)
	).then(() => {
		// いま登録したサービスワーカーが不活性ならリロード
		if (!navigator.serviceWorker.controller
			|| !navigator.serviceWorker.controller.scriptURL.match(/planet-sw/)
		) {
			window.location.reload();
		}
	});
}

「navigator.serviceWorker.controller.scriptURL」がポイント。いま働いているサービスワーカーのURL(名前)をこれで確認し、もしデモ用のサービスワーカーが働いていなければページをリロードするようにしています。

わたしが知らないだけで、リロードなんかしなくてもサービスワーカーを初回から完全に動作させる術があるかもしれません。この方法を鵜呑みにはしないように(記述の誤りやブラウザーの解釈・挙動次第で無限にリロードしかねないのも少々危険)。いずれにしても、この件は氷山の一角で、サービスワーカーの開発はこうした知ったこっちゃない挙動と対症療法の繰り返しになりがちです。