feat(stammbaum): tree visual polish + parent-midpoint layout

Aligns the SVG tree with docs/specs/stammbaum-tree-spec.html:

- Node outline: var(--c-primary) at stroke-width=1.5 (was the much
  paler --c-line at 1) and selected text uses var(--c-primary-fg)
  so it remains readable on the dark/light primary fill
- Spouse line and parent-child line now share the same stroke style;
  spouse keeps the midpoint dot (radius bumped to 4.5 per spec)
- When two parents are connected by SPOUSE_OF, draw a single shared
  parent-pair → child line from the spouse midpoint instead of two
  diverging lines
- ViewBox: enforces a 1200×800 minimum and centers the content so a
  single node no longer scales up to fill the whole canvas in the
  top-left
- Children are positioned at the average of their parents' x and
  packed left-to-right per row, keeping connectors close to vertical

Adds component tests for the centring, the shared parent-pair link
(verified vertical), and the fallback to two lines when parents are
not spouses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 21:44:19 +02:00
committed by marcel
parent a021355072
commit c40cc05f68
2 changed files with 362 additions and 30 deletions

View File

@@ -20,16 +20,29 @@ const NODE_W = 160;
const NODE_H = 56;
const COL_GAP = 40;
const ROW_GAP = 80;
const VIEWBOX_PAD = 80;
// Minimum viewBox dimensions — keeps a single node from being scaled up
// to fill the entire canvas. Roughly matches a typical desktop content area.
const MIN_VIEWBOX_W = 1200;
const MIN_VIEWBOX_H = 800;
type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
width: number;
height: number;
viewX: number;
viewY: number;
viewW: number;
viewH: number;
};
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
const viewBox = $derived(`0 0 ${layout.width / zoom} ${layout.height / zoom}`);
const viewBox = $derived.by(() => {
const w = layout.viewW / zoom;
const h = layout.viewH / zoom;
const cx = layout.viewX + layout.viewW / 2;
const cy = layout.viewY + layout.viewH / 2;
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
@@ -95,21 +108,69 @@ function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): La
});
}
// Position roots left-to-right; for every later generation, place each
// child below the midpoint of its parents and then pack the row left-to-
// right with a minimum gap. Keeps parent → child connectors close to
// vertical instead of fanning out diagonally.
const positions = new Map<string, { x: number; y: number }>();
let maxRowWidth = 0;
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (const g of sortedGens) {
for (let gi = 0; gi < sortedGens.length; gi++) {
const g = sortedGens[gi];
const ids = generations.get(g)!;
ids.forEach((id, idx) => {
positions.set(id, {
x: idx * (NODE_W + COL_GAP),
y: g * (NODE_H + ROW_GAP)
const y = g * (NODE_H + ROW_GAP);
if (gi === 0) {
ids.forEach((id, idx) => {
positions.set(id, { x: idx * (NODE_W + COL_GAP), y });
});
});
maxRowWidth = Math.max(maxRowWidth, ids.length * (NODE_W + COL_GAP));
continue;
}
const preferredX = new Map<string, number>();
for (const id of ids) {
const parentXs: number[] = [];
for (const parentId of childToParents.get(id) ?? []) {
const p = positions.get(parentId);
if (p) parentXs.push(p.x);
}
preferredX.set(
id,
parentXs.length > 0 ? parentXs.reduce((a, b) => a + b, 0) / parentXs.length : 0
);
}
const ordered = [...ids].sort((a, b) => (preferredX.get(a) ?? 0) - (preferredX.get(b) ?? 0));
let cursorX = -Infinity;
for (const id of ordered) {
const x = Math.max(preferredX.get(id) ?? 0, cursorX);
positions.set(id, { x, y });
cursorX = x + NODE_W + COL_GAP;
}
}
const height = (sortedGens.length || 1) * (NODE_H + ROW_GAP);
return { positions, generations, width: maxRowWidth + 80, height: height + 80 };
// Bounding box around the actual content, then expanded to MIN dimensions
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
// is centered on the content.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of positions.values()) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x + NODE_W);
maxY = Math.max(maxY, p.y + NODE_H);
}
if (positions.size === 0) {
minX = 0;
minY = 0;
maxX = 0;
maxY = 0;
}
const contentW = maxX - minX;
const contentH = maxY - minY;
const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
const viewX = minX + contentW / 2 - viewW / 2;
const viewY = minY + contentH / 2 - viewH / 2;
return { positions, generations, viewX, viewY, viewW, viewH };
}
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
@@ -133,6 +194,58 @@ function handleNodeKey(event: KeyboardEvent, id: string) {
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
function pairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
type ParentLinks = {
shared: { key: string; parentA: string; parentB: string; childId: string }[];
single: { key: string; parentId: string; childId: string }[];
};
const parentLinks = $derived.by<ParentLinks>(() => {
const spousePairs = new Set<string>();
for (const e of spouseEdges) {
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
}
const childToParents = new Map<string, string[]>();
for (const e of parentEdges) {
const list = childToParents.get(e.relatedPersonId) ?? [];
list.push(e.personId);
childToParents.set(e.relatedPersonId, list);
}
const shared: ParentLinks['shared'] = [];
const single: ParentLinks['single'] = [];
for (const [childId, parents] of childToParents) {
const consumed = new Set<string>();
for (let i = 0; i < parents.length; i++) {
if (consumed.has(parents[i])) continue;
for (let j = i + 1; j < parents.length; j++) {
if (consumed.has(parents[j])) continue;
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
shared.push({
key: `${pairKey(parents[i], parents[j])}->${childId}`,
parentA: parents[i],
parentB: parents[j],
childId
});
consumed.add(parents[i]);
consumed.add(parents[j]);
break;
}
}
}
for (const parentId of parents) {
if (consumed.has(parentId)) continue;
single.push({ key: `${parentId}->${childId}`, parentId, childId });
}
}
return { shared, single };
});
</script>
<svg
@@ -142,17 +255,34 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
aria-label="Stammbaum"
class="block h-full w-full"
>
<!-- Parent → child connectors -->
{#each parentEdges as e (e.id)}
{@const parentCenter = nodeCenter(e.personId)}
{@const childCenter = nodeCenter(e.relatedPersonId)}
<!-- Shared parent-pair → child connectors (drawn from spouse midpoint) -->
{#each parentLinks.shared as link (link.key)}
{@const aCenter = nodeCenter(link.parentA)}
{@const bCenter = nodeCenter(link.parentB)}
{@const childCenter = nodeCenter(link.childId)}
{#if aCenter && bCenter && childCenter}
<line
x1={(aCenter.x + bCenter.x) / 2}
y1={(aCenter.y + bCenter.y) / 2}
x2={childCenter.x}
y2={childCenter.y - NODE_H / 2}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{/each}
<!-- Single-parent → child connectors -->
{#each parentLinks.single as link (link.key)}
{@const parentCenter = nodeCenter(link.parentId)}
{@const childCenter = nodeCenter(link.childId)}
{#if parentCenter && childCenter}
<line
x1={parentCenter.x}
y1={parentCenter.y + NODE_H / 2}
x2={childCenter.x}
y2={childCenter.y - NODE_H / 2}
stroke="var(--c-line, #d4d4d4)"
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
@@ -168,15 +298,15 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
y1={aCenter.y}
x2={bCenter.x}
y2={bCenter.y}
stroke="var(--c-accent, #00c7b1)"
stroke-width="2"
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="3"
fill="var(--c-accent, #00c7b1)"
r="4.5"
fill="var(--c-primary)"
/>
{/if}
{/each}
@@ -202,12 +332,12 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
width={NODE_W}
height={NODE_H}
rx="4"
fill={isSelected ? 'var(--c-primary, #002850)' : 'var(--c-surface, white)'}
stroke="var(--c-line, #d4d4d4)"
stroke-width="1"
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if isSelected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent, #00c7b1)" />
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
@@ -215,7 +345,7 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
text-anchor="middle"
font-family="serif"
font-size="14"
fill={isSelected ? 'white' : 'var(--c-ink, #002850)'}
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
@@ -226,9 +356,8 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
text-anchor="middle"
font-family="sans-serif"
font-size="10"
fill={isSelected
? 'rgba(255,255,255,0.7)'
: 'var(--c-ink-3, #6b7280)'}
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={isSelected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>

View File

@@ -0,0 +1,203 @@
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';
function parseViewBox(svg: SVGElement): [number, number, number, number] {
const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number);
return [parts[0], parts[1], parts[2], parts[3]];
}
describe('StammbaumTree viewBox', () => {
it('uses the minimum size and centers a single node', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const svg = document.querySelector('svg')!;
const [x, y, w, h] = parseViewBox(svg);
// Single 160x56 node fits inside the 1200x800 minimum viewBox.
expect(w).toBe(1200);
expect(h).toBe(800);
// Node sits at content (0,0)(160,56). Its center should be the
// viewBox center → x + w/2 ≈ 80, y + h/2 ≈ 28.
expect(x + w / 2).toBeCloseTo(80, 5);
expect(y + h / 2).toBeCloseTo(28, 5);
});
it('draws one shared line from spouse midpoint when both parents share a child', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD = '00000000-0000-0000-0000-00000000000c';
render(StammbaumTree, {
nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
{ id: CHILD, displayName: 'Hans', familyMember: true }
],
edges: [
{
id: 'sp',
personId: PARENT_A,
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1',
personId: PARENT_A,
relatedPersonId: CHILD,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2',
personId: PARENT_B,
relatedPersonId: CHILD,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = Array.from(document.querySelectorAll('svg line'));
// Parent-child lines: count anything that's not the spouse-pair link.
const parentLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2'));
expect(parentLines).toHaveLength(1);
});
it('positions a single child at the midpoint of its two parents', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD = '00000000-0000-0000-0000-00000000000c';
render(StammbaumTree, {
nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
{ id: CHILD, displayName: 'Hans', familyMember: true }
],
edges: [
{
id: 'sp',
personId: PARENT_A,
relatedPersonId: PARENT_B,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF'
},
{
id: 'p1',
personId: PARENT_A,
relatedPersonId: CHILD,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2',
personId: PARENT_B,
relatedPersonId: CHILD,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
// The shared parent-child line goes from the spouse midpoint to the
// top of the child node. With centring, x1 must equal x2 → vertical.
const lines = Array.from(document.querySelectorAll('svg line'));
const slopedLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2'));
expect(slopedLines).toHaveLength(1);
const link = slopedLines[0];
expect(link.getAttribute('x1')).toEqual(link.getAttribute('x2'));
});
it('falls back to two separate lines when both parents are not spouses', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD = '00000000-0000-0000-0000-00000000000c';
render(StammbaumTree, {
nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
{ id: CHILD, displayName: 'Hans', familyMember: true }
],
edges: [
{
id: 'p1',
personId: PARENT_A,
relatedPersonId: CHILD,
personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
},
{
id: 'p2',
personId: PARENT_B,
relatedPersonId: CHILD,
personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const lines = Array.from(document.querySelectorAll('svg line'));
const parentLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2'));
expect(parentLines).toHaveLength(2);
});
it('centers two spouse nodes within the minimum viewBox', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [
{
id: 'e1',
personId: ID_A,
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const svg = document.querySelector('svg')!;
const [x, y, w, h] = parseViewBox(svg);
expect(w).toBe(1200);
expect(h).toBe(800);
// Two nodes side by side: positions (0,0) and (200,0). Right edge
// at 200+160 = 360. Content center x = 180, y = 28.
expect(x + w / 2).toBeCloseTo(180, 5);
expect(y + h / 2).toBeCloseTo(28, 5);
});
});