WebDesign Dackel

d3.jsでレスポンシブな折れ線グラフを描く

d3.jsでレスポンシブな折れ線グラフを描く

Hatena0
Google+0
Pocket0
Feedly0

はじめに

この前d3.jsについて書いた記事が、円グラフのレスポンシブ対応だったので、今回は折れ線グラフに対応してみたいと思います。
前回もそうでしたが、レスポンシブ対応だけは物足りない感じがするので、最終的にアニメーションをつけて、天気予報APIと連携するところまでチャレンジしてみたいと思います。

ちなみに前回の記事はこちらです。

d3.jsでレスポンシブな円グラフを描く

サンプルファイル

GitHubへ各ファイルをあげましたので、興味のある方はこちらからご確認いただけます。

サンプルファイル

基本的な折れ線グラフの描画

まずは基本を抑えてから、それを発展して次の内容へ移っていきたいと思います。動作確認は下記から行えます。

サンプル1

折れ線グラフの基本的な描画に関しては、沢山のブログで分かりやすく解説されているかと思うので問題ないかと思います。
また、以降大きくHTMLCSSは変わらないので、これをベースにして進めていきたいと思います。

HTML

<svg id="chart"></svg>

CSS

body {
  max-width:50em;
  margin-right:auto;
  margin-left:auto;
  padding:1em;
  font-family:sans-serif;
}
.axis path,
.axis line {
  fill:none;
  stroke:#000;
  shape-rendering:crispEdges;
}
.x.axis path {
  display:none;
}
.line {
  fill:none;
  stroke:#1572F9;
  stroke-width:1.5px;
}
text {
  font-size:10px;
}

JavaScript

// 表示サイズを設定
var margin = {
  top   : 40,
  right : 40,
  bottom: 40,
  left  : 40
};

var size = {
  width : 800,
  height: 400
};


// 表示するデータ
var data = [
  {date: "2015-01-01", value:20},
  {date: "2015-02-01", value:70},
  {date: "2015-03-01", value:100},
  {date: "2015-04-01", value:10},
  {date: "2015-05-01", value:69},
  {date: "2015-06-01", value:5},
  {date: "2015-07-01", value:75},
  {date: "2015-08-01", value:80},
  {date: "2015-09-01", value:55},
  {date: "2015-10-01", value:50},
  {date: "2015-11-01", value:32},
  {date: "2015-12-01", value:90}
];


// 時間のフォーマット
var parseDate = d3.time.format("%Y-%m-%d").parse;


// SVG、縦横軸などの設定
var svg = d3.select("#chart")
  .attr("width", size.width)
  .attr("height", size.height)
  .append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

var x = d3.time.scale()
  .range([0, size.width - margin.left - margin.right]);

var y = d3.scale.linear()
  .range([size.height - margin.top - margin.bottom, 0]);

var xAxis = d3.svg.axis()
  .scale(x)
  .orient("bottom")
  .tickFormat(d3.time.format("%m"));

var yAxis = d3.svg.axis()
  .scale(y)
  .orient("left");

var line = d3.svg.line()
  .x(function(d){ return x(d.date); })
  .y(function(d){ return y(d.value); });


// 描画
data.forEach(function(d){
  d.date = parseDate(d.date);
  d.value = +d.value;
});

x.domain(d3.extent(data, function(d){ return d.date; }));
y.domain(d3.extent(data, function(d){ return d.value; }));

svg.append("g")
  .attr("class", "x axis")
  .attr("transform", "translate(0, " + ( size.height - margin.top - margin.bottom ) + ")")
  .call(xAxis);

svg.append("g")
  .attr("class", "y axis")
  .call(yAxis)
  .append("text")
    .attr("transform", "rotate(-90)")
    .attr("y", 6)
    .attr("dy", ".7em")
    .style("text-anchor", "end")
    .text("値の単位");

svg.append("path")
  .datum(data)
  .attr("class", "line")
  .attr("d", line);

余白をとって、縦横の軸に置かれるテキストが隠れないようにするのがポイントかなと思います。ちなみに、折れ線グラフの作り方は下記の記事がとても参考になりました!

