d3.js + jsdomで国土地理院のベクトルタイルからSVGファイルを生成

※2018/02/08追記
jsdomの新しいバージョンに対応した記事を書きました
https://qiita.com/cieloazul310/items/1a24a85263a890feb6b0

[デモページ]

前回、[d3.jsで国土地理院のベクトルタイルを使う]という記事を書きました。この記事ではクライアント側でベクトルタイルを読み込み、SVGを生成しています。クライアント側で毎回毎回生成するSVGは、常に同じものです。出てくるSVGが同じなら先に処理してあげた方が多方面に優しいですよね。

ということで、サーバー側でベクトルタイルからSVGファイルを生成する方法を考えます。
なお、Node.jsについて多少の知識がある人向けの解説となりますのでご注意ください。

- Node.jsで実行、つまり```node ***.js```というコマンドで実行できる形にする
- d3.jsのサーバーサイドの処理はjsdomが推奨されているようなのでとりあえずjsdomを使う

### d3.jsとjsdomでSVGファイルを生成する方法

```shell-session
  npm install -D d3 jsdom
```

#### sample.js

```JavaScript
  var d3 = require("d3");
  var jsdom = require("jsdom").jsdom;

  var document = jsdom();
  var svg = d3.select(document.body)
              .append("svg");

  console.log(document.body.innerHTML);
```

```shell-session
  node sample.js > output.svg
```

#### output.svg
```xml
  <svg></svg>
```

参考にしたサイト: [D3.js による折れ線グラフ SVG の作成と PNG 変換 - Node.js]

こちらを基本形として改変していきます。

### 詰まったポイント

クライアント側で動作させるコードと大した違いはないので、前回のコードをほぼそのまま流用して、最後に```console.log(document.body.innerHTML);```を書けばいけるだろうと思ったのですが、詰まりました。
どういう部分で詰まったかというと、

1. ベクトルタイルを取得する```d3.json```は非同期処理なので、全ての処理が完了する前に```console.log(document.body.innerHTML);```が実行されてしまう。
2. ```selection.each(function)```の中の非同期処理の終了を検知したい。```Promise```を使うべきなのか（そもそもPromiseのこともよくわかってない）
3. 調べてみたら非同期処理のタスクを扱う[```d3-queue```]がある
4. これを```d3-tile```と併用するには…？

今まで非同期処理のことなどロクに意識したことがなかったので大変でした。
例えば、「この処理は大体15秒くらい経てばで終わるだろう」と適当に予測して、コードの終わりに

```JavaScript
  setTimeout(function(){
    console.log(document.body.innerHTML);
  }, 15000);
```
とか書けば、無事完成してしまうのですが、これだと知の退廃というか、敗北感がすごいのでちゃんとした方法でできないか勉強しました。

### 使用しているパッケージ

```
  d3@4.4.0
  d3-tile@0.0.3
  jsdom@9.9.1
```

### サンプルコード

#### sample.js

