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:
| M | static/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; }
}
}