距離計算

2点間の距離を、緯度経度を基に求める。 Leafletライブラリなしでも使える汎用的な計算法と、 Leaflet標準付属の関数による方法を順に説明する。 以下の方法と Geolocation を組み合わせて現在値との距離に応じて動きを変える Web アプリケーションを作れる。

汎用関数を定義して使う方法

Hubenyの距離計算式

地図ソフトウェアである カシミール3D利用されている簡便な ヒュベニの距離計算式 を利用して2点の距離を求める。

ある2点の経度と緯度が $$ \begin{align*} (x_1, y_1) & ~ ~ ~ ~ ~ ~ & 経度、緯度の順(ラジアン)\\ (x_2, y_2) & ~ ~ ~ ~ ~ ~ & \end{align*} $$ であるとするとき、2点の距離 $d$ は次の式で求められる。 $$ \displaystyle d = \sqrt{(D_yM)^2+(D_xN\cos{\mu}_y)^2} $$ ただし、 $$\displaystyle \begin{array}{lclll} D_y & = & y_2 - y_1 & \hspace{4em} & 緯度の差 \\ D_x & = & x_2 - x_1 & \hspace{4em} & 経度の差 \\ M & = & \frac{R_x(1-E^2)}{W^3} & & 子午線曲率半径\\ N & = & \frac{R_x}{W} & & 卯酉線曲率半径\\ \mu_y & = & \frac{y_1+y_2}{2} & & 緯度の平均\\ W & = & \sqrt{1-E^2\sin^2{\mu_y}} & & \\ E & = & \sqrt{\frac{R_x^2-R_y^2}{R_x^2}} & & 離心率\\ R_x & = & 6378137.000 & & 赤道半径(\mathrm{WGS84})\\ R_x & = & 6356752.314245 & & 極半径(\mathrm{WGS84}) \end{array} $$

Hubeny式のJavaScriptによる定義

これを求める関数を JavaScript で定義する。

function distance(x1, y1, x2, y2) {
    rx = 6378137;			// 赤道半径(m)
    ry = 6356752.314;			// 極半径(m)
    e2=(rx*rx-ry*ry)/rx/rx;		// 離心率 E^2
    dx = (x2-x1)*Math.PI/180;		// 経度の差をラジアン変換
    dy = (y2-y1)*Math.PI/180;		// 緯度の差をラジアン変換
    my = (y1+y2)/2.0*Math.PI/180;	// 緯度の平均をラジアン変換
    w = Math.sqrt(1-e2*Math.sin(my)*Math.sin(my));
    m = rx*(1-e2)/Math.pow(w,3);		// 子午線曲率半径
    n = rx/w;				// 卯酉線曲率半径
    return Math.sqrt(Math.pow(dy*m,2) + Math.pow(dx*n*Math.cos(my),2));
}

Leaflet付属関数

Leaflet の LatLng オブジェクトからは、 2点間の距離をメートル単位で求める distanceTo() 関数が利用できる。ある2点を表す LatLng オブジェクト x, y の距離は、

x.distanceTo(y)		// あるいは y.distanceTo(x)

で求められる。

距離計測プログラム

Leaflet.js で地図を実際に表示し、 ユーザが指定した2地点間の距離を提示するプログラムを作ってみよう。 距離計算は上で定義した distance() を利用する。

これらの点を踏まえて、クリック時に発生する イベントオブジェクトを捕まえて発動する関数を以下のような流れで設計する。

function startOrGoal(e) {		// イベントオブジェクトをもらう
  if 「始点マーカが未定義なら(1回目のクリック)」
    始点マーカを地図上に生成
  else if 「終点マーカが未定義なら(2回目のクリック)」
    始点マーカを地図上に生成し、
    始点との距離を計算して表示
  else  // 3回目のクリック)
    始点と終点を消して初期状態に戻る
}

始点マーカと終点マーカは関数が何回も呼ばれるたびに必要になるので 関数外スコープにしておく。これらをまとめると以下のようなプログラムとなる。

measure-d.js

// 例
// 2点の距離計測

var mymap = L.map("mymap").setView([38.891, 139.824], 16);
L.tileLayer('http://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', {
 attribution:
   '<a href="http://maps.gsi.go.jp/development/ichiran.html">国土地理院</a>'
}).addTo(mymap);

function distance(x1, y1, x2, y2) {	// ヒュベニ式による距離概算
    rx = 6378137;			// 赤道半径(m) WGS84
    ry = 6356752.314;			// 極半径(m)   WGS84
    e2=(rx*rx-ry*ry)/rx/rx;		// 離心率 E^2
    dx = (x2-x1)*Math.PI/180;		// 経度の差をラジアン変換
    dy = (y2-y1)*Math.PI/180;		// 緯度の差をラジアン変換
    my = (y1+y2)/2.0*Math.PI/180;	// 緯度の平均をラジアン変換
    w = Math.sqrt(1-e2*Math.sin(my)*Math.sin(my));
    m = rx*(1-e2)/Math.pow(w,3);		// 子午線曲率半径
    n = rx/w;				// 卯酉線曲率半径
    return Math.sqrt(Math.pow(dy*m,2) + Math.pow(dx*n*Math.cos(my),2));
}

