Vue + d3(d3-force) で理解しながら有向グラフを描画する

Vue
Vueフロントエンド

この記事では Vue + d3 を利用して有向グラフを描画する方法をステップ・バイ・ステップで説明します。

グラフ描画といえば d3 が有名ですが、直接使ったことはなく d3 をラッパーしたライブラリを使って いる方も多いと思います。この記事では d3 を利用してノードの表示やリンクの表示など1つ1つ動かしながら進めてグラフを書いています。

なお、Vite + Vue 3 の環境上で SFC(Single File Component)として作るようにしています。
末尾に全体のコードのせています。

環境のベース作成

create vite でひな形を作ります。Vue は Vue 3 になります。

yarn create vite vee-validate-examples --template vue

d3 をインストールします。型定義も一緒にインストールします。

npm install --save d3 @types/d3

SVG 要素の作成

まずは任意の .vue ファイルを作り、SVG の枠を作ります。
id=graph に対して、svg を挿入しています。viewBox はこれから描画するものが中心になるようにしています。

また、今回、データは d3 のサイトにあるものを利用しました。
こちら の miserables という変数に入っている nodes, links です。

検証に利用したデータ(miserables という変数に入っている nodes, links)
 検証に利用したデータ(miserables という変数に入っている nodes, links)
<template>
  <div id="graph"></div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { select, forceSimulation, forceManyBody, forceCenter } from "d3";
// テストデータ
import data from "../data.json";

const width = 1000;
const height = 800;

