JavaScript
SVG
javascriptグラフィックス
数学
Snap.svg

この記事の内容

SVGはXMLベースのベクター画像形式でW3Cが標準化しており各種ブラウザもサポートしている。Snap.svgはJSによるSVGのユーティリティライブラリでありSVGタグの自動生成などをしてくれる。SVGは汎用的な画像形式なのでほぼすべてのかたちを表現できる反面、XMLベースなので属性値で多くの見た目を制御している。それは色や線の太さだけでなく位置などの情報も属性値で制御する。Snap.svgでは位置情報は関数の引数にするが属性値の設定はjQueryライクな.attrとメソッドチェーンで行われる。また属性名も特殊なものが使われる場合が多いので注意が必要である。
この記事では数学関数をSVGを使ってグラフ化を通していくつかのSVGタグや属性そしてSnap.svgを使ってそれらをどう扱うかを解説する。

SVGの基礎知識

2種類のSVGタグ

SVGタグには2種類ある。タグの子要素にSVG要素を持てるタグでこれは<svg><g>がある。<svg>はcanvasAPIの<canvas>などと同じく描画領域を表し一番最上位に来る。Snap.svgではPaperというオブジェクト名になる。<g>はグループの略称で<svg>もしくは<g>の子要素として配置される、その子要素の同じ平行移動、回転などが定義できる。
それ以外は具体的な形を表すタグで<rect>(四角形)、<circle>(円)、<line>(線)、<path>などがある。

座標系

SVGの座標系(canvasAPIを含む)の座標系は<svg>左上(0,0)を基本としそこから右を$x>0$、を$y>0$である、詳しくはW3C 座標系変換を参照。これは一般的な数学の表記と異なるので変換が必要になる。

作り方

変換関数はクラスごとに分けやすいようにメンバ関数とした。SVG要素はselectorで選ばれた要素を親要素にそのサイズと同じサイズのSVG要素を作るようにする。自分自身の縦横のサイズを$1$としてそのサイズで指定できるようにしている。g要素やシェープ要素は関数を使って作られメソッドチェーンのために作ったものは返り値として返す。

top.js
export default class{
    constructor(selector){
        this.elem=document.querySelectorAll(selector)[0];
        this.top=Snap(this.elem.clientWidth, this.elem.clientHeight).remove();
        this.top.appendTo(this.elem);
    }

    group(x0, y0, x1, y1, id){
        const gx0=this.convert_x(x0), gx1=this.convert_x(x1), gy0=this.convert_y(y0), gy1=this.convert_y(y1);
        const gx=gx0<gx1 ? gx0 : gx1, gy=gy0<gy1 ? gy0 : gy1;
        const width=Math.abs(gx1-gx0), height=Math.abs(gy1-gy0);
        const g=this.top.g();
        if( id!=null ) g.attr("id", id);
        return new group(g, gx, gy, width, height);
    }
    graph(x0, y0, x1, y1){ return new graph(this, x0, y0, x1, y1); }
    line(x0, y0, x1, y1){ return util.line(this, x0, y0, x1, y1); }
    rect(x0, y0, x1, y1){ return util.rect(this, x0, y0, x1, y1); }

    appendTo(svg){ this.elem.appendTo(svg); }
    clear(){ this.top.clear(); }

    //*** general utility ***//                                                                                                                
    svg(){ return this.top; }
    x0(){ return 0; }
    y0(){ return 0; }
    h(){ return this.elem.clientHeight; }
    w(){ return this.elem.clientWidth; }
    convert_x(x){ return this.w()*x; }
    convert_y(y){ return this.h()-this.h()*y; }
    convert(pos){ return [ this.convert_x(pos[0]), this.convert_y(pos[1]) ]; }
}

g要素はgroupクラスでラップしtopクラスと同じように自分自身の大きさを$1$とした座標変換を実装しておく。

graph.js
import //(略)
export default class{
    constructor(g, x0, y0, w, h){ this.g=g, this.posx=x0, this.posy=y0, this.width=w, this.height=h; }

    group(x0, y0, x1, y1, id){
        const gx0=this.convert_x(x0), gx1=this.convert_x(x1), gy0=this.convert_y(y0), gy1=this.convert_y(y1);
        const gx=gx0<gx1 ? gx0 : gx1, gy=gy0<gy1 ? gy0 : gy1;
        const width=Math.abs(gx1-gx0), height=Math.abs(gy1-gy0);
        const g=this.g.g();
        if( id!=null ) g.attr("id", id);
        return new this.constructor(g, gx, gy, width, height);
    }

