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>
|
||||
|
||||
Reference in New Issue
Block a user