Compare commits

...

7 Commits

Author SHA1 Message Date
Marcel
e5784caa9d docs(glossary): define "lineage highlight" (#703)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m26s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:41:59 +02:00
Marcel
4583ee2c4d feat(stammbaum): centre the tapped person above the bottom sheet (#703)
On a touch viewport (below the md breakpoint, where the bottom sheet
overlays the lower part of the canvas), tapping a person now auto-centres
them via recentreAbove with a 0.3 height bias, so the highlighted anchor
lands in the band above the sheet instead of behind it (AC8). On desktop
the side panel is a flex sibling that never covers the tree, so the bias
is 0 and selection does not pan. StammbaumTree's recentre effect takes a
centreBiasFraction prop and the page drives it from a matchMedia flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:41:00 +02:00
Marcel
0a7b4fa265 feat(stammbaum): add recentreAbove pan helper for the mobile anchor (#703)
recentreAbove recentres on a node and lifts it above the viewBox centre
by a fraction of the zoomed viewBox height, measured against the
auto-zoomed height. On a phone this lands the tapped anchor in the band
above the bottom sheet instead of behind it (AC8). A zero bias is exactly
a legible recentre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:37:38 +02:00
Marcel
a3858b6c80 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>
2026-05-31 16:35:22 +02:00
Marcel
9f5d7b8570 feat(stammbaum): dim connectors outside the highlighted lineage (#703)
StammbaumConnectors gains an isConnectorActive(a, b) predicate prop and
wraps each logical connector in a <g opacity> group. A connector is full
strength only when both joined people are active; otherwise it dims to
DIMMED_OPACITY. The shared parent-pair drop+bar keys on both parents,
while each child vertical keys on both parents AND that child — so the
bar stays lit to a lineage child yet dims to a collateral sibling on the
same row. Defaults to always-active, so no highlight means no dimming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:30:29 +02:00
Marcel
f6da95014e feat(stammbaum): dim a node when outside the highlighted lineage (#703)
StammbaumNode gains an optional `dimmed` prop that sets group-level
opacity (DIMMED_OPACITY) on the node's root <g>, so the box, accent bar,
name, and dates fade together as one unit. A lineage-fade CSS transition
eases the change and is neutralised under prefers-reduced-motion. The
selected-node styling (active fill + mint accent bar) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:28:22 +02:00
Marcel
7a655ce6f4 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 <noreply@anthropic.com>
2026-05-31 16:26:24 +02:00
10 changed files with 621 additions and 58 deletions

View File

@@ -130,6 +130,8 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.
**lineage highlight** `[user-facing, #703]` — the focus+dim layer bound to the Stammbaum side panel: while a person is selected, that person, their full pedigree upward, their full descendant tree downward, and the spouses of all those blood people render at full strength while everyone else is dimmed (opacity, not a hue swap). Connectors dim unless both joined people are active. Computed by the pure traversal in `frontend/src/lib/person/genealogy/layout/highlightLineage.ts`.
---
## Other Domain Terms

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { type Layout, NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout';
import { DIMMED_OPACITY } from '$lib/person/genealogy/layout/highlightLineage';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -8,9 +9,20 @@ type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
edges: RelationshipDTO[];
positions: Layout['positions'];
/**
* Whether the connector joining two people is active (full strength). A
* connector is active only when both endpoints are active; otherwise it is
* dimmed. Defaults to always-active, so no lineage highlight means no dimming.
*/
isConnectorActive?: (aId: string, bId: string) => boolean;
}
let { edges, positions }: Props = $props();
let { edges, positions, isConnectorActive = () => true }: Props = $props();
/** SVG group opacity for a connector: full when active, dimmed otherwise. */
function connectorOpacity(active: boolean): number | undefined {
return active ? undefined : DIMMED_OPACITY;
}
function nodeCenter(id: string): { x: number; y: number } | null {
const p = positions.get(id);
@@ -104,26 +116,45 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const xs = childCenters.map((c) => c.x)}
{@const minX = Math.min(midX, ...xs)}
{@const maxX = Math.max(midX, ...xs)}
<line
x1={midX}
y1={parentBottomY}
x2={midX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if minX !== maxX}
<line x1={minX} y1={barY} x2={maxX} y2={barY} stroke="var(--c-primary)" stroke-width="1.5" />
{/if}
{#each childCenters as cc, i (group.childIds[i])}
{@const pairActive = isConnectorActive(group.parentA, group.parentB)}
<!-- Drop from the spouse midpoint to the sibling bar: joins the parent pair. -->
<g class="lineage-fade" opacity={connectorOpacity(pairActive)}>
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
x1={midX}
y1={parentBottomY}
x2={midX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if minX !== maxX}
<line
x1={minX}
y1={barY}
x2={maxX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
</g>
{#each childCenters as cc, i (group.childIds[i])}
<!-- Each vertical joins the parent pair to one child: active only when
both parents and that child are active, so the same bar can stay lit
to the lineage child while dimming to a collateral sibling. -->
{@const childActive =
isConnectorActive(group.parentA, group.childIds[i]) &&
isConnectorActive(group.parentB, group.childIds[i])}
<g class="lineage-fade" opacity={connectorOpacity(childActive)}>
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
</g>
{/each}
{/if}
{/each}
@@ -136,32 +167,35 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const parentBottomY = parentCenter.y + NODE_H / 2}
{@const childTopY = childCenter.y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
<line
x1={parentCenter.x}
y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
{@const active = isConnectorActive(link.parentId, link.childId)}
<g class="lineage-fade" opacity={connectorOpacity(active)}>
<line
x1={parentCenter.x}
y1={barY}
x2={childCenter.x}
y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
<line
x1={parentCenter.x}
y1={barY}
x2={childCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
</g>
{/if}
{/each}
@@ -170,20 +204,35 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const aCenter = nodeCenter(e.personId)}
{@const bCenter = nodeCenter(e.relatedPersonId)}
{#if aCenter && bCenter}
<line
x1={aCenter.x}
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="6"
fill="var(--c-primary)"
/>
{@const active = isConnectorActive(e.personId, e.relatedPersonId)}
<g class="lineage-fade" opacity={connectorOpacity(active)}>
<line
x1={aCenter.x}
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={e.toYear ? '4 4' : undefined}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="6"
fill="var(--c-primary)"
/>
</g>
{/if}
{/each}
<style>
/* Ease the lineage focus+dim transition; instant for reduced-motion users. */
.lineage-fade {
transition: opacity 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.lineage-fade {
transition: none;
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout';
import { DIMMED_OPACITY } from '$lib/person/genealogy/layout/highlightLineage';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
@@ -8,10 +9,12 @@ interface Props {
node: PersonNodeDTO;
pos: { x: number; y: number };
selected: boolean;
/** Dim the whole node when a lineage is highlighted and this person is outside it. */
dimmed?: boolean;
onSelect: (id: string) => void;
}
let { node, pos, selected, onSelect }: Props = $props();
let { node, pos, selected, dimmed = false, onSelect }: Props = $props();
// Each node owns its own focus-ring state (the focus ring is decorative; the
// `<g role="button">` is the real focus target).
@@ -35,11 +38,12 @@ const datesLabel = $derived(
aria-label="{node.displayName}{datesLabel}"
aria-expanded={selected}
transform="translate({pos.x}, {pos.y})"
opacity={dimmed ? DIMMED_OPACITY : undefined}
onclick={() => onSelect(node.id)}
onkeydown={handleKey}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
class="cursor-pointer focus:outline-none"
class="lineage-fade cursor-pointer focus:outline-none"
>
{#if focused}
<rect
@@ -88,3 +92,15 @@ const datesLabel = $derived(
</text>
{/if}
</g>
<style>
/* Ease the lineage focus+dim transition; instant for reduced-motion users. */
.lineage-fade {
transition: opacity 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.lineage-fade {
transition: none;
}
}
</style>

View File

@@ -13,11 +13,12 @@ import {
type PanZoomState,
clampZoom,
clampPan,
recentreOn,
recentreAbove,
cornerView,
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';
@@ -34,6 +35,11 @@ interface Props {
onPanZoom?: (state: PanZoomState) => void;
/** When set to a node id, the canvas recentres on that node (US-PAN-005). */
centreOnId?: string | null;
/**
* Fraction of the viewBox height to lift a recentred node above the centre,
* so on a phone the anchor clears the bottom sheet (#703 AC8). 0 centres it.
*/
centreBiasFraction?: number;
/** Fired on the first pointer interaction with the canvas (affordance dismiss). */
onActivity?: () => void;
/** When true, the initial view is anchored to the tree's top-left corner. */
@@ -55,6 +61,7 @@ let {
panZoom,
onPanZoom = () => {},
centreOnId = null,
centreBiasFraction = 0,
onActivity,
anchorTopLeft = false,
onSelect,
@@ -63,6 +70,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
@@ -207,7 +225,7 @@ $effect(() => {
if (!id) return;
untrack(() => {
const c = nodeCenter(id);
if (c) onPanZoom(recentreOn(c, baseCentre, panZoom, true));
if (c) onPanZoom(recentreAbove(c, baseCentre, panZoom, baseDims.h, centreBiasFraction));
});
});
@@ -288,7 +306,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 +320,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();
});
});

View File

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

View File

@@ -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<string, string[]>;
childToParents: Map<string, string[]>;
spouses: Map<string, Set<string>>;
};
export type LineageHighlight = {
/** Ids of people rendered at full strength while the root is selected. */
active: Set<string>;
/** A connector is active only when both people it joins are active. */
isConnectorActive: (aId: string, bId: string) => boolean;
};
function pushTo(map: Map<string, string[]>, key: string, value: string): void {
const list = map.get(key);
if (list) list.push(value);
else map.set(key, [value]);
}
function addSpouse(map: Map<string, Set<string>>, 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<string, string[]>();
const childToParents = new Map<string, string[]>();
const spouses = new Map<string, Set<string>>();
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<string, string[]>,
into: Set<string>
): 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<string>();
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)
};
}

View File

@@ -8,6 +8,7 @@ import {
pinchZoom,
stepInertia,
recentreOn,
recentreAbove,
clampPan,
cornerView,
lerpView,
@@ -188,6 +189,34 @@ describe('recentreOn', () => {
});
});
describe('recentreAbove', () => {
const node = { x: 300, y: 200 };
const base = { x: 100, y: 100 };
const baseH = 800;
it('matches the auto-zooming recentre when the bias is zero', () => {
expect(recentreAbove(node, base, { x: 0, y: 0, z: 2 }, baseH, 0)).toEqual(
recentreOn(node, base, { x: 0, y: 0, z: 2 }, true)
);
});
it('lifts the node up by a fraction of the zoomed viewBox height (clears the bottom sheet)', () => {
// recentreOn centres the node (pan.y = 100) at z=2; the bias adds
// 0.3 * (baseH / z) = 0.3 * 400 = 120, so the node sits ~20% from the top.
const next = recentreAbove(node, base, { x: 0, y: 0, z: 2 }, baseH, 0.3);
expect(next.x).toBe(200);
expect(next.z).toBe(2);
expect(next.y).toBeCloseTo(100 + 0.3 * (baseH / 2), 6);
});
it('measures the bias against the auto-zoomed height when starting zoomed out', () => {
// z=0.4 auto-zooms up to LEGIBLE_ZOOM (1); the bias then uses baseH / 1.
const next = recentreAbove(node, base, { x: 0, y: 0, z: 0.4 }, baseH, 0.3);
expect(next.z).toBe(LEGIBLE_ZOOM);
expect(next.y).toBeCloseTo(100 + 0.3 * baseH, 6);
});
});
describe('clampPan', () => {
// Base frame is 1000 x 800.
it('forbids panning when the whole tree fits (z <= 1)', () => {

View File

@@ -236,3 +236,21 @@ export function recentreOn(
z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z
};
}
/**
* Recentre on a node but lift it above the viewBox centre by `biasFraction` of
* the zoomed viewBox height, so on a phone the tapped anchor lands in the band
* above the bottom sheet rather than behind it (#703 AC8). The bias is measured
* against the auto-zoomed height ({@link recentreOn} may snap zoom up to
* {@link LEGIBLE_ZOOM}), and a zero bias is exactly a legible recentre.
*/
export function recentreAbove(
nodeCentre: { x: number; y: number },
baseCentre: { x: number; y: number },
state: PanZoomState,
baseH: number,
biasFraction: number
): PanZoomState {
const centred = recentreOn(nodeCentre, baseCentre, state, true);
return { ...centred, y: centred.y + biasFraction * (baseH / centred.z) };
}

View File

@@ -54,6 +54,31 @@ async function centreOnSelected() {
centreOnId = null;
}
// Below the md breakpoint the side panel is replaced by a bottom sheet that
// overlays the lower ~60dvh of the canvas. On a phone we therefore auto-centre
// the tapped person into the band above the sheet (#703 AC8); on desktop the
// panel is a flex sibling that never covers the tree, so no centring is needed.
const MOBILE_QUERY = '(max-width: 767px)';
/** How far above the viewBox centre to lift the tapped anchor on mobile. */
const MOBILE_CENTRE_BIAS = 0.3;
let isMobile = $state(
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_QUERY).matches
: false
);
$effect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mq = window.matchMedia(MOBILE_QUERY);
const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
async function selectPerson(id: string) {
selectedId = id;
if (isMobile) await centreOnSelected();
}
let cancelAnimation = () => {};
function fitToScreen() {
cancelAnimation();
@@ -140,10 +165,11 @@ $effect(() => {
selectedId={selectedId}
panZoom={view}
centreOnId={centreOnId}
centreBiasFraction={isMobile ? MOBILE_CENTRE_BIAS : 0}
anchorTopLeft={!page.url.searchParams.has('z')}
onPanZoom={(v) => (view = v)}
onActivity={() => (canvasActivity = true)}
onSelect={(id) => (selectedId = id)}
onSelect={selectPerson}
/>
<StammbaumAffordance dismissed={canvasActivity} />
<StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} />