LoginSignup
25
14

More than 3 years have passed since last update.

D3.jsを使ってカクカクしたツリー

Last updated at Posted at 2019-05-11

言わずと知れたD3.js

これを使ってツリーを描きたい!ということがあり、こちらのサイト様を参考にさせていただきました。
データビジュアライゼーション・ラボ

サンプルの内容をコピペするだけでこんな感じのツリーができると思います。
OriginalTree.PNG

このサンプルコードを改造していきます。ES6で書いていきますよ。

ちなみにChromeとEdgeでは動きました。Firefox,Opera,Safariは未確認。
IEはダメでした(ES6のfindIndexメソッドがダメっぽい?対応したい場合はこちらの互換コードでいけるかと…)

1. 目標

私はこんな感じにしたいわけですよ。
PerfectTree.PNG

御託はいいからサッサと完成品のコードを寄越せ!という方は一番下の「完成品」までジャンプ。

2. 改造していく

サンプルコード内のscriptとstyleはjsファイルとcssファイルに分割してくださいね。
また、基本的なD3やSVGの知識は前提として進んでいきます。(記事が長くなるのと、内容がブレるので)あしからず。

2-1. ノードの位置決め

初っ端ですがここが一番大変です。

まずノードというのはこれのこと。
node7.png

これらノードを目標のような位置にするには、自分より上に位置する末端ノードの数を計算しないといけません。この場合の"上"というのは階層ではなくポジション的に上という意味です。
それをもとに縦の位置を計算します。

例えば下の画像におけるFのノードは自分より上に3つの末端ノードがあるので、自身は4番目の位置に来るようにx座標を設定します。
同様にHのノードは上に5つの末端ノードがあるので、自身は6番目になります。
numbering1.png

※ちなみにD3.jsのtreeはもともと上から下に描画するように座標を算出しているので、横向きにするためにx座標y座標を入れ替えています。
なのでx座標が縦、y座標が横の位置になっています。混乱しないように注意してください。

この計算をするための準備としてcount()という関数を使います。
これはそれぞれのノードが持つ末端ノードの数を算出して、"value"というキー名でノードのデータに付与してくれます。

D3Tree.js
// 3. 描画用のデータ変換
root = d3.hierarchy(data);

var tree = d3.tree()
    .size([height, width - 160])
//  .nodeSize([50,300]) ;
//  .separation(function(a, b) { return(a.parent == b.parent ? 1 : 2); });

tree(root);
//各ノードが持つ末端ノードの数を付与
root.count();

こうするとノードは以下のように情報を持つので、例えばJのx座標を出すためには丸のついた数を合計すればいいわけです。
count5.png

そのためのコードがこちら

D3Tree.js
    //位置やサイズ情報
    const rectSize = {
        height: 20,
        width: 80
    };
    const basicSpace = {
        padding: 30,
        height: 50,
        width: 120
    };

    //x座標の計算
    const defineX = (wholeData, eachData, spaceInfo) => {
        //最上位から現在のデータまでの最短ルートを取得
        const path = wholeData.path(eachData);
        //渡された元データがJSONのままなのでHierarchy形式に変換
        const wholeTree = wholeData.descendants()
        //経由する各ノードのある階層から、経由地点より上に位置する末端ノードの個数を合計
        const leaves = path.map((ancestor) => {
            //経由地点のある階層のうちで親が同じデータを抽出
            const myHierarchy = wholeTree.filter((item, idx, ary) => item.depth === ancestor.depth && item.parent === ancestor.parent);
            //その階層における経由地点のインデックス取得
            var myIdx = myHierarchy.findIndex((item) => item.data.name == ancestor.data.name);
            //経由地点より上にあるものをフィルタリング
            const fitered = myHierarchy.filter((hrcyItem, hrcyIdx, hrcyAry) => hrcyIdx < myIdx);
            //valueを集計(配列が空の時があるので、reduceの初期値に0を設定)
            const sumValues = fitered.reduce((previous, current, index, array) => previous + current.value, 0);
            return sumValues;
        });
        //末端ノードの数を合計
        const sum = leaves.reduce((previous, current, index, array) => previous + current);
        return sum;
    };

    //位置決め
    const definePos = (treeData, spaceInfo) => {
        treeData.each((d) => {
            d.y = spaceInfo.padding + d.depth * spaceInfo.width;
            const sum = defineX(treeData, d, spaceInfo);
            d.x = spaceInfo.padding + sum * spaceInfo.height;
        })
    }

    definePos(root, basicSpace);

