d3.jsで国土地理院のベクトルタイルを使う

[ニューヨークタイムズがシリアの諸都市の被害状況を可視化した記事](http://www.nytimes.com/interactive/2015/02/12/world/middleeast/syria-civil-war-damage-maps.html)を読み、ここに使われている地図はオープンストリートマップのデータをd3.jsで読み込んでいるのではないかと考え、まあ結果的には違ったのですが、[国土地理院のベクトルタイル][国土地理院ベクトルタイル提供実験]で似たようなものを作れないかと思い、試してみました。

[VectorTiles / Mike Bostock's Block]

上記のコードを国土地理院のベクトルタイルの仕様に合わせて改造します。 とりあえず、先にデモページとサンプルコードを載せてみます。サンプルコードは上記のコードと比較しながら読むといいと思います。

[デモページ]

# 使用ライブラリ

```
d3.js v4.4.0
d3-tile v0.0.3
```

## html

```html
<html>
<head>
  略
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <!-- d3.js本体に加えて、d3-tileプラグインを読み込みます。 -->
  <script src="https://d3js.org/d3-tile.v0.0.min.js"></script>
</head>
<body>
  <div class="chartcontainer"></div>
</body>
</html>
```

## css

```css
.chartcontainer {
    width: 100%;
}

.map {
    position: relative;
    overflow: hidden;
}

path {
    fill: none;
    stroke-linejoin: round;
    stroke-linecap: round;
}

path.nation {
     stroke: #ccc;
     stroke-width: 4px;
}

path.pref {
     stroke: #ddd;
     stroke-width: 3px;
}

path.minor {
     stroke: #ddd;
}

path.highway {
     stroke: #aaa;
     stroke-width: 6px;
}

path.station {
     stroke: #e6d3ec;
     stroke-width: 3px;
}

path.rail {
     stroke: #777;
     stroke-width: 1px;
}

.info {
  position: absolute;
  bottom: 10px;
  left: 20px;
}

.landmarks {
  font-family: sans-serif;
  font-weight: 600;
  fill: #777;
  font-size:9px;
}

.landmarks.region {
  font-size:20px;
  fill: black;
}

.landmarks.area {
  font-weight: 200;
  fill: #333;
  font-size:16px;
}
```

## JavaScript

```javascript
var pi = Math.PI;
var tau = 2 * pi;

// 表示するズームレベルとタイルを取得するズームレベルを別個に定義
var zoom = {view: 13.5, tile: 16};

var center = [140.461321, 36.374950];
var width = $(".chartcontainer").width();
var height = 480;

// ズームレベルの差をdzとすると、2^dzを変数magで定義
// 今回の場合は2^(16-14)=2^2=4となる
var mag = Math.pow(2, zoom.tile - zoom.view);

// projectionのスケールは表示するズームレベルを指定
var projection = d3.geoMercator()
    .center(center)
    .scale(256 * Math.pow(2, zoom.view) / tau)
    .translate([width / 2, height / 2]);

var path = d3.geoPath()
    .projection(projection);

// d3.tile()のサイズにmagを掛ける
var tile = d3.tile()
    .size([width * mag, height * mag]);

var map = d3.select(".chartcontainer").append("svg")
    .attr("class", "map")
    .attr("width", width)
    .attr("height", height);

var info = d3.select(".chartcontainer").append("div")
              .attr("class", "info")
              .append("h4");

// geojsonファイルの属性からclassを与える関数
function roadClass(prop) {
    return prop == "国道" ? "nation" :
        prop == "都道府県道" ? "pref" :
        prop == "高速自動車国道等" ? "highway" : "minor";
}

map.selectAll(".tile")
    .data(tile
        .scale(projection.scale() * tau * mag) // magを掛ける
        .translate(projection([0, 0]).map(function(v){return v * mag;}))) //magを掛ける
    .enter().append("g")
    .attr("class","tile")
    .each(function(d) {
        // このgが各タイル座標となる
        var g = d3.select(this);

        // 道路中心線を取得
        d3.json("http://cyberjapandata.gsi.go.jp/xyz/experimental_rdcl/" + d[2] + "/" + d[0] + "/" + d[1] + ".geojson", function(error, json) {
            if (error) throw error;

            g.selectAll(".road")
                .data(json.features.filter(function(d){
                  return d.properties.rnkWidth !== "3m未満";
                })) // 小さい道路をフィルタリング（任意）
                .enter().append("path")
                .attr("class", function(d) {
                    return "road " +roadClass(d.properties.rdCtg);
                })
                .attr("d", path);
        });

        // 鉄道中心線を取得
        d3.json("http://cyberjapandata.gsi.go.jp/xyz/experimental_railcl/" + d[2] + "/" + d[0] + "/" + d[1] + ".geojson", function(error, json) {
            if (error) throw error;
            g.selectAll(".rail")
                .data(json.features)
                .enter().append("path")
                .attr("class", function(d) {
                    return d.properties.snglDbl == "駅部分" ? "station" :
                    "rail";
                })
                .attr("d", path);
        });
    });

  // オーバーレイを作成（任意）
  var layer1 = map.append("g");
  var layer2 = map.append("g");

  d3.json("../data/mito/landmarks.geojson", function(error, json) {
    if (error) throw error;

    var landmarks = layer1.selectAll(".landmarks")
                  .data(json.features)
                  .enter()
                  .append("text")
                  .attr("class", function(d){return "landmarks " + d.properties.type;})
                  .attr("transform", function(d) {
                      return "translate(" + projection(d.geometry.coordinates) + ")";
                  })
                  .attr("text-anchor","middle")
                  .text(function(d){return d.properties.name;});
  });


  d3.json("../data/mito/machinaka.geojson", function(error, json) {
      if (error) throw error;

      var machinaka = layer2.selectAll(".machinaka")
              .data(json.features)
              .enter()
              .append("g")
              .attr("class", "machinaka")
              .attr("transform", function(d) {
                  return "translate(" + projection(d.geometry.coordinates) + ")";
              })
              .on("mouseover", function(d) {
                  d3.select(this).select("circle")
                      .attr("r", 8)
                      .attr("fill", "rgba(255, 145, 0, 0.8)");
                  info.text(d.properties["施設名"]);
              })
              .on("mouseout", function(d) {
                  d3.select(this).select("circle")
                      .attr("r", 5)
                      .attr("fill", "rgba(255, 0, 0, 0.6)");
                  info.text("");
              });

          machinaka.append("circle")
              .attr("r", 5)
              .attr("fill", "rgba(255, 0, 0, 0.6)")
              .attr("stroke", "white");

      });
```

## 解説

[VectorTiles / Mike Bostock's Block]で使われている[Mapzen]のベクトルタイルとは異なり、国土地理院の道路中心線、鉄道中心線、河川中心線のベクトルタイルは**ズームレベル16のみで提供**されています。[`d3.tile()`][```d3.tile()```]で取得できるタイル座標のズームレベルは地図の縮尺に依存するため、ズームレベル16に対応する縮尺を設定しないと地図が白紙になってしまいます。 ですが、ズームレベル16に適した縮尺はそれなりに大縮尺なので、表示する範囲が非常に狭くなってしまします。ズームレベル16のベクトルタイルを取得しつつ、それなりに広域の範囲を表示する方法を考えます。

[`d3.tile()`][```d3.tile()```]は、`projection`の情報と出力領域のサイズを基に、地図の領域をカバーするタイル座標の配列を返します。この配列をsvgの中の`<g>`要素とバインドし、`d3.json()`でベクトルデータを取得していきます。

```javascript
console.log(map.selectAll(".tile").data());
  // => [[58330,25646,16],[58331,25646,16],[58332,25646,16],...,[58345,25654,16]]
  // タイルのx座標、y座標、ズームレベルで構成された配列の集合を返す
```

先述の通り、国土地理院の道路中心線ベクトルタイルを取得するには、[`d3.tile()`][```d3.tile()```]が返すタイル座標のズームレベルが16に固定されなければなりません。

表示する地図の縮尺と取得するタイルのレベルを補正するために、`mag`という変数を定義します。

```javascript
var zoom = {view: 14, tile:16};
  var mag = Math.pow(2, zoom.tile - zoom.view);
```

変数`mag`を使い、ベクトルタイルを取得する際に、実際に表示しているサイズの`mag^2`倍の大きさの地図の情報を[`d3.tile()`][```d3.tile()```]に渡しています。 例えば**地図のサイズが400px x 300px、表示したい縮尺がズームレベル14**のとき、この地図と同じ範囲のタイルをズームレベル16で取得するには、`400 * mag` x `300 * mag`、`mag = Math.pow(2, 16 - 14) = 4`なので**1600px x 1200px、ズームレベル16**という偽の情報を[`d3.tile()`][```d3.tile()```]に渡してやればいいのです。

```javascript
var tile = d3.tile()
      .size([width * mag, height * mag]);

  map.selectAll(".tile")
      .data(tile.scale(projection.scale() * tau * mag)
                .translate(projection([0, 0]).map(function(v){return v * mag;})))
      .enter().append("g")
```

ここで`tile.translate()`が何を意味しているのかいまいち理解してないのですが、とりあえず出てくる数値に`mag`を掛けるのが正解のようです。

400px x 300px の大きさのsvgに任意のズームレベルで、ズームレベル16のベクトルタイルを描写する場合、`zoom.view`、`zoom.tile`、`<svg>`のサイズ、`tile.size()`、`mag`は以下のような関係になります。

zoom.view | zoom.tile | svg     | tile.size()  | mag
--------- | --------- | ------- | ------------ | ---
13        | 16        | 400x300 | [3200, 2400] | 8
14        | 16        | 400x300 | [1600, 1200] | 4
15        | 16        | 400x300 | [800, 600]   | 2
16        | 16        | 400x300 | [400, 300]   | 1
17        | 16        | 400x300 | [200, 150]   | 0.5

これは地図を表示するズームレベル`zoom.view`を13.5のように浮動小数点の値にしてもきちんと動作します。

こうしてズームレベル16のベクトルタイルの読み込みに成功すれば、あとは[VectorTiles / Mike Bostock's Block]と同じようにclassとcssでスタイルを整えれば地図が出来上がります。

[デモページ]

ズームレベル15で提供されている[注記ベクトルタイル](https://github.com/gsi-cyberjapan/experimental_anno)や、ズームレベル18で提供されている[基盤地図情報ベクトルタイル](https://github.com/gsi-cyberjapan/experimental_fgd)も、以上の方法と同様に`zoom.tile`の値を固定することで地図の縮尺に影響されることなく利用することができます。

## 雑記

ベクトルタイルを読んで表示する処理をいちいちクライアント側でさせるのは面倒ですよね。 パンやズーム、インタラクティブな動作を実装しない静的な地図ならサーバー側で予め処理しておくのがいいんですかね。これから勉強します。

タイル地図の仕様やベクトルタイルなどの説明は省いているため、普段タイル地図に触れていない人にはわかりづらい説明になってしまっているとは思います。 以下のリンクを参照ください。

- [地理院タイル仕様]
- [タイル座標確認ページ]
- [国土地理院ベクトルタイル提供実験]

[```d3.geo()```]: https://github.com/d3/d3-geo/blob/master/README.md
[```d3.tile()```]: https://github.com/d3/d3-tile
[mapzen]: https://mapzen.com
[vectortiles / mike bostock's block]: https://bl.ocks.org/mbostock/5616813
[タイル座標確認ページ]: http://maps.gsi.go.jp/development/tileCoordCheck.html
[デモページ]: //cieloazul310.github.io/demo/d3vectortile.html
[国土地理院ベクトルタイル提供実験]: https://github.com/gsi-cyberjapan/vector-tile-experiment
[地理院タイル仕様]: http://maps.gsi.go.jp/development/siyou.html
[地理院地図]: http://maps.gsi.go.jp/#14/36.370157/140.460377/&base=std&ls=std&disp=1&lcd=std&vs=c1j0l0u0f1