familytree

A self-hosted family tree builder.
Log | Files | Refs | README

commit 1c0a3b1b853e90caec16ff3bea24ac9bf88d1017
parent 092b60117d8c44c4bcc4ee4083dbb1ff821a6114
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date:   Sun,  3 May 2026 06:49:12 +0000

Replace layout with bottom-up subtree width algorithm

Throw out the top-down anchor approach that kept misaligning parent rows.
Instead: compute the minimum horizontal span for each group and all its
descendants (subtree width) from leaves upward, then assign X positions
top-down so every parent is exactly centered above its children by
construction. Connection lines adapt automatically since they read pos[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Diffstat:
Mstatic/app.js | 153+++++++++++++++++++++++++++++++++++--------------------------------------------
1 file changed, 67 insertions(+), 86 deletions(-)

diff --git a/static/app.js b/static/app.js @@ -248,115 +248,96 @@ function computeLayout({ people, partnerships, parentLinks }) { byGen[g].push(p.id); } - const assignedX = {}; + // ── Horizontal layout: bottom-up subtree widths, top-down X assignment ────── + // Each couple/single is a "group". We compute the minimum horizontal span + // needed for a group and all its descendants (subtree width), then assign X + // positions top-down so every parent is automatically centered above its + // children — no top-down anchoring that can drift when children spread wider. + const MARGIN = 30; const grpW = grp => grp.length === 2 ? NODE_W * 2 + COUPLE_GAP : NODE_W; - const placeGrp = (grp, cx) => { - if (grp.length === 2) { - assignedX[grp[0]] = cx - NODE_W / 2 - COUPLE_GAP / 2; - assignedX[grp[1]] = cx + NODE_W / 2 + COUPLE_GAP / 2; - } else { - assignedX[grp[0]] = cx; - } - }; + // Build one group (partner-pair or single) per generation for every person. + const genGroups = {}; for (const g of Object.keys(byGen).sort((a, b) => Number(a) - Number(b))) { const ids = byGen[g]; const placed = new Set(); const groups = []; - for (const id of ids) { if (placed.has(id)) continue; - const partnerPs = partnerships.find(ps => - (ps.person1_id === id || ps.person2_id === id) && - gen[ps.person1_id] === gen[ps.person2_id] + const ps = partnerships.find(p => + (p.person1_id === id || p.person2_id === id) && + gen[p.person1_id] === gen[p.person2_id] ); - if (partnerPs) { - const other = partnerPs.person1_id === id ? partnerPs.person2_id : partnerPs.person1_id; + if (ps) { + const other = ps.person1_id === id ? ps.person2_id : ps.person1_id; if (!placed.has(other) && ids.includes(other)) { - groups.push([id, other]); - placed.add(id); placed.add(other); - continue; + groups.push([id, other]); placed.add(id); placed.add(other); continue; } } - groups.push([id]); - placed.add(id); + groups.push([id]); placed.add(id); } + genGroups[Number(g)] = groups; + } - // For each group, find its parent anchor (ideal center X) and a parent key - // so that siblings from the same parents can be clustered together. - const groupMeta = groups.map(grp => { - const childMembers = grp.filter(id => childIds.has(id)); - if (childMembers.length === 0) return { grp, ic: null, parentKey: null }; - const centers = []; - let parentKey = null; - for (const child of childMembers) { - const pl = parentLinks.find(l => l.child_id === child && l.partnership_id != null) - || parentLinks.find(l => l.child_id === child); - if (!pl) continue; - if (pl.partnership_id) { - const ps = partnerships.find(p => p.id === pl.partnership_id); - if (!ps) continue; - const x1 = assignedX[ps.person1_id], x2 = assignedX[ps.person2_id]; - if (x1 == null || x2 == null) continue; - centers.push((x1 + x2) / 2); - parentKey = parentKey ?? `ps${pl.partnership_id}`; - } else if (pl.parent_id != null) { - const px = assignedX[pl.parent_id]; - if (px != null) { centers.push(px); parentKey = parentKey ?? `p${pl.parent_id}`; } - } + // Map each person to their group object (array reference). + const personGroup = new Map(); + for (const gs of Object.values(genGroups)) + for (const grp of gs) for (const id of grp) personGroup.set(id, grp); + + // Return the child groups (next-gen groups) of a given group. + function childGroupsOf(grp) { + const seen = new Set(); const result = []; + for (const id of grp) { + const cids = []; + for (const ps of partnerships) + if (ps.person1_id === id || ps.person2_id === id) + cids.push(...(partnershipChildren[ps.id] || [])); + cids.push(...(singleParentChildren[id] || [])); + for (const cid of cids) { + const cg = personGroup.get(cid); + if (cg && !seen.has(cg)) { seen.add(cg); result.push(cg); } } - if (centers.length === 0) return { grp, ic: null, parentKey: null }; - const ic = centers.reduce((s, c) => s + c, 0) / centers.length; - return { grp, ic, parentKey }; - }); - - // Cluster groups that share the same parents so all siblings of a couple - // are spread symmetrically under them as a unit, not swept off to one side. - const clusterMap = new Map(); - for (const meta of groupMeta) { - const key = meta.parentKey ?? `__float_${meta.grp[0]}`; - if (!clusterMap.has(key)) clusterMap.set(key, { ic: meta.ic, grps: [] }); - clusterMap.get(key).grps.push(meta.grp); } - const clusters = [...clusterMap.values()] - .map(c => ({ ...c, width: c.grps.reduce((s, g, i) => s + grpW(g) + (i > 0 ? MARGIN : 0), 0) })) - .sort((a, b) => { - if (a.ic == null && b.ic == null) return 0; - if (a.ic == null) return 1; - if (b.ic == null) return -1; - return a.ic - b.ic; - }); - - const anchored = clusters.filter(c => c.ic != null); - const floating = clusters.filter(c => c.ic == null); + return result; + } - const placeCluster = (c, leftX) => { - let x = leftX; - for (const grp of c.grps) { placeGrp(grp, x + grpW(grp) / 2); x += grpW(grp) + MARGIN; } - }; + // Compute subtree widths bottom-up (deepest generation first). + const stw = new Map(); + for (const g of Object.keys(genGroups).map(Number).sort((a, b) => b - a)) { + for (const grp of genGroups[g]) { + const children = childGroupsOf(grp); + if (!children.length) { stw.set(grp, grpW(grp)); continue; } + const childSpan = children.reduce((s, cg, i) => s + stw.get(cg) + (i > 0 ? MARGIN : 0), 0); + stw.set(grp, Math.max(grpW(grp), childSpan)); + } + } - if (anchored.length === 0) { - // Gen 0 or fully unanchored: center the whole row. - const total = clusters.reduce((s, c, i) => s + c.width + (i > 0 ? MARGIN : 0), 0); - let x = -total / 2; - for (const c of clusters) { placeCluster(c, x); x += c.width + MARGIN; } + // Assign X positions top-down: each parent is centered above its children. + const assignedX = {}; + const placed2 = new Set(); + function placeGrp(grp, cx) { + if (placed2.has(grp)) return; + placed2.add(grp); + if (grp.length === 2) { + assignedX[grp[0]] = cx - NODE_W / 2 - COUPLE_GAP / 2; + assignedX[grp[1]] = cx + NODE_W / 2 + COUPLE_GAP / 2; } else { - // Each sibling cluster is centered under its parent midpoint; sweep - // left-to-right to push apart any clusters that would otherwise overlap. - const leftEdge = anchored.map(c => c.ic - c.width / 2); - for (let i = 1; i < anchored.length; i++) { - const min = leftEdge[i - 1] + anchored[i - 1].width + MARGIN; - if (leftEdge[i] < min) leftEdge[i] = min; - } - for (let i = 0; i < anchored.length; i++) placeCluster(anchored[i], leftEdge[i]); - - // Unanchored groups (root nodes pulled into this gen) go after the last cluster. - let floatX = leftEdge[anchored.length - 1] + anchored[anchored.length - 1].width + MARGIN; - for (const c of floating) { placeCluster(c, floatX); floatX += c.width + MARGIN; } + assignedX[grp[0]] = cx; } + const children = childGroupsOf(grp); + if (!children.length) return; + const total = children.reduce((s, cg, i) => s + stw.get(cg) + (i > 0 ? MARGIN : 0), 0); + let x = cx - total / 2; + for (const cg of children) { placeGrp(cg, x + stw.get(cg) / 2); x += stw.get(cg) + MARGIN; } } + // Root groups (gen 0) are placed left-to-right, centered at origin. + const roots = genGroups[0] || []; + const rootSpan = roots.reduce((s, grp, i) => s + stw.get(grp) + (i > 0 ? MARGIN : 0), 0); + let rx = -rootSpan / 2; + for (const grp of roots) { placeGrp(grp, rx + stw.get(grp) / 2); rx += stw.get(grp) + MARGIN; } + for (const p of people) { pos[p.id] = { x: assignedX[p.id] ?? 0, y: gen[p.id] * GEN_Y }; }