はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

d3.js: 分かった気になりたい人向けのd3.jsのオレオレ基礎

d3.jsは、ブラウザ上で様々なデータを可視化するための非常に強力なライブラリです。

モグリのワナビーな私もさわりだけでも嗜んでおこうと思い筆をとりました。

以下、d3.jsの試しやすい範囲*1の簡単な事例(ただし私が良かれと思っているものチョイス)のサンプルコードをいくつか確認してみています。

時間の限られた凄腕のメンバの代わりに、自分が先陣をきって公式サイトなどを流し読みしてd3.jsのシンタックスや考え方などをざっくりおさえて、こんな感じらしいよと伝える...というところを意識したつもりです。

確認に用いたd3.jsのバージョンは現在の最新版のv5です。

リンク

公式サイト d3js.org

公式サイトからリンクされているチュートリアル(の中でも全体を掴むのにおすすめらしきもの) observablehq.com

github github.com

実例: 全部入りサンプルコード

まずは今回ご紹介の例の全部入りののサンプルコード最初にご紹介することにします。

f:id:azotar:20200418150129p:plain

上記を実現するhtml(もちろん、d3のスクリプト入り)です↓

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <title>d3.jsのミニマムな事例</title>
</head>
<body>
    <script>

        // d3.js 公式サイト https://d3js.org
        //   https://observablehq.com/@d3/learn-d3
        // github
        //    https://github.com/d3


        // svg用のデータを初期化    
        const width = 200,
            height = 250
        const svg = d3.select("body").append("svg")
            .attr("width", width)
            .attr("height", height)

        //  https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Basic_Shapes
        //      の「基本的な図形」を(全部ではありませんが)再現してみます。

        //  ※一見まわりのように見えて、最初にSVGのスクリプトに目を慣らしておくと、d3.jsの理解が早い気がします。
        //  https://developer.mozilla.org/ja/docs/Web/SVG
        //  https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial

        // 矩形
        svg.append("rect")
            .attr("width", 30)
            .attr("height", 30)
            .attr("x", 10)
            .attr("y", 10)
            .attr("stroke", "black")
            .attr("fill", "transparent")

        // 円    
        svg.append("circle")
            .attr("cx", 25)
            .attr("cy", 75)
            .attr("r", 20)
            .attr("stroke", "red")
            .attr("fill", "transparent")

        // 以降、動的にデータを使う例も合わせて試してみる  ~~~~~~~~~~~~~~~~~~~~~~  
        const conf = { "a": 10, "b": 50, "c": 110, "d": 150 }

        // 直線
        svg.append("line")
            .datum(conf)
            .attr("x1", (d) => d.a)
            .attr("x2", (d) => d.b)
            .attr("y1", (d) => d.c)
            .attr("y2", (d) => d.d)
            .attr("stroke", "orange")
            .attr("fill", "none")

        // Path形状    
        svg.append("path")
            .attr("d", "M20,230 Q40,205 50,230 T90,230") // https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Paths
            .attr("stroke", "blue")
            .attr("fill", "transparent")


        // SVG以外のオブジェクトについて、仮想DOMみたいなこともできるみたいです。
        // しばらく、bodyに直接、SVG以外の要素を追加する例が続きます ~~~~~~~~~~~~~~~~~~~~~~
        d3.select("body")
            .append("div")
            .text("div要素を追加して、テキスト設定")

        //tableを追加して、インプットデータを元にした表を生成  その1 
        d3.select("body").append("table")
            .selectAll("tr")
            .data([1, 2, 3, 4]).enter().append("tr")
            .selectAll("td")
            .data(["A", "B", "C"]).enter().append("td")
            .text((d) => d)

        //tableを追加して、インプットデータを元にした表を生成  その2 
        d3.select("body").append("table")
            .selectAll("tr")
            .data(["1", "2", "3", "4"]).enter().append("tr")
            .selectAll("td")
            .data((d) => [d + "A", d + "B", d + "C"]).enter().append("td")
            .text((d) => d)


        // ちょっと複雑な動的(外部データ)を元にPathシェイプを生成する + d3.jsのAPIを使った例のチラ見せ
        //https://github.com/d3/d3/blob/master/API.md#shapes-d3-shape

        // Path(この場合は4点からなる折れ線をイメージ) 
        const diadata = [[110, 60], [140, 90], [160, 10], [190, 10]]
        // d3.jsのAPIのひとつである d3.line()を利用
        const mylinefunc = d3.line().x((d) => d[0]).y((d) => d[1])
        // consoleにダンプしてみる
        console.log(mylinefunc) // 関数らしい
        // datajoin する
        let myline = mylinefunc(diadata)
        // consoleにダンプしてみる
        console.log(myline)

        // SVGのPathsの実際のタグフォーマットをさほど意識しなくてもPathを描ける
        svg.append("path")
            .attr("d", myline)
            .attr("stroke", "green")
            .attr("fill", "none")

        //  「datum」を使ったこちらの例でも同上のシェイプを描画可能
        /*   
        svg.append("path")
            .datum(diadata)
            .attr("d", mylinefunc)
            .attr("stroke", "green")
            .attr("fill", "none")
        */

        // 曲げてみる    
        svg.append("path")
            .datum(diadata)
            .attr("d", mylinefunc.curve(d3.curveBasis))
            .attr("stroke", "red")
            .attr("fill", "none")

    </script>

</body>
</html>

