この記事は Data Visualization Advent Calendar 2015 の 5 日目の記事です。
JavaScript でタイルマップを扱うライブラリの一つである leaflet.js と、データや DOM の操作が充実したライブラリである d3.js を使って、地図上にデータヴィジュアライゼーションを作成する際に最低限必要な情報をまとめた技術解説です。
この記事で解説していることを bl.ocks.org にまとめています。コピペで動くサンプルコードはそちらをご参照ください。
geojson の表示については記事があったのですが、動的にデータを追加したり、緯度経度だけわかっているデータをちょっと表示するだけの場合に、すぐ使えそうなサンプルが見当たらなかったので作ってみました。
leaflet.js で地図表示
leaflet.js を使って OpenStreetMap のタイルマップを表示するには、例えば以下のようにします。
<!DOCTYPE html>
<meta charset='utf-8'>
<script src='//d3js.org/d3.v3.min.js'></script>
<link rel='stylesheet' href='http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.css' />
<script src='http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.js'></script>
<body>
<div style='width:960px;height:500px'></div>
<script>
var map = new L.Map(d3.select('div').node()).setView([35.678707, 139.739143], 12);
var tile = L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {
attribution : '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
</script>
body 要素のすぐ下の div 要素にタイルマップを作成します。地図のサイズは div 要素のサイズと同じになります。今回はインラインのstyle属性で大きさを指定しています。タイルマップの生成は、実質2行で済みます。
L.Map 関数でマップオブジェクト を生成します。引数は id 文字列や、DOMエレメントを渡すことができます。ソースではd3で取得したセレクションからDOMエレメントにアクセスしています。第二引数にオプションを指定することもできます。
L.tileLayer 関数で タイルレイヤを生成します。タイルはURLテンプレートによって指定します。ソースではOpenStreetMapを使っているので、attributionを追加します。最後にaddTo関数でマップオブジェクトに追加します。
SVG レイヤを追加する
var svgLayer = d3.select(map.getPanes().overlayPane).append('svg').attr('class', 'leaflet-zoom-hide').attr({width:960,height:500});
var plotLayer = svgLayer.append('g');
svg要素を追加するだけなら上の行だけで済みますが、タイルマップのズーム前後の座標位置を合わせるのにg要素を使います。
描画する要素はg要素の下にぶら下げていきます。svgにつけているleaflet-zoom-hideクラスは、タイルマップのズーム開始からズーム完了までの間、レイヤを非表示にするクラスだそうです。
緯度経度とSVG座標の相互変換
leaflet で緯度経度情報は L.LatLng オブジェクトとして扱います。L.LatLng オブジェクトから、レイヤ座標への変換には map.latLngToLayerPoint 関数を使います(mapはマップオブジェクト)。引数には L.LatLng オブジェクトを渡します。
d.pos = map.latLngToLayerPoint(new L.LatLng(d.latitude, d.longitude));
返り値は x と y という属性を持った L.point オブジェクトです。
L.point から L.LatLng への変換は map.layerPointToLatLng 関数を使います(今回のソースには登場しません)。クリックした位置の緯度経度を取得するといった使い方ができます。
データを地図上にプロットする
緯度経度から座標変換ができるようになったので、あとは通常のD3での描画と同じようなコードになります。描画する要素は何でもいいのですが、今回は山手線の駅の位置を使います。各要素に latitude 属性と longitude 属性を持った points という配列の中身を circle 要素でプロットしてきます。
points.forEach(function(d){
d.pos = map.latLngToLayerPoint(new L.LatLng(d.latitude, d.longitude));
});
plotLayer.selectAll('circle').data(points).enter().append('circle')
.attr({r:10, fill:'steelblue', stroke: 'white', 'stroke-width': 3})
.attr('cx', function(d){return d.pos.x;})
.attr('cy', function(d){return d.pos.y;});
前半3行で pos 属性に、変換後の座標を入れています。
後半4行で plotLayer 下に circle 要素を追加し、各属性を設定しています。
ズーム/スクロールに対応する
マップの表示が固定であれば、ここまでの内容で十分なのですが、最後に一つ、ズームやタイルのスクロールに対応するのにもう少しコードを追加する必要があります。
タイルレイヤ上のスクロールは、マップ全体の平行移動で表現されているので、svg要素もそれに引っ張られて一緒に平行移動していきます。
一方で、svg要素の描画領域から外れたオブジェクトはクリッピングされて見えなくなってしまうので、svg要素は常に表示領域全体を覆っているようにしなくてはなりません。
対応の仕方はいくつかあると思いますが、筆者が使っている方法を以下で紹介します。考え方は以下の通りです。
- 表示されている地図の北西、南東の端点の緯度経度をそれぞれ取得する
- その緯度経度をレイヤ座標系に変換する
-
svg要素の原点を平行移動で北西に合わせ、右下の点を南東に合うようにwidthとheightを設定する -
g要素を平行移動して、svg要素の平行移動をキャンセルする
コードで書くと以下のようになります。
var reset = function()
{
var bounds = map.getBounds();
var topLeft = map.latLngToLayerPoint(bounds.getNorthWest());
var bottomRight = map.latLngToLayerPoint(bounds.getSouthEast());
svgLayer.attr("width", bottomRight.x - topLeft.x)
.attr("height", bottomRight.y - topLeft.y)
.style("left", topLeft.x + "px")
.style("top", topLeft.y + "px");
plotLayer.attr('transform', 'translate('+ -topLeft.x + ',' + -topLeft.y + ')');
}
この関数をマップオブジェクトの move イベントにバインドしておきます。
map.on("move", reset);
描画する要素のレイヤ座標も再取得する必要があるため、その処理も追加します。まとめると下記のようになります。
var updatePosition = function(d)
{
d.pos = map.latLngToLayerPoint(new L.LatLng(d.latitude, d.longitude));
d3.select(this).attr( {cx: d.pos.x, cy: d.pos.y } );
}
map.on('move', function()
{
plotLayer.selectAll('circle').each(updatePosition);
});
まとめ
この記事では leaflet.js に SVG をオーバーレイして表示する際に必要なことを書きました。冒頭でも書きましたが、上記のことをまとめて、少し整理したコードを bl.ocks に置いてあります。合わせてご参照ください。
