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:
@@ -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>
|
||||
|
||||
203
frontend/src/lib/components/StammbaumTree.svelte.test.ts
Normal file
203
frontend/src/lib/components/StammbaumTree.svelte.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user