Leaflet.js と D3.js でタイル地図上にデータヴィジュアライズするための Tips

この記事は [Data Visualization Advent Calendar 2015](http://qiita.com/advent-calendar/2015/dataviz) の 5 日目の記事です。

JavaScript でタイルマップを扱うライブラリの一つである [`leaflet.js`](http://leafletjs.com/) と、データや DOM の操作が充実したライブラリである [`d3.js`](http://d3js.org/) を使って、地図上にデータヴィジュアライゼーションを作成する際に最低限必要な情報をまとめた技術解説です。

---

この記事で解説していることを [bl.ocks.org](http://bl.ocks.org/osoken/156891f13eb6e4943a03) にまとめています。コピペで動くサンプルコードはそちらをご参照ください。

![156891f13eb6e4943a03.png](https://qiita-image-store.s3.amazonaws.com/0/51853/017c58c3-3da5-1098-b7f6-8580ed189a86.png)

`geojson` の表示については[記事](http://bost.ocks.org/mike/leaflet/)があったのですが、動的にデータを追加したり、緯度経度だけわかっているデータをちょっと表示するだけの場合に、すぐ使えそうなサンプルが見当たらなかったので作ってみました。

# `leaflet.js` で地図表示

`leaflet.js` を使って [`OpenStreetMap`](http://osm.org/copyright) のタイルマップを表示するには、例えば以下のようにします。

```html
<!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 : '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
</script>
```

`body` 要素のすぐ下の `div` 要素にタイルマップを作成します。地図のサイズは `div` 要素のサイズと同じになります。今回はインラインの`style`属性で大きさを指定しています。タイルマップの生成は、実質2行で済みます。

`L.Map` 関数で[マップオブジェクト](http://leafletjs.com/reference.html#map-class) を生成します。引数は `id` 文字列や、`DOM`エレメントを渡すことができます。ソースでは`d3`で取得したセレクションから`DOM`エレメントにアクセスしています。第二引数に[オプション](http://leafletjs.com/reference.html#map-options)を指定することもできます。

`L.tileLayer` 関数で [タイルレイヤ](http://leafletjs.com/reference.html#tilelayer)を生成します。タイルは[URLテンプレート](http://leafletjs.com/reference.html#url-template)によって指定します。ソースでは`OpenStreetMap`を使っているので、`attribution`を追加します。最後に`addTo`関数でマップオブジェクトに追加します。

# `SVG` レイヤを追加する

```js
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`](http://leafletjs.com/reference.html#latlng) オブジェクトとして扱います。`L.LatLng` オブジェクトから、レイヤ座標への変換には `map.latLngToLayerPoint` 関数を使います（`map`はマップオブジェクト）。引数には `L.LatLng` オブジェクトを渡します。

```js
d.pos = map.latLngToLayerPoint(new L.LatLng(d.latitude, d.longitude));
```

返り値は `x` と `y` という属性を持った [`L.point`](http://leafletjs.com/reference.html#point) オブジェクトです。

`L.point` から `L.LatLng` への変換は `map.layerPointToLatLng` 関数を使います（今回のソースには登場しません）。クリックした位置の緯度経度を取得するといった使い方ができます。

# データを地図上にプロットする

緯度経度から座標変換ができるようになったので、あとは通常のD3での描画と同じようなコードになります。描画する要素は何でもいいのですが、今回は山手線の駅の位置を使います。各要素に `latitude` 属性と `longitude` 属性を持った `points` という配列の中身を `circle` 要素でプロットしてきます。

```js
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`要素は常に表示領域全体を覆っているようにしなくてはなりません。
対応の仕方はいくつかあると思いますが、筆者が使っている方法を以下で紹介します。考え方は以下の通りです。

1. 表示されている地図の北西、南東の端点の緯度経度をそれぞれ取得する
2. その緯度経度をレイヤ座標系に変換する
3. `svg`要素の原点を平行移動で北西に合わせ、右下の点を南東に合うように`width`と`height`を設定する
4. `g`要素を平行移動して、`svg`要素の平行移動をキャンセルする

コードで書くと以下のようになります。

```js
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` イベントにバインドしておきます。

```js
map.on("move", reset);
```

描画する要素のレイヤ座標も再取得する必要があるため、その処理も追加します。まとめると下記のようになります。

```js
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](http://bl.ocks.org/osoken/156891f13eb6e4943a03) に置いてあります。合わせてご参照ください。

# 参考記事

- [【D3.js】 LeafletにSVGをオーバーレイする。#GUNMAGISGEEK](http://shimz.me/blog/d3-js/3517)
- [D3 + Leaflet (bost.ocks.org)](http://bost.ocks.org/mike/leaflet/)