onMounted(() => {
  const svg = select("#graph")
    .append("svg")
    .attr("width", width)
    .attr("height", width)
    // viewBox="x, y, width, height"
    // x,y は左上基準のx, y座標、width,heightは描画エリアの幅と高さ
    // 以下で中心
    .attr("viewBox", [-width / 2, -height / 2, width, height]);
}
</script>

上の時点ではまだ画面上には何も表示されません。
ですが、画面の要素を見ると、ちゃんと svg が追加されているのが分かります。

実行結果
実行結果

ノードの描画

次はノードの描画です。1つ1つの処理はコメントに書いていますが、これで各ノードが指定した直径の青(royalblue)の円として描画されます。

  svg
    // selectAll: セレクタに一致する全ての要素を選択する
    // 実行時点で存在していなくてもよい
    .selectAll("circle")
    // join するデータの定義
    .data(data.nodes)
    // データのマージを実行。これで svg として生成される
    // 引数には結合するタイプを指定する。ここでは 上の circle
    .join("circle")
    // 塗りつぶしの色
    .style("fill", "royalblue")
    // 直径
    .attr("r", 28);

すべて同じ位置にあるので、1つの円にしか見えませんが、描画されていること、開発者ツールでみることで要素が生成されているのが分かります。

実行結果 - ノード描画
実行結果 – ノード描画

d3.forceSimulation の実行

ここからは作ったノードに対し、d3 の forceSimulation を適用させていきます。
d3-force のGithubのサイトにあるように、シミュレーションする force を設定していくようです。

const simulation = d3.forceSimulation(nodes)
.force(“charge”, d3.forceManyBody())
.force(“link”, d3.forceLink(links))
.force(“center”, d3.forceCenter());

https://github.com/d3/d3-force

なお、simulation のパラメータに関しては https://wizardace.com/d3-forcesimulation-info/ を参考にさせていただきました。d3 はドキュメントがあまり整備されていない感覚です。ので大変ありがたいです。

  // 引数はノードのデータ 
  forceSimulation(data.nodes)
    // クーロン力 / 万有引力
    // デフォルトは -30
    // 関数名が ? かもしれませんが、many-body force で多体力という意味だそうです。
    .force("charge", forceManyBody())
    // 重力の中心
    // デフォルトは中心のまま
    .force("center", forceCenter())
    // 処理計算を行うイベント。デフォルトでは 300回呼ばれます
    .on("tick", () => {
      svg
        .selectAll("circle")
        .data(data.nodes)
        .attr("cx", (d) => (d.x ? Number(d.x) : 0))
        .attr("cy", (d) => (d.y ? Number(d.y) : 0));
    });

上記の場合の結果です。
小さくて見えませんが、コンソールログも 300 回呼ばれていることが確認できます。

実行結果 - d3.forceSimulation
実行結果 – d3.forceSimulation

こちらは、クーロン力? を -10 に弱めて見た場合です。コードで言うと、forceManyBody().strength(-10) とします。確かに離れる力が弱くなりました。すご。

実行結果 - d3.forceSimulation(クーロン力を変えた場合)
実行結果 – d3.forceSimulation(クーロン力を変えた場合)

リレーション(リンク)の描画

次はノード間の線です。
リンクも基本的にはノードと同じです。線の色や幅などは指定していますが、後半にある通り line 要素に対し、データを定義して結合(要素の生成)を行っています。

  // リンクの設定
  const linkStroke = "#999";
  const linkStrokeOpacity = 0.5;
  const linkStrokeWidth = 1.5;
  const linkStrokeLinecap = "round";
  svg
    .append("g")
    // 線の色
    .attr("stroke", typeof linkStroke !== "function" ? linkStroke : null)
    // 線の Opacity
    .attr("stroke-opacity", linkStrokeOpacity)
    // 線幅
    .attr(
      "stroke-width",
      typeof linkStrokeWidth !== "function" ? linkStrokeWidth : null
    )
    // 線先
    .attr("stroke-linecap", linkStrokeLinecap)
    // line 要素を選択
    .selectAll("line")
    // line にセットするデータの指定
    .data(data.links)
    // 結合の実施
    .join("line");

forceSimulation にも link に関する記述を追加します。
forceLink でノード(circle)間をつなぐリンクが作成できます。id はノードを取得するためのキー設定として必要です。
また、distance がリンク(線)の長さに相当します。

forceSimulation(data.nodes)
    // クーロン力 / 万有引力
    // デフォルトは -30
    .force("charge", forceManyBody().strength(-60))
    // 重力の中心
    // デフォルトは中心のまま
    .force("center", forceCenter())
    .force(
      "link",
      forceLink(data.links)
        .distance(150)
        .id((l) => {
          // l は今回は次のような値を持っています
          //   group: 3
          //   id: "Marguerite"
          //   index: 12
          //   vx: 0.0021718040461215435
          //   vy: -0.0005220917409583505
          //   x: -80.44371892183801
          //   y: -49.516563719707676
          console.log(l);
          return map(data.nodes, (n) => n.id)[l.index];
        })
    )
    .on("tick", () => {
      svg
        .selectAll("circle")
        .data(data.nodes)
        .attr("cx", (d) => (d.x ? Number(d.x) : 0))
        .attr("cy", (d) => (d.y ? Number(d.y) : 0));
      svg
        .selectAll("line")
        .attr("x1", (d) => d.source.x)
        .attr("y1", (d) => d.source.y)
        .attr("x2", (d) => d.target.x)
        .attr("y2", (d) => d.target.y);
    });
});

リンクが表示されました。それっぽくなってきました。
ちなみに、以下は distance がデフォルト値(30)の場合です。重なりが多いですね。

実行結果 - リレーション(リンク)の描画
実行結果 – リレーション(リンク)の描画

こちらは、distance を 150 の場合です。広がってきましたね。

実行結果 - リレーション(リンク)の描画(distance: 150)

実行結果 – リレーション(リンク)の描画(distance: 150)

ちなみに上の画像だとリンクがノードの重なり部分では、リンクの方が前面になっています。このタイミングでは、処理を記述している順番がノード→リンクなので、この様になっていました。
逆にすると結果も逆になります。

実行結果 - リレーション(リンク)の描画(distance: 150),  処理の記述順で重なり順序が変わります
実行結果 – リレーション(リンク)の描画(distance: 150), 処理の記述順で重なり順序が変わります

ノードのドラッグ&ドロップ