```JavaScript
  var d3 = Object.assign({}, require("d3"), require("d3-tile"));
  var jsdom = require("jsdom").jsdom;

  var pi = Math.PI;
  var tau = 2 * pi;
  var zoom = {
      view: 13.5,
      tile: 16
  };
  var center = [139.737682,35.686930];
  var width = 1200;
  var height = 480;

  var mag = Math.pow(2, zoom.tile - zoom.view);

  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);

  var tile = d3.tile()
      .size([width * mag, height * mag]);
  //ここまでは前回のコードと一緒です。
  var document = jsdom();

  var map = d3.select(document.body)
      .append("svg")
      .attr("xmlns",'http://www.w3.org/2000/svg')
      .attr("width", width)
      .attr("height", height)
      .attr("xmin", projection.invert([0,0])[0])
      .attr("xmax", projection.invert([width,height])[0])
      .attr("ymin", projection.invert([width,height])[1])
      .attr("ymax", projection.invert([0,0])[1])
      .attr("scale", projection.scale());

  function roadClass(prop) {
      return prop == "国道" ? "nation" :
          prop == "都道府県道" ? "pref" :
          prop == "高速自動車国道等" ? "highway" : "minor";
  }

  function railType(prop) {
      return prop == "普通鉄道（ＪＲ）" ? "JR" :
          prop == "普通鉄道（ＪＲ以外）" ? "private" :
          "others";
  }

  function railState(prop) {
      return prop == "地下" ? "underground" :
          "ordinal";
  }

  function railsnglDbl(prop) {
      return prop == "駅部分" ? "station" :
          "rail";
  }

  var q = d3.queue();

  //タイル座標の組の配列
  var tiles = tile.scale(projection.scale() * tau * mag)
                  .translate(projection([0, 0]).map(function(d){return d * mag;}))();
  //=>Array [[z,x,y],[z,x,y],...,[z,x,y]]  

  //ここからが今回のポイントとなります。
  for (var i = 0; i < tiles.length; i++){
    var tileCoord = tiles[i];

    //タイル座標に対応する軌道中心線のベクトルタイルを取得
    q.defer(d3.json, "http://cyberjapandata.gsi.go.jp/xyz/experimental_railcl/" + tileCoord[2] + "/" + tileCoord[0] + "/" + tileCoord[1] + ".geojson");
![undefined]()

    //タイル座標に対応する道路中心線のベクトルタイルを取得
    q.defer(d3.json, "http://cyberjapandata.gsi.go.jp/xyz/experimental_rdcl/" + tileCoord[2] + "/" + tileCoord[0] + "/" + tileCoord[1] + ".geojson");
  }

  q.awaitAll(function(error, files){
    if(error) throw error;

    // 各{z}/{x}/{y}.geojsonに対応する<g>要素を生成
    // 道路中心線と鉄道中心線が一緒になっているので、filesを二つに分けるのもありかも
    var g = map.selectAll(".files")
                    .data(files.filter(function(d){return d !== undefined;}))
                    .enter()
                    .append("g")
                    .attr("class","files");

    // 各{z}/{x}/{y}.geojsonの中のfeatureに対応する<path>を生成
    var features = g.selectAll("path")
                      .data(function(d){return d.features.filter(function(feature){return feature.properties.class == "RailCL" || feature.properties.rnkWidth !== "3m未満";});})
                      .enter()
                      .append("path")
                      .attr("class",function(feature){return feature.properties.class;})
                      .attr("d", path)
                      .attr("fill", "none")
                      .attr("stroke", "silver")
                      .attr("stroke-width", 1)
                      .attr("stroke-linejoin","round")
                      .attr("stroke-linecap", "round");

    // 各featureのスタイリング
    // 少し冗長なコードになってしまいます。すいません。
    map.selectAll(".RailCL")
        .attr("class", function(feature){return ["RailCL",  railType(feature.properties.type), railState(feature.properties.railState), railsnglDbl(feature.properties.snglDbl)].join(" ");})
        .attr("stroke", "black")
        .attr("stroke-width", function(feature){return feature.properties.snglDbl == "駅部分" ? 4 : 1;});

    map.selectAll(".rail.underground")
            .attr("stroke", "#999");

    map.selectAll(".station.ordinal")
            .attr("stroke", "#955");

    map.selectAll(".station.underground")
            .attr("stroke", "#baa");

    map.selectAll(".RdCL")
        .attr("class", function(feature){return ["RdCL", roadClass(feature.properties.rdCtg)].join(" ");})
        .attr("stroke", "#ddd");

    map.selectAll(".highway")
          .attr("stroke-width", 6);

    map.selectAll(".nation")
          .attr("stroke-width", 3);

    map.selectAll(".pref")
          .attr("stroke-width", 2);

    map.selectAll(".minor")
          .attr("stroke-width", 1);

    // スタイリングを仕上げたらconsole.log()で書き出し
    console.log(document.body.innerHTML);

  });
```

