Mapbox GL JS で地理院標高タイル (ServiceWorker編)

# はじめに

@tattii88 さんの [Mapbox GL JSで地理院標高タイル](https://qiita.com/tattii88/items/3d88907c7116d1aeb028) では、Mapbox GL JS をフォークすることで地理院標高タイルを取り扱う方法を紹介なさっています。

このエントリは Mapbox GL JS には手を入れない、サーバも作らないという条件でなんとからないか、というある種の縛りプレイの記録です。ServiceWorker を使います。


# ServiceWorker

予備知識として[MDN : サービスワーカーの使用](https://developer.mozilla.org/ja/docs/Web/API/ServiceWorker_API/Using_Service_Workers) などをどうぞ。
地図系でのまともな使い道はやはりキャッシュではないかと思います。

1. ServiceWorker がインストールされる（当然オンライン）
2. `install` イベントをトリガーに一定範囲の地図タイルを GET してキャッシュが作られる
3. 屋外活動中（オフラインもありうる）にブラウザからのタイル取得要求が `fetch` イベントをトリガーに ServiceWorker に伝達され、インターネットではなくてキャッシュからデータを返すなどの処理をする

今回やる方法はこんな流れです。

1. ServiceWorker がインストールされる
2. ServiceWorker は `fetch` イベントのみを監視し、地理院標高PNGタイル以外へのリクエストならスルー
3. 標高PNGタイルを取得する
4. 取得した PNG は [標高タイル仕様](https://maps.gsi.go.jp/development/demtile.html) に準拠したものなので、これを [mapbox-terrain-rgb 形式](https://www.mapbox.com/help/access-elevation-data/) に変換する
5. 変換結果をブラウザに返す

# デモ


<https://frogcat.github.io/sw-gsidem2mapbox/>

![image.png](https://qiita-image-store.s3.amazonaws.com/0/82494/e0a1f804-f49d-08be-438a-105acde8eb14.png)


ソースは <https://github.com/frogcat/sw-gsidem2mapbox> においてあります。


# 解説

## index.html

```index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset='utf-8' />
  <title>sw-gsidem2mapbox</title>
  <meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
  <script src='https://api.tiles.mapbox.com/mapbox-gl-js/v0.51.0/mapbox-gl.js'></script>
  <link href='https://api.tiles.mapbox.com/mapbox-gl-js/v0.51.0/mapbox-gl.css' rel='stylesheet' />
</head>

<body style="padding:0;margin:0;">

  <div id='map' style='position:absolute;top:0;bottom:0;width:100%;'></div>
  <script>
    navigator.serviceWorker.register('./sw-gsidem2mapbox.js', {
      scope: './'
    }).then(registration => {
      if (!navigator.serviceWorker.controller) location.reload();
    });

    new mapboxgl.Map({
      container: 'map',
      style: {
        "version": 8,
        "sources": {
          "gsi-pale": {
            "type": "raster",
            "tiles": [
              "https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png"
            ],
            "tileSize": 256,
            "attribution": "<a href='https://maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>"
          },
          "dem": {
            "type": "raster-dem",
            "tiles": [
              "https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png"
            ],
            "tileSize": 256,
            "maxzoom": 15,
            "attribution": "<a href='https://maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>"
          }
        },
        "layers": [{
          "id": "gsi-pale",
          "source": "gsi-pale",
          "type": "raster"
        }, {
          "id": "hillshading",
          "source": "dem",
          "type": "hillshade"
        }]
      },
      center: [138.74, 35.36],
      zoom: 10,
      hash: true
    });
  </script>

</body>

</html>
```

冒頭に以下のような ServieWorker 登録部分があることを除けば、ふつうの mapbox-gl-js を呼び出すだけのコードです。ちなみに地理院のデータしか使っていないので、accessToken は無指定でも問題なく動作します。

```js

    navigator.serviceWorker.register('./sw-gsidem2mapbox.js', {
      scope: './'
    }).then(registration => {
      if (!navigator.serviceWorker.controller) location.reload();
    });
```

ここでもし登録済みでない場合には `sw-gsidem2mapbox.js` がインストールされます。

**【20181121追記】** 初めて ServieWorker を登録した直後には、この HTML ページからの処理に対して ServiceWorker は介入していない状態です。このケースに対して、ここでは単純にページをリロードすることで対処しています。


## ServiceWorker

```sw-gsidem2mapbox.js
const gsidem2mapbox = function(data) {
  var length = data.length;
  for (var i = 0; i < length; i += 4) {
    var rgb = (data[i] << 16) + (data[i + 1] << 8) + (data[i + 2]);
    var h = 0;
    if (rgb < 0x800000) h = rgb * 0.01;
    else if (rgb > 0x800000) h = (rgb - Math.pow(2, 24)) * 0.01;
    var rgb = Math.floor((h + 10000) / 0.1);
    data[i + 0] = (rgb & 0xff0000) >> 16;
    data[i + 1] = (rgb & 0x00ff00) >> 8;
    data[i + 2] = (rgb & 0x0000ff);
  }
};

self.addEventListener('fetch', (event) => {

  var url = event.request.url;
  if (url.indexOf("https://cyberjapandata.gsi.go.jp/xyz/dem_png/") !== 0) return;
  var zoom = parseInt(url.split("/")[5]);
  if (zoom === 15) url = url.replace("/dem_png/", "/dem5a_png/");
  else if (zoom <= 8) url = url.replace("/dem_png/", "/demgm_png/");

  var promise =
    fetch(url)
    .then(a => a.ok ? a.blob() : null)
    .then(blob => blob ? self.createImageBitmap(blob) : null)
    .then(image => {
      var canvas = new OffscreenCanvas(256, 256);
      var context = canvas.getContext("2d");
      if (image) {
        context.drawImage(image, 0, 0);
      } else {
        context.fillStyle = "#000000";
        context.fillRect(0, 0, canvas.width, canvas.height);
      }
      var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
      gsidem2mapbox(imageData.data);
      context.putImageData(imageData, 0, 0);
      return canvas.convertToBlob();
    }).then(blob => {
      return new Response(blob, {
        type: "image/png"
      });
    });

  event.respondWith(promise);

});

```

ServiceWorker の本体です。実際の処理は `self.addEventListener('fetch',()=>{...});` で記述しています。

まず以下の手順で標高PNGタイルの取得先を書き換えてしまって

- dem_png （z=0～14, 日本国内）へのリクエストでない場合にはなにもしない
- ズームレベルが 15 なら dem5a_png (z=15, 日本国内) を取りに行くように変更
- ズームレベルが8以下なら demgm_png (z=0～8, 全世界) を取りに行くように変更

その上で地理院標高タイル仕様から mapbox-terrain-rgb 形式に変換します。

- 標高PNGタイルを取得
- ~~pngjs~~ `createImageBitmap` と `OffscreenCanvas` でデコード
- `gsidem2mapbox` 関数でエンコーディング変換
- ~~pngjs~~ `OffscreenCanvas.convertToBlob` でエンコードしたものを返す


~~なお、ServiceWorker の中では OffscreenCanvas は使えるようなのですが、 Image のインスタンスを作る方法がなさそうなので断念。 [pngjs](https://www.npmjs.com/package/pngjs) を browserify したもので対処しています。~~

**【20181122追記】** [self.createImageBitmap()](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/createImageBitmap) と [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) で依存ライブラリなしに PNG の読み書きができることが分かったので、pngjs を使わない方法に変更しました。ただ、体感的には pngjs を使ったときのほうが早かったような気がします。


# まとめ

- ServiceWorker を使って地図タイル取得リクエストを横取りしたり、画像の加工を行って返すことができました
- 直接何かの役に立つということはないかもしれませんが何かの縛りプレイの際にどうぞ
- ServiceWorker で遊んでそのままにして忘れてしまうと、ゾンビ化した ServiceWorker があなたのリクエストを勝手に加工したり行先を変更したりしていることがあるかもしれません。注意しましょう。