記事の目的
Vue.jsと地図アプリ作成の勉強のために、地図(メルカトル図法)を表示するコンポーネントを作成してみる。
地図タイルは国土地理院のものを利用する。
作成する地図アプリについて
作成するアプリの仕様は以下とする。
- 指定した緯度・経度の周辺の地図が表示できる
- マウスのドラッグなどで、地図がスクロールできる
実装の前準備
地図タイル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>