```shell-session
  node sample.js > output.svg
```

### 解説みたいなもの
[前回のコード]を一度忘れ、[```d3.queue()```]を使ってベクトルタイルを取得することにしました。```d3.queue()```では以下のような書き方で非同期処理のコントロールを行うことができます。

```JavaScript
  var q = d3.queue();

  q.defer(d3.json, "fileA.json")
    .defer(d3.json, "fileB.json")
    .defer(d3.json, "fileC.json");

  q.await(function(error, fileA, fileB, fileC){
      if(error) throw error;
      console.log(fileA);
      //=> Object
      console.log(fileB);
      //=> Object
      console.log(fileC);
      //=> Object
    });
```

また、```q.await(callback)```のコールバック関数は```defer```の数だけ取得したファイルを引数として取りますが、数が多くなると煩雑になります。```q.awaitAll(callback)```とすることで、取得したファイルをまとめて一つの配列として扱うことができます。

```JavaScript
  var q = d3.queue();

  q.defer(d3.json, "fileA.json")
    .defer(d3.json, "fileB.json")
    .defer(d3.json, "fileC.json");

  q.awaitAll(function(error, files){
      if(error) throw error;
      console.log(files);
      //=> Array [fileA, fileB, fileC]
  });
```

これが便利なことに、```d3.json```でリクエストに失敗したものに関しては、エラーを吐かずに```files```の中に```undefined```を返します。

```JavaScript
  var q = d3.queue();

  q.defer(d3.json, "fileA.json")
    .defer(d3.json, "fileB.json") //存在しないファイル
    .defer(d3.json, "fileC.json");

  q.awaitAll(function(error, files){
      if(error) throw error;
      console.log(files);
      //=> Array [fileA, undefined, fileC]
  });
```

表示領域を覆うタイルの組の配列を```d3.tile```で取得し、配列の数だけ```for```ループで```d3.queue()```にベクトルタイルのリクエストを繋げていきます。```d3.queue()```を```for```ループと併用する方法を思いついた時は、「よしきた！」と思ったものですが、[```d3.queue()```]のAPIドキュメントに普通に載ってました。ちゃんと読めばよかった。

そして```d3.queue().awaitAll()```で取得したgeojsonファイルから```<path>```を生成し、最後に```console.log(document.body.innerHTML)```を書けば**sample.js**は完成です。

コマンドラインで、```node sample.js > output.svg```と打ち込めば、ベクトルタイルからSVGファイルを作成することができます。できますが、何故か```d3.awaitAll(callback)```で吐かないはずのエラーを吐くときがあります。全く同じコードで成功することもあれば失敗することもあり、この原因は謎なのですが、エラーが続くときは少し時間を置いてもう一度やり直してみるといいかもしれません。

また、ズームレベルにも依りますが、生成したSVGはかなりサイズが大きいファイルになることが予想されるので、何らかの方法でpngに変換するといいかもしれません。模索中なのですが、とりあえずimagemagickはあまりお勧めできないことをお知らせしておきます。

- [デモページ]
- [SVGファイルとdivで描いた地図の例](//cieloazul310.github.io/demo/vt2svg_divmap.html)
- [SVGをページの背景として使用した例](//cieloazul310.github.io/demo/vt2svg_bg.html)

[国土地理院ベクトルタイル提供実験]: https://github.com/gsi-cyberjapan/vector-tile-experiment
[デモページ]: //cieloazul310.github.io/demo/vt2svg.html
[d3.jsで国土地理院のベクトルタイルを使う]: http://qiita.com/cieloazul310/items/bef587cf22e37a3652e5
[前回のコード]: http://qiita.com/cieloazul310/items/bef587cf22e37a3652e5#javascript
[D3.js による折れ線グラフ SVG の作成と PNG 変換 - Node.js]: http://fits.hatenablog.com/entry/2016/02/22/012432
[```d3.queue()```]: https://github.com/d3/d3-queue/blob/master/README.md