feat(stammbaum): highlight the selected person's bloodline (#703) #704
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
119
frontend/src/lib/person/genealogy/layout/highlightLineage.ts
Normal file
119
frontend/src/lib/person/genealogy/layout/highlightLineage.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user