React Diagramsが最高だった件

React Diagramsが最高だった件

仕事で、javascriptでフロー図を作成するツールが必要になりそうだったので、土日でReact Diagramsを触ってわかったこと・カスタマイズの方法を書きます

React Diagramsとは

a super simple, no-nonsense diagramming library written in react that just works

https://github.com/projectstorm/react-diagrams

シンプルなダイアグラム描画ライブラリ(diagramming library)です。ReactとTypescriptで構成されていて、シンプルで拡張性が高いフロー図を作成することができます。

デモページでは、ラベル付きのリンクのデモドラッグドロップでの要素の追加ができるデモなどいろいろなデモを見ることができます。GitHubはこちらです。

使い方とカスタマイズの方法について書きます

ノード・ポート・リンク

このライブラリでは、フロー図はノード・ポート・リンクという概念で構成されています。

/img/2020-07-12_07.png

ノード. フロー図でのブロックを表す概念

/img/2020-07-12_09.png

リンク. フロー図でのノード間をつなぐ線を表す概念

/img/2020-07-12_08.png

ポート. ノードとリンクをつなぐ部分を表す概念

使い方

もろもろライブラリインストールします

$ yarn add @projectstorm/react-diagrams@next

$ yarn add closest lodash react ml-matrix dagre pathfinding paths-js @emotion/core

# 追加で`@emotion/styled`が必要
$ yarn add @emotion/styled resize-observer-polyfill

ノードを2つリンクを1つ配置するコードを記述します

import React from "React";
import ReactDOM from "react-dom";
import "./index.css";
// import App from './App';

import createEngine, {
  DiagramModel,
  DefaultNodeModel,
  DefaultLinkModel,
} from "@projectstorm/react-diagrams";

import { CanvasWidget } from '@projectstorm/react-canvas-core';

const engine = createEngine();

const model = new DiagramModel();

const node1 = new DefaultNodeModel({
  name: "ノード1"
});
node1.setPosition(300, 200);
node1.addInPort("ポート1-1");
node1.addInPort("ポート1-2");

const node2 = new DefaultNodeModel({
  name: "ノード2"
});
node2.setPosition(100, 400);
node2.addOutPort("ポート2-1");

const link1 = new DefaultLinkModel()
link1.setTargetPort(node1.getPort("ポート1-1")!)
link1.setSourcePort(node2.getPort("ポート2-1")!)

model.addAll(node1, node2, link1);

// install the model into the engine
engine.setModel(model);

document.addEventListener("DOMContentLoaded", () => {
  ReactDOM.render(
    <CanvasWidget className="diagram-container"  engine={engine} />,
    document.querySelector("#root")
  );
});
.diagram-container{
  background: #333333;
	width: 100vw;
	height: 100vh;
}

これで実行した結果がこちらです

/img/2020-07-12_02.gif

上記のコードだけでこれだけインタラクティブなフロー図が作成でます

上記のコードを少し分解してみます。

ノード・ポートの登録

ノードは以下のように定義します。

const node1 = new DefaultNodeModel({
  name: "ノード1"
});
node1.setPosition(300, 200); // 表示位置の変更
node1.addInPort("ポート1-1"); // ポートの追加
node1.addInPort("ポート1-2"); // ポートの追加

リンクの登録

リンクは以下のように定義します

const link1 = new DefaultLinkModel()
link1.setSourcePort(taskNode1.getPort("ポート1-1")!)  // 接続元のポートを指定
link1.setTargetPort(taskNode2.getPort("ポート2-1")!)   // 接続先のポートを指定

モデルに登録

これらで定義したノード・リンクはモデルに追加する必要があります

model.addAll(node1, node2, link1);

基本的な使い方は以上です。ノードを作ったりリンクをつなげたり程度しか用意されていません。

しかし、このライブラリでは用意されたクラスを継承して、フロー図を柔軟にカスタマイズすることができます

カスタマイズ

ノード・ポート・リンクはそれぞれ

  • Factory(主にModelとWidgetの紐付け)
  • Model(主にデータ・ロジックの定義)
  • Widget(主にUIの提供)

