familytree

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

commit 092b60117d8c44c4bcc4ee4083dbb1ff821a6114
parent 342025d2f6e345894003c0ef2b2d47caa01e2e2f
Author: Julian Piribauer <julian.piribauer@gmail.com>
Date:   Sun,  3 May 2026 06:43:03 +0000

Fix sibling cluster centering under shared parents

Each generation's groups are now clustered by shared parent before placement.
The whole sibling cluster is centered under the parent midpoint as a unit,
so multiple children of the same couple spread symmetrically rather than
being pushed rightward one-by-one by the overlap sweep.

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

Diffstat:
Mstatic/app.js | 96++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
1 file changed, 53 insertions(+), 43 deletions(-)

diff --git a/static/app.js b/static/app.js @@ -283,28 +283,44 @@ function computeLayout({ people, partnerships, parentLinks }) { placed.add(id); } - // Ideal center X for each group: midpoint of its parents in the row above. - // Prefer a partnership link over a single-parent link. - const idealCenter = groups.map(grp => { - const child = grp.find(id => childIds.has(id)); - if (!child) return null; - const pl = parentLinks.find(l => l.child_id === child && l.partnership_id != null) - || parentLinks.find(l => l.child_id === child); - if (!pl) return null; - if (pl.partnership_id) { - const ps = partnerships.find(p => p.id === pl.partnership_id); - if (!ps) return null; - const x1 = assignedX[ps.person1_id], x2 = assignedX[ps.person2_id]; - if (x1 == null || x2 == null) return null; - return (x1 + x2) / 2; + // 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}`; } + } } - if (pl.parent_id != null) return assignedX[pl.parent_id] ?? null; - return null; + 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 }; }); - // Sort groups by ideal center; groups with no anchor go last. - const sorted = groups - .map((grp, i) => ({ grp, ic: idealCenter[i] })) + // 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; @@ -312,38 +328,32 @@ function computeLayout({ people, partnerships, parentLinks }) { return a.ic - b.ic; }); - const anchored = sorted.filter(o => o.ic != null); - const floating = sorted.filter(o => o.ic == null); + const anchored = clusters.filter(c => c.ic != null); + const floating = clusters.filter(c => c.ic == null); + + const placeCluster = (c, leftX) => { + let x = leftX; + for (const grp of c.grps) { placeGrp(grp, x + grpW(grp) / 2); x += grpW(grp) + MARGIN; } + }; if (anchored.length === 0) { // Gen 0 or fully unanchored: center the whole row. - const total = sorted.reduce((s, o, i) => s + grpW(o.grp) + (i > 0 ? MARGIN : 0), 0); + const total = clusters.reduce((s, c, i) => s + c.width + (i > 0 ? MARGIN : 0), 0); let x = -total / 2; - for (const { grp } of sorted) { - const w = grpW(grp); - placeGrp(grp, x + w / 2); - x += w + MARGIN; - } + for (const c of clusters) { placeCluster(c, x); x += c.width + MARGIN; } } else { - // Place each anchored group at its parent midpoint, then sweep left-to-right - // so no two groups overlap. - const leftEdge = anchored.map(({ grp, ic }) => ic - grpW(grp) / 2); + // 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 minLeft = leftEdge[i - 1] + grpW(anchored[i - 1].grp) + MARGIN; - if (leftEdge[i] < minLeft) leftEdge[i] = minLeft; - } - for (let i = 0; i < anchored.length; i++) { - placeGrp(anchored[i].grp, leftEdge[i] + grpW(anchored[i].grp) / 2); + 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]); - // Floating groups (no parents, pulled into this gen via partnership) go - // right of the last anchored group. - let floatX = leftEdge[anchored.length - 1] + grpW(anchored[anchored.length - 1].grp) + MARGIN; - for (const { grp } of floating) { - const w = grpW(grp); - placeGrp(grp, floatX + w / 2); - floatX += w + MARGIN; - } + // 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; } } }