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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user