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