From 7a655ce6f41de6e1520c4038b1704255e742a33c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 16:26:24 +0200 Subject: [PATCH] feat(stammbaum): add lineage highlight traversal module (#703) Pure, DOM-free traversal over the family graph. Given the relationship edges and a selected root, highlightLineage returns the active id set (root + full pedigree upward + full descendant tree downward + every spouse of those blood people, as active leaves) and a connector predicate active only when both joined people are active. The walk is guarded by the accumulating visited set, so cyclic PARENT_OF data terminates (REQ-STAMMBAUM-04 / AC10). SIBLING_OF and social relation types are ignored, so collaterals never enter the active set. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/highlightLineage.test.ts | 150 ++++++++++++++++++ .../genealogy/layout/highlightLineage.ts | 117 ++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts create mode 100644 frontend/src/lib/person/genealogy/layout/highlightLineage.ts diff --git a/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts b/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts new file mode 100644 index 00000000..403be550 --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { buildLineageIndex, highlightLineage } from './highlightLineage'; +import type { components } from '$lib/generated/api'; + +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +// Fixture builders mirror buildLayout.test.ts. Ids are plain readable strings — +// the traversal is id-agnostic, so UUIDs add no value here. +function parentEdge(parentId: string, childId: string, id = parentId + childId): RelationshipDTO { + return { + id, + personId: parentId, + relatedPersonId: childId, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'PARENT_OF' + }; +} + +function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO { + return { + id, + personId: a, + relatedPersonId: b, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'SPOUSE_OF' + }; +} + +function siblingEdge(a: string, b: string, id = a + b): RelationshipDTO { + return { + id, + personId: a, + relatedPersonId: b, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'SIBLING_OF' + }; +} + +function lineage(edges: RelationshipDTO[], rootId: string) { + return highlightLineage(buildLineageIndex(edges), rootId); +} + +function activeIds(edges: RelationshipDTO[], rootId: string): string[] { + return [...lineage(edges, rootId).active].sort(); +} + +describe('highlightLineage — active set (#703)', () => { + it('an isolated person highlights only themselves', () => { + expect(activeIds([], 'root')).toEqual(['root']); + }); + + it('walks the full pedigree upward (parents and grandparents)', () => { + const edges = [ + parentEdge('father', 'root'), + parentEdge('mother', 'root'), + parentEdge('fatherFather', 'father'), + parentEdge('fatherMother', 'father'), + parentEdge('motherFather', 'mother'), + parentEdge('motherMother', 'mother') + ]; + expect(activeIds(edges, 'root')).toEqual([ + 'father', + 'fatherFather', + 'fatherMother', + 'mother', + 'motherFather', + 'motherMother', + 'root' + ]); + }); + + it('walks the full descendant tree downward (children and grandchildren)', () => { + const edges = [ + parentEdge('root', 'child'), + parentEdge('child', 'grandchild'), + parentEdge('child', 'grandchild2') + ]; + expect(activeIds(edges, 'root')).toEqual(['child', 'grandchild', 'grandchild2', 'root']); + }); + + it('activates all spouses of a blood person, including remarriages', () => { + // root's father remarried (mother + secondWife); root married twice (s1 + s2). + const edges = [ + parentEdge('father', 'root'), + parentEdge('mother', 'root'), + spouseEdge('father', 'mother'), + spouseEdge('father', 'secondWife'), + spouseEdge('root', 's1'), + spouseEdge('root', 's2') + ]; + expect(activeIds(edges, 'root')).toEqual([ + 'father', + 'mother', + 'root', + 's1', + 's2', + 'secondWife' + ]); + }); + + it('stops at a married-in spouse — the in-law is active but their parents stay dimmed', () => { + const edges = [spouseEdge('root', 'inLaw'), parentEdge('inLawParent', 'inLaw')]; + const { active } = lineage(edges, 'root'); + expect(active.has('inLaw')).toBe(true); + expect(active.has('inLawParent')).toBe(false); + }); + + it('never pulls a collateral relative into the active set via a SIBLING_OF edge', () => { + const edges = [ + parentEdge('parent', 'root'), + parentEdge('parent', 'sibling'), + siblingEdge('root', 'sibling') + ]; + const { active } = lineage(edges, 'root'); + expect(active.has('parent')).toBe(true); // ancestor + expect(active.has('sibling')).toBe(false); // collateral — reached only down from an ancestor, never walked + }); +}); + +describe('highlightLineage — connector predicate (#703)', () => { + it('is active only when both joined people are active', () => { + // root—inLaw (spouse), inLaw—inLawParent (parent). Only root + inLaw are active. + const edges = [ + parentEdge('parent', 'root'), + spouseEdge('root', 'inLaw'), + parentEdge('inLawParent', 'inLaw') + ]; + const { isConnectorActive } = lineage(edges, 'root'); + expect(isConnectorActive('root', 'parent')).toBe(true); // active ↔ active + expect(isConnectorActive('root', 'inLaw')).toBe(true); // active ↔ active spouse + expect(isConnectorActive('inLaw', 'inLawParent')).toBe(false); // active spouse ↔ dimmed in-law parent + }); +}); + +describe('highlightLineage — cyclic data (REQ-STAMMBAUM-04 / AC10)', () => { + it('terminates on a PARENT_OF cycle and resolves the cycle members as active', () => { + // A is parent of B and B is parent of A — a cycle. The visited guard stops the + // walk re-visiting; both cycle members are reachable from root and stay active. + const edges = [parentEdge('a', 'b'), parentEdge('b', 'a')]; + expect(activeIds(edges, 'a')).toEqual(['a', 'b']); + }); + + it('terminates on a self-referential PARENT_OF edge', () => { + const edges = [parentEdge('x', 'x')]; + expect(activeIds(edges, 'x')).toEqual(['x']); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/highlightLineage.ts b/frontend/src/lib/person/genealogy/layout/highlightLineage.ts new file mode 100644 index 00000000..a9c5a149 --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/highlightLineage.ts @@ -0,0 +1,117 @@ +/** + * Lineage highlighting for the Stammbaum (#703). + * + * Pure, DOM-free traversal over the family graph: given the relationship edges + * and a selected root person, it returns the set of people whose nodes stay at + * full strength (the root's full pedigree upward, full descendant tree downward, + * and every spouse of those blood people) plus a predicate that decides whether + * a connector between two people is active. Everyone and every connector outside + * the active set is rendered dimmed by the presentation layer. + * + * Kept beside `buildLayout.ts` and free of Svelte/DOM so it is unit-testable in + * the node project and the components stay presentation-only. + */ +import type { components } from '$lib/generated/api'; + +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +/** + * Opacity applied to dimmed nodes and connectors. ~0.4 keeps names legible while + * clearly de-emphasised, and works as a lightness cue in both themes (the colour + * tokens are theme-aware) so the cue does not rely on hue (WCAG 1.4.1 / NFR-A11Y-001). + */ +export const DIMMED_OPACITY = 0.4; + +/** Adjacency index over the family graph, built once per edge set. */ +export type LineageIndex = { + parentToChildren: Map; + childToParents: Map; + spouses: Map>; +}; + +export type LineageHighlight = { + /** Ids of people rendered at full strength while the root is selected. */ + active: Set; + /** A connector is active only when both people it joins are active. */ + isConnectorActive: (aId: string, bId: string) => boolean; +}; + +function pushTo(map: Map, key: string, value: string): void { + const list = map.get(key); + if (list) list.push(value); + else map.set(key, [value]); +} + +function addSpouse(map: Map>, a: string, b: string): void { + const set = map.get(a); + if (set) set.add(b); + else map.set(a, new Set([b])); +} + +/** + * Build the adjacency index from the raw edges. Only `PARENT_OF` and `SPOUSE_OF` + * shape a bloodline; `SIBLING_OF` and the social relation types are ignored, so a + * sibling never enters the active set. Spouse pairs are stored symmetrically. + */ +export function buildLineageIndex(edges: RelationshipDTO[]): LineageIndex { + const parentToChildren = new Map(); + const childToParents = new Map(); + const spouses = new Map>(); + + for (const edge of edges) { + if (edge.relationType === 'PARENT_OF') { + pushTo(parentToChildren, edge.personId, edge.relatedPersonId); + pushTo(childToParents, edge.relatedPersonId, edge.personId); + } else if (edge.relationType === 'SPOUSE_OF') { + addSpouse(spouses, edge.personId, edge.relatedPersonId); + addSpouse(spouses, edge.relatedPersonId, edge.personId); + } + } + + return { parentToChildren, childToParents, spouses }; +} + +/** + * Collect every id reachable from `start` along `adjacency`, into `into`. The + * `into.has(id)` check doubles as the visited guard, so a cyclic `PARENT_OF` + * chain terminates instead of recursing unbounded (REQ-STAMMBAUM-04 / AC10). + */ +function collectReachable( + start: string, + adjacency: Map, + into: Set +): void { + // Seed with the start's neighbours and add the start unconditionally: the + // start may already be in `into` from an earlier walk (the root is shared by + // the ancestor and descendant passes), and a pre-visited start must still be + // expanded rather than short-circuited. + into.add(start); + const stack = [...(adjacency.get(start) ?? [])]; + while (stack.length > 0) { + const id = stack.pop()!; + if (into.has(id)) continue; + into.add(id); + for (const next of adjacency.get(id) ?? []) stack.push(next); + } +} + +/** + * Compute the active set for the selected `rootId`: the root, all ancestors, all + * descendants, and every spouse of those blood people (spouses are active leaves + * — we never climb into a married-in spouse's own bloodline). + */ +export function highlightLineage(index: LineageIndex, rootId: string): LineageHighlight { + const blood = new Set(); + collectReachable(rootId, index.childToParents, blood); + collectReachable(rootId, index.parentToChildren, blood); + + const active = new Set(blood); + for (const id of blood) { + for (const spouse of index.spouses.get(id) ?? []) active.add(spouse); + } + + return { + active, + isConnectorActive: (aId, bId) => active.has(aId) && active.has(bId) + }; +}