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
|
ZOOM_STEP_KB
|
||||||
} from '$lib/person/genealogy/panZoom';
|
} from '$lib/person/genealogy/panZoom';
|
||||||
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
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 StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
|
||||||
import StammbaumConnectors from '$lib/person/genealogy/StammbaumConnectors.svelte';
|
import StammbaumConnectors from '$lib/person/genealogy/StammbaumConnectors.svelte';
|
||||||
import StammbaumNode from '$lib/person/genealogy/StammbaumNode.svelte';
|
import StammbaumNode from '$lib/person/genealogy/StammbaumNode.svelte';
|
||||||
@@ -63,6 +64,17 @@ let {
|
|||||||
|
|
||||||
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
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+
|
// 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
|
// viewports, carrying the G{n} label per generation row. Hidden entirely on
|
||||||
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
|
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
|
||||||
@@ -288,7 +300,11 @@ function handleCanvasKey(event: KeyboardEvent) {
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<StammbaumConnectors edges={edges} positions={layout.positions} />
|
<StammbaumConnectors
|
||||||
|
edges={edges}
|
||||||
|
positions={layout.positions}
|
||||||
|
isConnectorActive={isConnectorActive}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Nodes -->
|
<!-- Nodes -->
|
||||||
{#each nodes as node (node.id)}
|
{#each nodes as node (node.id)}
|
||||||
@@ -298,6 +314,7 @@ function handleCanvasKey(event: KeyboardEvent) {
|
|||||||
node={node}
|
node={node}
|
||||||
pos={pos}
|
pos={pos}
|
||||||
selected={selectedId === node.id}
|
selected={selectedId === node.id}
|
||||||
|
dimmed={!isNodeActive(node.id)}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -894,3 +894,136 @@ describe('StammbaumTree generation rail (#689, #692)', () => {
|
|||||||
await vi.waitFor(() => expect(railLabels()).toContain('Generation 3'));
|
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