というクラスから構成されています。

例えば、リンクのUIを変更したいのであればリンクのWidget。ノードにデータを追加させたいのであればノードのModelを変更することで可能になります。

リンクの見た目の変更

例えば、リンクの色と太さを変えたい場合は以下のようにモデルを変更するだけです。

export class MyLinkWidget extends React.Component<{
  model: AdvancedLinkModel;
  path: string;
}> {
  
  render() {
    return (
      <path
          fill="none"
          strokeWidth={2}           // 太さを変更
          stroke="rgba(255,255,0)"  // 色を変更
          d={this.props.path}
          className="pointer-events-all"
        />
    );
  }
}

ただし、SVGのパスはデフォルトだとイベントが無効になってしまうので、リンクに対するクリックやホバーなどのイベントが効かなくなってしまいます。このようにスタイルを当てる必要があります

.pointer-events-all {
  pointer-events: all;
}

また、ModelとWidgetを紐付けるためにFactoryも修正する必要があります

export class MyLinkFactory extends DefaultLinkFactory {

  generateLinkSegment(
    model: DefaultLinkModel,
    selected: boolean,
    path: string
  ) {
    return (
      <g>
        <MyLinkWidget model={model} path={path} />
      </g>
    );
  }
}

上記で作成したFactoryを今回のengineにセットします

const engine = createEngine();

+engine.getLinkFactories().registerFactory(new MyLinkFactory());

const model = new DiagramModel();

// ...

実行すると以下のように黄色く細いリンクが表示されていると思います。ポートをドラッグして新しく伸ばすと、そのリンクも同様に黄色く細いリンクに変更されていることがわかります。

/img/2020-07-12_03.png

ノードにデータを追加する

ノードにデータを追加のであれば、ノードのModelのカスタマイズが必要になります。

ここでは、ノードを1つのタスクとみなして、タスク名(name)・作業日(scheduledWorkDays)・進捗率(progress)をデータとして保存できるようにしたいと思います

/img/2020-07-12_04.png

完成イメージ

Modelクラスを実装します

export class TaskNodeModel extends NodeModel {
  // クラスにプロパティとして定義
	name: string   
	scheduledWorkDays: number       
	progress: number                

	constructor(options: TaskNodeModelOptions = {}) {
		super({
			...options,
			type: 'default'
		});

    // 初期化
		this.name = options.name || '';
		this.scheduledWorkDays = options.scheduledWorkDays || 1;
		this.progress = options.progress || 0;

		this.addPort(
			new DefaultPortModel({
				in: true,
				name: 'in'
			})
		);
		this.addPort(
			new DefaultPortModel({
				in: false,
				name: 'out'
			})
		);
	}

	serialize() {
		return {
			...super.serialize(),
      // JSONへのシリアライズ時の挙動
			name: this.name,
			scheduledWorkDays: this.scheduledWorkDays,
			progress: this.progress
		};
	}

	deserialize(event: any): void {
		super.deserialize(event);
    // JSONからの復元時の挙動
		this.name = event.data.name || '';
		this.scheduledWorkDays = event.data.scheduledWorkDays || 1;
		this.progress = event.data.progress || 0;
	}
}

export interface TaskNodeModelOptions extends BaseModelOptions {
	name?: string
	scheduledWorkDays?: number
	progress?: number
}

また、タスク名や進捗率なども表示したいので見た目も変更します

export interface TaskNodeWidgetProps {
  node: TaskNodeModel;
  engine: DiagramEngine;
}

export interface TaskNodeWidgetState {}

export class TaskNodeWidget extends React.Component<
  TaskNodeWidgetProps,
  TaskNodeWidgetState
