diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 7d6caab8..a957ccd4 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -18,6 +18,7 @@ import { ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures'; +import { buildLineageIndex, highlightLineage } from '$lib/person/genealogy/layout/highlightLineage'; import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte'; import StammbaumConnectors from '$lib/person/genealogy/StammbaumConnectors.svelte'; import StammbaumNode from '$lib/person/genealogy/StammbaumNode.svelte'; @@ -63,6 +64,17 @@ let { const layout = $derived.by(() => buildLayout(nodes, edges)); +// Lineage highlight (#703). The adjacency index is rebuilt only when the edges +// change; the cheap walk re-runs whenever the selection changes. A null +// highlight (no selection) means full strength everywhere — nothing dims. +const lineageIndex = $derived(buildLineageIndex(edges)); +const highlight = $derived.by(() => + selectedId ? highlightLineage(lineageIndex, selectedId) : null +); +const isNodeActive = (id: string) => highlight === null || highlight.active.has(id); +const isConnectorActive = (aId: string, bId: string) => + highlight === null || highlight.isConnectorActive(aId, bId); + // Stammbaum gutter (#689). 100 px column on the left of the canvas on md+ // viewports, carrying the G{n} label per generation row. Hidden entirely on // phones (canvas is already overflow-scroll; 100 px of permanent chrome is @@ -288,7 +300,11 @@ function handleCanvasKey(event: KeyboardEvent) { {/each} {/if} - + {#each nodes as node (node.id)} @@ -298,6 +314,7 @@ function handleCanvasKey(event: KeyboardEvent) { node={node} pos={pos} selected={selectedId === node.id} + dimmed={!isNodeActive(node.id)} onSelect={onSelect} /> {/if} diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 7b1bc107..b84b12d1 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -894,3 +894,136 @@ describe('StammbaumTree generation rail (#689, #692)', () => { await vi.waitFor(() => expect(railLabels()).toContain('Generation 3')); }); }); + +describe('StammbaumTree lineage highlight (#703)', () => { + // A three-generation family. Selecting "Vater" highlights his pedigree + // (Grossvater, Grossmutter), his descendant (Kind), and the spouses of those + // blood people (Mutter, his married-in wife). "Tante" is a collateral sibling + // of Vater and must dim. + type Edge = { + id: string; + personId: string; + relatedPersonId: string; + personDisplayName: string; + relatedPersonDisplayName: string; + relationType: 'PARENT_OF' | 'SPOUSE_OF'; + }; + const edge = ( + personId: string, + relatedPersonId: string, + relationType: 'PARENT_OF' | 'SPOUSE_OF' + ): Edge => ({ + id: `${personId}-${relationType}-${relatedPersonId}`, + personId, + relatedPersonId, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType + }); + + const NODES = [ + { id: 'gf', displayName: 'Grossvater', familyMember: true }, + { id: 'gm', displayName: 'Grossmutter', familyMember: true }, + { id: 'vater', displayName: 'Vater', familyMember: true }, + { id: 'mutter', displayName: 'Mutter', familyMember: true }, + { id: 'kind', displayName: 'Kind', familyMember: true }, + { id: 'tante', displayName: 'Tante', familyMember: true } + ]; + const EDGES = [ + edge('gf', 'gm', 'SPOUSE_OF'), + edge('gf', 'vater', 'PARENT_OF'), + edge('gm', 'vater', 'PARENT_OF'), + edge('gf', 'tante', 'PARENT_OF'), + edge('gm', 'tante', 'PARENT_OF'), + edge('vater', 'mutter', 'SPOUSE_OF'), + edge('vater', 'kind', 'PARENT_OF'), + edge('mutter', 'kind', 'PARENT_OF') + ]; + + function nodeOpacity(displayName: string): string | null { + const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`); + if (!g) throw new Error(`No node group rendered for ${displayName}`); + return g.getAttribute('opacity'); + } + const DIM = '0.4'; + + it('renders every node at full strength when nothing is selected (AC1)', () => { + render(StammbaumTree, { + nodes: NODES, + edges: EDGES, + selectedId: null, + panZoom: { x: 0, y: 0, z: 1 }, + showGutter: false, + onSelect: () => {} + }); + + for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull(); + }); + + it('keeps the bloodline + spouses full and dims collaterals when a person is selected (AC2)', () => { + render(StammbaumTree, { + nodes: NODES, + edges: EDGES, + selectedId: 'vater', + panZoom: { x: 0, y: 0, z: 1 }, + showGutter: false, + onSelect: () => {} + }); + + // Anchor, ancestors, descendant, and the spouses of blood people stay full. + for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) { + expect(nodeOpacity(name)).toBeNull(); + } + // The collateral sibling dims. + expect(nodeOpacity('Tante')).toBe(DIM); + }); + + it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => { + const { rerender } = render(StammbaumTree, { + nodes: NODES, + edges: EDGES, + selectedId: 'vater', + panZoom: { x: 0, y: 0, z: 1 }, + showGutter: false, + onSelect: () => {} + }); + expect(nodeOpacity('Tante')).toBe(DIM); + expect(nodeOpacity('Vater')).toBeNull(); + + // Select Tante: her lineage is now active (her parents stay full), while + // Vater's descendant branch (Kind, Mutter) drops out of the active set. + await rerender({ + nodes: NODES, + edges: EDGES, + selectedId: 'tante', + panZoom: { x: 0, y: 0, z: 1 }, + showGutter: false, + onSelect: () => {} + }); + expect(nodeOpacity('Tante')).toBeNull(); + expect(nodeOpacity('Kind')).toBe(DIM); + expect(nodeOpacity('Mutter')).toBe(DIM); + }); + + it('returns the whole tree to full strength when the selection is cleared (AC7)', async () => { + const { rerender } = render(StammbaumTree, { + nodes: NODES, + edges: EDGES, + selectedId: 'vater', + panZoom: { x: 0, y: 0, z: 1 }, + showGutter: false, + onSelect: () => {} + }); + expect(nodeOpacity('Tante')).toBe(DIM); + + await rerender({ + nodes: NODES, + edges: EDGES, + selectedId: null, + panZoom: { x: 0, y: 0, z: 1 }, + showGutter: false, + onSelect: () => {} + }); + for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull(); + }); +});