ReactFlowによる家系図の可視化
家系図はグラフです。データは人物を表すノードの集合であり、親子、配偶者、兄弟姉妹といった種類別のエッジで結ばれています。可視化の課題は、3世代の表示から、何世代にもわたり数百人を含む深い系譜まで、さまざまな規模のグラフを分かりやすく配置することです。ReactFlowは描画と操作の基盤を提供します。設計上の重要な判断は、カスタムノードタイプ、Dagreのレイアウトアルゴリズム、関係の種類に応じたエッジの表示、家系図が大きくなるにつれて必要になるパフォーマンス対策にあります。
この記事では、当社が実装時に行った判断、そのトレードオフ、そしてReactFlowの抽象化が開発を助けた点と制約になった点を紹介します。
ReactFlowを選んだ理由
代替案として真剣に検討したのはD3です。D3のツリーレイアウトアルゴリズムは成熟しており、ドキュメントも充実し、系譜の可視化に長年使われてきました。問題は、D3ではDOMを直接管理する必要があることです。操作可能な要素にReactコンポーネントを使う場合、Reactの差分調整の仕組みと衝突します。両者を組み合わせる方法も機能しますが、保守が難しくなります。
ReactFlowでは、すべてをReactのコンポーネントモデル内で扱えます。ノードとエッジは、状態として保持する通常のJavaScriptオブジェクトです。ライブラリが描画(HTMLのノードとSVGのエッジ)、パンとズーム、操作イベントを処理します。カスタムノードタイプもReactコンポーネントそのものです。そのため、写真のプレースホルダー、展開可能な詳細欄、操作ボタンといった豊富な操作要素を、Reactのモデルから外れることなく各ノードに組み込めます。
一方、ReactFlowはD3よりも設計上の制約が多くあります。ノードとエッジのデータモデルには決まった形式があり、レイアウトは外部で処理します(ReactFlow自体にレイアウトアルゴリズムは含まれません)。また、ノード数が非常に多い場合は、D3だけを使う場合よりも明示的なパフォーマンス管理が必要です。数百ノード規模のプロジェクトでは、こうした制約を考慮しても利点が上回ります。
カスタムノードタイプ:人物カード
ReactFlowのカスタムノード機能は、特に優れた機能の一つです。カスタムノードタイプは、ノードのデータをpropsとして受け取り、任意の内容を描画するReactコンポーネントです。必要なのは、エッジを接続する位置にHandleコンポーネントを描画することだけです。
PersonNodeコンポーネントは、氏名、生年、没年(該当する場合)、写真のプレースホルダーを含むカードを描画します。写真がない場合はイニシャルを表示します。カードには控えめな枠線があり、世代の深さに応じて色が変わります。深い家系図でも、閲覧者が位置を把握しやすくするための視覚的な手がかりです。存命の人物と故人には、異なる表示を使います。
分かりやすさを大きく改善した設計上の判断があります。ノードにはエッジの種類ごとに別々のハンドルを設け、親子用は上部と下部、配偶者用は左側と右側に配置しました。親子のエッジは下から上へ接続し、家系図が一貫した方向に縦に流れるようにします。配偶者のエッジは、同じ世代のノード同士を横方向に接続します。ハンドル位置によってエッジの種類を分けることで、関係の方向をすぐに読み取れます。
ノードコンポーネントは、深いサブツリーの「折りたたみ」状態も扱います。サブツリーを折りたたむと、非表示になっている子孫の人数をバッジで表示します。バッジをクリックするとサブツリーが展開されます。この操作は、大規模な家系図を使いやすくするために欠かせません。400ノードを一度に表示すると読み取れませんが、大半を折りたたんで必要な枝だけを展開すれば、家系図をたどれます。
Dagreによる階層配置
ReactFlowはノードの位置を計算しません。ノードデータの一部として位置を指定すると、その位置に描画します。家系図の位置を手作業で計算するには、階層レイアウトアルゴリズムを実装し直す必要があります。一般的な選択肢はDagreです。有向非巡回グラフのレイアウトライブラリで、エッジの交差を抑えた見やすい階層レイアウトを生成します。
Dagreとの連携は、ReactFlowの描画サイクルの外で行います。ノードやエッジのデータが変わると、レイアウト関数が同期的に実行され、各ノードに位置を割り当て、配置済みのノード配列を返します。ReactFlowはその配列を描画します。関数は元のノードとエッジを受け取り、Dagreグラフを構築してレイアウトを実行し、計算されたxとyの値を読み取ります。
import dagre from '@dagrejs/dagre';
function layoutTree(nodes, edges, nodeWidth, nodeHeight) {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'TB', ranksep: 80, nodesep: 40 });
nodes.forEach(node => {
g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach(edge => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
return nodes.map(node => {
const { x, y } = g.node(node.id);
return { ...node, position: { x: x - nodeWidth / 2, y: y - nodeHeight / 2 } };
});
}
Dagreのrankdirパラメータは、レイアウトの方向を制御します。世代が進むにつれて時間が下に流れる家系図には、「TB」(上から下)が自然な選択です。ranksepは世代間の縦方向の間隔、nodesepは兄弟姉妹間の横方向の間隔を制御します。これらの値は、使用するノードの大きさに合わせて調整する必要があります。
家系図にDagreを使う際の制約は、グラフをDAGとして扱うことです。配偶者のエッジは、同じ階層の2つのノードを結ぶ無向エッジであり、DAGのエッジには自然に当てはまりません。そこで、親子のエッジだけでDagreのレイアウトを実行し、世代の階層と横方向の位置を決めた後、配偶者のエッジを同じy座標上の直線として別途描画します。上の簡略化した関数は受け取ったすべてのエッジを使ってレイアウトを計算するため、呼び出す前にエッジのリストを親子のエッジだけに絞り込んでください。また、Dagreだけでは配偶者が隣同士や同じ階層に配置されるとは限りません。親の記録がなく、表示中の家系図に子もいない配偶者には、親子のエッジが1本もありません。そのため、配偶者が同じ高さに並び、横向きのエッジで結ばれるという意図した表示にするには、レイアウト計算後に配偶者の位置を調整する処理が必要です。
関係の種類に応じたエッジの表示
ReactFlowは、カスタムノードと同様にカスタムエッジコンポーネントもサポートします。親子、配偶者、養親子、継親子という関係の種類ごとに、異なる線のスタイルを使います。親子は実線、配偶者は細い横線、養親子は破線、継親子は点線で表示します。
エッジコンポーネントは、接続元と接続先のノード位置をpropsとして受け取り、SVGパスを描画します。直線のエッジなら処理は簡単です。異なる位置のノード間を曲線で結ぶ場合は、ReactFlowのパス生成用ヘルパー関数(getStraightPath、getBezierPath、getSmoothStepPath)を使うと、指定された端点座標に合ったSVGパス文字列を生成できます。
カスタムエッジコンポーネントでは、マウスポインターを重ねた際にラベルを表示し、関係の説明を詳しく示すこともできます。複雑な家族構成では、線のスタイルだけでは関係が分かりにくい場合があります。エッジにポインターを重ねると、「養親:1987年」や「1975年に結婚、1989年に離婚」と表示します。ラベルはSVG内のforeignObjectとして描画するため、通常のHTMLとCSSでツールチップのスタイルを設定できます。
大規模な家系図のパフォーマンス
ReactFlowは、数百ノードまでの中規模の家系図であれば、特別な最適化なしで処理できます。それを超えると、いくつかの対策が必要になります。
ReactFlowは、現在のビューポート内のノードとエッジだけを描画できます。この機能は初期設定では無効で、onlyRenderVisibleElementsプロパティで有効にします。有効にすると、ノードコンポーネント自体の描画負荷が高くなければ、大規模な家系図のパフォーマンス上の問題の大半に対応できます。PersonNodeが描画のたびに外部データを取得したり重い計算をしたりすると、画面外のノードを描画対象から外しても動作がもたつきます。ノードコンポーネントは副作用のない軽量な処理にしてください。
2つ目の対策は、ノード配列とエッジ配列のメモ化です。ReactFlowはこれらの配列の参照が変わるたびに再描画するため、親コンポーネントの描画ごとに新しい配列を渡すと、不要な処理が発生します。レイアウト計算をuseMemoで囲み、派生した状態ではなく元データを依存値に指定すれば、元データが実際に変わった場合にだけ、負荷の高いレイアウト計算とそれに伴う再描画を実行できます。
3つ目の対策は、カスタムノードの節で説明した折りたたみと展開です。400ノードの家系図でも、一度に40ノードだけを表示するなら、40ノードの家系図に近いパフォーマンスになります。折りたたみ状態は、元のデータモデルではなく、可視化を担うReactの状態として管理します。サブツリーを折りたたむと、ReactFlowに渡すノード配列とエッジ配列から該当部分を除外し、展開すると戻します。Dagreは絞り込んだデータでレイアウトを再計算します。
インタラクションデザインの考慮事項
家系図の可視化では、配置や描画の仕組みだけでなく、操作の設計も慎重に考える必要があります。パンとズームはReactFlowの標準機能ですが、初期表示とズーム倍率は使いやすさを大きく左右します。レイアウトの描画後にfitView()を呼び出せば、読み込み時に家系図が適切な大きさで画面内に表示されます。
人物をクリックして画面の中央に表示したり、エッジをたどって隣の世代に移動したりするには、ReactFlowインスタンスのsetCenter()とfitBounds()を使います。これらはuseReactFlow()フック経由で利用します。滑らかなアニメーションを有効にして呼び出すことで、利用者が移動前後の位置関係を把握しやすくなります。
ある程度の規模の家系図では、氏名で特定の人物を探してその位置へ移動する検索・フィルタリング機能が一般に求められます。この実装では、該当人物を画面中央に移動した後、ノードを脈動させるように強調表示します。該当ノードにCSSアニメーションのクラスを短時間付け、後で外すことで実現します。ReactFlowのupdateNodeメソッドを使えば、家系図全体のレイアウトを再計算せずに処理できます。
関連記事
- クロージャーテーブルによる関係の計算 :「この2人はどのような関係か?」を単一のクエリで求めるデータベース層。ReactFlowの可視化層を補完します。
- 国旗ウィジェット:ロケール依存の国名よりISO国コードを使う理由 :名前ではなく国コードという安定した識別子を使うことで、気付きにくいバグを防いだ別のプロジェクトです。