test+feat(stammbaum): merge sibling blocks across same-rank spouse edge (#361)

AC2 — intra-family marriage. When two parented persons at the same
imported generation are spouses but live in separate sibling blocks
(each under their own parent), the block-packer used to leave them
split, drawing a long spouse line that crossed through any intervening
siblings. The new step 3.5 detects that case, moves the focal members
to the join boundary (A's spouse rightmost in A's block, B's spouse
leftmost in B's), and concatenates B's members onto A's; the combined
block centres on the average of the two parents' midpoints.

Latent against today's data (no intra-family marriage in the canonical
fixture); covered by a synthetic two-family scenario in
buildLayout.test.ts. Packer growth stays comfortably under Markus's
80-LoC extraction threshold, so packBlocks.ts is not yet warranted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 20:18:23 +02:00
parent 557f37be54
commit 652100a9c2
2 changed files with 84 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { buildLayout, NODE_H, ROW_GAP } from './buildLayout'; import { buildLayout, NODE_W, NODE_H, COL_GAP, ROW_GAP } from './buildLayout';
import canonicalFixture from '../__fixtures__/stammbaum.json'; import canonicalFixture from '../__fixtures__/stammbaum.json';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -234,6 +234,40 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
expect(x1925).toBeLessThan(xNull); expect(x1925).toBeLessThan(xNull);
}); });
it('intra_family_marriage_places_both_spouses_adjacent_across_sibling_blocks', () => {
// AC2 (#361). Two parented persons at the same imported generation,
// each in a separate sibling block under their own parent, marry each
// other. Before the fix the block-packer left them split, drawing a
// long spouse line across an intervening sibling. After the fix the
// two blocks merge with the spouses sitting on the join boundary.
const A1 = '00000000-0000-0000-0000-0000000000d1';
const B1 = '00000000-0000-0000-0000-0000000000d2';
const A2 = '00000000-0000-0000-0000-0000000000d3';
const A3 = '00000000-0000-0000-0000-0000000000d4';
const B2 = '00000000-0000-0000-0000-0000000000d5';
const layout = buildLayout(
[
node(A1, 'A1', 0),
node(B1, 'B1', 0),
node(A2, 'A2', 1),
node(A3, 'A3', 1),
node(B2, 'B2', 1)
],
[
parentEdge(A1, A2, 'p1'),
parentEdge(A1, A3, 'p2'),
parentEdge(B1, B2, 'p3'),
spouseEdge(A2, B2, 'sp')
]
);
const posA2 = layout.positions.get(A2)!;
const posB2 = layout.positions.get(B2)!;
expect(posA2.y).toBe(posB2.y);
expect(Math.abs(posA2.x - posB2.x)).toBe(NODE_W + COL_GAP);
});
it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => { it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => {
// Real-data assertion: 0 of 28 SPOUSE_OF rows in the canonical fixture // Real-data assertion: 0 of 28 SPOUSE_OF rows in the canonical fixture
// have fromYear populated, so the sort collapses to alphabetical by // have fromYear populated, so the sort collapses to alphabetical by

View File

@@ -277,6 +277,41 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
} }
for (const key of removed) blocksByKey.delete(key); for (const key of removed) blocksByKey.delete(key);
// Step 3.5 (#361 AC2): Intra-family marriage. Two parented members at
// the same rank in different sibling blocks who marry each other are
// merged into one block — A's siblings on the left, the spouses on
// the join boundary, B's siblings on the right — so the spouse line
// stays short and no other node sits between them.
const mergedKeys = new Set<string>();
for (const [aKey, aBlock] of blocksByKey) {
if (aKey.startsWith('__loose__')) continue;
if (mergedKeys.has(aKey)) continue;
for (const aMember of aBlock.members) {
if (!aMember.parented) continue;
const partners = spousePairs.get(aMember.id);
if (!partners) continue;
for (const partnerId of partners) {
const partnerLookup = memberLookup.get(partnerId);
if (!partnerLookup || !partnerLookup.parented) continue;
if (partnerLookup.key === aKey) continue;
if (partnerLookup.key.startsWith('__loose__')) continue;
if (mergedKeys.has(partnerLookup.key)) continue;
const bBlock = blocksByKey.get(partnerLookup.key)!;
// A's spouse to the right-most slot in A's block; B's spouse
// to the left-most slot in B's block; then concatenate.
moveMemberToEnd(aBlock.members, aMember.id);
moveMemberToStart(bBlock.members, partnerId);
for (const m of bBlock.members) {
memberLookup.set(m.id, { key: aKey, parented: m.parented });
}
aBlock.members.push(...bBlock.members);
aBlock.center = (aBlock.center + bBlock.center) / 2;
mergedKeys.add(partnerLookup.key);
}
}
}
for (const key of mergedKeys) blocksByKey.delete(key);
// Step 4: centre each block on its anchor (parented members) and pack. // Step 4: centre each block on its anchor (parented members) and pack.
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center); const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
let cursorRight = -Infinity; let cursorRight = -Infinity;
@@ -346,3 +381,17 @@ function mapAddToSet<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
function spousePairKey(a: string, b: string): string { function spousePairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`; return a < b ? `${a}|${b}` : `${b}|${a}`;
} }
function moveMemberToEnd<T extends { id: string }>(members: T[], id: string) {
const idx = members.findIndex((m) => m.id === id);
if (idx < 0 || idx === members.length - 1) return;
const [m] = members.splice(idx, 1);
members.push(m);
}
function moveMemberToStart<T extends { id: string }>(members: T[], id: string) {
const idx = members.findIndex((m) => m.id === id);
if (idx <= 0) return;
const [m] = members.splice(idx, 1);
members.unshift(m);
}