LoginSignup
0
0

More than 1 year has passed since last update.

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

Posted at

記事の目的

Vue.jsと地図アプリ作成の勉強のために、地図(メルカトル図法)を表示するコンポーネントを作成してみる。
地図タイルは国土地理院のものを利用する。

作成する地図アプリについて

作成するアプリの仕様は以下とする。

  1. 指定した緯度・経度の周辺の地図が表示できる
  2. マウスのドラッグなどで、地図がスクロールできる

実装の前準備

地図タイルAPIについて

国土地理院の標準地図のAPIは以下。

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)

対象の緯度・経度を含むタイルの求め方

このAPIを使うには、対象の緯度・経度からタイルの番号x, yを求める必要があるので、以下の式で求める。

\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倍になる。
緯度・経度の範囲 は東経・北緯としているが、西経・南緯は負の値として扱えば同じ式で計算できると思う。

対象の緯度・経度のピクセル位置の求め方

対象の緯度・経度の地点を画面中央に表示するためには、タイル内のピクセル位置が必要となるので、以下の式で求める。

\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} \\

ピクセル変化量に対する緯度・経度の変化量の求め方

マウスでのスクロールなどにより対象の緯度・経度を動かすために、スクロール量に応じた緯度・経度の変化量が必要となるので、以下の式で求める。

\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のコンポーネントは以下のようになった。

<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>
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0