    graph(x0, y0, x1, y1){ return new graph(this, x0, y0, x1, y1); }
    line(x0, y0, x1, y1){ return util.make(this, x0, y0, x1, y1); }
    rect(x0, y0, x1, y1){ return util.rect(this, x0, y0, x1, y1); }

    appendTo(svg){ this.g.appendTo(svg); }
    clear(){ return this.g.clear(); }

    //*** general utility ***//                                                                                                                
    svg(){ return this.g; }
    x0(){ return this.posx; }
    y0(){ return this.posy; }
    h(){ return this.height; }
    w(){ return this.width; }
    convert_x(x){ return this.x0()+this.w()*x; }
    convert_y(y){ return this.y0()+this.h()*(1-y); }
    convert(pos){ return [ this.convert_x(pos[0]), this.convert_y(pos[1]) ]; }
}

実際のグラフはこの全体を描画されるdisplayクラスと軸x(y)_axisクラスをもったクラスにする。描画範囲はgroupクラスを通してdisplayクラスに渡される。

graph.js
export default class{
    constructor(group, dispx0, dispy0, dispx1, dispy1){
        this.parent=group;
        this.disp=new Display(this.parent.group(dispx0, dispy0, dispx1, dispy1));
        this.x_axis=new Xaxis(this.parent.group(dispx0, 0, dispx1, dispy0));
        this.y_axis=new Yaxis(this.parent.group(0, dispy0, dispx0, dispy1));
    }

    set_range(xmin, xmax, ymin, ymax){ this.disp.set_range(xmin, xmax, ymin, ymax); }
    set_range_x(xmin, xmax){ this.disp.set_range_x(xmin, xmax); }
    set_range_y(ymin, ymax){ this.disp.set_range_x(ymin, ymax); }

    draw(target){
        if( typeof target==='function' ) this.disp.draw_func(target);

        this.x_axis.draw(this.disp.xmin, this.disp.xmax);
        this.y_axis.draw(this.disp.ymin, this.disp.ymax);
    }

    clear(){
        this.disp.svg().clear();
        this.x_axis.svg().clear();
        this.y_axis.svg().clear();
    }                                                                                                              
}

displayクラスの座標変換は描画範囲が基準となる。コンストラクタやdrawが呼ばれた場合などは枠線が自動的に描かれる。draw_funcの引数は関数オブジェクト(func(x)が呼べる)であることが想定されている、簡易な分類はグラフクラスでやらせている。

display.js
export default class{
    constructor(group, xmin, xmax, ymin, ymax){
        this.group=group;
        this.xmin=xmin;
        this.xmax=xmax;
        this.ymin=ymin;
        this.ymax=ymax;
        this.group.rect(0, 0, 1, 1).attr({ fill: 'none', strokeWidth: 1, stroke: 'black' });
    }

    draw_func(func){
        this.clear();
        this.group.rect(0, 0, 1, 1).attr({ fill: 'none', strokeWidth: 1, stroke: 'black' });
        return func_to_path(this, func).attr({ stroke: 'red', strokeWidth: 2});
    }
    set_range(xmin, xmax, ymin, ymax){ this.xmin=xmin, this.xmax=xmax, this.ymin=ymin, this.ymax=ymax; }
    set_range_x(xmin, xmax){ this.xmin=xmin; this.xmax=xmax; }
    set_range_y(ymin, ymax){ this.ymin=ymin; this.ymax=ymax; }

    svg(){ return this.group.svg(); }
    x0(){ return this.group.x0(); }
    y0(){ return this.group.y0(); }
    h(){ return this.group.h(); }
    w(){ return this.group.w(); }
    convert_x(x){ return this.x0()+(this.w()/(this.xmax-this.xmin))*(x-this.xmin); }
    convert_y(y){ return this.y0()+this.h()-(this.h()/(this.ymax-this.ymin))*(y-this.ymin); }

    clear(){ this.svg().clear(); }
}

軸クラスはdrawに範囲(min, max)を渡すことによりその範囲のラベルをかけるようにする。

x_axis.js
export default class{
    constructor(group){ this.group=group; }

    draw(min, max){
        this.clear();
        util.x_axis(this.group, min, max);
    }

    clear(){ this.svg().clear(); }
    svg(){ return this.group.svg(); }
}

実際のSVGのシェープ要素を作る部分

実際にSVGの各要素を作る部分はutilの関数として共通化する。util自体は各関数をまとめたオブジェクトで関数は別ファイルにしてある。

