地理院地図の標高タイルを使って標高APIを作る

指定した緯度経度の標高(m)を取得する Web API はさまざまなものがありますが、パラメータとして複数の緯度経度「群」を与えられるものは多くないようです。

Google Maps Elevation API は [緯度経度群を渡すことができる](https://developers.google.com/maps/documentation/elevation/overview#ElevationRequests) ようですが、当然ながら [無料ではない](https://developers.google.com/maps/documentation/elevation/usage-and-billing) です。

なので自作してみることにしました（なお、日本限定です）。

## 地理院地図の標高タイルとは

国土地理院が提供しているさまざまなデータのうちの一つです。
Google マップなどで採用されている(Webメルカトルの)地図タイルと同じ形式で標高データを提供してくれています。

- [地理院地図｜標高タイルの詳細仕様](https://maps.gsi.go.jp/development/demtile.html)

一つの標高タイルは、256x256 のセルがあり、それぞれに標高値を持ちます。
適当にイメージ化すると次のような感じです。

![](https://storage.googleapis.com/zenn-user-upload/bnzqz3cke8as1ubtkkj1wfm9z2rr)

## TypeScript で実装してみる

ロジックは以下のようになります。

1. 呼び出しパラメータで渡された緯度経度からWebメルカトルのタイルインデックス(x,y)を求める(ズームレベルは 10 で固定)
2. x,yとズームレベルを指定して標高タイルを取得する。内容は CSV テキストなので、パースして2次元配列にする
3. 渡された緯度経度から、取得したタイル内の X,Y 座標(タイル内XY座標とします)を求め、2次元配列から該当位置の標高値を得てレスポンスする

### ズームレベル「10」固定について

![](https://storage.googleapis.com/zenn-user-upload/dn66be8mpy0kg0p3nxaylnx0tdg5)

ズームレベル「10」のタイルの幅(高さ)はおよそ 32000ｍ なのでそれを 256 で割った 125m 間隔で標高値が得られることになります。一般利用する標高APIとしては十分な精度と思います。もちろんズームレベル値を大きくすれば精度は上がり、小さくすれば精度が下がります。
今回は複数の緯度経度群（要はルート）を一括で処理する際に、地図タイルデータを使い回したかったので、徒歩や自転車程度のルートを与えたときに使い回し率が高くなるであろうズームレベル10を採用しました。

※1 地理院地図でのタイル表示は、設定 → グリッド → タイル座標 で行えます
※2 距離計測は、ツールで行えます

## ソースコード

標高を取得するクラス ``ElevationService`` のソースコードは次のようになります。
使い方は、``await new ElevationService().getElevations([{lat:35.362, lng:138.731}, {lat:35.232, lng:138.62}])`` という感じで。

```typescript
import axios from 'axios';

type LatLng = { lat: number, lng: number };
type Point = { x: number, y: number };

class ElevationService {

  private readonly tileCache = new Map<string, string[][]>();
  private readonly zoom: number = 10;

  async getTileCsv(zoomXY: string): Promise<string[][]> {
    const cache = this.tileCache.get(zoomXY);
    if (cache !== undefined) {
      return cache;
    }

    const res = await axios.get(`https://cyberjapandata.gsi.go.jp/xyz/dem/${zoomXY}.txt`);
    const tileCsv = res.data as string;
    const arr = tileCsv.split('\n').map(row => row.split(','));

    this.tileCache.set(zoomXY, arr);
    return arr;
  }

  async getElevations(aLatLngs: LatLng[]): Promise<(number | undefined)[]> {
    const elevations = [];
    for (const aLatLng of aLatLngs) {
      const p = this.latLngToPoint(aLatLng, this.zoom);
      const tileX = Math.floor(p.x / 256);
      const tileY = Math.floor(p.y / 256);
      const zoomXY = `${this.zoom}/${tileX}/${tileY}`;

      const arr = await this.getTileCsv(zoomXY);

      const xInTile = Math.floor(p.x % 256);
      const yInTile = Math.floor(p.y % 256);

      const elev = Number(arr[yInTile][xInTile]);
      elevations.push(Number.isNaN(elev) ? undefined : elev);
    }

    return elevations;
  }

  private latLngToPoint(aLatLng: LatLng, zoom: number): Point {
    const lat = aLatLng.lat / 360 * Math.PI * 2;
    const lng = aLatLng.lng / 360 * Math.PI * 2;
    const yrad = Math.log( Math.tan( Math.PI / 4 + lat / 2 ) );
    const xrad = lng + Math.PI;
    const x = Math.round( xrad / ( Math.PI * 2 ) * 256 * Math.pow( 2, zoom ) );
    const y = Math.round( ( Math.PI - yrad ) / ( Math.PI * 2 ) * 256 * Math.pow( 2, zoom ) );
    return { x: x, y: y };
  }
}
```

一度読んだタイルは ``tileCache`` へキャッシュしておき、緯度経度が同じタイルに属するならば再利用するようにしています。

## Firebase Functions へデプロイしてみる

[Firebase](https://firebase.google.com/)へ適当なプロジェクトを作ってデプロイしてみよう、と思ったら Firebase Functions は無料の Spark プランでは使えなくなったのですね、しばらくハマりました。

- [【Firebase】Cloud Functionsが従量課金プランでしか使えなくなるらしい - Qiita](https://qiita.com/azukiazusa/items/edd6ca9cba2d48c4c3e2)

仕方がないので Blaze プランへアップグレードして Function がデプロイできるようになりました（多分大丈夫だと思いますがクラウド死しないように [課金アラート](https://cloud.google.com/billing/docs/how-to/budgets?hl=ja&visit_id=637374866048559657-309708469&rd=1) も設定しておきました）。
Firebase Functions としてのソースコードは次のようになります。

```typescript
import * as functions from 'firebase-functions';

// sample request https://your-proj-id.cloudfunctions.net/elevation?locations=35.362,138.731|35.232,138.62
export const elevation = functions.https.onRequest(async (request: any, response: any) => {

  // 1,2|3,4 → [{lat:1, lng:2}, {lat:3,lng:4}]
  const text = request.query.locations as string ?? '';
  const latlngs = text.split('|').map(ll => {
    const arr = ll.split(',');
    return { lat: Number(arr[0]), lng: Number(arr[1]) } as LatLng;
  });

  const elevations = await new ElevationService().getElevations(latlngs);

  response.send(JSON.stringify({
    elevations: elevations
  }));
});
```

緯度と経度はカンマ(``,``)区切りで、複数の緯経度群はパイプ(``|``)で繋いで ``locations=`` に渡します。
レスポンスは JSON 形式で、locations に指定した座標数と同数の標高値が ``elevations`` 配列に設定されます。

```json
{
  elevations:[
    121.4,
    44.3
  ]
}
```

## デプロイしてみた

自アカウントの Firebase Functions へデプロイしてみました。
以下の URL を実行すると、標高値が返ってきます。

ttps://us-central1-elev-api.cloudfunctions.net/elevation?locations=35.362,138.731|35.677,139.759

2つの緯度経度を渡していて、一つ目は富士山山頂付近、二つ目は東京駅付近です。
レスポンスは、

```json
{
  "elevations":[
    3598.8,
    5.43
  ]
}
```
となっているので、おおよそ合っていると思います。

## repo

https://github.com/amay077/elevation-api

へ一式置いてあります。
main ブランチへの push で Firebase へのデプロイが実行されるように、GitHub Actions を設定してあります。
デプロイは ``docker-compose.yml`` に任せていて、GitHub Actions はそれを呼び出しているだけです。
``FIREBASE_TOKEN`` を GitHub の secrets に指定しておくと、任意のアカウントへデプロイできます。