ノードのドラッグ&ドロップは以下のように、call メソッドを呼び出してドラッグ処理のイベントをセットします。

  // ノードの設定
  svg
    // selectAll: セレクタに一致する全ての要素を選択する
    // 実行時点で存在していなくてもよい
    .selectAll("circle")
    // join するデータの定義
    .data(data.nodes)
    // データのマージを実行。これで svg として生成される
    // 引数には結合するタイプを指定する。ここでは 上の circle
    .join("circle")
    // 塗りつぶしの色
    .style("fill", "royalblue")
    // 直径
    .attr("r", 28)
    .call(_drag(simulation));

具体的な実装は以下の通り。やっていることはドラッグの開始、中、終了時点のイベントをセットし、それぞれのイベント発生時にターゲットとなるノードの位置を現在(ドラッグ中)の位置にセットするというシンプルなものです。

また、ドラッグが終わったタイミングでシミュレーションを restart させることで、他のノード含めて動きます(alpha は動きの大きさで、例えば 0 にすると全く動きません)。

// ノードのドラッグ処理
const _drag = (simulation) => {
  const dragstarted = (event) => {
    // fx, fy は ノードの固定 x, y 位置。これを今の x,y に移動させる
    event.subject.fx = event.subject.x;
    event.subject.fy = event.subject.y;
  };

  const dragged = (event) => {
    // fx, fy は ノードの固定 x, y 位置。これを今の x,y に移動させる
    event.subject.fx = event.x;
    event.subject.fy = event.y;
  };

  const dragended = (event) => {
    // fx, fy は ノードの固定 x, y 位置。これを今の x,y に移動させる
    event.subject.fx = event.x;
    event.subject.fy = event.y;
    // シミュレーション再実行。全体のノードが動く
    simulation.alpha(0.6).restart();
  };

  return drag()
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended);
};

結果は以下の通りです。ドラッグしたあと全体的に動いていますが、aplpha を 0 にした場合はこの動きが全くない状態になります。

実行結果 - ノードのドラッグ&ドロップ
実行結果 – ノードのドラッグ&ドロップ

ノードにテキストを表示する

各ノードの中にテキストを表示します。テストデータの id の内容を表示させます。
文字の長さは今回は6文字までにします。

テキストの表示は、ノードの中にそのまま text を書けばいいんじゃ、と思っていたのですが、それだとだめでした。別途テキストノードをデータ分作って、それぞれ位置を指定させる必要があります。もちろんドラッグしたときの位置もノードと同様に移動させる必要があります。

まずはテキストの追加。text 要素を作ります。ここでは .enter().append("text") で text 要素を作成していますが、.join("text") でも作れます。https://github.com/d3/d3-selection#selection_join に記載されている内容ですね。

  const texts = svg
    .selectAll("text.label")
    .data(data.nodes)
    .enter()
    .append("text")
    .attr("class", "label")
    .attr("fill", "white")
    .text((d) => d.id.slice(0, 6));

tick イベントで行う処理が増えてきたこと、また、上記の戻り値 texts が処理必要だったので、tick の処理は関数化し、末尾に今回の処理を追加しました。texts を使用して tick イベント毎に位置を調整してあげます(ただノードやリンクと同じように svg.selectAll でもできる気はします)。

  function tick() {
    console.log("called");
    svg
      .selectAll("circle")
      .attr("cx", (d) => {
        console.log(d);
        return d.x ? Number(d.x) : 0;
      })
      .attr("cy", (d) => (d.y ? Number(d.y) : 0));
    svg
      .selectAll("line")
      .attr("x1", (d) => d.source.x)
      .attr("y1", (d) => d.source.y)
      .attr("x2", (d) => d.target.x)
      .attr("y2", (d) => d.target.y);
    texts.attr(
      "transform",
      (d) => "translate(" + (d.x - nodeR) + "," + (d.y + nodeR / 4) + ")"
    );
  }

これで行けました。要素によってテキストの位置が中央になってなかったり、はみ出ていたり・・・するので、実際にはもう少し微調整が必要ですが基本という意味ではできました。

実行結果 - ノードにテキストを表示する
実行結果 – ノードにテキストを表示する

ノードの色をグループごとに変える

テストデータにある group の値ごとにノードの色をかえます。
これは、ノードの設定の部分で 青を設定していた箇所に関数を渡します。以下のコメントにもありますが、d3-scale-chromatic で色々なパターンが事前に用意されているので、今回はこちらを使います。