ここはほとんどサンプルのままで問題無い気がします。

レスポンシブに対応してみる

基本的な部分は大丈夫そうなので、次に本題のレスポンシブへの対応を進めてみたいと思います。

まずはサンプルをご確認ください。

サンプル2

先ほどと違って、ウィンドウをリサイズした時にグラフのサイズが変わる様になりました。
実装に入りますがHTMLは変わらないのでCSSからです。

CSS

前回の記事と同様に、SVGの横幅をいっぱいに広げるように設定しておきます。

また、直接図の描画には関係ないですが縦横軸のテキストがはみ出してしまうことがあったので、メディアクエリで700px以下の場合は少し小さくしています。

svg {
  width:100%;
}

@media screen and (max-width:700px){
  text {
    font-size:8px;
  }
  .line {
    stroke-width:1px;
  }
}

JavaScript

// 表示サイズを設定
var margin = {
  top   : 40,
  right : 40,
  bottom: 40,
  left  : 40
};

var size = {
  width : 800,
  height: 400
};


// 元の表示サイズを保持しておく
margin.original = clone(margin);
size.original = clone(size);

// 縦横比率と現在の倍率を保持しておく
size.scale = 1;
size.aspect = size.width / size.height;

// 表示するデータ
// ... 省略

// 時間のフォーマット
var parseDate = d3.time.format("%Y-%m-%d").parse;


// SVG、縦横軸などの設定
var win = d3.select(window);
var svg = d3.select("#chart");
var g = svg.append("g");
var x = d3.time.scale();
var y = d3.scale.linear();

var xAxis = d3.svg.axis()
  .scale(x)
  .orient("bottom")
  .tickFormat(d3.time.format("%m"));

var yAxis = d3.svg.axis()
  .scale(y)
  .orient("left");

var line = d3.svg.line()
  .x(function(d){ return x(d.date); })
  .y(function(d){ return y(d.value); });


// 描画
function render(){
  data.forEach(function(d){
    d.date = parseDate(d.date);
    d.value = +d.value;
  });

  x.domain(d3.extent(data, function(d){ return d.date; }));
  y.domain(d3.extent(data, function(d){ return d.value; }));

  g.append("g")
    .attr("class", "x axis");

  g.append("g")
    .attr("class", "y axis")
    .append("text")
      .attr("transform", "rotate(-90)")
      .attr("y", 6)
      .attr("dy", ".7em")
      .style("text-anchor", "end")
      .text("値の単位");

  g.append("path")
    .attr("class", "line");
}


// グラフサイズの更新
function update(){

  // SVGのサイズを取得
  size.width = parseInt(svg.style("width"));
  size.height = size.width / size.aspect;

  // 現在の倍率を元に余白の量も更新
  // 最小値がそれぞれ30pxになるように調整しておく
  size.scale = size.width / size.original.width;
  margin.top    = Math.max(20, margin.original.top * size.scale);
  margin.right  = Math.max(20, margin.original.right * size.scale);
  margin.bottom = Math.max(20, margin.original.bottom * size.scale);
  margin.left   = Math.max(20, margin.original.left * size.scale);

  // <svg>のサイズを更新
  svg
    .attr("width", size.width)
    .attr("height", size.height);

  // 縦横の最大幅を新しいサイズに合わせる
  x.range([0, size.width - margin.left - margin.right]);
  y.range([size.height - margin.top - margin.bottom, 0]);

  // 中心位置を揃える
  g.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

  // 横軸の位置
  g.selectAll("g.x")
    .attr("transform", "translate(0, " + ( size.height - margin.top - margin.bottom ) + ")")
    .call(xAxis);

  // 縦軸の位置
  g.selectAll("g.y")
    .call(yAxis);

  // 折れ線の位置
  g.selectAll("path.line")
    .datum(data)
    .attr("d", line);
}


// オブジェクトのコピーを作成する簡易ヘルパー
function clone(obj){
  var copy = {};
  for( var attr in obj ){
    if( obj.hasOwnProperty(attr) ) copy[attr] = obj[attr];
  }
  return copy;
}


// 初期化
render();
update();
win.on("resize", update);