> {
  constructor(props: TaskNodeWidgetProps) {
    super(props);
    this.state = {};
  }

  addProgress(percent: number) {
    this.props.node.addProgress(percent);
  }

  downProgress(percent: number) {
    this.props.node.downProgress(percent);
  }

  render() {
    return (
      <div className="task-node">
        {this.props.node.getPort("in") && (
          <PortWidget
            engine={this.props.engine}
            port={this.props.node.getPort("in")!}
          >
            <div className="circle-port" />
          </PortWidget>
        )}

        {this.props.node.getPort("out") && (
          <PortWidget
            engine={this.props.engine}
            port={this.props.node.getPort("out")!}
          >
            <div className="circle-port" />
          </PortWidget>
        )}
        <div className="task-node-content has-text-light">
          <p>
            <b>{this.props.node.name}</b>
          </p>
          <p>
            <b>{this.props.node.scheduledWorkDays}</b>日(
            <b>{this.props.node.progress}</b>%
          </p>
          <div className="field has-addons">
            <p className="control">
              <button
                onMouseDown={() => this.addProgress(10)}
                className="button is-small"
              >
                10%👍
              </button>
            </p>
            <p className="control">
              <button
                onMouseDown={() => this.downProgress(10)}
                className="button is-small"
              >
                10%👎
              </button>
            </p>
          </div>
        </div>
      </div>
    );
  }
}

また、Factoryも修正します

export class TaskNodeFactory extends AbstractReactFactory<TaskNodeModel, DiagramEngine> {
	constructor() {
		super('default');
	}

	generateModel(initialConfig: any) {
		return new TaskNodeModel();
	}

	generateReactWidget(event: any): JSX.Element {
		return <TaskNodeWidget engine={this.engine as DiagramEngine} node={event.model} />;
	}
}

上記で作成したFactoryを今回のengineにセットします

const engine = createEngine();

engine.getLinkFactories().registerFactory(new AdvancedLinkFactory());
+engine.getNodeFactories().registerFactory(new TaskNodeFactory());

const model = new DiagramModel();

// ...

これで設定は完了したので、実際にフロー図を書いてみましょう。以下のフローをコピペすると画像のように表示されていると思います

const taskNode1 = new TaskNodeModel({ name: "顧客ヒアリング", scheduledWorkDays: 2 });
taskNode1.setPosition(200, 200);
const taskNode2 = new TaskNodeModel({ name: "プロトタイプ作成", scheduledWorkDays: 2 });
taskNode2.setPosition(500, 0);
const taskNode4 = new TaskNodeModel({ name: "要件定義書作成", scheduledWorkDays: 2 });
taskNode4.setPosition(500, 400);
const taskNode6 = new TaskNodeModel({ name: "契約書締結", scheduledWorkDays: 2 });
taskNode6.setPosition(800, 200);

const taskNode3 = new TaskNodeModel({ name: "画面遷移図作成", scheduledWorkDays: 2 });
taskNode3.setPosition(1100, 0);
const taskNode5 = new TaskNodeModel({ name: "DB設計", scheduledWorkDays: 2 });
taskNode5.setPosition(1100, 400);
const taskNode7 = new TaskNodeModel({ name: "API実装", scheduledWorkDays: 2 });
taskNode7.setPosition(1400, 400);
const taskNode8 = new TaskNodeModel({ name: "フロントエンド実装", scheduledWorkDays: 2 });
taskNode8.setPosition(1400, 0);

const taskNode9 = new TaskNodeModel({ name: "結合テスト", scheduledWorkDays: 2 });
taskNode9.setPosition(1700, 200);
const taskNode10 = new TaskNodeModel({ name: "総合テスト", scheduledWorkDays: 2 });
taskNode10.setPosition(2000, 200);
const taskNode12 = new TaskNodeModel({ name: "納品", scheduledWorkDays: 2 });
taskNode12.setPosition(2300, 200);
const taskNode11 = new TaskNodeModel({ name: "検収修正", scheduledWorkDays: 2 });
taskNode11.setPosition(2600, 200);

const link1 = new AdvancedLinkModel({})
link1.setSourcePort(taskNode1.getPort("out")!)
link1.setTargetPort(taskNode2.getPort("in")!)
const link2 = new AdvancedLinkModel({})
link2.setSourcePort(taskNode1.getPort("out")!)
link2.setTargetPort(taskNode4.getPort("in")!)
const link3 = new AdvancedLinkModel({})
link3.setSourcePort(taskNode2.getPort("out")!)
link3.setTargetPort(taskNode6.getPort("in")!)
const link4 = new AdvancedLinkModel({})
link4.setSourcePort(taskNode4.getPort("out")!)
link4.setTargetPort(taskNode6.getPort("in")!)

const link5 = new AdvancedLinkModel({})
link5.setSourcePort(taskNode6.getPort("out")!)
link5.setTargetPort(taskNode3.getPort("in")!)
const link6 = new AdvancedLinkModel({})
link6.setSourcePort(taskNode6.getPort("out")!)
link6.setTargetPort(taskNode5.getPort("in")!)
const link7 = new AdvancedLinkModel({})
link7.setSourcePort(taskNode3.getPort("out")!)
link7.setTargetPort(taskNode8.getPort("in")!)
const link8 = new AdvancedLinkModel({})
link8.setSourcePort(taskNode5.getPort("out")!)
link8.setTargetPort(taskNode7.getPort("in")!)
const link9 = new AdvancedLinkModel({})
link9.setSourcePort(taskNode7.getPort("out")!)
link9.setTargetPort(taskNode9.getPort("in")!)

const link10 = new AdvancedLinkModel({})
link10.setSourcePort(taskNode8.getPort("out")!)
link10.setTargetPort(taskNode9.getPort("in")!)

const link11 = new AdvancedLinkModel({})
link11.setSourcePort(taskNode9.getPort("out")!)
link11.setTargetPort(taskNode10.getPort("in")!)

const link12 = new AdvancedLinkModel({})
link12.setSourcePort(taskNode10.getPort("out")!)
link12.setTargetPort(taskNode12.getPort("in")!)

const link13 = new AdvancedLinkModel({})
link13.setSourcePort(taskNode12.getPort("out")!)
link13.setTargetPort(taskNode11.getPort("in")!)

model.addAll(taskNode1, taskNode2, taskNode3, taskNode4, taskNode5, taskNode6, taskNode7, taskNode8, taskNode9, taskNode10, taskNode11, taskNode12,
   link1, link2, link3, link4, link5, link6, link7, link8, link9, link10, link11, link12, link13);

実行するとこんな感じになると思います

/img/2020-07-12_05.png

ボタンを押すと、ボタンのonMouseDownからTaskNodeModeladdProgressおよびdownProgressを呼び出すことができているので、画面で表示している進捗率(0%の部分)がリアクティブに変化していることがわかると思います

/img/2020-07-12_06.gif

同様の方法で、例えばリンクにデータを追加してリンクがクリックされた時に設定モーダルを表示したり、ノードを動的に追加・削除したりすることも簡単にできます。

他のフロー図を描画するライブラリを調べましたが、既存の状態管理フレームワーク(ReactやAngularやVue.js)で扱えるもののなかで最高に使いやすいライブラリでした。みなさんもこのライブラリを使ってみてくださいー!

所感

  • Reactのよさを生かしている
    • ウィジェットとモデルがそれぞれ見た目とロジックを表していて、分離されていて分かりやすい
    • 正直Vue.jsで再現しようとしても、ModelとWidgetの結合方法がどうしてもわかりにくくなりそう
  • オブジェクト指向に沿っていてカスタマイズが直感的
    • 手続き的なもの(.on("portclicked")みたいなの)を一切書かなくていい
  • 公式ドキュメントがまだ未整備だが、Typescriptを採用しているためドキュメントがないことのツラみを感じない
  • pointer-events: all;のcssの存在を知らなかったため、リンクをクリックできない事象に悩まされ続けた
    • サンプルプロジェクトのリンクのスタイルと比較してやっと原因を特定できた
  • サンプルプロジェクトが多いため、よくある機能は参考にすれば実装が可能な見通しがつく
  • 結論:めっちゃ使いやすい

See also