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 NODE_H = 56;
|
||||||
const COL_GAP = 40;
|
const COL_GAP = 40;
|
||||||
const ROW_GAP = 80;
|
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 = {
|
type Layout = {
|
||||||
positions: Map<string, { x: number; y: number }>;
|
positions: Map<string, { x: number; y: number }>;
|
||||||
generations: Map<number, string[]>;
|
generations: Map<number, string[]>;
|
||||||
width: number;
|
viewX: number;
|
||||||
height: number;
|
viewY: number;
|
||||||
|
viewW: number;
|
||||||
|
viewH: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
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 {
|
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
||||||
const parentToChildren = new Map<string, string[]>();
|
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 }>();
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
let maxRowWidth = 0;
|
|
||||||
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
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)!;
|
const ids = generations.get(g)!;
|
||||||
ids.forEach((id, idx) => {
|
const y = g * (NODE_H + ROW_GAP);
|
||||||
positions.set(id, {
|
if (gi === 0) {
|
||||||
x: idx * (NODE_W + COL_GAP),
|
ids.forEach((id, idx) => {
|
||||||
y: g * (NODE_H + ROW_GAP)
|
positions.set(id, { x: idx * (NODE_W + COL_GAP), y });
|
||||||
});
|
});
|
||||||
});
|
continue;
|
||||||
maxRowWidth = Math.max(maxRowWidth, ids.length * (NODE_W + COL_GAP));
|
}
|
||||||
|
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) {
|
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 parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
|
||||||
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_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>
|
</script>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
@@ -142,17 +255,34 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
|
|||||||
aria-label="Stammbaum"
|
aria-label="Stammbaum"
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
>
|
>
|
||||||
<!-- Parent → child connectors -->
|
<!-- Shared parent-pair → child connectors (drawn from spouse midpoint) -->
|
||||||
{#each parentEdges as e (e.id)}
|
{#each parentLinks.shared as link (link.key)}
|
||||||
{@const parentCenter = nodeCenter(e.personId)}
|
{@const aCenter = nodeCenter(link.parentA)}
|
||||||
{@const childCenter = nodeCenter(e.relatedPersonId)}
|
{@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}
|
{#if parentCenter && childCenter}
|
||||||
<line
|
<line
|
||||||
x1={parentCenter.x}
|
x1={parentCenter.x}
|
||||||
y1={parentCenter.y + NODE_H / 2}
|
y1={parentCenter.y + NODE_H / 2}
|
||||||
x2={childCenter.x}
|
x2={childCenter.x}
|
||||||
y2={childCenter.y - NODE_H / 2}
|
y2={childCenter.y - NODE_H / 2}
|
||||||
stroke="var(--c-line, #d4d4d4)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -168,15 +298,15 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
|
|||||||
y1={aCenter.y}
|
y1={aCenter.y}
|
||||||
x2={bCenter.x}
|
x2={bCenter.x}
|
||||||
y2={bCenter.y}
|
y2={bCenter.y}
|
||||||
stroke="var(--c-accent, #00c7b1)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="2"
|
stroke-width="1.5"
|
||||||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={(aCenter.x + bCenter.x) / 2}
|
cx={(aCenter.x + bCenter.x) / 2}
|
||||||
cy={(aCenter.y + bCenter.y) / 2}
|
cy={(aCenter.y + bCenter.y) / 2}
|
||||||
r="3"
|
r="4.5"
|
||||||
fill="var(--c-accent, #00c7b1)"
|
fill="var(--c-primary)"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -202,12 +332,12 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
|
|||||||
width={NODE_W}
|
width={NODE_W}
|
||||||
height={NODE_H}
|
height={NODE_H}
|
||||||
rx="4"
|
rx="4"
|
||||||
fill={isSelected ? 'var(--c-primary, #002850)' : 'var(--c-surface, white)'}
|
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||||||
stroke="var(--c-line, #d4d4d4)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
{#if isSelected}
|
{#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}
|
{/if}
|
||||||
<text
|
<text
|
||||||
x={NODE_W / 2}
|
x={NODE_W / 2}
|
||||||
@@ -215,7 +345,7 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
|
|||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
font-family="serif"
|
font-family="serif"
|
||||||
font-size="14"
|
font-size="14"
|
||||||
fill={isSelected ? 'white' : 'var(--c-ink, #002850)'}
|
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||||||
>
|
>
|
||||||
{node.displayName}
|
{node.displayName}
|
||||||
</text>
|
</text>
|
||||||
@@ -226,9 +356,8 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')
|
|||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
font-family="sans-serif"
|
font-family="sans-serif"
|
||||||
font-size="10"
|
font-size="10"
|
||||||
fill={isSelected
|
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
||||||
? 'rgba(255,255,255,0.7)'
|
opacity={isSelected ? 0.75 : 1}
|
||||||
: 'var(--c-ink-3, #6b7280)'}
|
|
||||||
>
|
>
|
||||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||||
</text>
|
</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