Vue.jsで地図を表示してみる

# 記事の目的
Vue.jsと地図アプリ作成の勉強のために、地図(メルカトル図法)を表示するコンポーネントを作成してみる。
地図タイルは[国土地理院](https://maps.gsi.go.jp/development/ichiran.html)のものを利用する。

# 作成する地図アプリについて
作成するアプリの仕様は以下とする。
1. 指定した緯度・経度の周辺の地図が表示できる
1. マウスのドラッグなどで、地図がスクロールできる

# 実装の前準備
## 地図タイルAPIについて
[国土地理院の標準地図のAPI](https://maps.gsi.go.jp/development/ichiran.html#std)は以下。
```
https://cyberjapandata.gsi.go.jp/xyz/std/{zoom}/{x}/{y}.png
ただし、
zoom: ズームレベル(5~18)
x: タイルの東西方向の番号(西経180°を0とし、東を正の向きとする)
y: タイルの南北方向の番号(北緯約85.051129を0とし、南を正の向きとする)
```
このAPIにGETリクエストを送信すると、256x256の画像ファイルが取得できる。
パラメータのフォーマット(/zoom/x/y.png)や画像サイズは、大抵の地図タイルAPIで共通の模様。(参考: [OpenStreetMap](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames))

## 対象の緯度・経度を含むタイルの求め方
このAPIを使うには、対象の緯度・経度からタイルの番号x, yを求める必要があるので、以下の式で求める。
```math
\begin{align} \\
タイル番号 \\
x &= int(2^{zoom} x_r) \\
y &= int(2^{zoom} y_r) \\
世界地図上の位置 \\
x_r &= \frac{180 + \theta}{360} \\
y_r &= \frac{1}{2} - \frac{1}{2\pi} \ln{\bigl(\tan{(\frac{\pi}{4} + \frac{\phi}{2} \frac{2\pi}{360}}})\bigr) \\
 \\
パラメータ \\
zoom &: ズームレベル[0 \sim] \\
\theta &: 経度[東経0° \sim 東経180°] \\
\phi &: 緯度[北緯0° \sim 北緯約85.051129°] \\
\end{align} \\
```
**タイル番号x, y** は整数になり、範囲は [0 ~ 2のzoom乗 -1]となる。
**世界地図上の位置** とは、世界地図(メルカトル図法)を1×1の正方形としたときの位置とする。(正方形とするため、北緯約85.051129以北、南緯約85.051129以南は削除される)
**ズームレベル** は、1増えるごとに辺のタイル数が2倍になり、総タイル数は4倍になる。
**緯度・経度の範囲** は東経・北緯としているが、西経・南緯は負の値として扱えば同じ式で計算できると思う。

## 対象の緯度・経度のピクセル位置の求め方
対象の緯度・経度の地点を画面中央に表示するためには、タイル内のピクセル位置が必要となるので、以下の式で求める。
```math
\begin{align} \\
タイル内のピクセル位置 \\
px &= int(tileSize \times 2^{zoom} x_r) \mod tileSize \\
py &= int(tileSize \times 2^{zoom} y_r) \mod tileSize \\
パラメータ \\
tileSize &: タイルサイズ \\
\end{align} \\
```

## ピクセル変化量に対する緯度・経度の変化量の求め方
マウスでのスクロールなどにより対象の緯度・経度を動かすために、スクロール量に応じた緯度・経度の変化量が必要となるので、以下の式で求める。
```math
\begin{align} \\
経度・緯度の変化量 \\
\Delta \theta &= \frac{360}{tileSize} \times (\frac{1}{2})^{zoom} \Delta px \\
\Delta \phi &= \frac{360}{tileSize} \times (\frac{1}{2})^{zoom} \Delta py \times \cos{(\phi\frac{2\pi}{360})} \\
パラメータ \\
\Delta \theta &: 経度の変化量 \\
\Delta \phi &: 緯度の変化量 \\
\Delta px &: 東西方向のピクセル変化量 \\
\Delta py &: 南北方向のピクセル変化量 \\
\end{align} \\
```

# 実装
以上を踏まえてVue.jsのコンポーネントは以下のようになった。
```vuejs
<template>
  <div>
    <div>
      <p class="paramRow">
        <label for="zoomLevel" class="label paramRow__label">ズームレベル:</label>
        <input id="zoomLevel" class="input paramRow__input" type="number" v-model="data.zoom" @input="drawMap" />
      </p>
      <p class="paramRow">
        <label for="latitude" class="label paramRow__label">緯度:</label>
        <input id="latitude" class="input paramRow__input" type="number" v-model="data.latitude" @input="drawMap" />
      </p>
      <p class="paramRow">
        <label for="longitude" class="label paramRow__label">経度:</label>
        <input id="longitude" class="input paramRow__input" type="number" v-model="data.longitude" @input="drawMap" />
      </p>
    </div>
    <canvas id="board" class="canvas" :width="data.clientWidth" :height="data.clientHeight" @mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp" @mouseleave="onMouseUp" />
  </div>
</template>

<script setup lang="ts">
import { UnwrapNestedRefs, onMounted, computed, reactive } from 'vue'

/**
 * 地図タイルの取得・表示を行う.
 */
class TileSet {
  constructor(
    public name: string,
    public url: string,
    public tileSize: number,
    public zoomRange: { min: number, max: number }
  ) {
  }

  private cachedTiles: Map<string, HTMLImageElement> = new Map()

  /**
   * 指定されたキャンバスのコンテキストに地図タイルを描画する.
   * @param context 
   */
  draw(context: CanvasRenderingContext2D, numOfRowTiles: number, numOfColTiles: number) {
    const x = Math.floor(Math.pow(2, data.zoom) * xr.value)
    const y = Math.floor(Math.pow(2, data.zoom) * yr.value)
    const px = Math.floor(this.tileSize * Math.pow(2, data.zoom) * xr.value) % this.tileSize
    const py = Math.floor(this.tileSize * Math.pow(2, data.zoom) * yr.value) % this.tileSize
    for (let i = -Math.floor(numOfRowTiles / 2 + 1); i < Math.floor(numOfRowTiles / 2 + 2); i++) {
      for (let j = -Math.floor(numOfColTiles / 2 + 1); j < Math.floor(numOfColTiles / 2 + 2); j++) {
        this.getTileAt(data.zoom, x + i, y + j)
          .then(chara => context.drawImage(
            chara,
            this.tileSize * i - px + data.clientWidth / 2,
            this.tileSize * j - py + data.clientHeight / 2
          ))
      }
    }
  }

  /**
   * タイル画像を取得する.
   * 取得した画像はキャッシュに保持しておく.
   */
  private getTileAt(z: number, x: number, y: number): Promise<HTMLImageElement> {
    const key = this.toKey(z, x, y)
    const value = this.cachedTiles.get(key)
    if (!!value) {
      return new Promise(r => r(value))
    }
    const image = new Image();
    image.src = this.formatString(this.url, { z: z, x: x, y: y })
    this.cachedTiles.set(key, image)
    return new Promise(r => {
      image.onload = () => {
        r(image)
      }
    })
  }

  private toKey(z: number, x: number, y: number) {
    return `${z}-${x}-${y}`
  }

  /**
   * String.format() ライクな関数.
   * 例:
   *  assert(
   *    this.format("hello, {who}.", { who: "world" }),
   *    "hello, world."
   *  )
   */
  private formatString(str: string, params: any) {
    let result = str;
    for (let key in params) {
      result = result.replace("{" + key + "}", params[key]);
    }
    return result;
  }
}

/**
 * data
 */
const data: UnwrapNestedRefs<{
  zoom: number,
  longitude: number,
  latitude: number,
  clientWidth: number,
  clientHeight: number,
}> = reactive({
  zoom: 13,
  longitude: 139.7673068,
  latitude: 35.6809591,
  clientWidth: 256 * 3,
  clientHeight: 256 * 2,
})

/** 世界地図上の位置.  */
const xr = computed(() => (180 + data.longitude) / 360)
const yr = computed(() => 0.5 - 0.5 * Math.log(Math.tan(Math.PI / 4 + data.latitude * Math.PI / 360)) / Math.PI)

/** 表示するタイル数.  */
const numOfRowTiles = computed(() => data.clientWidth / tileSet.tileSize)
const numOfColTiles = computed(() => data.clientHeight / tileSet.tileSize)

/** 表示対象のタイルAPI.  */
const tileSet = new TileSet(
  "国土地理院-標準",
  "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png",
  256,
  { min: 5, max: 18 }
)

/** キャンバスのコンテキスト */
let context: undefined | CanvasRenderingContext2D = undefined

/** ドラッグ中のマウスイベントオブジェクト */
let dragged: any = undefined

onMounted(() => {
  const board = <HTMLCanvasElement>document.querySelector("#board")
  context = <CanvasRenderingContext2D>board.getContext("2d")
  new Promise((resolve, reject) =>
    // クライアントのGPS座標を取得する.
    navigator.geolocation.getCurrentPosition(
      pos => resolve(pos),
      error => reject(error)
    )
  ).then(pos => {
    data.longitude = pos.coords.longitude
    data.latitude = pos.coords.latitude
  }).catch(error => {
    console.log(error)
  }).then(() => {
    // クライアントのGPS座標が取得できればそれを、そうでなければデフォルトの座標を描画する.
    tileSet.draw(context!, numOfRowTiles.value, numOfColTiles.value)
  })
})

const drawMap = () => {
  tileSet.draw(context!, numOfRowTiles.value, numOfColTiles.value)
}

const onMouseDown = (event: MouseEvent) => {
  dragged = event
}

const onMouseMove = (event: MouseEvent) => {
  if (!!dragged) {
    translateImage(dragged.x - event.x, - dragged.y + event.y)
    dragged = event
  }
}

const onMouseUp = (event: MouseEvent) => {
  dragged = undefined
}

const translateImage = (dx: number, dy: number) => {
  data.longitude += 360 / tileSet.tileSize / Math.pow(2, data.zoom) * dx
  data.latitude += 360 / tileSet.tileSize / Math.pow(2, data.zoom) * dy * Math.cos(data.latitude * Math.PI / 180)
  tileSet.draw(context!, numOfRowTiles.value, numOfColTiles.value)
}

</script>

<style>
.paramRow {
  margin: 2px;
}

.paramRow__label {
  margin-right: 10px;
}
.paramRow__input {
  margin: 2px;
}

.label {
  display: inline-block;
  width: 150px;
  text-align: right;
}

.canvas {
  border: 1px solid;
}
</style>
```