最初に四角の高さと幅(目標ではノードを四角にしたいので)やノード間の間隔、svg要素に対する全体のpaddingを設定しておきます。
spaceInfo.png

位置決めの処理は下の関数から見ていってほしいのですが、definePos()という関数でx座標とy座標を決めます。

y座標は単純に、「padding+階層の深さ×図形どうしの横の間隔」とします。
x座標は、「padding+自分より上にある末端ノードの数×図形どうしの縦の間隔」で算出します。

この「自分より上にある末端ノードの数」を計算するのが、関数defineX()です。
例として"J"の場合で処理を見ていきましょう。

D3Tree.js
        //最上位から現在のデータまでの最短ルートを取得
        const path = wholeData.path(eachData);
        //渡された元データがJSONのままなのでHierarchy形式に変換
        const wholeTree = wholeData.descendants()

まずD3.jsの関数path()を使って最上位から現在地までをたどるノードを配列として取り出します。
Jの場合は以下の赤いノードの情報が"path"に詰まっています。
path1.png

"wholeData"はJSON形式になってるので、Hierarchy形式(表現あってるかわかりませんが…)に変換します。

D3Tree.js
        //経由する各ノードのある階層から、経由地点より上に位置する末端ノードの個数を配列として取り出す
        const leaves = path.map((ancestor) => {
            //経由地点のある階層のうちで親が同じデータを抽出
            const myHierarchy = wholeTree.filter((item, idx, ary) => item.depth === ancestor.depth && item.parent === ancestor.parent);
            //その階層における経由地点のインデックス取得
            var myIdx = myHierarchy.findIndex((item) => item.data.name == ancestor.data.name);
            //経由地点より上にあるものをフィルタリング
            const fitered = myHierarchy.filter((hrcyItem, hrcyIdx, hrcyAry) => hrcyIdx < myIdx);
            //valueを集計(配列が空の時があるので、reduceの初期値に0を設定)
            const sumValues = fitered.reduce((previous, current, index, array) => previous + current.value, 0);
            return sumValues;
        });
        //末端ノードの数を合計
        const sum = leaves.reduce((previous, current, index, array) => previous + current);
        return sum;

ここがややこしいですが、とりあえず下のGIFを見てください。
1557551265jVY0cHqPpTC2_wJ1557551110.gif

てなわけでJより上には6個のノードが存在することがようやく判明しました。

ここまでで実際の画面はこんな感じになってるはず。
PositionDefinedTree.PNG

というわけであとは消化試合

2-2. ノードのデザイン

ノードを四角にしてデザインを変えます。以下のソースを…

D3Tree.js
node.append("circle")
    .attr("r", 8)
    .attr("fill", "#999");

こうします。

D3Tree.js
    node.append("rect")
        .attr("width", rectSize.width)
        .attr("height", rectSize.height)
        .attr("fill", "white")
        .attr("stroke", "black");

"circle"を"rect"にして、半径の設定を消して幅と高さの設定をします。
幅と高さの設定値は先ほど"rectSize"として定義しましたね。
塗りつぶしの色もグレーじゃなく白にして、枠線を黒に設定。

で画面はこんな感じ。
rected.PNG

2-3. テキスト

テキストのところのソースは以下の部分です。

D3Tree.js
node.append("text")
    .attr("dy", 3)
    .attr("x", function (d) { return d.children ? -12 : 12; })
    .style("text-anchor", function (d) { return d.children ? "end" : "start"; })
    .attr("font-size", "200%")
    .text(function (d) { return d.data.name; });

とりあえず余計な設定をなくしてみます。

D3Tree.js
node.append("text")
    .text(function (d) { return d.data.name; });

planeText.PNG

あとは位置の調整。

D3Tree.js
    //テキスト
    node.append("text")
        .text(function (d) { return d.data.name; })
        .attr("transform", "translate(" + 10 + "," + 15 + ")");

fixedTextPosition.PNG

これぐらいの位置でしょうか。

2-4. 線の調整

最後はノードをつなぐ線、path要素です。

D3Tree.js
g = d3.select("svg").append("g").attr("transform", "translate(80,0)");
var link = g.selectAll(".link")
    .data(root.descendants().slice(1))
    .enter()
    .append("path")
    .attr("class", "link")
    .attr("d", function (d) {
        return "M" + d.y + "," + d.x +
            "C" + (d.parent.y + 100) + "," + d.x +
            " " + (d.parent.y + 100) + "," + d.parent.x +
            " " + d.parent.y + "," + d.parent.x;
    });