util.js
import line from './util/line.js'
import rect from './util/rect.js'
import x_axis from './util/x_axis.js'

export const util={
    line: line,
    rect: rect,
    x_axis: x_axis,
    y_axis: y_axis
}
//util/line.js
export default function(group, x0, y0, x1, y1){
    return group.svg().line( group.convert_x(x0), group.convert_y(y0), group.convert_x(x1), group.convert_y(y1));
}
//util/rect.js
export default function(group, x0, y0, x1, y1){
    const gx0=group.convert_x(x0), gx1=group.convert_x(x1), gy0=group.convert_y(y0), gy1=group.convert_y(y1);
    const gx=gx0<gx1 ? gx0 : gx1, gy=gy0<gy1 ? gy0 : gy1;
    const width=Math.abs(gx1-gx0), height=Math.abs(gy1-gy0);
    return group.svg().rect(gx, gy, width, height);
}
//util/x_axis.js
export default function(group, min, max, N){
    N=N | 10;
    const delta=(max-min)/N;

    for( let i=0; i<=N; i++ ){
        const val=min+i*delta;
        group.svg().text(group.convert_x(1.0*i/N), group.convert_y(1.0), val.toPrecision(2))
            .attr({ textAnchor: "middle", dominantBaseline: "hanging"});
    }
}
//util/y_axis.js
export default function(group, min, max, N){
    N=N | 10;
    const delta=0.9*(max-min)/N;
    min=min+0.05*(max-min);

    for( let i=0; i<=N; i++ ){
        const val=min+i*delta;
        group.svg().text(group.convert_x(1.0), group.convert_y(0.05+(0.9*i)/N), val.toPrecision(2))
            .attr({ textAnchor: "end", dominantBaseline: "middle"});
    }
}

Snapではrectは左上位置と横幅、高さでの指定であるが2点で定義したほうが使いやすいと思ったのでそちらに変えてある。
各軸の値はNumber.toPrecisionで桁数を決めている。表記は2桁を超えると科学表記になる。(数値表記は、toFixed(桁数)、toExponential(桁数):科学表記、必ずeXがつく、toString(基数)等、かなり自由に文字列に変換できる)
text-anchor(textAnchor)属性は横方向の位置調整,dominant-baseline(dominantBaseline)属性は(主に)縦方向の位置調整である(その他いろいろ指定できるがその分、属性値がややこしくなる)。属性値はSnapを使う場合はローワーキャメルケースになる。X軸は下中心にY軸は左中心に描くようにattrで設定してある。
displayクラスには専用のfunc_to_path関数が用意されている。

func_to_path.js
export default function(disp, func, N){
    N=N | 100000;
    const delta=(disp.xmax-disp.xmin)/N;

    let path_data='', is_over_range=true;
    for( let i=0; i<=N; i++ ){
        const x=disp.xmin+delta*i;
        const y=func(x);

        if( disp.ymin<y && y<disp.ymax ){
            if( is_over_range ) path_data+='M'+disp.convert_x(x)+','+disp.convert_y(y)+' ';
            else path_data+='L'+disp.convert_x(x)+','+disp.convert_y(y)+' ';
            is_over_range=false;
        }
        else is_over_range=true;
    }

    return disp.svg().path(path_data).attr({ fill: 'none', strokeWidth: 1, stroke: 'black' });
}

パス要素は(コマンド)文字列で設定するのでつぎの点につなげるときはL、新しい起点を作るときはMを使う。MLがコマンドでSVG自体は他にいくつかのコマンドを持っている。描画範囲を超えた点は描かせないようにしてある。fill属性をnoneに設定しないと起点と終点を結んでfill領域を作って塗るのでnoneに設定する。

動作確認HTML等

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" type="text/css" href="css/graph.css">
    <title>Math by Javascript</title>
    <script src="lib/Snap.svg-0.5.1/dist/snap.svg-min.js"></script>
    <script src="node_modules/mathjax/MathJax.js?config=TeX-AMS_CHTML"></script>
    <script type="text/x-mathjax-config">
      MathJax.Hub.Config({
      displayAlign: "left",
      displayIndent: "2em"
      });
    </script>
    <script src="dist/graph_sample.js"></script>
  </head>
  <body>
    <h1>JavascriptとSnap.svgで関数を書く</h1>
    <form>
      関数系:<input value="sin(x)" id="func_form"></input><br>
      x範囲[<input id="gra_xmin" type="Number" min="-1000" max="1000", step="0.00001", value="0">:
      <input id="gra_xmax" type="Number" min="-1000" max="1000", step="0.00001", value="10">]<br>

      <input id="y_auto" type="checkbox">Y自動
      <span id="y_range">
      y範囲[<input id="gra_ymin" type="Number" min="-1000" max="1000", step="0.00001", value="0">:
      <input id="gra_ymax" type="Number" min="-1000" max="1000", step="0.00001", value="10">]
      </span><br>

      <button id="draw" type="button">描画</button>
    </form>

    <div id="canvas">
    </div>
  </body>
