2012年4月25日水曜日

svgとjavascriptを使って円グラフを作ろう

svgを使って円グラフを作る方法には様々なものが考えられるが,javascriptの力を借りてクライアントサイドでグラフを描画するスクリプトを書いてみた.なお,埋め込みのsvg要素を使ったものではないのであしからず.但しコードそのものは応用可能だと思う.

グラフを描く処理は予めコンポーネント化しておき,あとから使いまわすことを考えて, 値と項目名称のリストをurlのクエリ文字列経由で引き渡すようにしている.

なお,作成したsvgファイルをimg要素に指定した場合はjavascriptが実行されない(おそらくwindowオブジェクトの取扱いの問題)ので,一般的なhtmlファイルと同様にiframe要素を使ってsvgファイルを表示させる.

扇形をpath要素で描画するのだが,この部分が一番面倒である.というのも,全体に占める割合によってlarge-arc-flag値を切り替える必要があるからだ.50%以下の場合は円弧の短い方を,そうでない場合は円弧の長い方を採用しなければならない.ここさえ乗り越えてしまえば後は三角関数を使った座標の計算だけなのでそれほど難しくはない.

結局やってることはhtmlの時と全く同じ.取り扱うオブジェクトがhtmlElementからsvgElementに代わるだけなのでそれほど違和感は感じない.全ての要素をposition:absoluteで配置しているようなものだし.



下記にコードを載せる.svg(構造)+js(動作)+css(表示)の3つのファイルから構成される.要するに一般的なhtmlの構成と同じ形.同じ事をcanvasで行う場合はこれらが一緒くたになるので面倒かもしれない.またfilter要素を使ってグラフに装飾をつけるのも楽チン.

iframe(svg呼び出し側)
パラメータvalues,namesを取り替えることでグラフの内容を変更可能とした.
<iframe height="300px" 
src="http://www.h2.dion.ne.jp/%7Edefghi/svg/svgGraph.svg?values=10,20,30,40,50&names=aaa,bbb,ccc,ddd,eee" 
width="500px"></iframe>

svg(グラフ描画部)
filter要素を使って円グラフに影をつけている.
<?xml version="1.0" standalone="no"?>
<?xml-stylesheet href="svgGraph.css" type="text/css"?>
<svg width="500px" height="300px" viewBox="0 0 500 300" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
 <script xlink:href="svgGraph.js" charset="utf-8"/>
 <defs>
  <filter id="shadow">
   <feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur"/>
   <feOffset in="blur" dx="3" dy="3" result="offsetBlur"/>
   <feMerge>
    <feMergeNode in="offsetBlur"/>
    <feMergeNode in="SourceGraphic"/>
   </feMerge>
  </filter>
 </defs>
 <g filter="url(#shadow)" id="graph"/>
</svg>

css
pathオブジェクトを取得した後,d属性にコマンドを記述しているが,この部分の操作の為のメソッドが事細やかに提供されているみたい.
rect,path{
 stroke:black;
}
text{
 fill:black;
 font-size:16;
}
.c0{fill:red;}
.c1{fill:blue;}
.c2{fill:green;}
.c3{fill:yellow;}
.c4{fill:pink;}
.c5{fill:orange;}
.c6{fill:aqua;}
.c7{fill:purple;}

javascript
属性値をsetAttributeメソッドで直接いじる方法の他,専用のメソッドを利用する方法もある.
下ではpath要素のd属性を専用メソッドを使って設定している.コードの見やすさを考慮して使い分けると良い.
  1. SVGPathElementを取得する.
  2. createSVGPathSegXXXメソッドを用いてパスの切片を作る.
  3. pathSegList.appendItemに2で作ったパス切片を渡す.