とその前に、最初の行にある、全体をラップしているg要素に対するtransform属性の設定は邪魔なので消しましょう。
すでにsvg要素内のノードの位置をちょうどいいとこに調整しましたからね。

さてpath要素の改造ですが、d属性のところを変えます。
path要素のd属性とかわからんという人は調べてください。

目標は折れ線にすることなので、とりあえず"C"を"L"にします。

D3Tree.js
g = d3.select("svg").append("g");
var link = g.selectAll(".link")
    .data(root.descendants().slice(1))
    .enter()
    .append("path")
    .attr("class", "link")
    .attr("d", function (d) {
        return "M" + d.y + "," + d.x +
            "L" + (d.parent.y + 100) + "," + d.x +
            " " + (d.parent.y + 100) + "," + d.parent.x +
            " " + d.parent.y + "," + d.parent.x;
    });

changeCIntoL.PNG

もうそれっぽくなりましたが、まだもうちょいです。
この時のpathは以下の赤線のように存在しています。
path2.png

ちゃんときれいに配置したいですね。
さてこのpath要素ですが、実は子から親へ向かって描かれています。
そのため図のようなポイントを4か所指定すればいいわけです。
pathPoint.png

1つ目は、子のy座標, 子のx座標 + 四角の高さ / 2
2つ目は、親のy座標 + 四角の幅 + (ノード同士の幅 - 四角の幅) / 2, 子のx座標 + 四角の高さ / 2
3つ目は、親のy座標 + 四角の幅 + (ノード同士の幅 - 四角の幅) / 2, 親のx座標 + 四角の高さ / 2
4つ目は、親のy座標, 親のx座標 + 四角の高さ / 2

ただ、4点ともx座標に「四角の高さ / 2」を足しているので、これはtransform属性で一気にずらしちゃいましょう。

D3Tree.js
    g.selectAll(".link")
        .data(root.descendants().slice(1))
        .enter()
        .append("path")
        .attr("class", "link")
        .attr("d", function (d) {
            return "M" + d.y + "," + d.x +
                "L" + (d.parent.y + rectSize.width + (basicSpace.width - rectSize.width) / 2) + "," + d.x +
                " " + (d.parent.y + rectSize.width + (basicSpace.width - rectSize.width) / 2) + "," + d.parent.x +
                " " + (d.parent.y + rectSize.width) + "," + d.parent.x
        })
        .attr("transform", function (d) { return "translate(0," + rectSize.height / 2 + ")"; });

あとCSSですが、線に透過度が指定されていると重なっている部分だけ色が濃くなったりして見栄えが悪いので、以下のように変えておきます。

D3Tree.css
.link {
    fill: none;
    stroke: black;
    /*stroke-opacity: 0.4;*/
    stroke-width: 1.5px;
}

3. 完成品

そんなこんなで完成したコードは以下のようになります。

D3Tree.js
// 2. 描画用のデータ準備
var width = document.querySelector("svg").clientWidth;
var height = document.querySelector("svg").clientHeight;
var data = {
    "name": "A",
    "children": [
        { "name": "B" },
        {
            "name": "C",
            "children": [{ "name": "D" }, { "name": "E" }, { "name": "F" }]
        },
        { "name": "G" },
        {
            "name": "H",
            "children": [{ "name": "I" }, { "name": "J" }]
        },
        { "name": "K" }
    ]
};

// 3. 描画用のデータ変換
root = d3.hierarchy(data);

var tree = d3.tree()
    .size([height, width - 160])
//  .nodeSize([50,300]) ;
//  .separation(function(a, b) { return(a.parent == b.parent ? 1 : 2); });

tree(root);
//各ノードが持つ末端ノードの数を付与
root.count();

//位置やサイズ情報
const rectSize = {
    height: 20,
    width: 80
};

const basicSpace = {
    padding: 30,
    height: 50,
    width: 120
};

