feat(stammbaum): bind the lineage highlight to the selected person (#703)

StammbaumTree derives the active set from the raw selectedId rune: the
adjacency index is built once per edge set ($derived on edges) and the
walk re-runs on selection change ($derived.by on selectedId). It passes
`dimmed` to each node and the isConnectorActive predicate to the
connectors. A null highlight (no selection) leaves everything full
strength, so an unselected tree never dims (AC1) and a ?focus deep link
paints already dimmed on load (AC9, selectedId seeded server-side).

Adds StammbaumTree.svelte.test.ts cases for AC1 (no dimming when
unselected), AC2 (bloodline + spouses full, collaterals dim), AC6
(re-select recomputes and clears the previous highlight), and AC7
(close returns the whole tree to full strength).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-31 16:35:22 +02:00
parent 9f5d7b8570
commit a3858b6c80
2 changed files with 151 additions and 1 deletions

View File

@@ -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<Layout>(() => 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}
<StammbaumConnectors edges={edges} positions={layout.positions} />
<StammbaumConnectors
edges={edges}
positions={layout.positions}
isConnectorActive={isConnectorActive}
/>
<!-- Nodes -->
{#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}

View File

@@ -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();
});
});