Compare commits

...

13 Commits

Author SHA1 Message Date
Marcel
4ebebe1e07 test(stammbaum): assert AC8 recentre via viewBox, not replaceState (#703)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m44s
CI / fail2ban Regex (push) Successful in 45s
CI / Semgrep Security Scan (push) Successful in 20s
CI / Compose Bucket Idempotency (push) Successful in 1m0s
The desktop AC8 test flaked in CI: it asserted replaceState was never
called after a tap, but the mount-time URL mirror fired late with the
unchanged default view (cx=0&cy=0&z=1), tripping the assertion. Assert on
the rendered viewBox instead — a pure function of the view state — so a
recentre shows as a shifted origin and a desktop tap leaves it identical,
with no dependence on the noisy mirror-effect timing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:44:19 +02:00
Marcel
81224829a2 test(stammbaum): prove the AC8 mobile-centre wiring at the route layer (#703)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m38s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m36s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Sara/Elicit noted AC8 was proven only as recentreAbove geometry, never as
wired behaviour. Add route-level tests that mock window.matchMedia: a tap
recentres the canvas (mirror effect re-fires) when the mobile breakpoint
matches, and leaves the view untouched on desktop where the side panel is a
flex sibling that never overlaps the canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:21:24 +02:00
Marcel
7cc2ddc6ad refactor(stammbaum): carry child id on the connector centre object (#703)
The shared parent-pair child loop read group.childIds[i] while iterating
the filtered childCenters, so a child without a position would desync the
id from the centre — and that index now also drives the active-connector
lookup. Ride the id on the mapped {id,x,y} centre so the two never drift;
a positionless child drops out of both together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:17:34 +02:00
Marcel
da3067150d test(stammbaum): assert connector dimming at the render layer (#703 AC5)
Sara/Elicit flagged that AC5 was proven only at the isConnectorActive
predicate level. Add render-layer assertions: no connector group carries a
dim opacity when nothing is selected, and selecting Vater dims exactly the
vertical feeding the collateral child Tante. Exercises the shared
parent-pair per-child <g opacity> wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:15:54 +02:00
Marcel
10249c33be fix(stammbaum): raise dimmed opacity to 0.45 and bind tests to the constant (#703)
Bump DIMMED_OPACITY 0.4 -> 0.45 so dimmed outlines/labels stay legible
against bg-surface in both themes (dark mode dims already-light mint, the
riskier case). Import the constant into StammbaumTree.svelte.test.ts so the
node-opacity assertions track it instead of a hard-coded '0.4'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:13:49 +02:00
Marcel
9c12f62345 fix(stammbaum): keep dimmed nodes opaque so connectors do not bleed through (#703)
Group opacity on the node <g> made the whole node translucent — including
its card fill — so the connector lines drawn beneath a dimmed node showed
through it. Render the card fill at full strength outside the dim group and
move the lineage focus+dim onto an inner content group (outline + labels)
only. The focus ring also leaves the dim group, so a dimmed-but-focused
node keeps a full-strength ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:12:39 +02:00
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
11 changed files with 803 additions and 83 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);
@@ -94,8 +106,11 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const aCenter = nodeCenter(group.parentA)}
{@const bCenter = nodeCenter(group.parentB)}
{@const childCenters = group.childIds
.map((id) => nodeCenter(id))
.filter((c): c is { x: number; y: number } => c !== null)}
.map((id) => {
const c = nodeCenter(id);
return c ? { id, x: c.x, y: c.y } : null;
})
.filter((c): c is { id: string; x: number; y: number } => c !== null)}
{#if aCenter && bCenter && childCenters.length > 0}
{@const midX = (aCenter.x + bCenter.x) / 2}
{@const parentBottomY = aCenter.y + NODE_H / 2}
@@ -104,26 +119,46 @@ 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 (cc.id)}
<!-- 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. The child
id rides on the centre object, so it never desyncs from the filtered
centres (a child without a position drops out of both together). -->
{@const childActive =
isConnectorActive(group.parentA, cc.id) && isConnectorActive(group.parentB, cc.id)}
<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 +171,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 +208,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 node's outline + labels 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).
@@ -53,38 +56,62 @@ const datesLabel = $derived(
stroke-width="2"
/>
{/if}
<!-- Opaque card fill — full strength even when dimmed, so the connectors
drawn beneath the node never bleed through. The lineage dim lives on the
content group below; group opacity here would make the fill translucent. -->
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if selected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<!-- Outline + labels carry the lineage focus+dim; the box stays opaque. -->
<g class="lineage-fade" opacity={dimmed ? DIMMED_OPACITY : undefined}>
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill="none"
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if selected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={selected ? 0.75 : 1}
font-family="serif"
font-size="16"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
{node.displayName}
</text>
{/if}
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={selected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
</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

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom';
import { DIMMED_OPACITY } from './layout/highlightLineage';
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
@@ -894,3 +895,194 @@ 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')
];
// The lineage dim lives on the inner content group (outline + labels); the
// card fill renders outside it at full strength so connectors beneath never
// bleed through a dimmed node.
function nodeOpacity(displayName: string): string | null {
const content = nodeContentGroup(displayName);
return content.getAttribute('opacity');
}
function nodeContentGroup(displayName: string): Element {
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
if (!g) throw new Error(`No node group rendered for ${displayName}`);
const content = g.querySelector('g.lineage-fade');
if (!content) throw new Error(`No content group rendered for ${displayName}`);
return content;
}
/** The opaque card fill is a direct-child rect of the node, outside the dim group. */
function cardFillIsOpaque(displayName: string): boolean {
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
if (!g) throw new Error(`No node group rendered for ${displayName}`);
const fill = g.querySelector(':scope > rect');
if (!fill) throw new Error(`No card-fill rect rendered for ${displayName}`);
return fill.getAttribute('opacity') === null && fill.getAttribute('fill-opacity') === null;
}
const DIM = String(DIMMED_OPACITY);
// Connector groups render as direct <svg> children; the node content groups
// (also .lineage-fade) are nested inside g[role="button"], so the child
// combinator scopes cleanly to connectors.
function dimmedConnectorCount(): number {
return Array.from(document.querySelectorAll('svg > g.lineage-fade')).filter(
(g) => g.getAttribute('opacity') === DIM
).length;
}
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 — but its card fill stays opaque so the
// connectors drawn beneath it do not show through (the dim is on the
// outline + labels only).
expect(nodeOpacity('Tante')).toBe(DIM);
expect(cardFillIsOpaque('Tante')).toBe(true);
});
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();
});
it('leaves every connector at full strength when nothing is selected (AC5)', () => {
render(StammbaumTree, {
nodes: NODES,
edges: EDGES,
selectedId: null,
panZoom: { x: 0, y: 0, z: 1 },
showGutter: false,
onSelect: () => {}
});
expect(dimmedConnectorCount()).toBe(0);
});
it('dims exactly the connector feeding the collateral child at the render layer (AC5)', () => {
render(StammbaumTree, {
nodes: NODES,
edges: EDGES,
selectedId: 'vater',
panZoom: { x: 0, y: 0, z: 1 },
showGutter: false,
onSelect: () => {}
});
// Every connector among the bloodline + spouses stays full strength; only
// the vertical joining the active parent pair (Grossvater+Grossmutter) to
// the dimmed collateral child (Tante) renders at DIMMED_OPACITY. This proves
// the <g opacity> render wiring — not just the isConnectorActive predicate —
// and exercises the shared parent-pair per-child path.
expect(dimmedConnectorCount()).toBe(1);
});
});

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,119 @@
/**
* 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 node outlines/labels and connectors. 0.45 keeps names
* legible against bg-surface in both themes (dark mode dims already-light mint, the
* riskier case) while clearly de-emphasised, and works as a lightness cue so the cue
* does not rely on hue (WCAG 1.4.1 / NFR-A11Y-001). The dim is applied to the node's
* outline + labels only — the card fill stays opaque, see StammbaumNode.svelte.
*/
export const DIMMED_OPACITY = 0.45;
/** 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} />

View File

@@ -24,7 +24,28 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
afterEach(cleanup);
// The page reads window.matchMedia('(max-width: 767px)').matches at init to
// decide whether to centre a tapped person above the bottom sheet (#703 AC8).
// Make the mock query-aware so only the mobile breakpoint flips; other media
// queries (e.g. prefers-reduced-motion) keep their benign default.
const originalMatchMedia = window.matchMedia;
function mockMatchMedia(isMobile: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes('max-width') ? isMobile : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false
})) as unknown as typeof window.matchMedia;
}
afterEach(() => {
cleanup();
window.matchMedia = originalMatchMedia;
});
async function loadComponent() {
return (await import('./+page.svelte')).default;
@@ -35,6 +56,12 @@ const sampleNodes = [
{ id: 'p-2', firstName: 'Bert', lastName: 'Schmidt', displayName: 'Bert Schmidt' }
];
// Typed family nodes for the AC8 tests — familyMember is required on the DTO.
const familyNodes = [
{ id: 'p-1', displayName: 'Anna Schmidt', familyMember: true },
{ id: 'p-2', displayName: 'Bert Schmidt', familyMember: true }
];
describe('stammbaum page', () => {
it('shows the empty state when there are no family nodes', async () => {
mockPage.url = new URL('http://localhost/stammbaum');
@@ -125,4 +152,58 @@ describe('stammbaum page', () => {
expect(url.searchParams.has('cx')).toBe(true);
expect(url.searchParams.has('cy')).toBe(true);
});
// AC8 — the tapped person must clear the bottom sheet on a phone, but the
// desktop side panel is a flex sibling that never overlaps the canvas, so no
// centring should fire there. These tests prove the matchMedia gate around
// selectPerson, not just the recentreAbove geometry (covered in panZoom.test).
// We assert on the rendered viewBox — a pure function of the view state — so a
// recentre is observed as a shifted origin, with no dependence on the noisy
// mount-time URL-mirror timing.
function svgViewBox(): string {
const svg = document.querySelector('svg[aria-label="Stammbaum"]');
if (!svg) throw new Error('No Stammbaum svg rendered');
return svg.getAttribute('viewBox') ?? '';
}
it('recentres the tapped person when matchMedia reports mobile (#703 AC8)', async () => {
mockMatchMedia(true);
mockPage.url = new URL('http://localhost/stammbaum');
const Stammbaum = await loadComponent();
render(Stammbaum, {
props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
});
const before = await vi.waitFor(() => {
const vb = svgViewBox();
expect(vb).toBeTruthy();
return vb;
});
await page.getByRole('button', { name: 'Anna Schmidt' }).click();
// The mobile tap recentres the canvas, lifting the anchor above the sheet,
// so the viewBox origin shifts.
await vi.waitFor(() => expect(svgViewBox()).not.toBe(before));
});
it('does not recentre on tap when matchMedia reports desktop (#703 AC8)', async () => {
mockMatchMedia(false);
mockPage.url = new URL('http://localhost/stammbaum');
const Stammbaum = await loadComponent();
render(Stammbaum, {
props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
});
const before = await vi.waitFor(() => {
const vb = svgViewBox();
expect(vb).toBeTruthy();
return vb;
});
await page.getByRole('button', { name: 'Anna Schmidt' }).click();
// The tap registers — the desktop side panel opens — but no recentre fires,
// so the viewBox is unchanged.
await expect.element(page.getByRole('complementary')).toBeVisible();
expect(svgViewBox()).toBe(before);
});
});