//x座標の計算
const defineX = (wholeData, eachData, spaceInfo) => {
    //最上位から現在のデータまでの最短ルートを取得
    const path = wholeData.path(eachData);
    //渡された元データがJSONのままなのでHierarchy形式に変換
    const wholeTree = wholeData.descendants()
    //経由する各ノードのある階層から、経由地点より上に位置する末端ノードの個数を合計
    const leaves = path.map((ancestor) => {
        //経由地点のある階層のうちで親が同じデータを抽出
        const myHierarchy = wholeTree.filter((item, idx, ary) => item.depth === ancestor.depth && item.parent === ancestor.parent);
        //その階層における経由地点のインデックス取得
        var myIdx = myHierarchy.findIndex((item) => item.data.name == ancestor.data.name);
        //経由地点より上にあるものをフィルタリング
        const fitered = myHierarchy.filter((hrcyItem, hrcyIdx, hrcyAry) => hrcyIdx < myIdx);
        //valueを集計(配列が空の時があるので、reduceの初期値に0を設定)
        const sumValues = fitered.reduce((previous, current, index, array) => previous + current.value, 0);
        return sumValues;
    });
    //末端ノードの数を合計
    const sum = leaves.reduce((previous, current, index, array) => previous + current);
    return sum;
};

//位置決め
const definePos = (treeData, spaceInfo) => {
    treeData.each((d) => {
        d.y = spaceInfo.padding + d.depth * spaceInfo.width;
        const sum = defineX(treeData, d, spaceInfo);
        d.x = spaceInfo.padding + sum * spaceInfo.height;
    })
}

definePos(root, basicSpace);

// 4. svg要素の配置
g = d3.select("svg").append("g");
var link = g.selectAll(".link")
    .data(root.descendants().slice(1))
    .enter()
    .append("path")
    .attr("class", "link")
    .attr("d", function (d) {
        return "M" + d.y + "," + d.x +
            "L" + (d.parent.y + rectSize.width + (basicSpace.width - rectSize.width) / 2) + "," + d.x +
            " " + (d.parent.y + rectSize.width + (basicSpace.width - rectSize.width) / 2) + "," + d.parent.x +
            " " + (d.parent.y + rectSize.width) + "," + d.parent.x
    })
    .attr("transform", function (d) { return "translate(0," + rectSize.height / 2 + ")"; });

var node = g.selectAll(".node")
    .data(root.descendants())
    .enter()
    .append("g")
    .attr("class", "node")
    .attr("transform", function (d) { return "translate(" + d.y + "," + d.x + ")"; })

node.append("rect")
    .attr("width", rectSize.width)
    .attr("height", rectSize.height)
    .attr("fill", "white")
    .attr("stroke", "black");

node.append("text")
    .text(function (d) { return d.data.name; })
    .attr("transform", "translate(" + 10 + "," + 15 + ")");

これまでの変更内容をそのまま記載してますが、もっとキレイに整理したほうがいいです。

4. きれい版

2020/10/25追記

久々に見返して整理したくなったので、折角なので追記しときます。意外と見ていただいている方もいるようなので

CSSは無しですべてjsで描画されます。

import * as d3 from "d3";

// 描画する四角(ノード)のサイズ
const rectSize = {
  height: 20,
  width: 80,
};

// ノード間のスペースなど
const basicSpace = {
  padding: 30,
  height: 50,
  width: 120,
};

// モックデータ
const sampleData = {
  name: "1-A",
  children: [
    { name: "2-A" },
    {
      name: "2-B",
      children: [
        { name: "3-A" },
        {
          name: "3-B",
          children: [{ name: "4-A" }, { name: "4-B" }, { name: "4-C" }],
        },
        { name: "3-C" },
      ],
    },
    { name: "2-C" },
    {
      name: "2-D",
      children: [{ name: "3-D" }, { name: "3-E" }],
    },
    { name: "2-E" },
  ],
};

// ツリー用データ設定
const root = d3.hierarchy(sampleData);
const tree = d3.tree();
// treeレイアウトのためのx, y座標をデータに付与してくれる
tree(root);
// それぞれのノードが持つ末端ノードの数を算出して、"value"というキー名でノードのデータに付与
root.count();
// console.log(root);

// #region 全体svg要素の高さと幅を計算し生成
// 末端ノードの数 * ノードの高さ + (末端ノードの数 - 1) * (ノードの基準点どうしの縦幅 - ノードの高さ) + 上下の余白
const height =
  root.value * rectSize.height +
  (root.value - 1) * (basicSpace.height - rectSize.height) +
  basicSpace.padding * 2;
// (rootの高さ + 1) * ノードの幅 + rootの高さ * (ノードの基準点どうしの横幅 - ノードの幅) + 上下の余白
// 最終的に90度回転した状態になるためrootの存在する高さで横幅を計算する
const width =
  (root.height + 1) * rectSize.width +
  root.height * (basicSpace.width - rectSize.width) +
  basicSpace.padding * 2;
