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:
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