LoginSignup
10
8

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-01-19

※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ファイルを生成する方法

  npm install -D d3 jsdom

sample.js

  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);
  node sample.js > output.svg

output.svg

  <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秒くらい経てばで終わるだろう」と適当に予測して、コードの終わりに

  setTimeout(function(){
    console.log(document.body.innerHTML);
  }, 15000);

とか書けば、無事完成してしまうのですが、これだと知の退廃というか、敗北感がすごいのでちゃんとした方法でできないか勉強しました。

使用しているパッケージ

  d3@4.4.0
  d3-tile@0.0.3
  jsdom@9.9.1

サンプルコード

sample.js

  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)" ? "JR" :
          prop == "普通鉄道(JR以外)" ? "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);

  });
  node sample.js > output.svg

解説みたいなもの

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

  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)とすることで、取得したファイルをまとめて一つの配列として扱うことができます。

  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を返します。

  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はあまりお勧めできないことをお知らせしておきます。

10
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8