"use strict";
window.onload = function(){

 var NS = "http://www.w3.org/2000/svg";
 var cx = 150;
 var cy = 150;
 var r = 100;
 var graph = document.getElementById("graph");

 //get value from url query parameters.
 function getQueryParameter(key, defaultValue){
  var re = new RegExp("(^\\?|&)" + key + "=([^\\?&]+)([\\?&]|$)");
  return location.search != "" 
   && location.search.toLowerCase().match(re) 
   ? decodeURIComponent(RegExp.$2) : defaultValue;
 }

 //値の配列を取得する.
 function getValues(){
  var values = getQueryParameter("values", "").split(",");
  for(var i = 0, len = values.length; i < len; i++){
   values[i] = ~~values[i];
  }
  return values;
 }

 //名称の配列を取得する.
 function getNames(){
  return getQueryParameter("names", "").split(",");
 }

 //値の合計を取得する.
 function getTotal(values){
  var total = 0;
  for(var i = 0, len = values.length; i < len; i++){
   total += values[i];
  }
  return total;
 }

 //値の合計に対する比率を取得する.
 function getRatios(values, total){
  var ratios = [];
  for(var i = 0, len = values.length; i < len; i++){
   ratios.push(values[i] / total);
  }
  return ratios;
 }

 //円グラフの頂点を取得する.
 function getPoints(ratios){
  var points = [];
  var ratioStep = 0;
  for(var i = 0, len = ratios.length; i < len; i++){
   ratioStep += ratios[i];
   var deg = Math.PI * 2 * ratioStep;
   points.push({
    x: r * Math.sin(deg) + cx,
    y: - r * Math.cos(deg) + cy
   });
  }
  return points;
 }

 //円グラフを構成する扇形を描画する.
 function drawPaths(ratios){
  
  if(ratios.length == 1){
   drawCircle();
   return;
  }

  var points = getPoints(ratios);
  for(var i = 0, len = points.length; i < len; i++){
   if(i == 0){
    drawPath({x:cx, y:cy-r}, points[0], ratios[0], i);
   }else{
    drawPath(points[i-1], points[i], ratios[i], i);
   }
  }
 }
 
 //円を描画する(100%の場合)
 function drawCircle(){
  var circle = document.createElementNS(NS, "circle");
  circle.setAttribute("cx", cx);
  circle.setAttribute("cy", cy);
  circle.setAttribute("r", r);
  circle.setAttribute("class", "c0");
  graph.appendChild(circle);
 }

 //扇形を描画する.
 function drawPath(point1, point2, ratio, i){
  var path = document.createElementNS(NS, "path");
  /*
  直接d属性を書き換えるパターン
  path.setAttribute("d", getD(point1, point2, ratio));
  */
  //SVGPathElementのもつメソッドを利用したパターン
  path.pathSegList.appendItem(path.createSVGPathSegMovetoAbs(cx, cy));
  path.pathSegList.appendItem(path.createSVGPathSegLinetoAbs(point1.x, point1.y));
  path.pathSegList.appendItem( 
   path.createSVGPathSegArcAbs(
    point2.x, point2.y, 
    r, r, 0, 
    ratio <= 0.5 ? false: true,true
   )
  );
  path.pathSegList.appendItem(path.createSVGPathSegClosePath());
  path.setAttribute("class", "c" + (i % 8));
  graph.appendChild(path);
 }

 //扇形を描画するコマンドを取得する.
 function getD(point1, point2, ratio){
  return "M " + cx + "," + cy 
   + " L " + fix(point1.x) + "," + fix(point1.y) 
   + " A " + r + "," + r + " 0 " 
   + (ratio <= 0.5 ? "0": "1") + ",1 " 
   + fix(point2.x) + "," + fix(point2.y);
 }
 
 //パーセンテージの表示基準点を取得する.
 function getRatioPoints(ratios){
  var points = [];
  var ratioStep = 0;
  for(var i = 0, len = ratios.length; i < len; i++){
   ratioStep += ratios[i];
   var deg = Math.PI * 2 * (ratioStep - ratios[i]/2);
   points.push({
    x: r * 1.25 * Math.sin(deg) + cx,
    y: - r * 1.25 * Math.cos(deg) + cy
   });
  }
  return points;
 }

 //パーセンテージを表示する.
 function drawRatios(ratios){
  var ratioPoints = getRatioPoints(ratios);
  for(var i = 0, len = ratios.length; i<len; i++){
   var text = document.createElementNS(NS, "text");
   text.setAttribute("x", ratioPoints[i].x);
   text.setAttribute("y", ratioPoints[i].y + 8);
   text.setAttribute("text-anchor", "middle");
   text.textContent = Math.round(ratios[i] * 1000)/10 + "%";
   document.documentElement.appendChild(text);
  }
 }

 //浮動小数点形式の値を丸める.
 function fix(value){
  return Math.round(value * 100)/100;
 }
 
 //名称を描画する.
 function drawNames(names){
  for(var i = 0, len = names.length; i<len; i++){
   drawName(names[i], i);
  }
 }
 
 //名称を描画する.
 function drawName(name, i){
  
  var rect = document.createElementNS(NS, "rect");
  rect.setAttribute("x", 300);
  rect.setAttribute("y", 30 + 20 * i);
  rect.setAttribute("width", 16);
  rect.setAttribute("height", 16);
  rect.setAttribute("class", "c" + (i % 8));
  document.documentElement.appendChild(rect);
  
  var text = document.createElementNS(NS, "text");
  text.setAttribute("x", 320);
  text.setAttribute("y", 30 + 14 + 20 * i);
  text.textContent = name;
  document.documentElement.appendChild(text);
 }
 
 //メイン処理
 function start(){
  var values = getValues();
  var total = getTotal(values);
  if(total == 0){
   return;
  }
  var ratios = getRatios(values, total)
  drawPaths(ratios);
  drawRatios(ratios);
  
  var names = getNames();
  drawNames(names);
 }
 start();
}

0 件のコメント:

コメントを投稿