var start = null, goal = null;		// 測定始点と終点
var sMarker, gMarker;			// 始点終点マーカ
function startOrGoal(e) {
    var info = document.getElementById("info");
    var imsg = "北緯 "+e.latlng.lat+" 東経 "+e.latlng.lng;
    if (!start) {			// 始点未設定なら
	start = e.latlng;		// クリックされた緯度経度
	// 始点マーカを生成しマップに貼り付けポップアップ文字列を登録
	sMarker = L.marker(start).addTo(mymap).bindPopup(imsg);
	info.innerHTML = imsg;		// 情報表示
    } else if (!goal) {
	goal = e.latlng;
	let lng1 = start.lng, lat1 = start.lat,	// 緯度経度を個別に取得
	    lng2 = goal.lng,  lat2 = goal.lat;
	// 終点マーカを生成しマップに貼り付けポップアップ文字列を登録
	gMarker = L.marker(goal).addTo(mymap).bindPopup(imsg);
	let d = distance(lng1, lat1, lng2, lat2);		// 距離を計算
	info.innerHTML = "距離: "+Math.round(d*100)/100+"m";	// 表示
    } else {				// 始点終点ともに存在するとき
	sMarker.remove(mymap);		// 始点マーカの除去
	gMarker.remove(mymap);		// 終点マーカの除去
	start = goal = sMarker = gMarker = null;
	info.innerHTML = "クリアしました";
    }
}
mymap.on('click', startOrGoal);		// クリックイベントで startOrGoal()

これに沿う形のHTMLファイルを示す。必要な点としては、

の2点が挙げられる。

measure-d.html

<!DOCTYPE html>
<html lang="ja">
<head>
<title>マップ: 距離計測</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="../src/leaflet.css" />
<script src="../src/leaflet.js"></script>
<style type="text/css">
<!--
div#mymap {width: 90vw; height: 80vh; margin: 0 auto;}
#info {background: pink;}
-->
</style>
</head>

<body>
<h1>2点の直線距離を計る</h1>
<h2 id="info">2箇所クリックしてください</h2>
<div id="mymap"></div>
<script type="text/javascript" src="measure-d.js" charset="utf-8"></script>
</body>
</html>

実際に動かす例を measure-d.html に示す。

練習問題

measure-d は2点間をマーカで表し、その間の直線距離を表示するものであった。

最初にクリックした点から始めて、次々とクリックしていく点を順次直線で繋ぎ、 折れ線を描きつつ各線分の距離を足し合わせた積算距離を表示する Web ページを mesure-path.js と measure-path.html の組み合わせで作成せよ。

細かい挙動はどのように設計しても構わないが、 作りやすいよう以下のように定めるものとする。

折れ線測定のイメージ

折れ線距離測定のイメージ

ただし、スマートフォンやタブレット機器では「SHIFT+クリック」 といった操作はできないので、上記仕様の末尾2点の「右クリック」と 「SHIFT+クリック」に関しては代替として使用できる button を用意しておく(図「折れ線距離測定のイメージ」参照)。

測定中イメージ

距離測定中のイメージ

地図上の任意の点を順次クリックするとその点を通過点とする Polyline が作られる。始点と終点にはマーカが立ち、各線分間の距離を足し合 わせた値が表示される(図「距離測定中のイメージ」参照)。

なお、これは Leaflet 1.0 近辺での問題点だが、 マップ上での発生イベント捕捉に際し、「ダブルクリックがクリックイベント 2 回」でも処理される。デフォルトではダブルクリックがズームインなので 地図のズームインが進みつつ、普通のクリック処理が2回され混乱を招く。 このため、クリックを連続して行なうような処理を開始するときは、 マップ上でのダブルクリックのズームインを禁止して、 終了してから許可するとよい。禁止と許可はそれぞれマップオブジェクトの関数 doubleClickZoom.disable() と doubleClickZoom.enable() を呼べばよい。

解答例

まずは画面設計から進める。JavaScript プログラムから利用する要素は以下の4つである。

  1. マップ用のdiv要素
  2. 情報表示用の要素
  3. 「1つ取消」ボタン
  4. 「終了」ボタン

いずれも id 属性を付けてプログラムからアクセスできるようにしておく。

measure-path.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>マップ: 距離計測</title>
<link rel="stylesheet" href="../src/leaflet.css" />
<script src="../src/leaflet.js"></script>
<style type="text/css">
<!--
div#mymap {width: 90vw; height: 80vh; margin: 0 auto;}
#info {background: pink;}
button {font-size: 200%;} /* タブレット等指先タップ用に大きく */
-->
</style>
</head>

<body>
<h1>折れ線の距離を計る</h1>
<h2 id="info">順次クリックしてください(SHIFTクリックで終了)</h2>
<p>
<button type="button" id="undo">1つ取消</button>
<button type="button" id="finish">終了</button>
</p>
<div id="mymap"></div>
<script type="text/javascript" src="measure-path.js" charset="utf-8"></script>
</body>
</html>

