FamilyTreeGraph レイアウトアルゴリズム
概要
FamilyTreeGraph は、従業員の家族構成を世代ベースの階層グラフとして可視化するコンポーネントである。
- 目的: 年末調整(年始調整)における扶養親族・配偶者の関係を直感的に把握可能にする
- 技術: @xyflow/react + カスタムレイアウトフック
- ドメイン知識: → 親族の基礎概念
設計原則:
- 本人(従業員)を中心に配置し、世代を上下に展開
- 現配偶者は右側、前配偶者は左側
- 子は婚姻ノード直下にセンタリング
- 直系親族は子孫の上に、傍系親族は右端に配置
データフロー
処理の流れ
- ドメインデータ入力:
FamilyMemberData[]とFamilyRelationshipData[]を受け取る - グラフ構築 (
buildGraphElements): ドメインデータからノード・エッジを生成(位置は未定) - レイアウト計算 (
useFamilyLayout): 7フェーズで全ノードの X/Y 座標を決定 - 描画 (
ReactFlow): 配置済みノード・エッジを表示
世代計算 (computeGenerations)
BFS(幅優先探索)で本人からの相対的な世代番号を計算する。
デルタルール
| 関係タイプ | デルタ | 説明 |
|---|---|---|
spouse | 0 | 配偶者は同世代 |
blood / affinal (from→to) | +1 | from=親, to=子 |
blood / affinal (to→from) | -1 | 逆方向(子→親) |
世代番号の例
アルゴリズム
1. 隣接リストを構築: memberId → [{ neighbor, delta }]
2. BFS キュー初期化: employee を generation=0 で追加
3. キューから取り出し、未訪問の隣接ノードに currentGen + delta を割り当て
4. 全メンバーの世代が確定するまで繰り返し続柄解決 (resolveRelationship)
各家族メンバーの「続柄」(本人から見た関係名称)を解決する。
2パスアプローチ
Pass 1: 本人直接
- 本人が from または to である関係から、相手側メンバーに
relationshipを割り当て
Pass 2: 伝播(繰り返し)
- 本人を含まない関係について、片方が解決済み・片方が未解決なら、未解決側に
relationshipを割り当て - 変更がなくなるまで繰り返し(多段階チェーン対応: 例 祖父母→親→本人)
規約
各関係の relationship は本人視点で遠い方の親族を記述する:
- 本人→配偶者 (
relationship='妻') → 配偶者に「妻」 - 父→本人 (
relationship='父') → 父に「父」 - 配偶者→連れ子 (
relationship='連れ子') → 連れ子に「連れ子」
グラフ構築 (buildGraphElements)
ドメインデータから ReactFlow のノードとエッジを生成する。
メンバーノード
| プロパティ | 値 |
|---|---|
| type | 'familyMember' |
| width | 200px |
| height | computeMemberHeight(badgeCount) — バッジなし: 64px, バッジあり: 96px |
| data | { member, age, relationship, seikeiWoItsuNiSuru, generation } |
婚姻ノード
| プロパティ | 値 |
|---|---|
| type | 'marriage' |
| width / height | 1px × 1px(不可視コネクタ) |
| data | { isFormer, side, generation } |
生成ルール:
- 本人の各配偶者関係ごとに1つの婚姻ノード
- 本人以外の夫婦関係(祖父母等)にも婚姻ノードを生成
エッジ生成
ハンドル配置
| ハンドル | 用途 |
|---|---|
right / left | 現配偶者との水平接続 |
left-out / right-in | 前配偶者との水平接続 |
bottom | 親側から子への垂直接続 |
top | 子側から親への垂直接続 |
親子エッジのルーティング
findParentMarriageNode() により、親子関係を婚姻ノード経由でルーティング:
- 親が婚姻関係にある場合: 婚姻ノードの
bottom→ 子のtop - 片親の場合: 親の
bottom→ 子のtop(婚姻ノードなし)
レイアウトアルゴリズム (useFamilyLayout) — 7フェーズ
useFamilyLayout フックは useMemo 内で applyFamilyLayout を呼び出し、全ノードに X/Y 座標を割り当てる。
Phase 1: 世代読み取り
各メンバーノードの data.generation から世代番号を取得する。世代計算は buildGraphElements 内の computeGenerations で完了済み。
Phase 2: 婚姻グループ構築
婚姻ノードごとに MarriageGroup を構築する。
interface MarriageGroup {
marriage: Node // 婚姻コネクタノード
employee: Node | null // 本人(gen-0婚姻の場合)
spouse: Node | null // 配偶者
children: Node[] // 子ノード(dateOfBirth 昇順ソート)
side: 'right' | 'left' // 配偶者の配置方向
generation: number // 婚姻の世代
subtreeWidth: number // サブツリー全体幅
}サブツリー幅の計算:
coupleWidth = memberW + coupleGap + marriageW + coupleGap + memberW
= 200 + 40 + 1 + 40 + 200 = 481px
childrenWidth = N × memberW + (N-1) × nodesep
= N × 200 + (N-1) × 60
subtreeWidth = max(coupleWidth, childrenWidth)追加収集:
directChildren: 婚姻ノードを経由せず本人に直接接続された子(片親ケース)individualParents: 婚姻グループに属さない親(片親ケース)
Phase 3: 世代グルーピング
全メンバーノードから存在する世代番号を収集し、昇順ソートする。 gen-0 の婚姻グループを right(現配偶者)と left(前配偶者)に分類する。
Phase 4: Gen 0(本人行)X配置
本人を原点 x=0 に配置し、婚姻グループを左右に展開する。
← 左方向(前配偶者) 右方向(現配偶者) →
[前配偶者] [gap] [婚姻] [gap] [本人] [gap] [婚姻] [gap] [配偶者]
x=0右方向展開 (現配偶者):
rightX = memberW + coupleGap (= 240)
for each gen0Right group:
marriage.x = rightX
spouse.x = rightX + marriageW + coupleGap
rightX += spouseBlockW + extraForChildren + nodesep左方向展開 (前配偶者):
leftX = -(coupleGap + marriageW + coupleGap + memberW)
for each gen0Left group:
spouse.x = leftX
marriage.x = leftX + memberW + coupleGap
leftX -= spouseBlockW + extraForChildren + nodesepextraForChildren: 子のサブツリーがカップル幅を超える場合の追加スペース。
Phase 5: Gen +N(子行)X配置
各 gen-0 婚姻グループの子を、婚姻ノード中心にセンタリング配置する。
[婚姻]
|
+----- + -----+
| | |
[子1] [子2] [子3]
←--- centered ---→marriageCenterX = marriage.x + marriageW / 2
totalChildW = N × memberW + (N-1) × nodesep
childX = marriageCenterX - totalChildW / 2
for each child:
child.x = childX
childX += memberW + nodesep片親の直接子(directChildren)は本人の中心にセンタリング。
Phase 5b: 子行オーバーラップ解消
左側婚姻グループと右側婚姻グループの子がオーバーラップする場合、ペアワイズシフトで解消する。
手順:
- 左婚姻グループ → 直接子 → 右婚姻グループ の順で
ChildGroupBoundsを構築 - 中心X でソート
- 隣接ペアのオーバーラップを検出:
overlap = left.maxX + nodesep - right.minX - オーバーラップ量の半分ずつ左右に押し出し(関連する婚姻・配偶者ノードも一緒にシフト)
Phase 6: Gen -N(親行)X配置
直系親族の BFS 識別
本人と gen-0 配偶者から BFS で上方探索し、直系(本人の親・義親・祖父母等)の婚姻グループを特定する。
traceQueue = [employee, ...gen0Spouses]
for each memberId in queue:
if a parentMarriageGroup has this member as child:
mark as directLineage
add the spouse pair to queue (for higher generations)アンカーベースセンタリング
世代の近い方(-1)から順に処理。各直系親婚姻グループを、そのアンカー子孫(配置済みの子)の真上にセンタリングする。
[親1] [婚姻] [親2]
←--- centered ---→
|
[アンカー子] ← 配置済み (Phase 4/6 で確定)anchorCenterX = anchorChild.x + memberW / 2
startX = anchorCenterX - coupleW / 2
spouse1.x = startX
marriage.x = startX + memberW + coupleGap
spouse2.x = startX + memberW + coupleGap + marriageW + coupleGap同世代オーバーラップ解消
同じ世代に複数の直系親婚姻がある場合(例: 本人の両親と配偶者の両親)、左から右へ走査してオーバーラップを右方向にシフトして解消する。
片親配置
婚姻グループに属さない個人の親は、本人中心にセンタリングして配置する(直系親婚姻がない場合のみ)。
Phase 6b: 傍系親族配置
直系配置で位置が決まらなかったメンバーを右端に順次配置する。
婚姻グループ単位の配置:
for each remaining marriage group:
rightEdge = max(positioned members in same generation).maxX
spouse1.x = rightEdge + nodesep
marriage.x = spouse1.x + memberW + coupleGap
spouse2.x = marriage.x + marriageW + coupleGap
center children under marriage個人メンバーの配置:
for each generation:
for each unpositioned member (sorted by dateOfBirth):
member.x = rightEdge + nodesep
rightEdge = member.x + memberWPhase 7: Y座標割り当て
全ノードの Y 座標を世代行の累積高さとして計算する。
手順:
- 各世代行の最大ノード高さを計算
- 世代を昇順(上から下)で走査し、累積 Y を計算
- メンバーノード:
y = genY[generation] - 婚姻ノード:
y = genY[generation] + (rowHeight - marriageH) / 2(行内で垂直中央揃え)
cumulativeY = 0
for each generation (ascending):
genY[gen] = cumulativeY
cumulativeY += maxHeight[gen] + ranksep(80px)定数
| 定数 | 値 | 用途 |
|---|---|---|
NODE_DIMENSIONS.member.width | 200px | メンバーカード幅 |
NODE_DIMENSIONS.member.baseHeight | 64px | バッジなしメンバー高さ |
NODE_DIMENSIONS.member.badgeSectionHeight | 32px | バッジあり追加高さ |
NODE_DIMENSIONS.marriage.width | 1px | 婚姻コネクタ幅 |
NODE_DIMENSIONS.marriage.height | 1px | 婚姻コネクタ高さ |
LAYOUT.nodesep | 60px | 兄弟間隔 |
LAYOUT.ranksep | 80px | 世代間隔 |
LAYOUT.coupleGap | 40px | 配偶者-婚姻ノード間隔 |
対応パターン (Storybook ストーリー)
| ストーリー | パターン | 世代 |
|---|---|---|
| NuclearFamily | 核家族(本人+配偶者+子2人) | 0, +1 |
| Remarriage | 再婚(現配偶者+前配偶者+連れ子+実子) | 0, +1 |
| ThreeGenerations | 3世代(祖父母+両親+子) | -1, 0, +1 |
| FourGenerations | 4世代(曾祖父母+祖父母+両親+子) | -2, -1, 0, +1 |
| SingleParent | 片親(母のみ+本人+配偶者+子) | -1, 0, +1 |
| NonResidentFamily | 非居住者バッジ付き家族 | -1, 0, +1 |
| EmployeeOnly | 本人のみ | 0 |
| UncleAunt | 伯叔父母(親の兄弟) | -1, 0 |
| NephewNiece | 甥姪(本人の兄弟の子) | -1, 0, +1 |
| InLawParents | 義父母(配偶者の親) | -1, 0 |
| BothSidesParents | 両家の親(本人側+配偶者側) | -1, 0 |
| LargeFamily | 多子世帯(子5人) | 0, +1 |
| SingleGrandparent | 片祖父母 | -1, 0, +1 |
ソースコード
| ファイル | 責務 |
|---|---|
frontend/src/components/ui/family-tree/family-tree-graph.utils.ts | computeGenerations, buildGraphElements, resolveRelationship, getMemberBadges |
frontend/src/components/ui/family-tree/use-family-layout.ts | useFamilyLayout — 7フェーズレイアウト |
frontend/src/components/ui/family-tree/family-tree-graph.constants.ts | 定数定義 (NODE_DIMENSIONS, LAYOUT, BADGE_COLORS) |
frontend/src/components/ui/family-tree/family-tree-graph.types.ts | ドメイン型 (FamilyMemberData, FamilyRelationshipData) |
frontend/src/components/ui/family-tree/family-tree-graph.stories.tsx | Storybook ストーリー(全パターン) |