735 lines
27 KiB
JavaScript
735 lines
27 KiB
JavaScript
/**
|
|
* Custom family layout for the sidebar graph.
|
|
*
|
|
* Purpose-built for immediate-family display: parents, siblings,
|
|
* self + spouse(s), and children. Handles wrapping for large families
|
|
* and half-siblings from multiple parent couples.
|
|
*
|
|
* No external dependencies (replaces ELK).
|
|
*/
|
|
|
|
/**
|
|
* @param {Array} persons - Flat array of { id, data, rels }
|
|
* @param {string} mainId - Root person ID
|
|
* @param {object} config - { cardWidth, cardHeight, horizontalSpacing, verticalSpacing, targetWidth }
|
|
* @returns {{ persons: Array, connections: Array }}
|
|
*/
|
|
export function computeLayout(persons, mainId, config) {
|
|
const personById = new Map();
|
|
for (const p of persons) personById.set(p.id, p);
|
|
|
|
const cw = config.cardWidth;
|
|
const ch = config.cardHeight;
|
|
const hGap = config.horizontalSpacing;
|
|
const vGap = config.verticalSpacing;
|
|
const halfH = ch / 2;
|
|
const targetWidth = config.targetWidth || 320;
|
|
const maxPerRow = Math.max(2, Math.floor(targetWidth / (cw + hGap)));
|
|
|
|
// 1. Assign generations via BFS
|
|
const gen = assignGenerations(personById, mainId);
|
|
|
|
// 2. Identify family units (couple → shared children)
|
|
const families = identifyFamilies(personById, gen);
|
|
|
|
// 2a. Identify root's spouse families with children (max 2 shown)
|
|
const rootChildFamilies = families.filter(
|
|
(f) => f.parents.includes(mainId) && f.children.length > 0
|
|
);
|
|
const activeRootFamilies = rootChildFamilies.slice(0, 2);
|
|
|
|
// 2b. Move siblings to their own row (gen -0.5) above root+spouse(s)
|
|
// But if root has no spouses, keep siblings at gen 0 on the same row
|
|
const root = personById.get(mainId);
|
|
const rootSpouses = new Set(root ? root.rels.spouses || [] : []);
|
|
if (rootSpouses.size > 0) {
|
|
for (const [id, g] of gen) {
|
|
if (g === 0 && id !== mainId && !rootSpouses.has(id)) {
|
|
gen.set(id, -0.5);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2c. When multiple spouse families have children, move those spouses
|
|
// to gen 0.5 (row below root). Childless spouses stay at gen 0 with root.
|
|
const activeSpouseIds = new Set();
|
|
for (const f of activeRootFamilies) {
|
|
for (const pid of f.parents) {
|
|
if (pid !== mainId) activeSpouseIds.add(pid);
|
|
}
|
|
}
|
|
if (activeRootFamilies.length >= 1 && rootSpouses.size >= 2) {
|
|
for (const sid of activeSpouseIds) {
|
|
if (gen.has(sid)) gen.set(sid, 0.5);
|
|
}
|
|
}
|
|
|
|
// 3. Group by generation
|
|
const genGroups = new Map();
|
|
for (const [id, g] of gen) {
|
|
if (!genGroups.has(g)) genGroups.set(g, []);
|
|
genGroups.get(g).push(id);
|
|
}
|
|
|
|
// 4. Order + position each generation row
|
|
const positions = new Map();
|
|
const sortedGens = [...genGroups.keys()].sort((a, b) => a - b);
|
|
let currentY = 0;
|
|
|
|
for (const g of sortedGens) {
|
|
const ids = genGroups.get(g);
|
|
const ordered = orderGeneration(ids, g, mainId, families, personById, activeRootFamilies);
|
|
|
|
// Single active spouse staggered: position at right column (same x as S1)
|
|
if (g === 0.5 && activeRootFamilies.length === 1) {
|
|
const rowWidth = 2 * cw + hGap;
|
|
const rightX = -rowWidth / 2 + cw / 2 + cw + hGap;
|
|
for (const id of ordered) {
|
|
positions.set(id, { x: rightX, y: currentY });
|
|
}
|
|
currentY += ch + vGap * 0.6;
|
|
continue;
|
|
}
|
|
|
|
// Multi-spouse children: position each family's children as
|
|
// two side-by-side columns (1 card wide each) to stay within 2-card width
|
|
if (g >= 1 && activeRootFamilies.length >= 2) {
|
|
const leftKids = ordered.filter((id) => activeRootFamilies[0].children.includes(id));
|
|
const rightKids = ordered.filter((id) => activeRootFamilies[1].children.includes(id));
|
|
|
|
// Each group gets 1 card per row, aligned to the standard 2-wide grid
|
|
const leftRows = wrapIntoRows(leftKids, 1);
|
|
const rightRows = wrapIntoRows(rightKids, 1);
|
|
const maxRowCount = Math.max(leftRows.length, rightRows.length);
|
|
const rowWidth = 2 * cw + hGap;
|
|
const leftX = -rowWidth / 2 + cw / 2;
|
|
const rightX = leftX + cw + hGap;
|
|
|
|
for (let r = 0; r < maxRowCount; r++) {
|
|
const lRow = leftRows[r] || [];
|
|
const rRow = rightRows[r] || [];
|
|
|
|
for (const id of lRow) {
|
|
positions.set(id, { x: leftX, y: currentY });
|
|
}
|
|
for (const id of rRow) {
|
|
positions.set(id, { x: rightX, y: currentY });
|
|
}
|
|
|
|
currentY += ch + (r < maxRowCount - 1 ? vGap * 0.5 : 0);
|
|
}
|
|
|
|
currentY += vGap;
|
|
continue;
|
|
}
|
|
|
|
// Gen-0 with spouses: never wrap — all on one row
|
|
// Gen-0 without spouses (siblings included): respect 2-wide limit
|
|
const rowLimit = (g === 0 && rootSpouses.size > 0) ? ordered.length : 2;
|
|
const rows = wrapIntoRows(ordered, rowLimit);
|
|
|
|
for (let r = 0; r < rows.length; r++) {
|
|
const row = rows[r];
|
|
const rowWidth = row.length * cw + (row.length - 1) * hGap;
|
|
let startX = -rowWidth / 2 + cw / 2;
|
|
|
|
// Sibling row with odd count: offset left so no card
|
|
// sits dead center — the connector drops through the gap
|
|
if (g === -0.5 && row.length % 2 === 1) {
|
|
startX -= (cw + hGap) / 2;
|
|
}
|
|
|
|
for (let c = 0; c < row.length; c++) {
|
|
positions.set(row[c], {
|
|
x: startX + c * (cw + hGap),
|
|
y: currentY,
|
|
});
|
|
}
|
|
|
|
currentY += ch + (r < rows.length - 1 ? vGap * 0.5 : 0);
|
|
}
|
|
|
|
// Less vertical space for tightly coupled rows
|
|
currentY += (g === -0.5 || g === 0.5) ? vGap * 0.6 : vGap;
|
|
}
|
|
|
|
// 5. Center on root
|
|
const rootPos = positions.get(mainId);
|
|
if (rootPos) {
|
|
const ox = rootPos.x;
|
|
const oy = rootPos.y;
|
|
for (const pos of positions.values()) {
|
|
pos.x -= ox;
|
|
pos.y -= oy;
|
|
}
|
|
}
|
|
|
|
// 6. Build output
|
|
const outPersons = [];
|
|
for (const [id, pos] of positions) {
|
|
const p = personById.get(id);
|
|
outPersons.push({
|
|
x: pos.x, y: pos.y,
|
|
id, isMain: id === mainId,
|
|
data: p.data,
|
|
});
|
|
}
|
|
|
|
// 7. Build connectors
|
|
const connections = buildConnectors(families, positions, config, mainId, activeRootFamilies, personById);
|
|
|
|
return { persons: outPersons, connections };
|
|
}
|
|
|
|
// ─── Generation assignment ────────────────────────────────────────────
|
|
|
|
function assignGenerations(personById, mainId) {
|
|
const gen = new Map();
|
|
gen.set(mainId, 0);
|
|
const queue = [mainId];
|
|
const visited = new Set([mainId]);
|
|
|
|
while (queue.length > 0) {
|
|
const id = queue.shift();
|
|
const g = gen.get(id);
|
|
const p = personById.get(id);
|
|
if (!p) continue;
|
|
|
|
// Spouses = same generation
|
|
for (const sid of p.rels.spouses || []) {
|
|
if (!visited.has(sid) && personById.has(sid)) {
|
|
gen.set(sid, g);
|
|
visited.add(sid);
|
|
queue.push(sid);
|
|
}
|
|
}
|
|
// Parents = one up
|
|
for (const pid of p.rels.parents || []) {
|
|
if (!visited.has(pid) && personById.has(pid)) {
|
|
gen.set(pid, g - 1);
|
|
visited.add(pid);
|
|
queue.push(pid);
|
|
}
|
|
}
|
|
// Children = one down
|
|
for (const cid of p.rels.children || []) {
|
|
if (!visited.has(cid) && personById.has(cid)) {
|
|
gen.set(cid, g + 1);
|
|
visited.add(cid);
|
|
queue.push(cid);
|
|
}
|
|
}
|
|
}
|
|
|
|
return gen;
|
|
}
|
|
|
|
// ─── Family identification ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Identify family units from children's parent sets.
|
|
* Each family = { parents: [id, ...], children: [id, ...] }
|
|
*/
|
|
function identifyFamilies(personById, gen) {
|
|
const familyMap = new Map(); // "pA|pB" → { parents, children }
|
|
|
|
for (const [id, person] of personById) {
|
|
const parents = (person.rels.parents || []).filter((pid) => personById.has(pid));
|
|
if (parents.length === 0) continue;
|
|
|
|
const key = [...parents].sort().join("|");
|
|
if (!familyMap.has(key)) {
|
|
familyMap.set(key, { parents: [...parents].sort(), children: [] });
|
|
}
|
|
familyMap.get(key).children.push(id);
|
|
}
|
|
|
|
return [...familyMap.values()];
|
|
}
|
|
|
|
// ─── Generation ordering ──────────────────────────────────────────────
|
|
|
|
function orderGeneration(ids, g, mainId, families, personById, activeRootFamilies) {
|
|
if (g === -0.5) return orderSiblings(ids, families, personById);
|
|
if (g < 0) return orderParents(ids, families);
|
|
if (g === 0) return orderSelfGeneration(ids, mainId, families, personById, activeRootFamilies);
|
|
if (g === 0.5) return orderActiveSpouses(ids, activeRootFamilies);
|
|
return orderChildren(ids, mainId, families, activeRootFamilies);
|
|
}
|
|
|
|
/**
|
|
* Parents: group by couple, bridging through shared parents.
|
|
* e.g. [Dad, Mom, StepDad] when Mom is in two families.
|
|
*/
|
|
function orderParents(ids, families) {
|
|
const parentFamilies = families.filter((f) =>
|
|
f.parents.some((pid) => ids.includes(pid))
|
|
);
|
|
if (parentFamilies.length === 0) return [...ids];
|
|
|
|
const ordered = [];
|
|
const placed = new Set();
|
|
const remaining = [...parentFamilies];
|
|
|
|
// Start with first family
|
|
const first = remaining.shift();
|
|
for (const pid of first.parents) {
|
|
if (ids.includes(pid) && !placed.has(pid)) {
|
|
ordered.push(pid);
|
|
placed.add(pid);
|
|
}
|
|
}
|
|
|
|
// Process remaining, preferring those that share a parent (bridge)
|
|
while (remaining.length > 0) {
|
|
const idx = remaining.findIndex((f) =>
|
|
f.parents.some((pid) => placed.has(pid))
|
|
);
|
|
const next =
|
|
idx >= 0 ? remaining.splice(idx, 1)[0] : remaining.shift();
|
|
|
|
for (const pid of next.parents) {
|
|
if (ids.includes(pid) && !placed.has(pid)) {
|
|
ordered.push(pid);
|
|
placed.add(pid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Any remaining
|
|
for (const id of ids) {
|
|
if (!placed.has(id)) ordered.push(id);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
/**
|
|
* Siblings row: sorted chronologically by birth year.
|
|
* Grouped by parent family, with each group sorted by birth.
|
|
*/
|
|
function orderSiblings(ids, families, personById) {
|
|
const ordered = [];
|
|
const placed = new Set();
|
|
|
|
// Group siblings by parent family
|
|
for (const family of families) {
|
|
const famSiblings = family.children.filter(
|
|
(cid) => ids.includes(cid) && !placed.has(cid)
|
|
);
|
|
// Sort by birth year (unknown years go last)
|
|
famSiblings.sort((a, b) => {
|
|
const ya = personById.get(a)?.data?.birthYear || 9999;
|
|
const yb = personById.get(b)?.data?.birthYear || 9999;
|
|
return ya - yb;
|
|
});
|
|
for (const cid of famSiblings) {
|
|
ordered.push(cid);
|
|
placed.add(cid);
|
|
}
|
|
}
|
|
|
|
// Any remaining
|
|
for (const id of ids) {
|
|
if (!placed.has(id)) ordered.push(id);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
/**
|
|
* Self generation: root + spouse(s).
|
|
* Multi-spouse: active spouses are at gen 0.5, so gen 0 has root + childless spouses.
|
|
* Single spouse: root + spouse on same row.
|
|
*/
|
|
function orderSelfGeneration(ids, mainId, families, personById, activeRootFamilies) {
|
|
const root = personById.get(mainId);
|
|
const ordered = [];
|
|
const placed = new Set();
|
|
|
|
// Root first
|
|
ordered.push(mainId);
|
|
placed.add(mainId);
|
|
|
|
// Remaining spouses (childless ones in multi-spouse, or all in single-spouse)
|
|
for (const sid of root.rels.spouses || []) {
|
|
if (ids.includes(sid) && !placed.has(sid)) {
|
|
ordered.push(sid);
|
|
placed.add(sid);
|
|
}
|
|
}
|
|
|
|
// Any remaining gen-0 persons
|
|
for (const id of ids) {
|
|
if (!placed.has(id)) ordered.push(id);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
/**
|
|
* Active spouses (gen 0.5): ordered to match activeRootFamilies (left, right).
|
|
*/
|
|
function orderActiveSpouses(ids, activeRootFamilies) {
|
|
const ordered = [];
|
|
const placed = new Set();
|
|
|
|
for (const family of activeRootFamilies) {
|
|
for (const pid of family.parents) {
|
|
if (ids.includes(pid) && !placed.has(pid)) {
|
|
ordered.push(pid);
|
|
placed.add(pid);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of ids) {
|
|
if (!placed.has(id)) ordered.push(id);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
/**
|
|
* Children: grouped by spouse family.
|
|
* Uses activeRootFamilies order so left family's kids come first.
|
|
*/
|
|
function orderChildren(ids, mainId, families, activeRootFamilies) {
|
|
const ordered = [];
|
|
const placed = new Set();
|
|
|
|
// Active root families first (left, then right)
|
|
for (const family of activeRootFamilies) {
|
|
for (const cid of family.children) {
|
|
if (ids.includes(cid) && !placed.has(cid)) {
|
|
ordered.push(cid);
|
|
placed.add(cid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Any remaining children from other families
|
|
const rootFamilies = families.filter((f) => f.parents.includes(mainId));
|
|
for (const family of rootFamilies) {
|
|
if (activeRootFamilies.includes(family)) continue;
|
|
for (const cid of family.children) {
|
|
if (ids.includes(cid) && !placed.has(cid)) {
|
|
ordered.push(cid);
|
|
placed.add(cid);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of ids) {
|
|
if (!placed.has(id)) ordered.push(id);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
// ─── Row wrapping ─────────────────────────────────────────────────────
|
|
|
|
function wrapIntoRows(ids, maxPerRow) {
|
|
const rows = [];
|
|
for (let i = 0; i < ids.length; i += maxPerRow) {
|
|
rows.push(ids.slice(i, i + maxPerRow));
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
// ─── Connector drawing ────────────────────────────────────────────────
|
|
|
|
function buildConnectors(families, positions, config, mainId, activeRootFamilies, personById) {
|
|
const connections = [];
|
|
const halfH = config.cardHeight / 2;
|
|
const cw = config.cardWidth;
|
|
const vGap = config.verticalSpacing;
|
|
const rootPerson = personById.get(mainId);
|
|
const rootSpouseIds = rootPerson?.rels?.spouses || [];
|
|
const multiSpouse = activeRootFamilies.length >= 2;
|
|
const staggeredLayout = activeRootFamilies.length >= 1 && rootSpouseIds.length >= 2;
|
|
const rootPos = positions.get(mainId);
|
|
const margin = 12;
|
|
|
|
for (const family of families) {
|
|
const rootFamIdx = activeRootFamilies.indexOf(family);
|
|
const isActiveRootFamily = rootFamIdx >= 0;
|
|
|
|
// Staggered/multi-spouse active families get custom routing below
|
|
if (isActiveRootFamily && staggeredLayout) continue;
|
|
|
|
const parentPos = family.parents
|
|
.map((pid) => positions.get(pid))
|
|
.filter(Boolean);
|
|
const childPos = family.children
|
|
.map((cid) => ({ id: cid, ...positions.get(cid) }))
|
|
.filter((c) => c.x !== undefined);
|
|
|
|
if (parentPos.length === 0 || childPos.length === 0) continue;
|
|
|
|
// Standard family connector (parents, siblings, single-spouse children)
|
|
const parentBottomY = Math.max(...parentPos.map((p) => p.y)) + halfH;
|
|
const childTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
|
|
const gap = childTopY - parentBottomY;
|
|
const coupleBarY = parentBottomY + gap * 0.3;
|
|
const unionX =
|
|
parentPos.reduce((s, p) => s + p.x, 0) / parentPos.length;
|
|
|
|
if (parentPos.length >= 2) {
|
|
const xs = parentPos.map((p) => p.x).sort((a, b) => a - b);
|
|
connections.push({
|
|
path: `M ${xs[0]} ${coupleBarY} L ${xs[xs.length - 1]} ${coupleBarY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
}
|
|
for (const p of parentPos) {
|
|
connections.push({
|
|
path: `M ${p.x} ${p.y + halfH} L ${p.x} ${coupleBarY}`,
|
|
cssClass: "link ancestor-link",
|
|
});
|
|
}
|
|
drawChildConnectors(connections, childPos, unionX, coupleBarY, halfH, vGap);
|
|
}
|
|
|
|
// ── Multi-spouse: custom routing for active root families ──
|
|
if (multiSpouse && rootPos) {
|
|
for (let fi = 0; fi < activeRootFamilies.length; fi++) {
|
|
const family = activeRootFamilies[fi];
|
|
const spouseId = family.parents.find((p) => p !== mainId);
|
|
const spousePos = spouseId ? positions.get(spouseId) : null;
|
|
const childPos = family.children
|
|
.map((cid) => ({ id: cid, ...positions.get(cid) }))
|
|
.filter((c) => c.x !== undefined);
|
|
|
|
if (!spousePos || childPos.length === 0) continue;
|
|
|
|
const spouseBottomY = spousePos.y + halfH;
|
|
const childTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
|
|
const gap = childTopY - spouseBottomY;
|
|
const singleChild = childPos.length === 1;
|
|
|
|
// Outer spine X: outside edge for routing from root
|
|
let outerX;
|
|
if (fi === 0) {
|
|
outerX = Math.min(...childPos.map((c) => c.x)) - cw / 2 - margin;
|
|
} else {
|
|
outerX = Math.max(...childPos.map((c) => c.x)) + cw / 2 + margin;
|
|
}
|
|
|
|
// Couple bar below spouse — marriage bar that connects everything
|
|
const coupleBarY = spouseBottomY + gap * 0.3;
|
|
|
|
// ── Root down outer edge to couple bar ──
|
|
const junctionY = (rootPos.y + halfH + spousePos.y - halfH) / 2;
|
|
|
|
connections.push({
|
|
path: `M ${rootPos.x} ${rootPos.y + halfH} L ${rootPos.x} ${junctionY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
connections.push({
|
|
path: `M ${rootPos.x} ${junctionY} L ${outerX} ${junctionY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
// Outer edge down to couple bar
|
|
connections.push({
|
|
path: `M ${outerX} ${junctionY} L ${outerX} ${coupleBarY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
|
|
// ── Spouse bottom down to couple bar ──
|
|
connections.push({
|
|
path: `M ${spousePos.x} ${spouseBottomY} L ${spousePos.x} ${coupleBarY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
|
|
// ── Horizontal couple bar connecting outer spine and spouse ──
|
|
connections.push({
|
|
path: `M ${outerX} ${coupleBarY} L ${spousePos.x} ${coupleBarY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
|
|
// ── Children from couple bar ──
|
|
if (singleChild) {
|
|
// Single child: straight down from spouse X on the couple bar
|
|
connections.push({
|
|
path: `M ${spousePos.x} ${coupleBarY} L ${spousePos.x} ${childPos[0].y - halfH}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
} else {
|
|
// Multiple children: add a second horizontal bar below the couple bar,
|
|
// connected by a short vertical at the couple bar midpoint —
|
|
// visually separates marriage connector from children connector
|
|
const firstChildTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
|
|
const childBarY = firstChildTopY - 6;
|
|
const coupleBarMidX = (outerX + spousePos.x) / 2;
|
|
// Short vertical from couple bar midpoint down to children bar
|
|
connections.push({
|
|
path: `M ${coupleBarMidX} ${coupleBarY} L ${coupleBarMidX} ${childBarY}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
// Children spine at outerX
|
|
drawChildConnectors(connections, childPos, outerX, childBarY, halfH, vGap);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Single active spouse staggered: root → S2 → children ──
|
|
if (!multiSpouse && staggeredLayout && rootPos) {
|
|
const family = activeRootFamilies[0];
|
|
const spouseId = family.parents.find((p) => p !== mainId);
|
|
const spousePos = spouseId ? positions.get(spouseId) : null;
|
|
const childPos = family.children
|
|
.map((cid) => ({ id: cid, ...positions.get(cid) }))
|
|
.filter((c) => c.x !== undefined);
|
|
|
|
if (spousePos && childPos.length > 0) {
|
|
const spouseBottomY = spousePos.y + halfH;
|
|
const childTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
|
|
const gap = childTopY - spouseBottomY;
|
|
const coupleBarY = spouseBottomY + gap * 0.3;
|
|
|
|
// Root vertical down to couple bar
|
|
connections.push({
|
|
path: `M ${rootPos.x} ${rootPos.y + halfH} L ${rootPos.x} ${coupleBarY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
// Horizontal couple bar from root to spouse
|
|
connections.push({
|
|
path: `M ${rootPos.x} ${coupleBarY} L ${spousePos.x} ${coupleBarY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
// Spouse vertical down to couple bar
|
|
connections.push({
|
|
path: `M ${spousePos.x} ${spouseBottomY} L ${spousePos.x} ${coupleBarY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
// Children from couple bar, spine centered between root and spouse
|
|
const spineX = (rootPos.x + spousePos.x) / 2;
|
|
drawChildConnectors(connections, childPos, spineX, coupleBarY, halfH, vGap);
|
|
}
|
|
}
|
|
|
|
// ── Couple links for childless spouses (same row as root) ──
|
|
const childlessActiveIds = new Set();
|
|
for (const f of activeRootFamilies) {
|
|
for (const pid of f.parents) {
|
|
if (pid !== mainId) childlessActiveIds.add(pid);
|
|
}
|
|
}
|
|
if (rootPerson && rootPos && staggeredLayout) {
|
|
for (const sid of rootPerson.rels.spouses || []) {
|
|
if (childlessActiveIds.has(sid)) continue;
|
|
const spPos = positions.get(sid);
|
|
if (!spPos) continue;
|
|
// Horizontal couple bar on same row
|
|
const barY = rootPos.y + halfH + 8;
|
|
connections.push({
|
|
path: `M ${rootPos.x} ${rootPos.y + halfH} L ${rootPos.x} ${barY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
connections.push({
|
|
path: `M ${rootPos.x} ${barY} L ${spPos.x} ${barY}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
connections.push({
|
|
path: `M ${spPos.x} ${barY} L ${spPos.x} ${spPos.y + halfH}`,
|
|
cssClass: "link couple-link",
|
|
});
|
|
}
|
|
}
|
|
|
|
return connections;
|
|
}
|
|
|
|
/**
|
|
* Draw connectors from a spine X down to a set of children.
|
|
* Handles single-row and wrapped (multi-row) children.
|
|
*/
|
|
function drawChildConnectors(connections, childPos, spineX, coupleBarY, halfH, vGap) {
|
|
// Group children by Y level (detects wrapping)
|
|
const rowMap = new Map();
|
|
for (const c of childPos) {
|
|
const ry = Math.round(c.y * 10) / 10;
|
|
if (!rowMap.has(ry)) rowMap.set(ry, []);
|
|
rowMap.get(ry).push(c);
|
|
}
|
|
const rowYs = [...rowMap.keys()].sort((a, b) => a - b);
|
|
const wrapped = rowYs.length > 1;
|
|
|
|
if (!wrapped) {
|
|
const computedBusY = rowYs[0] - halfH - 6;
|
|
// Use whichever is closer to the children — if the caller already
|
|
// positioned childBarY near the cards, honour that instead of recalculating
|
|
const busBaseY = Math.max(computedBusY, coupleBarY);
|
|
|
|
// Vertical stem from couple bar to bus
|
|
if (coupleBarY < busBaseY) {
|
|
connections.push({
|
|
path: `M ${spineX} ${coupleBarY} L ${spineX} ${busBaseY}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
}
|
|
|
|
if (childPos.length === 1) {
|
|
if (Math.abs(childPos[0].x - spineX) > 1) {
|
|
connections.push({
|
|
path: `M ${spineX} ${busBaseY} L ${childPos[0].x} ${busBaseY}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
}
|
|
connections.push({
|
|
path: `M ${childPos[0].x} ${busBaseY} L ${childPos[0].x} ${childPos[0].y - halfH}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
} else {
|
|
const xs = childPos.map((c) => c.x).sort((a, b) => a - b);
|
|
const busL = Math.min(xs[0], spineX);
|
|
const busR = Math.max(xs[xs.length - 1], spineX);
|
|
connections.push({
|
|
path: `M ${busL} ${busBaseY} L ${busR} ${busBaseY}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
for (const c of childPos) {
|
|
connections.push({
|
|
path: `M ${c.x} ${busBaseY} L ${c.x} ${c.y - halfH}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// Wrapped children — one continuous vertical spine with
|
|
// horizontal buses branching off per sub-row
|
|
const rowBuses = rowYs.map((ry) => ry - halfH - 6);
|
|
// If caller positioned coupleBarY below the first bus, use it instead
|
|
if (coupleBarY > rowBuses[0]) rowBuses[0] = coupleBarY;
|
|
|
|
// Draw one continuous spine from couple bar to last bus
|
|
const spineEnd = rowBuses[rowBuses.length - 1];
|
|
connections.push({
|
|
path: `M ${spineX} ${coupleBarY} L ${spineX} ${spineEnd}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
|
|
for (let ri = 0; ri < rowYs.length; ri++) {
|
|
const rowChildren = rowMap.get(rowYs[ri]);
|
|
const rowBusY = rowBuses[ri];
|
|
|
|
const xs = rowChildren.map((c) => c.x).sort((a, b) => a - b);
|
|
const busL = Math.min(xs[0], spineX);
|
|
const busR = Math.max(xs[xs.length - 1], spineX);
|
|
|
|
connections.push({
|
|
path: `M ${busL} ${rowBusY} L ${busR} ${rowBusY}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
|
|
for (const c of rowChildren) {
|
|
connections.push({
|
|
path: `M ${c.x} ${rowBusY} L ${c.x} ${c.y - halfH}`,
|
|
cssClass: "link descendant-link",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|