const svg = d3.select("body").append("svg").attr("width", width).attr("height", height);
// #endregion

// 渡されたnameを含む階層階層を探索(同じparentの)
const seekParent = (currentData, name) => {
  // 今処理しているノードの親の子たちを取得することでその階層のデータを取得
  const crntHrcy = currentData.parent.children;
  // 取得した階層に、今探しているnameを含むものがいれば、それが目的の階層
  const target = crntHrcy.find((contents) => contents.data.name == name);
  // 見つかればその階層をnameとセットで返却
  // 見つからなければ親を渡して再帰処理させることで一つ上の階層を探索させる
  return target ? { name: name, hierarchy: crntHrcy } : seekParent(currentData.parent, name);
};

// 自分より上にいる末端ノードの数を配列として取り出す
const calcLeaves = (names, currentData) => {
  // 親の含まれる階層をそれぞれ抽出する(nameと階層のJSONで)
  const eachHierarchies = names.map((name) => seekParent(currentData, name));
  // それぞれの階層における、そのnameの位置(インデックス)を取得
  const eachIdxes = eachHierarchies.map((item) =>
    item.hierarchy.findIndex((contents) => contents.data.name == item.name)
  );
  // 先ほど取得したインデックスを使って、それぞれの階層をスライスする
  const filteredHierarchies = eachHierarchies.map((item, idx) =>
    item.hierarchy.slice(0, eachIdxes[idx])
  );
  // それぞれの階層に含まれるvalueを抽出
  const values = filteredHierarchies.map((hierarchy) => hierarchy.map((item) => item.value));
  // 平坦化して返却
  return values.flat();
};

// y座標の計算
const defineY = (data, spaceInfo) => {
  // 親をたどる配列からバインドされたデータを抽出
  const ancestorValues = data.ancestors().map((item) => item.data.name);
  // 自分より上にいる末端ノードの数を配列として取り出す
  const leaves = calcLeaves(ancestorValues.slice(0, ancestorValues.length - 1), data);
  // ノードの数を合計
  const sumLeaves = leaves.reduce((previous, current) => previous + current, 0);
  // y座標を計算 末端ノードの数 * ノードの基準点同士の縦幅 + 上の余白
  return sumLeaves * spaceInfo.height + spaceInfo.padding;
};

// 位置決め
const definePos = (treeData, spaceInfo) => {
  treeData.each((d) => {
    // x座標は 深さ * ノード間の幅 + 左側の余白
    d.x = d.depth * spaceInfo.width + spaceInfo.padding;
    d.y = defineY(d, spaceInfo);
  });
};
definePos(root, basicSpace);

// 全体をグループ化
const g = svg.append("g");

// path要素の追加
g.selectAll(".link")
  .data(root.descendants().slice(1))
  .enter()
  .append("path")
  .attr("class", "link")
  .attr("fill", "none")
  .attr("stroke", "black")
  .attr("d", (d) =>
    `M${d.x},${d.y}
    L${d.parent.x + rectSize.width + (basicSpace.width - rectSize.width) / 2},${d.y}
    ${d.parent.x + rectSize.width + (basicSpace.width - rectSize.width) / 2},${d.parent.y}
    ${d.parent.x + rectSize.width},${d.parent.y}`
      .replace(/\r?\n/g, "")
      .replace(/\s+/g, " ")
  )
  .attr("transform", (d) => `translate(0, ${rectSize.height / 2})`);

// 各ノード用グループの作成
const node = g
  .selectAll(".node")
  .data(root.descendants())
  .enter()
  .append("g")
  .attr("class", "node")
  .attr("transform", (d) => `translate(${d.x}, ${d.y})`);

// 四角
node
  .append("rect")
  .attr("width", rectSize.width)
  .attr("height", rectSize.height)
  .attr("fill", "#fff")
  .attr("stroke", "black");

// テキスト
node
  .append("text")
  .text((d) => d.data.name)
  .attr("transform", `translate(5, 15)`);

5. おわりに

以上、D3.jsを使ってカクカクしたツリーを作ってみました。

階層構造を持つデータなので内容の編集やらツリーの開閉やらが画面からできると便利ですかね。
気が向いたらそれもやって記事書こうかと思います。

指摘等ありましたらコメントにズバズバ書いてください。

読了ありがとうございました。

そういえばD3のhieararchy系のライブラリってこのtreeとcluster以外はどれくらい使われてるんですかね?
正直あんまり見ない気がしますが…

25
14
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
25
14