import { schemePaired,  ...その他 } from "d3";
~
// 塗りつぶしの色
// ノードの情報が引数に渡ってくるので、ここでは group を取得
// group に応じたカラーを設定。schemePaired は
// d3-scale-chromatic で定義されているカラーパターン。
// 他にもいろんなパターンが用意されています
.attr("fill", ({ group }) => schemePaired[group])

参考:

https://github.com/d3/d3-scale-chromatic/blob/main/README.md
https://github.com/d3/d3-scale-chromatic/blob/main/README.md

これで、いい感じになりました。
実際には、group ではなく、中心となる1ノードを起点として、階層ごとに色を変えたり、ノードの種類ごとに色を変えたり、があると思います。その場合も要領としては同じで、各ノードに必要な情報をもたせる or 計算出来る状況を作っておき、関数で色を指定するで、実現できます。

実行結果 - ノードの色変更(group の値ごとに色を変える)
実行結果 – ノードの色変更(group の値ごとに色を変える)

向き(矢印)の作成

最後はリンク(関係)の向きです。SVG の path を使って矢印を表現します。
リンクのコードに以下を追加し、marker-start を追加します。

.attr("marker-start", "url(#start)");

あとは、ノードやリンクと同じように矢印の要素を追加します(svg:defs で、<svg><defs></defs></svg> の要素が作れるようで、今回はそこに marker 要素を作っています。

  svg
    .append("svg:defs")
    .selectAll("marker")
    .data(["start"])
    .enter()
    .append("svg:marker")
    .attr("id", "start")
    .attr("viewBox", "0 -5 10 10")
    .attr("refX", -32) // ここはノードの直径に合わせて調整が必要です
    .attr("refY", -0.5)
    .attr("markerWidth", 6)
    .attr("markerHeight", 6)
    .attr("orient", "auto")
    .attr("fill", linkStroke)
    .append("svg:path")
    .attr("d", "M 0,0 L 10,5 L 10, -5"); // ここが矢印を形作ります

いい感じになりました。ドラッグ&ドロップしても、矢印は崩れることなく追随されます。
これで有向グラフの基本の形はできました。

実行結果 - 向きの表現
実行結果 – 向きの表現

まとめ

コードは整理できていませんが、上のグラフを表示するための SFC は以下です。
d3-xxxx でそれぞれ必要なモジュールを import していますが、すべて from “d3” で import することも可能です。

今回はちゃんとした Vue コンポーネント化まではしていませんが、後は可変にしたいパラメータを props にすることで、Vue で簡単にグラフを描画させることができそうです。
また、TypeScript でかちっとしたかったのですが、思った以上に難敵でしたので、今回は一旦気にせず進めています。こうするんだよ!がある方はぜひ教えてください。
とは言え、1コンポーネントに閉じれるところがコンポーネント志向のいいところですね。

<template>
  <div id="graph"></div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
  forceSimulation,
  forceManyBody,
  forceCenter,
  forceLink,
} from "d3-force";
import { select } from "d3-selection";
import { drag } from "d3-drag";
import { schemePaired } from "d3-scale-chromatic";
import { map } from "d3-array";
import data from "../data.json";

const width = 1000;
const height = 800;
const nodeR = 28;

// ノードのドラッグ処理
const _drag = (simulation) => {
  const dragstarted = (event) => {
    // fx, fy は ノードの固定 x, y 位置。これを今の x,y に移動させる
    event.subject.fx = event.subject.x;
    event.subject.fy = event.subject.y;
  };

  const dragged = (event) => {
    // fx, fy は ノードの固定 x, y 位置。これを今の x,y に移動させる
    event.subject.fx = event.x;
    event.subject.fy = event.y;
  };

  const dragended = (event) => {
    // fx, fy は ノードの固定 x, y 位置。これを今の x,y に移動させる
    event.subject.fx = event.x;
    event.subject.fy = event.y;
    // シミュレーション再実行。全体のノードが動く
    simulation.alpha(0.6).restart();
  };

  return drag()
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended);
};

