refactor(stammbaum): extract buildLayout to pure module
Move the layout function out of StammbaumTree.svelte (lines 47-275) into a new pure TypeScript module at frontend/src/lib/person/genealogy/layout/ buildLayout.ts so it can be exercised by direct unit tests. Drops the eslint-disable svelte/prefer-svelte-reactivity blanket; switches the remaining scope-local Maps/Sets in parentLinks to SvelteMap/SvelteSet to satisfy the rule per-call-site. No behaviour change — existing StammbaumTree tests must pass byte-for-byte. Refs #689 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
to a single $derived.by computation; never mutated after layout. */
|
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { buildLayout, NODE_W, NODE_H, type Layout } from '$lib/person/genealogy/layout/buildLayout';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -16,25 +16,6 @@ interface Props {
|
|||||||
|
|
||||||
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
||||||
|
|
||||||
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[]>;
|
|
||||||
viewX: 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.by(() => {
|
const viewBox = $derived.by(() => {
|
||||||
const w = layout.viewW / zoom;
|
const w = layout.viewW / zoom;
|
||||||
@@ -44,242 +25,6 @@ const viewBox = $derived.by(() => {
|
|||||||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
|
||||||
const parentToChildren = new Map<string, string[]>();
|
|
||||||
const childToParents = new Map<string, string[]>();
|
|
||||||
const spousePairs = new Map<string, string>();
|
|
||||||
|
|
||||||
for (const e of allEdges) {
|
|
||||||
switch (e.relationType) {
|
|
||||||
case 'PARENT_OF':
|
|
||||||
mapPush(parentToChildren, e.personId, e.relatedPersonId);
|
|
||||||
mapPush(childToParents, e.relatedPersonId, e.personId);
|
|
||||||
break;
|
|
||||||
case 'SPOUSE_OF':
|
|
||||||
spousePairs.set(e.personId, e.relatedPersonId);
|
|
||||||
spousePairs.set(e.relatedPersonId, e.personId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterative longest-path generation assignment.
|
|
||||||
//
|
|
||||||
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
|
|
||||||
// Then spouses are pulled to share the deeper generation. Pulling a spouse
|
|
||||||
// down can shift their own descendants, so we iterate until stable rather
|
|
||||||
// than running BFS once like the previous implementation (which left
|
|
||||||
// e.g. a child of a "later-pulled" spouse stranded one row too high).
|
|
||||||
const generation = new Map<string, number>();
|
|
||||||
for (const n of allNodes) generation.set(n.id, 0);
|
|
||||||
const maxIters = allNodes.length + 4;
|
|
||||||
for (let it = 0; it < maxIters; it++) {
|
|
||||||
let changed = false;
|
|
||||||
for (const n of allNodes) {
|
|
||||||
const parents = childToParents.get(n.id) ?? [];
|
|
||||||
if (parents.length === 0) continue;
|
|
||||||
let maxParentGen = -1;
|
|
||||||
for (const pid of parents) {
|
|
||||||
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
|
|
||||||
}
|
|
||||||
const newGen = maxParentGen + 1;
|
|
||||||
if ((generation.get(n.id) ?? 0) < newGen) {
|
|
||||||
generation.set(n.id, newGen);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [a, b] of spousePairs) {
|
|
||||||
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
|
|
||||||
if ((generation.get(a) ?? 0) < m) {
|
|
||||||
generation.set(a, m);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if ((generation.get(b) ?? 0) < m) {
|
|
||||||
generation.set(b, m);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!changed) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by generation, then sort within generation by display name.
|
|
||||||
const generations = new Map<number, string[]>();
|
|
||||||
for (const n of allNodes) {
|
|
||||||
const g = generation.get(n.id) ?? 0;
|
|
||||||
if (!generations.has(g)) generations.set(g, []);
|
|
||||||
generations.get(g)!.push(n.id);
|
|
||||||
}
|
|
||||||
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
|
||||||
for (const ids of generations.values()) {
|
|
||||||
ids.sort((a, b) => {
|
|
||||||
const an = byId.get(a)?.displayName ?? '';
|
|
||||||
const bn = byId.get(b)?.displayName ?? '';
|
|
||||||
return an.localeCompare(bn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-generation layout:
|
|
||||||
//
|
|
||||||
// 1. Build sibling-groups (children of the same parent set) — these become
|
|
||||||
// the layout "blocks" that are centred under their parents' midpoint.
|
|
||||||
// 2. Attach loose spouses (people with no parents in the graph but a
|
|
||||||
// spouse who *is* in a sibling group) on the outside of their partner,
|
|
||||||
// so the spouse line stays short and adjacent.
|
|
||||||
// 3. Merge dual-loose spouse pairs into a single 2-person block.
|
|
||||||
// 4. Centre each block such that its *parented* members average sits
|
|
||||||
// exactly under the parent midpoint (keeping all connectors at 90°),
|
|
||||||
// then pack blocks left-to-right.
|
|
||||||
type Block = {
|
|
||||||
members: { id: string; parented: boolean }[];
|
|
||||||
center: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const positions = new Map<string, { x: number; y: number }>();
|
|
||||||
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
|
||||||
|
|
||||||
for (let gi = 0; gi < sortedGens.length; gi++) {
|
|
||||||
const g = sortedGens[gi];
|
|
||||||
const ids = generations.get(g)!;
|
|
||||||
const y = g * (NODE_H + ROW_GAP);
|
|
||||||
|
|
||||||
const blocksByKey = new Map<string, Block>();
|
|
||||||
const memberLookup = new Map<string, { key: string; parented: boolean }>();
|
|
||||||
|
|
||||||
// Step 1: place every node with parents-in-graph into a sibling block.
|
|
||||||
for (const id of ids) {
|
|
||||||
const parents = childToParents.get(id) ?? [];
|
|
||||||
if (parents.length === 0) continue;
|
|
||||||
const blockKey = [...parents].sort().join('|');
|
|
||||||
let block = blocksByKey.get(blockKey);
|
|
||||||
if (!block) {
|
|
||||||
const parentCenters: number[] = [];
|
|
||||||
for (const pid of parents) {
|
|
||||||
const p = positions.get(pid);
|
|
||||||
if (p) parentCenters.push(p.x + NODE_W / 2);
|
|
||||||
}
|
|
||||||
const center =
|
|
||||||
parentCenters.length > 0
|
|
||||||
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
|
|
||||||
: 0;
|
|
||||||
block = { members: [], center };
|
|
||||||
blocksByKey.set(blockKey, block);
|
|
||||||
}
|
|
||||||
block.members.push({ id, parented: true });
|
|
||||||
memberLookup.set(id, { key: blockKey, parented: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort members within each sibling block alphabetically.
|
|
||||||
for (const block of blocksByKey.values()) {
|
|
||||||
block.members.sort((a, b) =>
|
|
||||||
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2 + 3: handle loose nodes.
|
|
||||||
for (const id of ids) {
|
|
||||||
if (memberLookup.has(id)) continue;
|
|
||||||
const spouse = spousePairs.get(id);
|
|
||||||
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
|
|
||||||
|
|
||||||
if (spouseLookup && spouseLookup.parented) {
|
|
||||||
// Spouse is parented — attach this loose node next to them on
|
|
||||||
// the outer edge of their sibling block so the marriage line
|
|
||||||
// is short and the sibling order is preserved.
|
|
||||||
const block = blocksByKey.get(spouseLookup.key)!;
|
|
||||||
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
|
|
||||||
const insertOnRight = spouseIdx >= block.members.length / 2;
|
|
||||||
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
|
|
||||||
block.members.splice(insertAt, 0, { id, parented: false });
|
|
||||||
memberLookup.set(id, { key: spouseLookup.key, parented: false });
|
|
||||||
} else {
|
|
||||||
// No usable parented spouse: put in its own loose block. We
|
|
||||||
// merge dual-loose spouse pairs in the next pass.
|
|
||||||
const blockKey = `__loose__${id}`;
|
|
||||||
blocksByKey.set(blockKey, {
|
|
||||||
members: [{ id, parented: false }],
|
|
||||||
center: 0
|
|
||||||
});
|
|
||||||
memberLookup.set(id, { key: blockKey, parented: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge dual-loose spouse blocks into a single 2-person block.
|
|
||||||
const removed = new Set<string>();
|
|
||||||
for (const [key, block] of blocksByKey) {
|
|
||||||
if (!key.startsWith('__loose__')) continue;
|
|
||||||
if (removed.has(key)) continue;
|
|
||||||
const member = block.members[0];
|
|
||||||
const spouse = spousePairs.get(member.id);
|
|
||||||
if (!spouse) continue;
|
|
||||||
const spouseLookup = memberLookup.get(spouse);
|
|
||||||
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
|
|
||||||
if (spouseLookup.key === key) continue;
|
|
||||||
if (!spouseLookup.key.startsWith('__loose__')) continue;
|
|
||||||
const otherBlock = blocksByKey.get(spouseLookup.key)!;
|
|
||||||
block.members.push(...otherBlock.members);
|
|
||||||
removed.add(spouseLookup.key);
|
|
||||||
}
|
|
||||||
for (const key of removed) blocksByKey.delete(key);
|
|
||||||
|
|
||||||
// Step 4: centre each block on its anchor (parented members) and pack.
|
|
||||||
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
|
||||||
let cursorRight = -Infinity;
|
|
||||||
for (const block of ordered) {
|
|
||||||
const n = block.members.length;
|
|
||||||
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
|
|
||||||
const anchorIndices: number[] = [];
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
if (block.members[i].parented) anchorIndices.push(i);
|
|
||||||
}
|
|
||||||
const avgAnchorIdx =
|
|
||||||
anchorIndices.length > 0
|
|
||||||
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
|
|
||||||
: (n - 1) / 2;
|
|
||||||
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
|
|
||||||
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
positions.set(block.members[i].id, {
|
|
||||||
x: groupLeft + i * (NODE_W + COL_GAP),
|
|
||||||
y
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cursorRight = groupLeft + groupWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const arr = map.get(key);
|
|
||||||
if (arr) arr.push(value);
|
|
||||||
else map.set(key, [value]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeCenter(id: string): { x: number; y: number } | null {
|
function nodeCenter(id: string): { x: number; y: number } | null {
|
||||||
const p = layout.positions.get(id);
|
const p = layout.positions.get(id);
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
@@ -312,22 +57,25 @@ type ParentLinks = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parentLinks = $derived.by<ParentLinks>(() => {
|
const parentLinks = $derived.by<ParentLinks>(() => {
|
||||||
const spousePairs = new Set<string>();
|
const spousePairs = new SvelteSet<string>();
|
||||||
for (const e of spouseEdges) {
|
for (const e of spouseEdges) {
|
||||||
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const childToParents = new Map<string, string[]>();
|
const childToParents = new SvelteMap<string, string[]>();
|
||||||
for (const e of parentEdges) {
|
for (const e of parentEdges) {
|
||||||
const list = childToParents.get(e.relatedPersonId) ?? [];
|
const list = childToParents.get(e.relatedPersonId) ?? [];
|
||||||
list.push(e.personId);
|
list.push(e.personId);
|
||||||
childToParents.set(e.relatedPersonId, list);
|
childToParents.set(e.relatedPersonId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
|
const sharedMap = new SvelteMap<
|
||||||
|
string,
|
||||||
|
{ parentA: string; parentB: string; childIds: string[] }
|
||||||
|
>();
|
||||||
const single: ParentLinks['single'] = [];
|
const single: ParentLinks['single'] = [];
|
||||||
for (const [childId, parents] of childToParents) {
|
for (const [childId, parents] of childToParents) {
|
||||||
const consumed = new Set<string>();
|
const consumed = new SvelteSet<string>();
|
||||||
for (let i = 0; i < parents.length; i++) {
|
for (let i = 0; i < parents.length; i++) {
|
||||||
if (consumed.has(parents[i])) continue;
|
if (consumed.has(parents[i])) continue;
|
||||||
for (let j = i + 1; j < parents.length; j++) {
|
for (let j = i + 1; j < parents.length; j++) {
|
||||||
|
|||||||
257
frontend/src/lib/person/genealogy/layout/buildLayout.ts
Normal file
257
frontend/src/lib/person/genealogy/layout/buildLayout.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
export const NODE_W = 160;
|
||||||
|
export const NODE_H = 56;
|
||||||
|
export const COL_GAP = 40;
|
||||||
|
export const ROW_GAP = 80;
|
||||||
|
export const VIEWBOX_PAD = 80;
|
||||||
|
export const MIN_VIEWBOX_W = 1200;
|
||||||
|
export const MIN_VIEWBOX_H = 800;
|
||||||
|
|
||||||
|
export type Layout = {
|
||||||
|
positions: Map<string, { x: number; y: number }>;
|
||||||
|
generations: Map<number, string[]>;
|
||||||
|
viewX: number;
|
||||||
|
viewY: number;
|
||||||
|
viewW: number;
|
||||||
|
viewH: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
||||||
|
const parentToChildren = new Map<string, string[]>();
|
||||||
|
const childToParents = new Map<string, string[]>();
|
||||||
|
const spousePairs = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const e of allEdges) {
|
||||||
|
switch (e.relationType) {
|
||||||
|
case 'PARENT_OF':
|
||||||
|
mapPush(parentToChildren, e.personId, e.relatedPersonId);
|
||||||
|
mapPush(childToParents, e.relatedPersonId, e.personId);
|
||||||
|
break;
|
||||||
|
case 'SPOUSE_OF':
|
||||||
|
spousePairs.set(e.personId, e.relatedPersonId);
|
||||||
|
spousePairs.set(e.relatedPersonId, e.personId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterative longest-path generation assignment.
|
||||||
|
//
|
||||||
|
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
|
||||||
|
// Then spouses are pulled to share the deeper generation. Pulling a spouse
|
||||||
|
// down can shift their own descendants, so we iterate until stable rather
|
||||||
|
// than running BFS once like the previous implementation (which left
|
||||||
|
// e.g. a child of a "later-pulled" spouse stranded one row too high).
|
||||||
|
const generation = new Map<string, number>();
|
||||||
|
for (const n of allNodes) generation.set(n.id, 0);
|
||||||
|
const maxIters = allNodes.length + 4;
|
||||||
|
for (let it = 0; it < maxIters; it++) {
|
||||||
|
let changed = false;
|
||||||
|
for (const n of allNodes) {
|
||||||
|
const parents = childToParents.get(n.id) ?? [];
|
||||||
|
if (parents.length === 0) continue;
|
||||||
|
let maxParentGen = -1;
|
||||||
|
for (const pid of parents) {
|
||||||
|
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
|
||||||
|
}
|
||||||
|
const newGen = maxParentGen + 1;
|
||||||
|
if ((generation.get(n.id) ?? 0) < newGen) {
|
||||||
|
generation.set(n.id, newGen);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [a, b] of spousePairs) {
|
||||||
|
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
|
||||||
|
if ((generation.get(a) ?? 0) < m) {
|
||||||
|
generation.set(a, m);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if ((generation.get(b) ?? 0) < m) {
|
||||||
|
generation.set(b, m);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by generation, then sort within generation by display name.
|
||||||
|
const generations = new Map<number, string[]>();
|
||||||
|
for (const n of allNodes) {
|
||||||
|
const g = generation.get(n.id) ?? 0;
|
||||||
|
if (!generations.has(g)) generations.set(g, []);
|
||||||
|
generations.get(g)!.push(n.id);
|
||||||
|
}
|
||||||
|
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
||||||
|
for (const ids of generations.values()) {
|
||||||
|
ids.sort((a, b) => {
|
||||||
|
const an = byId.get(a)?.displayName ?? '';
|
||||||
|
const bn = byId.get(b)?.displayName ?? '';
|
||||||
|
return an.localeCompare(bn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-generation layout:
|
||||||
|
//
|
||||||
|
// 1. Build sibling-groups (children of the same parent set) — these become
|
||||||
|
// the layout "blocks" that are centred under their parents' midpoint.
|
||||||
|
// 2. Attach loose spouses (people with no parents in the graph but a
|
||||||
|
// spouse who *is* in a sibling group) on the outside of their partner,
|
||||||
|
// so the spouse line stays short and adjacent.
|
||||||
|
// 3. Merge dual-loose spouse pairs into a single 2-person block.
|
||||||
|
// 4. Centre each block such that its *parented* members average sits
|
||||||
|
// exactly under the parent midpoint (keeping all connectors at 90°),
|
||||||
|
// then pack blocks left-to-right.
|
||||||
|
type Block = {
|
||||||
|
members: { id: string; parented: boolean }[];
|
||||||
|
center: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (let gi = 0; gi < sortedGens.length; gi++) {
|
||||||
|
const g = sortedGens[gi];
|
||||||
|
const ids = generations.get(g)!;
|
||||||
|
const y = g * (NODE_H + ROW_GAP);
|
||||||
|
|
||||||
|
const blocksByKey = new Map<string, Block>();
|
||||||
|
const memberLookup = new Map<string, { key: string; parented: boolean }>();
|
||||||
|
|
||||||
|
// Step 1: place every node with parents-in-graph into a sibling block.
|
||||||
|
for (const id of ids) {
|
||||||
|
const parents = childToParents.get(id) ?? [];
|
||||||
|
if (parents.length === 0) continue;
|
||||||
|
const blockKey = [...parents].sort().join('|');
|
||||||
|
let block = blocksByKey.get(blockKey);
|
||||||
|
if (!block) {
|
||||||
|
const parentCenters: number[] = [];
|
||||||
|
for (const pid of parents) {
|
||||||
|
const p = positions.get(pid);
|
||||||
|
if (p) parentCenters.push(p.x + NODE_W / 2);
|
||||||
|
}
|
||||||
|
const center =
|
||||||
|
parentCenters.length > 0
|
||||||
|
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
|
||||||
|
: 0;
|
||||||
|
block = { members: [], center };
|
||||||
|
blocksByKey.set(blockKey, block);
|
||||||
|
}
|
||||||
|
block.members.push({ id, parented: true });
|
||||||
|
memberLookup.set(id, { key: blockKey, parented: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort members within each sibling block alphabetically.
|
||||||
|
for (const block of blocksByKey.values()) {
|
||||||
|
block.members.sort((a, b) =>
|
||||||
|
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 + 3: handle loose nodes.
|
||||||
|
for (const id of ids) {
|
||||||
|
if (memberLookup.has(id)) continue;
|
||||||
|
const spouse = spousePairs.get(id);
|
||||||
|
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
|
||||||
|
|
||||||
|
if (spouseLookup && spouseLookup.parented) {
|
||||||
|
// Spouse is parented — attach this loose node next to them on
|
||||||
|
// the outer edge of their sibling block so the marriage line
|
||||||
|
// is short and the sibling order is preserved.
|
||||||
|
const block = blocksByKey.get(spouseLookup.key)!;
|
||||||
|
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
|
||||||
|
const insertOnRight = spouseIdx >= block.members.length / 2;
|
||||||
|
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
|
||||||
|
block.members.splice(insertAt, 0, { id, parented: false });
|
||||||
|
memberLookup.set(id, { key: spouseLookup.key, parented: false });
|
||||||
|
} else {
|
||||||
|
// No usable parented spouse: put in its own loose block. We
|
||||||
|
// merge dual-loose spouse pairs in the next pass.
|
||||||
|
const blockKey = `__loose__${id}`;
|
||||||
|
blocksByKey.set(blockKey, {
|
||||||
|
members: [{ id, parented: false }],
|
||||||
|
center: 0
|
||||||
|
});
|
||||||
|
memberLookup.set(id, { key: blockKey, parented: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge dual-loose spouse blocks into a single 2-person block.
|
||||||
|
const removed = new Set<string>();
|
||||||
|
for (const [key, block] of blocksByKey) {
|
||||||
|
if (!key.startsWith('__loose__')) continue;
|
||||||
|
if (removed.has(key)) continue;
|
||||||
|
const member = block.members[0];
|
||||||
|
const spouse = spousePairs.get(member.id);
|
||||||
|
if (!spouse) continue;
|
||||||
|
const spouseLookup = memberLookup.get(spouse);
|
||||||
|
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
|
||||||
|
if (spouseLookup.key === key) continue;
|
||||||
|
if (!spouseLookup.key.startsWith('__loose__')) continue;
|
||||||
|
const otherBlock = blocksByKey.get(spouseLookup.key)!;
|
||||||
|
block.members.push(...otherBlock.members);
|
||||||
|
removed.add(spouseLookup.key);
|
||||||
|
}
|
||||||
|
for (const key of removed) blocksByKey.delete(key);
|
||||||
|
|
||||||
|
// Step 4: centre each block on its anchor (parented members) and pack.
|
||||||
|
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
||||||
|
let cursorRight = -Infinity;
|
||||||
|
for (const block of ordered) {
|
||||||
|
const n = block.members.length;
|
||||||
|
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
|
||||||
|
const anchorIndices: number[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (block.members[i].parented) anchorIndices.push(i);
|
||||||
|
}
|
||||||
|
const avgAnchorIdx =
|
||||||
|
anchorIndices.length > 0
|
||||||
|
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
|
||||||
|
: (n - 1) / 2;
|
||||||
|
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
|
||||||
|
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
positions.set(block.members[i].id, {
|
||||||
|
x: groupLeft + i * (NODE_W + COL_GAP),
|
||||||
|
y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursorRight = groupLeft + groupWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const arr = map.get(key);
|
||||||
|
if (arr) arr.push(value);
|
||||||
|
else map.set(key, [value]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user