/** * 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 >= 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); // 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 (root + spouses): never wrap — all on one row const rowLimit = (g === 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 multiSpouse = activeRootFamilies.length >= 2; const rootPos = positions.get(mainId); const margin = 12; for (const family of families) { const rootFamIdx = activeRootFamilies.indexOf(family); const isActiveRootFamily = rootFamIdx >= 0; // Multi-spouse active families get custom routing below if (isActiveRootFamily && multiSpouse) 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); } } } // ── Couple links for childless spouses (same row as root) ── const rootPerson = personById.get(mainId); const childlessActiveIds = new Set(); for (const f of activeRootFamilies) { for (const pid of f.parents) { if (pid !== mainId) childlessActiveIds.add(pid); } } if (rootPerson && rootPos && multiSpouse) { 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", }); } } } }