Compare commits
7 Commits
3b594c0b0b
...
e5784caa9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5784caa9d | ||
|
|
4583ee2c4d | ||
|
|
0a7b4fa265 | ||
|
|
a3858b6c80 | ||
|
|
9f5d7b8570 | ||
|
|
f6da95014e | ||
|
|
7a655ce6f4 |
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
117
frontend/src/lib/person/genealogy/layout/highlightLineage.ts
Normal file
117
frontend/src/lib/person/genealogy/layout/highlightLineage.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user