</html>
entry.js
import $ from 'jquery'
import { svg } from '../src/svg.js'
import { math } from '../src/math.js'

$(()=>{
    console.log('===== Graph Sample =====');
    const max_range=100;
    const canvas=svg.make.top('#canvas');
    const graph=canvas.graph(0.1, 0.1, 0.95, 0.95);
    const xmin=$("#gra_xmin"), xmax=$("#gra_xmax"), ymin=$("#gra_ymin"), ymax=$("#gra_ymax"), y_auto=$("#y_auto");
    draw();
    function draw(){
        const func=math.func.parse($('#func_form').val());

        const x0=parseFloat(xmin.val()), x1=parseFloat(xmax.val());
        let y0=parseFloat(ymin.val()), y1=parseFloat(ymax.val());
        if( y_auto.prop('checked') ){
            y0=math.min(func, x0, x1);
            y1=math.max(func, x0, x1);
            if( y0<-max_range ) y0=-max_range;
            if( y1> max_range ) y1=max_range;
            console.log(y0, y1);
        }

        graph.set_range(x0, x1, y0, y1);
        graph.draw(func);
    }
    y_auto.change(()=>{
        if( y_auto.prop("checked") ) $("#y_range").hide();
        else  $("#y_range").show();
    });

    $("#draw").click(()=>{ draw(); })
});
// math/func/parse.js
export default function(str){
    console.log('===== math::func::parse('+str+')');
    const tokens = util.token.decompose(str);
    let code='';

    while( tokens.length!==0 ){
        if( is_math_func(tokens[0]) ) code+='Math.'+tokens.shift();
        else code+=tokens.shift();                                                                                                        
    }                                                                                                      
    return new Function('x', 'return '+code);
}

function is_math_func(str){
    const list=[ 'sin', 'sec', 'cos', 'tan', 'cot', 'csc', 'asin', 'asec', 'acos', 'atan', 'acot', 'acsc',
                 'sinh', 'sech', 'cosh', 'tanh', 'coth', 'csch', 'asinh', 'asech', 'acosh', 'atanh', 'acoth', 'acsch',
                 'exp', 'log', 'log10', 'pow' ];

    for( const elem of list ) if( str===elem ) return true;
    return false;
}

Javascriptの関数は文字列で構築できるのでその機能を使ってテキストエリアから書いた関数を表示できるようにした。ただし数学系はMathオブジェクトで定義されているためそれを補う必要がある。それをテキストエリアに入れるのもイマイチだと思ったので以前の記事でトークン分解のみ作ったのでそれを使って分解させ数学関数っぽいものだけMath.を補うようにした。

まとめなど

SVGは汎用性は高いが座標系や属性設定など特殊でイマイチ使いにくい分があったので特に数学のグラフ表示に特化させてユーティリティを書いた。
SVGには形を記述するオブジェクトとそれを一つにまとめるSVG要素とg要素がある、今回はこの2つの要素だけラップして形を作る要素を関数をとおして設定できるようにした。
特にpathオブジェクトは文字列での設定など特殊な部分が多い、textオブジェクの属性などもややこしい部分があるので注意が必要である。
SVGとは直接関係がないが関数オブジェクトをテキストベースで作った。意外と使えそうな小技だと思う。

SVGの要素や属性の一覧、またSnap.svgの使い方等は
Snap.svgの使い方まとめ
svg要素の基本的な使い方まとめ
等がある。もしくはW3C schoolやMDNなども調べてみると良いかもしれないが仕様自体が膨大なので上の2つにざっと目を通しておくとだいたいやりたいことが実現できると思う。

感想

SVGはXMLの一種なので全て文字列であるが画像を文字列として扱うのはやっぱり限界があると感じた。Snap.svgはかなり素のSVGに近いのでそれだけだとまだまだ力不足感はあるがインターフェイスとしては優秀であると思う。だからこそ、各分野に特化したSVGライブラリがあり、そのライブラリの土台部分にSnap.svgもしくはその前身のraphael.jsが使われているのだろう。
そういったライブラリを使ってもいいが細かなことをするにはやっぱり自分で書くしかないと思います。