このHTML文書から呼び出されるプログラムの例を示す。

measure-path.js

// 例
// 折れ線(PolyLine)の長さ

var mymap = L.map('mymap').setView([38.891, 139.824], 16);
L.tileLayer('http://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', {
 attribution:
   '<a href="http://maps.gsi.go.jp/development/ichiran.html">国土地理院</a>'
}).addTo(mymap);

function latlngdist(pos1, pos2) {	// leaflet.js標準 distanceTo()
    return pos1.distanceTo(pos2);	// を利用して距離(m)を求める
}
var path = [];				// クリックしたすべての点を保持する
var line = null;			// polylineオブジェクトを保持する
var lineprop = {			// polylineのプロパティ
    color: 'navy', opacity: 0.6, weight: 8
};
var sMarker, gMarker;			// 始点終点マーカ
var totalDist = 0;			// 積算距離
var info = document.getElementById('info');	// 情報表示用要素
function updatedistance(delta) {	// 距離の加算を行ない結果を
    totalDist += delta;			// info要素に距離を表示する
    info.innerHTML = '距離: '+Math.round(totalDist*100)/100+'m';
}
function resetPath() {			// 初期状態に戻す
    sMarker.remove(mymap);		// 始点マーカの除去
    gMarker.remove(mymap);		// 終点マーカの除去
    line.remove(mymap);			// 軌跡Polylineの除去
    line = sMarker = gMarker = null;
    path = [];
    totalDist = 0;
    mymap.doubleClickZoom.enable();	// ダブルクリックズーム許可
    info.innerHTML = 'クリアしました';
}
function measurePath(e) {		// クリック時の主となる処理
    var imsg = '北緯 '+e.latlng.lat+' 東経 '+e.latlng.lng;
    mymap.doubleClickZoom.disable();	// ダブルクリックズーム禁止
    if (path.length > 1
	&& e.latlng.lat == path[path.length-1].lat
	&& e.latlng.lng == path[path.length-1].lng) {
	// ダブルクリック等により同じ箇所で2度クリックが起きた場合は
	return null;	// 何もせず return
    }
    path.push(e.latlng);		// クリック緯度経度を配列に追加
    if (path.length == 1) {		// 始点未設定なら
	// 始点終点マーカを生成しマップに貼り付けポップアップ文字列を登録
	sMarker = L.marker(path[0]).addTo(mymap).bindPopup(imsg);
	gMarker = L.marker(path[0]).addTo(mymap).bindPopup(imsg);
	line = L.polyline(path, lineprop).addTo(mymap)	// polyline生成
	info.innerHTML = imsg;		// 情報表示
    } else {				// 2個目以後のポイント打ちなら
	line.addLatLng(e.latlng);	// polylineにクリック点を追加
	gMarker.setLatLng(e.latlng);	// 終点マーカをそこに移動
    }
    if (path.length > 1) {	// 2個以上点が打たれたら計算
	updatedistance(latlngdist(path[path.length-2], path[path.length-1]));
    }
    if (e.originalEvent.shiftKey) {	// SHIFT+クリックで
	resetPath(e);			// 終了
    }
}
function removePoint(e) {		// 1つ取消
    if (path.length > 1) {		// 2つ以上点があれば
	let rm = path.pop();		// 点保持配列の末尾を抜き取る
	updatedistance(-latlngdist(rm, path[path.length-1])); // 引き算
	line.setLatLngs(path);		// polylineの点リストを更新
	gMarker.setLatLng(path[path.length-1]);	// 終点マーカを最終点に移動
	mymap.panTo(path[path.length-1]);	// 最終点が見えるようにする
    } else {				// polylineの点が1つ以下なら
	info.innerHTML = '始点は削れません。'
    }
}
mymap.on('click', measurePath);		// クリックイベントで measurePath()
mymap.on('contextmenu', removePoint);	// 右クリックで removePoint()
// id="undo" のボタンクリックで「1つ取消」
document.getElementById('undo').addEventListener('click', removePoint);
// id="finish" のボタンクリックで「終了」
document.getElementById('finish').addEventListener('click', resetPath);

実際に動かした例を示す(全画面表示)。

順次クリックしてください(SHIFTクリックで終了)

折れ線(Polyline)の操作に関しては L.Polyline ドキュメントのMethods節を見ながら進める。ここでは、 地点を1つ追加するための addLatLng() と、 複数地点が格納された配列値で折れ線を置き換える setLatLngs() を利用した。

なお、removePoint に関して、受け取る1引数にイベントオブジェクトを期待しているが、 2箇所のイベントリスナの登録が異なることに注意したい。

mymap.on('contextmenu', removePoint);	// 右クリックで removePoint()
document.getElementById("undo").addEventListener('click', removePoint);

とあるように、Leafletマップのオブジェクトに発生するイベントからも、 documentオブジェクトに発生するイベントからも呼ばれている。 前者の場合には引数 e に Leaflet ライブラリによって付加された地図上の位置情報が含まれるため、 e.latLng などのプロパティが取得できる。
参考: Leaflet Docs - Events