コピペしてhoge.htmlなどとして保存した後、モダンブラウザでオープンすると、インターネットに繋がっていれば、動作する、というものにしてあります。

解説1 (d3.js を分かった気になるために、d3.jsは、とにかくSVG編集の「 DSL」だと念じると理解が進みそう... という今更の所感)

上記のサンプルですが、 https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Basic_Shapes の例を全てではありませんが、d3.jsで再現したもの + αです。

d3.jsはいろいろできるのはもちろんですが、入り口としては、SVGをVirtualDOM風に構築するDSLだと思うと親しみやすいような気がしました。

ですので、他の言語などでもしばしばあるメソッドチェーンぽい外面に慣れるとともに、SVGマークアップに目を慣らしておくのが良いと思います。

解説2(selections〜起点のイディオムに慣れる)

もっと高度なことをしようとすると多少変わってくるのかもしれませんが、d3.jsのAPIなどを用いてスクラッチでシェイプの描画を行う場合、次のような定石になっているようです。

  1. selectionsと呼ばれるモジュール(selectやselectAll)によりターゲットとするオブジェクトを選択。jQuery、というかCSSセレクタに似てますね。その時点で該当の要素が存在しなくても大丈夫です。
  2. 選択されたオブジェクトに対する属性の設定(attrなど)。attr()の引数は、SVGならSVG関連の属性タグはひととおり使えそうです。
  3. 外部データなどを適用して、map関数風の編集を行う場合は、data()やdatum()といった、data joinsを適用すると、呼び出しもとのselectionsに指定のデータがjoinされた扱いになる。また、その後、無名関数でjoinされたデータを元にした値を要素に連携できる。

解説3(data joins)

外部のJavaScriptの変数に格納されたデータを動的に取り込んで、SVGを編集できます。

約束事はありますが、多くのデータ形式に対応しており、大半のものは宣言的なコーディングでタイプ量を控えめで、成り行き素敵なグラフ表示などが可能です。

この宣言的にデータを紐づけるところの動きは「data joins」と呼ばれるようです。

data joins は、次のように作用します。上記のサンプルコードの例を、ある程度恣意的にネストを変えてみています。

f:id:azotar:20200418150442p:plain

また、今回のサンプルでは、おまじないとして「.enter()」をコールしていますが、実用の話でいうと、下記リンクで解説があるように「更新パターン」に応じて、exit().remove()や merge()などを使い分けすることになると思います。

bost.ocks.org

... が、上記の私の書いた絵のようなイメージ*2にある程度慣れるまでは、data().enter()は定型句だと思って進めても良いかと思いました。

ということで、少し説明を端折った気もしないでもないですが以上でした。

おわり




★追伸: (d3.jsで生成したSVGPNG画像で保存)

一旦終わりとしましたが、追伸です。

この流れで、せっかくブラウザ側で動的にSVGレンダリングしたのでそれを保存したい(結構よく見る気がする)というユースケースに思いあたりました。

f:id:azotar:20200502113806p:plain

で、どうやるのかというところですが、定石があるようです。CSSなどで(個人的にはよくみた気がする)DataURLという仕組みをからめるとよいようです。

ググった限りでは比較的古い記事が多かったです。

むしろ定番化して、大半のブラウザで動作することから、最近の寄稿がないのかなと思い、念の為今だとどうなのかなということで、試してみました。

↓ 結果

試してみてそれほど苦労せず動作したので*3、とは言え何もないところからは想像もつかない方法なので、改めて以下に例を示します。


<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <title>d3.jsでSVGを編集して、ダウンロードする</title>
</head>
<body>
    <script>

        const width = 200,
            height = 250
        const svg = d3.select("body").append("svg")
            .attr("width", width)
            .attr("height", height)

        svg.append("rect")
            .attr("width", 30)
            .attr("height", 30)
            .attr("x", 10)
            .attr("y", 10)
            .attr("stroke", "black")
            .attr("fill", "red")

        // d3.jsで出力したSVGに限らないが、SVGをダウンロードさせるにあたり、ほぼ定石らしい手順    
        //  ポイントとなるのは次のあたりらしい
        //    XMLSerializerでsvgをシリアライズ
        //    canvasエレメントを生成
        //    canvas と image の連携
        //    dataURL
        const d3svg = document.querySelector("svg")
        const svgData = new XMLSerializer().serializeToString(d3svg)
        const canvas = document.createElement("canvas")
        const ctx = canvas.getContext("2d")
        const image = new Image()
        image.src = "data:image/svg+xml;charset=utf-8;base64," + btoa(unescape(encodeURIComponent(svgData)))

        image.onload = () => {
            ctx.drawImage(image, 0, 0)
            // ダウンロードリンクを動的に追加(ここでのポイントはcanvas.toDataURLであり、d3.jsを使わなくても良いが、せっかくなので。
            d3.select("body").append("a")
                .attr("href", canvas.toDataURL("image/png"))
                .attr("type", "application/octet-stream")
                .attr("download", 'dl.png')
                .text("ダウンロードできます")
        }

    </script>

</body>
</html>

参考にさせてもらったリンク:

qiita.com

www.jkawamoto.info

*1:d3.jsのフルパワーを解放できているわけではないという意味です。

*2:技術的に正確ではない面もあるため、この絵のとおりでなくても良いですが、data()を使った場合の動作についてご自身なりのイメージをもっていただくとして

*3:先人の例をまるっと拝借したからですが...