基本的な描画に比べてだいぶ変わりました! 変更箇所が多いですが、内容はほとんど先ほどと同じです。
細かい点についてはコメントアウトに書いていますが、ポイントを上げると下記のような感じになるかと思います。

  • 描画とサイズの変更をそれぞれ関数に分けた
  • windowに対してリサイズイベントを設定して、update()が呼ばれるように

これは円グラフと同様ですね!

円グラフと違う点について

円グラフの場合は、横幅に応じて縦幅は比率が必ず1:1でしたが、折れ線グラフでは縦横のサイズが違うことの方が多いと思い、元のサイズを倍率1として、リサイズ毎に倍率を求めた上で、余白、横幅、縦幅のサイズを再調整するようにしています。

アニメーションをつけてみる

下記サンプルを開くと、下からボヨンと線が浮かんでくる簡単なアニメーションが展開されます。

サンプル3

CSSの変更はありませんので、JavaScriptを書いていきます。

JavaScript

まずはアニメーション終了用の判定フラグと、アニメーション本体の関数を用意しておきます。

// ... 省略

var size = {
  width : 800,
  height: 400
};

// アニメーション判定フラグ
var isAnimated = false;

// ... 省略
// アニメーションを実行
function animate(){

  // アニメーション用のダミーデータ
  var dummy = [];
  data.forEach(function(d, i){
    dummy[i] = clone(d);
    dummy[i].value = 0;
  });

  g.selectAll("path.line")
    .datum(dummy)
    .attr("d", line)
    .transition()
    .delay(500)
    .duration(1000)
    .ease("back-out")
    .attr("d", line(data))
    .each("end", function(){
      isAnimated = true;
      update();
    });
}

下からボヨン、がやりたかったので、最初に値を0にしたダミーの値を渡してからアニメーションさせるようにしています。

アニメーション中にウィンドウをリサイズした時に変な動きにならないようにupdate()を少し変更します。

// グラフサイズの更新
function update(){
  // ... 省略

  // 折れ線の位置
  if( isAnimated ){
    g.selectAll("path.line")
      .datum(data)
      .attr("d", line);
  }
}

最後に、初期化時にanimate()を実行するするようにしておきます。

// 初期化
render();
update();
animate(); //←追加
win.on("resize", update);

これでサンプル3と同じく、アニメーションがついたかと思います。

アニメーションのリサイズをどうにかしたい!

ちょっと残念なのは、アニメーションにリサイズが発生した際に、固定値を使っていることです。(一応アニメーションが終わった後に最新の情報へ更新していますが…)

なので、アニメーションもちゃんとリサイズに追従したいという場面では、dataの中身もいい感じに変更を加えないと行けないかもです。
こうすると良いよというご指摘お待ちしております…。

天気予報APIと連携してみる

なんでもいいので、適当なデータではなくちゃんとしたデータを使ってみたいと思ったので無料で利用できるOpenWeatherMapAPIを使ったグラフを書いてみました。

2015.11.13 追記:久しぶりに記事を読み返してみたらOpenWeatherMapの仕様変更をしていたみたいです。そのため、下記サンプルは動作しませんのでご了承下さい…。

サンプル4

東京の2週間分の天気予報から、平均気温を抜粋してグラフにしています。
やっていることはアニメーションを付ける部分を変わらないのですが、データの取得とフォーマットの部分が違うのがポイントです。

JavaScript

// 表示サイズを設定
var margin = {
  top   : 40,
  right : 40,
  bottom: 40,
  left  : 40
};

var size = {
  width : 800,
  height: 400
};

var data = [];


// アニメーション判定フラグ
var isAnimated = false;


// 元の表示サイズを保持しておく
margin.original = clone(margin);
size.original = clone(size);

// 縦横比率と現在の倍率を保持しておく
size.scale = 1;
size.aspect = size.width / size.height;


// SVG、縦横軸などの設定
var win = d3.select(window);
var svg = d3.select("#chart");
var g = svg.append("g");
var x = d3.time.scale();
var y = d3.scale.linear();

var xAxis = d3.svg.axis()
  .scale(x)
  .orient("bottom")
  .tickFormat(d3.time.format("%d"));