onMounted(() => {
  const svg = select("#graph")
    .append("svg")
    .attr("width", width)
    .attr("height", width)
    // viewBox="x, y, width, height"
    // x,y は左上基準のx, y座標、width,heightは描画エリアの幅と高さ
    // 以下で中心
    .attr("viewBox", [-width / 2, -height / 2, width, height]);

  const simulation = forceSimulation(data.nodes)
    // クーロン力 / 万有引力
    // デフォルトは -30
    .force("charge", forceManyBody().strength(-60))
    // 重力の中心
    // デフォルトは中心のまま
    .force("center", forceCenter())
    .force(
      "link",
      forceLink(data.links)
        .distance(150)
        .id((l) => {
          // l は次のような値を持っています
          //   group: 3
          //   id: "Marguerite"
          //   index: 12
          //   vx: 0.0021718040461215435
          //   vy: -0.0005220917409583505
          //   x: -80.44371892183801
          //   y: -49.516563719707676
          console.log(l);
          // node を取得するためのキー設定
          return map(data.nodes, (n) => n.id)[l.index];
        })
    )
    .on("tick", tick);

  // リンクの設定
  const linkStroke = "#999";
  const linkStrokeOpacity = 0.5;
  const linkStrokeWidth = 1.5;
  const linkStrokeLinecap = "round";
  const links = svg
    .append("g")
    // 線の色
    .attr("stroke", typeof linkStroke !== "function" ? linkStroke : null)
    // 線の Opacity
    .attr("stroke-opacity", linkStrokeOpacity)
    // 線幅
    .attr(
      "stroke-width",
      typeof linkStrokeWidth !== "function" ? linkStrokeWidth : null
    )
    // 線先
    .attr("stroke-linecap", linkStrokeLinecap)
    // line 要素を選択
    .selectAll("line")
    // line にセットするデータの指定
    .data(data.links)
    // 結合の実施
    .join("line")
    .attr("marker-start", "url(#start)");

  // ノードの設定
  const nodes = svg
    // selectAll: セレクタに一致する全ての要素を選択する
    // 実行時点で存在していなくてもよい
    .selectAll("circle")
    // join するデータの定義
    .data(data.nodes)
    // データのマージを実行。これで svg として生成される
    // 引数には結合するタイプを指定する。ここでは 上の circle
    .join("circle")
    // 直径
    .attr("r", nodeR)
    // 塗りつぶしの色
    // ノードの情報が引数に渡ってくるので、ここでは group を取得
    // group に応じたカラーを設定。schemePaired は
    // d3-scale-chromatic で定義されているカラーパターン。
    // 他にもいろんなパターンが用意されています
    .attr("fill", ({ group }) => schemePaired[group])
    .call(_drag(simulation));

  const texts = svg
    .selectAll("text.label")
    .data(data.nodes)
    .enter()
    .append("text")
    .attr("class", "label")
    .attr("fill", "white")
    .text((d) => d.id.slice(0, 6))
    .call(_drag(simulation));

  svg
    .append("svg:defs")
    .selectAll("marker")
    .data(["start"])
    .enter()
    .append("svg:marker")
    .attr("id", "start")
    .attr("viewBox", "0 -5 10 10")
    .attr("refX", -32)
    .attr("refY", -0.5)
    .attr("markerWidth", 6)
    .attr("markerHeight", 6)
    .attr("orient", "auto")
    .attr("fill", linkStroke)
    .append("svg:path")
    .attr("d", "M 0,0 L 10,5 L 10, -5");

  function tick() {
    console.log("called");
    svg
      .selectAll("circle")
      .attr("cx", (d) => (d.x ? Number(d.x) : 0));
      .attr("cy", (d) => (d.y ? Number(d.y) : 0));
    svg
      .selectAll("line")
      .attr("x1", (d) => d.source.x)
      .attr("y1", (d) => d.source.y)
      .attr("x2", (d) => d.target.x)
      .attr("y2", (d) => d.target.y);
    texts.attr(
      "transform",
      (d) => "translate(" + (d.x - nodeR) + "," + (d.y + nodeR / 4) + ")"
    );
  }
});
</script>

コメント