PWA地図アプリ向けタイルキャッシュservice workerフレームワーク"Weiwudi"を開発した

この記事は[PWA Advent Calendar 2020](https://qiita.com/advent-calendar/2020/pwa)の22日目の記事になります。

### 何をやったかを簡単に
PWAで地図アプリケーションを作る際に、地図タイルのオフライン利用可能なキャッシュを制御し、現在のキャッシュ容量の取得や事前一括ダウンロード、キャッシュ一括削除などができるservice workerのためのフレームワークを作ってみました。
Weiwudiという名前で、[Github](https://github.com/code4history/Weiwudi)と[npm](https://www.npmjs.com/package/weiwudi)で公開しています。
また動作例としては、私の作っている古地図アプリシリーズ、[ぷらっと奈良](https://s.maplat.jp/r/naramap/)や[ぷらっと館林](https://s.maplat.jp/r/tatebayashimap/)などで実運用に投入されています(ただし、逐次キャッシュとキャッシュ容量取得、キャッシュ削除だけで、一括ダウンロードはまだ利用アプリ側での対応をさせていません)。

### 地図タイル画像の仕組み
PWA Advent Calendarに参加したものの、私は基本、PWAに関してはかなり門外漢、ド素人で、代わりに地図技術などに関してはかなりのエキスパートです。
門外の場に現れたので、私が当然に思っているような地図技術のノウハウなどは知らない方も多いと思うので、軽く触れておきます。

今、Webでの地図データ配信は、Googleが発明したWebメルカトルタイルという標準データ形式(WMTS Web Map Tile Serviceと言います)で配信されています([私のブログでの過去記事](https://blog.code4history.dev/entry/20120927/1348749299))。
このデータ形式では、世界の地図がズーム(z)、X座標(x)、Y座標(y)という3つの変数で決まった範囲のタイル状に切り刻まれており、この3変数で変わるURLテンプレートを元に、仕様に沿った範囲の地図画像を配信することになっています。
URLテンプレートの形式としては、例えばこんな感じですね。

```:URLテンプレートの例
https://example.com/mapname/{z}/{x}/{y}.png
```
このURLテンプレートを元に、どこの経緯度の時にどのタイルを読み込むかなどは、一般的な地図API - たとえば[Leaflet](https://leafletjs.com/)、[OpenLayers](https://openlayers.org/)、[Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/api/)、あるいは[Google Maps API](https://cloud.google.com/maps-platform)など - を使っている限りは、このURLテンプレートを各APIに与えるだけで、URLの解決は表示範囲を元に各APIから自動計算してくれますので、経緯度とタイルURLの対応付けをユーザが気にする必要はありません。
そのような形の利用の場合、Weiwudiはバックグラウンドで動作し、自動でアクセスのあったURLタイルを横取りし、IndexedDBにキャッシュします。

しかし、単にそれだけだと、オンライン時に表示した範囲の地図タイルしかキャッシュしてくれません。
Weiwudiはそれに加え、事前にズームの範囲と経緯度の範囲を指定した場合に限りですが、その範囲のタイルを事前に一括ダウンロードしてくれる機能を持ちます。
この機能を個別に実装しようと思うと、経緯度をメルカトル座標というものに変換してさらにXY座標の範囲に変換する必要が生じるのですが、Weiwudiを使うとその辺をまるっと私の知識で実装してるので、利用者はズームの範囲と経緯度の範囲を設定すれば後は一括ダウンロードメソッド叩くだけ、あら簡単！という寸法になっております。

### やっと使い方
長々とタイル周りの説明をのたまってきましたが、いよいよ使い方に移りたいと思います。

#### service worker側の設定
Weiwudiは[Workbox](https://developers.google.com/web/tools/workbox/)の導入を前提に動作します。
なのでWorkboxと一緒に読み込んでください。

```js:sw.js
importScripts("https://storage.googleapis.com/workbox-cdn/releases/5.1.4/workbox-sw.js");
importScripts("https://cdn.jsdelivr.net/npm/weiwudi@0.1.0/src/weiwudi_sw.js");
```
これ、今気づきましたがnpmのREADME、バージョンアップとともに更新できてなくて読み込むライブラリのバージョンが0.0.2のままになってますね。
現時点の最新バージョンは0.1.0なので、それに対応したライブラリをimportScriptsするようにしてください。

#### フロントエンドJS側の処理
##### ライブラリの読み込み

```js:Weiwudi読み込み
import Weiwudi from 'weiwudi';
```

##### service workerの登録

```js:service_worker登録
try {
    // Register service worker
    await Weiwudi.registerSW('./sw.js', {scope: './'});

    ...
} catch(e) {
    // For error cases (E.g. browser doesn't support service worker) 
    ...
}
```
service workerの登録は、PWA周りが苦手な私がごちゃごちゃ考えなくても登録できるように上記の`registerSW`クラスメソッドを用意していますが、これを使う必要は全くないです。
PWAのエキスパートの方々は私などよりよほどこだわったservice workerの登録手順があると思いますので、それ経由でservice worker登録していただいて全くかまいません。
というか、むしろノウハウ教えてください...。

##### インスタンス生成(URLテンプレートの登録)
```js:インスタンス生成(URLテンプレートの登録、WMTSの場合)
    // Register map setting to service worker
    // WMTS map case
    const map1 = await Weiwudi.registerMap('wmts_map', {
        type: 'wmts',
        minLat: 35.0,
        maxLat: 35.1,
        minLng: 135.0,
        maxLng: 135.1,
        minZoom: 17,
        maxZoom: 18,
        url: 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png'
    });
```
Weiwudiのservice workerにキャッシュ対象のURLテンプレートを登録するには、`registerMap`クラスメソッドを使ってWeiwudiクラスのインスタンスを生成します。
第一引数は地図のIDで、URLテンプレートごとに(要するに地図ごとに)一意のIDを指定してください。
第二引数はオプションで、このうち`type`と`url`は必須属性で、`type`はWMTSデータ形式の場合は`"wmts"`を指定します(他のデータタイプについては後述)。
`url`はURLテンプレートを登録します。
`minZoom`、`maxZoom`、`minLat`、`maxLat`、`minLng`、`maxLng`はそれぞれ最小ズーム、最大ズーム、最小緯度、最大緯度、最小経度、最大経度を表し、オプション属性ですが、これが全部指定されていないとタイル一括ダウンロードはできません。
`minLat`、`maxLat`、`minLng`、`maxLng`については、設定する場合は4つ揃って設定されている必要があります。

普通のユースケースの場合はWMTSのデータ形式だけを意識しておけばいいのですが、Weiwudiは私自身の必要性のために作ったので、私の必要とするユースケースとして、古地図などの画像をタイル化した場合に対応するXYZタイル形式があります。
これは単純に、一枚物の画像などをタイル化した場合への対応で、以下のような感じですが、ほぼ利用されないと思うので無視していただいても問題ないです。

```js:XYZユースケースでの登録
    // XYZ map case
    const map2 = await Weiwudi.registerMap('xyz_map', {
        type: 'xyz',
        width: 10000,
        height: 6000,
        url: 'http://example.com/{z}/{x}/{y}.jpg'
    });
```
必須属性はWMTSと同様の`type`、`url`に加え、画像の幅、高さピクセル数に相当する`width`、`height`属性も必須になります。

##### 地図APIに登録する用のURLテンプレートを取得する

```js:地図APIに登録する用のURLテンプレートを取得
    // Get url template of cached map
    const map1_url = map1.url;
```
地図APIに登録する用のURLテンプレートを取得するプロパティが`url`です。
上記の`map1`の場合、Weiwudiと一緒に使わない場合はURLテンプレートは`https://b.tile.openstreetmap.org/{z}/{x}/{y}.png`になるわけですが、これをそのまま地図APIに登録するとWeiwudiと連携できなくなるので、代わりにこの`map1.url`で取得できるURLテンプレートを地図APIに登録します。
そのことで、地図APIとWeiwudiが連携できるようになります。
具体的には、`https://weiwudi.example.com/api/cache/{地図ID}/{z}/{x}/{y}`のような値になるはずです。

##### 現在のキャッシュ状況取得

```js:現在のキャッシュ状況取得
    // Get current caching status
    const status = await map1.stats();
```
現在の地図タイルキャッシュ状況を返すインスタンスメソッドは`stats`です。
戻り値はオブジェクトで、以下のような感じになります。

```js:stats戻り値
{
    count: 0 // 現在キャッシュされているタイル画像数
    percent: 0 // 全部一括ダウンロードした場合に対する現キャッシュ数のパーセント(WMTSで必要属性が設定されていない場合は省略)
    size: 0 // 現在のキャッシュ容量をバイト数で
    total: 8408 // 全部一括ダウンロードした場合のタイル数(WMTSで必要属性が設定されていない場合は省略)
}
```

###### 一括ダウンロード実行

```js:一括ダウンロード実行
    // Fetch all tiles
    map1.addEventListener('proceed', (e) => {
        // Write some codes for handling event of proceeding to fetch tiles
    });
    map1.addEventListener('finish', (e) => {
        // Write some codes for handling event of finishing to fetch tiles
    });
    map1.addEventListener('stop', (e) => {
        // Write some codes for handling event of stopping to fetch tiles by some errors
    });
    map1.addEventListener('canceled', (e) => {
        // Write some codes for handling event of cancelling to fetch tiles by user
    });
    // Start fetching 
    await map1.fetchAll();
```
一括ダウンロードが可能な場合に、一括ダウンロードを実行するインスタンスメソッドは`fetchAll`です。
進捗状況はイベントで伝えられますので、検出するイベントを`addEventListener`で登録する必要があります。
存在するイベントタイプは、`proceed(進捗中)`、`finish(完了)`、`stop(エラーなどによる停止)`、`canceled(ユーザ操作による停止)`があります。
イベントの引数`e`には`e.parameter`にWeiwudiからの状況フィードバックが含まれており、その値は

```js:e.parameterの値(proceed)
{
    type: "proceed", // イベントの種類
    message: "Proceeding the tile fetching: wmts_map 80% (800 / 1000)", // イベントメッセージ
    percent: 80, // 全体の進捗%
    processed 800, // 処理済みタイル数
    error: 2, // 処理済みタイル中、エラーの数(実行時エラーなどでないネットワークエラーなどの場合は、ここにカウントされるだけで処理は途切れず続きます)
    total: 1000, // 全タイル数
    mapID: "wmts_map" // 処理中の地図ID
}
```

```js:e.parameterの値(finish)
{
    type: "finish", // イベントの種類
    message: "`Fetched all tiles of wmts_map with 2 error cases", // イベントメッセージ
    error: 2, // 処理済みタイル中、エラーの数
    total: 1000, // 全タイル数
    mapID: "wmts_map" // 処理中の地図ID
}
```

```js:e.parameterの値(stop)
{
    type: "stop", // イベントの種類
    message: "Fetching stopped: wmts_map 800 / 1000", // イベントメッセージ
    reason: "...", // システムのエラーメッセージをそのまま伝達
    processed: 800, // 処理済みタイル数
    total: 1000, // 全タイル数
    mapID: "wmts_map" // 処理中の地図ID
}
```

```js:e.parameterの値(canceled)
{
    type: 'canceled', // イベントの種類
    message: "Fetching tile of wmts_map is canceled", // イベントメッセージ
    mapID: "wmts_map" // 処理中の地図ID
}
```

のようになります。

##### 一括ダウンロード中のキャンセル

```js:一括ダウンロード中のキャンセル
    // Cancel fetchAll process
    await map1.cancel();
```
`cancel`インスタンスメソッドで、現在進行中の一括ダウンロードプロセスをキャンセルします。
キャンセルの完了はイベントで通知されます。
現在進行中の一括ダウンロードプロセスの地図IDと同じWeiwudiインスタンスからのキャンセルしか受け付けられません。

##### 地図キャッシュのクリア

```js:地図キャッシュのクリア
    // Clean all cached tile images
    await map1.clean();
```
`clean`インスタンスメソッドで、Weiwudiインスタンスに紐づく地図IDのタイルキャッシュをすべて解放します。
一括ダウンロードプロセスが進行中の場合は受け付けられません(他地図IDのプロセスが進行中の場合は大丈夫です)。

##### 地図登録の解除

```js:地図登録の解除
    // Remove registered map setting
    await map2.remove();
```
`remove`インスタンスメソッドで、Weiwudiインスタンスに紐づく地図IDの登録が削除されます。
これにより、インスタンスは解放を待つだけの非活性状態になり、インスタンスメソッドを呼ぶとエラーが出る状態になります。

### 今後の課題
#### PWA的に正しい方法へのブラッシュアップ
以上が、Weiwudiの現行機能の説明になります。
最初にも書いた通り、私は地図技術の方面ではエキスパートですがPWA関連では素人なので、service workerの動作のさせ方など、問題あるかもしれません。
もしそういう点がありましたら、詳しい方にぜひご鞭撻いただきたいと思います。

特に知りたいのは、完全に初めてのアクセス時に、Weiwudiを動作させる方法です。
今の実装だと、2回目以降のアクセスでは(多分)完璧に動作しているのですが、最初のアクセスではWeiwudiが動作せず、キャッシュ機構が働かない地図サイトになってしまっています。
これを何とか初回から動作するようにしたいのですが...。

#### ベクタタイルへの対応
現在、地図API技術は過渡期にあり、地図タイルの配信をラスタ(画像。サーバサイドでレンダリングして地図画像を配信する)で行うかベクタ(データ。点や線、面といった描画前のデータを送り、ブラウザ側でレンダリングする)で行うかの切り替わり時期にあります。
私は主に古地図を使った地図アプリに特化した開発を行ってきたため、実はベクタタイルについてはそれほど知識がありません。
どちらで送る場合でもタイルで送る仕様には変わりなく、キャッシュ配信時のMIMEなどさえ正しく設定してやれば基本的には似た形で動作するはずなのですが、ベクタ配信の場合タイルデータだけキャッシュできてもあまりうれしくないと思われ、ブラウザ側のレンダリングを行うスタイルファイルなどものキャッシュもタイルとセットで動作すべきだと思うのですが、私がその辺の仕様に詳しくないので対応に躊躇するような状況になっています。
ベクタタイルの配信に詳しい人に助けていただければベクタタイルにも対応できると思われますので、ぜひ助けていただけると幸いです。

#### ズーム範囲、経緯度範囲をメソッド呼び出し時に指定できるようにする
元々、Weiwudiは自分のために作り始めたので、自分のユースケースのために作っています。
特定の街を対象にした古地図アプリ作成などを想定しているので、ズーム範囲、経緯度範囲を地図の登録時に固定するユースケースしか考えていませんでした。
が、よく考えると、この記事を書いている間に気づいたのですが、地図の表示範囲としては全世界を対象にしつつも、ある時だけある地域の地図を全部事前にダウンロードしておきたいようなユースケースも普通にあるはずです。
そんな時は、fetchAllメソッドの実行時にズーム範囲、経緯度範囲を指定したい場合もあると思いました。
今後のバージョンアップでそのようなケースに対応したいと思っています。

===========================================

以上、ちょっと異色だったかもしれませんが作ってみたライブラリ、Weiwudiの説明をさせていただきました。
ご興味を持たれたら使ってみていただければ幸いです。