var yAxis = d3.svg.axis()
  .scale(y)
  .orient("left");

var line = d3.svg.line()
  .x(function(d){ return x(d.date); })
  .y(function(d){ return y(d.value); })
  .interpolate("basis");


// 描画
function render(){
  x.domain(d3.extent(data, function(d){ return d.date; }));
  y.domain(d3.extent(data, function(d){ return d.value; }));

  g.append("g")
    .attr("class", "x axis");

  g.append("g")
    .attr("class", "y axis")
    .append("text")
      .attr("transform", "rotate(-90)")
      .attr("y", 6)
      .attr("dy", ".7em")
      .style("text-anchor", "end")
      .text("気温");

  g.append("path")
    .attr("class", "line");
}


// グラフサイズの更新
function update(){

  // SVGのサイズを取得
  size.width = parseInt(svg.style("width"));
  size.height = size.width / size.aspect;

  // 現在の倍率を元に余白の量も更新
  // 最小値がそれぞれ30pxになるように調整しておく
  size.scale = size.width / size.original.width;
  margin.top    = Math.max(30, margin.original.top * size.scale);
  margin.right  = Math.max(30, margin.original.right * size.scale);
  margin.bottom = Math.max(30, margin.original.bottom * size.scale);
  margin.left   = Math.max(30, margin.original.left * size.scale);

  // <svg>のサイズを更新
  svg
    .attr("width", size.width)
    .attr("height", size.height);

  // 縦横の最大幅を新しいサイズに合わせる
  x.range([0, size.width - margin.left - margin.right]);
  y.range([size.height - margin.top - margin.bottom, 0]);

  // 中心位置を揃える
  g.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

  // 横軸の位置
  g.selectAll("g.x")
    .attr("transform", "translate(0, " + ( size.height - margin.top - margin.bottom ) + ")")
    .call(xAxis);

  // 縦軸の位置
  g.selectAll("g.y")
    .call(yAxis);

  // 折れ線の位置
  if( isAnimated ){
    g.selectAll("path.line")
      .datum(data)
      .attr("d", line);
  }
}


// アニメーションを実行
function animate(){

  // アニメーション用のダミーデータ
  var dummy = [];
  data.forEach(function(d, i){
    dummy[i] = clone(d);
    dummy[i].value = 0;
  });

  g.selectAll("path.line")
    .datum(dummy)
    .attr("d", line)
    .transition()
    .delay(500)
    .duration(1000)
    .ease("back-out")
    .attr("d", line(data))
    .each("end", function(){
      isAnimated = true;
      update();
    });
}


// オブジェクトのコピーを作成する簡易ヘルパー
function clone(obj){
  var copy = {};
  for( var key in obj ){
    if( obj.hasOwnProperty(key) ) copy[key] = obj[key];
  }
  return copy;
}


// 東京の2週間分の天気予報をデータとして使用する
// http://openweathermap.org/api
d3.json("http://api.openweathermap.org/data/2.5/forecast/daily?q=Tokyo,jp&mode=json&cnt=14", function(error, results){

  // データ形式を整える
  data = results.list;
  data.forEach(function(d){
    d.date = new Date(d.dt * 1000); //UNIX - Date object
    d.value = Math.round(d.temp.day - 273.15); //平均気温をケルビン係数を摂氏に 
  });

  // 初期化
  render();
  update();
  animate();
  win.on("resize", update);
});

一応全部のコードを載せてみましたが、違う点は下記です。

  • 縦軸のテキストを「気温」に
  • 横軸の表示フォーマットを日単位に
  • APIからJSONで値を受け取って整形したものをデータセットに使用する

ベースがあれば、データを差し替えるのは簡単ですね。

まとめ

ここまで書いておいてなんですが、円グラフに比べて情報量が多くなりそうな折れ線グラフでレスポンシブはちょっと無理があるんじゃないかな?と思いました。
実際に使う場合は、掲載する情報量との相談になりそうですね。

以上、同じようなJSのせいで長くなってしまいましたが、折れ線グラフをレスポンシブ対応したかった!という方の参考になればと思います。