familytree

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

app.js (60244B)


      1 /* ── State ── */
      2 const state = {
      3   username: '',
      4   email: '',
      5   trees: [],
      6   activeTreeId: null,
      7   graph: { people: [], partnerships: [], parentLinks: [] },
      8   selectedPersonId: null,
      9   canEdit: false,
     10   _editingPersonId: null,
     11   _editingPartnershipId: null,
     12   _ctxTreeId: null,
     13   _connectingPersonId: null,
     14   _siblingMode: false,
     15   _siblingModePersonId: null,
     16 };
     17 
     18 /* ── API helpers ── */
     19 async function api(method, path, body) {
     20   const opts = { method, headers: {} };
     21   if (body !== undefined) {
     22     opts.headers['Content-Type'] = 'application/json';
     23     opts.body = JSON.stringify(body);
     24   }
     25   const r = await fetch(path, opts);
     26   if (!r.ok) {
     27     const err = await r.json().catch(() => ({ detail: r.statusText }));
     28     throw new Error(err.detail || r.statusText);
     29   }
     30   if (r.status === 204) return null;
     31   return r.json();
     32 }
     33 
     34 function showError(msg) {
     35   console.error(msg);
     36   const el = document.createElement('div');
     37   el.style.cssText = 'position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%);background:var(--red);color:#fff;padding:0.6rem 1.2rem;border-radius:8px;font-size:0.875rem;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,0.4);white-space:nowrap';
     38   el.textContent = msg;
     39   document.body.appendChild(el);
     40   setTimeout(() => el.remove(), 4000);
     41 }
     42 
     43 /* ── Boot ── */
     44 async function boot() {
     45   try {
     46     const me = await api('GET', '/api/me');
     47     state.username = me.username;
     48     state.email = me.email;
     49     document.getElementById('topbarUsername').textContent = me.display_name || me.username;
     50     await loadTrees();
     51   } catch(e) {
     52     showError('Failed to load: ' + e.message);
     53   }
     54 }
     55 
     56 async function loadTrees() {
     57   state.trees = await api('GET', '/api/trees');
     58   renderSidebar();
     59   if (state.activeTreeId) {
     60     const still = state.trees.find(t => t.id === state.activeTreeId);
     61     if (still) await loadGraph(state.activeTreeId);
     62     else selectTree(null);
     63   } else if (state.trees.length) {
     64     const saved = parseInt(localStorage.getItem('activeTreeId'));
     65     const match = saved && state.trees.find(t => t.id === saved);
     66     if (match) await selectTree(match.id);
     67     else if (state.trees.length === 1) await selectTree(state.trees[0].id);
     68   }
     69 }
     70 
     71 /* ── Sidebar ── */
     72 function renderSidebar() {
     73   const list = document.getElementById('treeList');
     74   list.innerHTML = '';
     75   for (const t of state.trees) {
     76     const item = document.createElement('div');
     77     item.className = 'tree-item' + (t.id === state.activeTreeId ? ' active' : '');
     78     item.dataset.id = t.id;
     79     item.innerHTML = `
     80       <svg class="tree-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
     81         <circle cx="12" cy="5" r="2.5"/>
     82         <line x1="12" y1="7.5" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
     83         <line x1="12" y1="11" x2="7" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
     84         <line x1="12" y1="11" x2="17" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
     85         <circle cx="7" cy="18" r="2"/>
     86         <circle cx="17" cy="18" r="2"/>
     87       </svg>
     88       <span class="tree-item-name">${esc(t.name)}</span>
     89       <span class="tree-item-role">${t.role}</span>`;
     90     item.addEventListener('click', () => selectTree(t.id));
     91     item.addEventListener('contextmenu', e => openCtxMenu(e, t.id));
     92     list.appendChild(item);
     93   }
     94 }
     95 
     96 async function selectTree(id) {
     97   state.activeTreeId = id;
     98   if (id) localStorage.setItem('activeTreeId', id);
     99   else localStorage.removeItem('activeTreeId');
    100   closeDetailPanel();
    101   renderSidebar();
    102   if (!id) { showEmptyState(); return; }
    103   try {
    104     await loadGraph(id);
    105   } catch(e) {
    106     showError('Could not load tree: ' + e.message);
    107   }
    108 }
    109 
    110 async function loadGraph(treeId) {
    111   const data = await api('GET', `/api/trees/${treeId}/graph`);
    112   state.graph = {
    113     people: data.people,
    114     partnerships: data.partnerships,
    115     parentLinks: data.parent_links,
    116   };
    117   const tree = state.trees.find(t => t.id === treeId);
    118   state.canEdit = tree && (tree.role === 'owner' || tree.role === 'editor');
    119   renderCanvas();
    120 }
    121 
    122 /* ── Canvas / D3 ── */
    123 let svg, gMain, zoom;
    124 const NODE_W = 160, NODE_H = 62, GEN_Y = 130, COUPLE_GAP = 40;
    125 
    126 function showEmptyState() {
    127   document.getElementById('emptyState').style.display = 'flex';
    128   document.getElementById('treeCanvas').style.display = 'none';
    129   document.getElementById('canvasToolbar').style.display = 'none';
    130 }
    131 
    132 function renderCanvas() {
    133   document.getElementById('emptyState').style.display = 'none';
    134   document.getElementById('canvasToolbar').style.display = 'flex';
    135   document.getElementById('btnAddPerson').style.display = state.canEdit ? '' : 'none';
    136 
    137   const canvasSvg = document.getElementById('treeCanvas');
    138   canvasSvg.style.display = 'block';
    139 
    140   if (!svg) {
    141     svg = d3.select('#treeCanvas');
    142     gMain = svg.append('g').attr('class', 'canvas-root');
    143     zoom = d3.zoom().scaleExtent([0.1, 3]).on('zoom', e => gMain.attr('transform', e.transform));
    144     svg.call(zoom);
    145   }
    146 
    147   gMain.selectAll('*').remove();
    148 
    149   if (state.graph.people.length === 0) {
    150     gMain.append('text')
    151       .attr('x', 0).attr('y', 0)
    152       .attr('text-anchor', 'middle')
    153       .attr('fill', 'var(--polar4)')
    154       .attr('font-size', 14)
    155       .attr('font-family', 'Inter, sans-serif')
    156       .text(t('no-people-yet'));
    157     requestAnimationFrame(fitCanvas);
    158     return;
    159   }
    160 
    161   const positions = computeLayout(state.graph);
    162   drawEdges(positions);
    163   drawNodes(positions);
    164   // Defer so the browser lays out the SVG before we measure bounding boxes
    165   requestAnimationFrame(fitCanvas);
    166 }
    167 
    168 /* ── fitCanvas: reads dimensions from the parent div, not the SVG element ── */
    169 function fitCanvas() {
    170   const area = document.getElementById('canvasArea');
    171   const W = area.clientWidth, H = area.clientHeight;
    172   if (!W || !H) return;
    173   const bb = gMain.node().getBBox();
    174   if (!bb.width || !bb.height) {
    175     svg.call(zoom.transform, d3.zoomIdentity.translate(W / 2, H / 2));
    176     return;
    177   }
    178   const scale = Math.min(0.9, Math.min(W / (bb.width + 80), H / (bb.height + 80)));
    179   const tx = W / 2 - scale * (bb.x + bb.width / 2);
    180   const ty = H / 2 - scale * (bb.y + bb.height / 2);
    181   svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
    182 }
    183 
    184 function buildAcyclicParentLinks(people, partnerships, parentLinks) {
    185   function getParents(pl) {
    186     if (pl.partnership_id) {
    187       const ps = partnerships.find(p => p.id === pl.partnership_id);
    188       return ps ? [ps.person1_id, ps.person2_id] : [];
    189     }
    190     return pl.parent_id != null ? [pl.parent_id] : [];
    191   }
    192 
    193   const adj = new Map(people.map(p => [p.id, new Set()]));
    194 
    195   function canReachAny(from, targets) {
    196     const q = [from], seen = new Set([from]);
    197     while (q.length) {
    198       const cur = q.shift();
    199       for (const next of (adj.get(cur) || [])) {
    200         if (targets.has(next)) return true;
    201         if (!seen.has(next)) { seen.add(next); q.push(next); }
    202       }
    203     }
    204     return false;
    205   }
    206 
    207   const sorted = [...parentLinks].sort((a, b) => a.id - b.id);
    208   const safe = [];
    209 
    210   for (const pl of sorted) {
    211     const parents = getParents(pl);
    212     if (parents.length && canReachAny(pl.child_id, new Set(parents))) {
    213       console.warn(`Layout: skipping cyclic parent link id=${pl.id} (child=${pl.child_id} would create cycle)`);
    214     } else {
    215       safe.push(pl);
    216       for (const pid of parents) {
    217         if (adj.has(pid)) adj.get(pid).add(pl.child_id);
    218       }
    219     }
    220   }
    221   return safe;
    222 }
    223 
    224 function computeLayout({ people, partnerships, parentLinks: rawLinks }, opts = {}) {
    225   const parentLinks = buildAcyclicParentLinks(people, partnerships, rawLinks);
    226   const pos = {};
    227   const _NW = opts.nodeW ?? NODE_W;
    228   const _CG = opts.coupleGap ?? COUPLE_GAP;
    229   const _GY = opts.genY ?? GEN_Y;
    230 
    231   const partnershipChildren = {};
    232   const singleParentChildren = {};
    233   for (const pl of parentLinks) {
    234     if (pl.partnership_id) {
    235       if (!partnershipChildren[pl.partnership_id]) partnershipChildren[pl.partnership_id] = [];
    236       partnershipChildren[pl.partnership_id].push(pl.child_id);
    237     } else if (pl.parent_id) {
    238       if (!singleParentChildren[pl.parent_id]) singleParentChildren[pl.parent_id] = [];
    239       singleParentChildren[pl.parent_id].push(pl.child_id);
    240     }
    241   }
    242 
    243   const childIds = new Set(parentLinks.map(pl => pl.child_id));
    244   const gen = {};
    245   const roots = people.filter(p => !childIds.has(p.id));
    246   const queue = roots.map(p => ({ id: p.id, g: 0 }));
    247   const visited = new Set();
    248 
    249   while (queue.length) {
    250     const { id, g } = queue.shift();
    251     if (visited.has(id)) continue;
    252     visited.add(id);
    253     gen[id] = g;
    254     for (const ps of partnerships) {
    255       if (ps.person1_id === id || ps.person2_id === id) {
    256         for (const cid of (partnershipChildren[ps.id] || [])) {
    257           if (!visited.has(cid)) queue.push({ id: cid, g: g + 1 });
    258         }
    259       }
    260     }
    261     for (const cid of (singleParentChildren[id] || [])) {
    262       if (!visited.has(cid)) queue.push({ id: cid, g: g + 1 });
    263     }
    264   }
    265   for (const p of people) { if (gen[p.id] === undefined) gen[p.id] = 0; }
    266 
    267   // Iteratively fix generations until stable:
    268   // 1. Align partners to the same row (max of the two).
    269   // 2. Push children to be exactly one row below their parents.
    270   // 3. Pull parents down so they are exactly one row above their children
    271   //    (handles the case where a newly-added ancestor level pushes one side
    272   //    of a partnership down but leaves the other side's ancestors too high).
    273   let changed = true;
    274   let _safetyIter = 0;
    275   while (changed && _safetyIter++ < people.length * 4) {
    276     changed = false;
    277     // Step 1 – partner alignment
    278     for (const ps of partnerships) {
    279       const g1 = gen[ps.person1_id] ?? 0, g2 = gen[ps.person2_id] ?? 0;
    280       if (g1 !== g2) {
    281         const maxG = Math.max(g1, g2);
    282         if (!childIds.has(ps.person1_id)) { gen[ps.person1_id] = maxG; changed = true; }
    283         else if (!childIds.has(ps.person2_id)) { gen[ps.person2_id] = maxG; changed = true; }
    284         else {
    285           // Both have parents — push the lower one down to match the higher
    286           if (g1 < maxG) { gen[ps.person1_id] = maxG; changed = true; }
    287           else { gen[ps.person2_id] = maxG; changed = true; }
    288         }
    289       }
    290     }
    291     // Step 2 – push children down
    292     for (const pl of parentLinks) {
    293       let parentGen;
    294       if (pl.partnership_id) {
    295         const ps = partnerships.find(p => p.id === pl.partnership_id);
    296         if (ps) parentGen = Math.max(gen[ps.person1_id] ?? 0, gen[ps.person2_id] ?? 0);
    297       } else if (pl.parent_id != null) {
    298         parentGen = gen[pl.parent_id] ?? 0;
    299       }
    300       if (parentGen !== undefined) {
    301         const expected = parentGen + 1;
    302         if ((gen[pl.child_id] ?? 0) < expected) { gen[pl.child_id] = expected; changed = true; }
    303       }
    304     }
    305     // Step 3 – pull parents down (increase their gen so they sit exactly one
    306     // row above their child, not two or three rows above)
    307     for (const pl of parentLinks) {
    308       const expectedParentGen = (gen[pl.child_id] ?? 0) - 1;
    309       if (pl.partnership_id) {
    310         const ps = partnerships.find(p => p.id === pl.partnership_id);
    311         if (ps) {
    312           for (const pid of [ps.person1_id, ps.person2_id]) {
    313             if ((gen[pid] ?? 0) < expectedParentGen) { gen[pid] = expectedParentGen; changed = true; }
    314           }
    315         }
    316       } else if (pl.parent_id != null) {
    317         if ((gen[pl.parent_id] ?? 0) < expectedParentGen) { gen[pl.parent_id] = expectedParentGen; changed = true; }
    318       }
    319     }
    320   }
    321 
    322   const byGen = {};
    323   for (const p of people) {
    324     const g = gen[p.id];
    325     if (!byGen[g]) byGen[g] = [];
    326     byGen[g].push(p.id);
    327   }
    328 
    329   // ── Horizontal layout ──────────────────────────────────────────────────────
    330   // Two-phase approach:
    331   //   Phase 1 – ordering only (Sugiyama barycentric): alternating downward and
    332   //             upward sweeps determine a crossing-free left-to-right order for
    333   //             every generation, including childless intermediate groups.
    334   //   Phase 2 – coordinates only: seed rough positions from the Phase-1 order,
    335   //             then refine bottom-up so each parent row centres over its children.
    336 
    337   const MARGIN = opts.margin ?? 30;
    338   const grpW = grp => grp.length === 2 ? _NW * 2 + _CG : _NW;
    339 
    340   // Each person belongs to exactly one layout group: their couple, or themselves.
    341   const genGroups = {};
    342   for (const g of Object.keys(byGen).sort((a, b) => Number(a) - Number(b))) {
    343     const ids = byGen[g];
    344     const placed = new Set();
    345     const groups = [];
    346     for (const id of ids) {
    347       if (placed.has(id)) continue;
    348       const ps = partnerships.find(p =>
    349         (p.person1_id === id || p.person2_id === id) &&
    350         gen[p.person1_id] === gen[p.person2_id]
    351       );
    352       if (ps) {
    353         const other = ps.person1_id === id ? ps.person2_id : ps.person1_id;
    354         if (!placed.has(other) && ids.includes(other)) {
    355           groups.push([id, other]); placed.add(id); placed.add(other); continue;
    356         }
    357       }
    358       groups.push([id]); placed.add(id);
    359     }
    360     genGroups[Number(g)] = groups;
    361   }
    362 
    363   const personGroup = new Map();
    364   for (const gs of Object.values(genGroups))
    365     for (const grp of gs) for (const id of grp) personGroup.set(id, grp);
    366 
    367   function childGroupsOf(grp) {
    368     const seen = new Set(); const result = [];
    369     for (const id of grp) {
    370       const cids = [];
    371       for (const ps of partnerships)
    372         if (ps.person1_id === id || ps.person2_id === id)
    373           cids.push(...(partnershipChildren[ps.id] || []));
    374       cids.push(...(singleParentChildren[id] || []));
    375       for (const cid of cids) {
    376         const cg = personGroup.get(cid);
    377         if (cg && !seen.has(cg)) { seen.add(cg); result.push(cg); }
    378       }
    379     }
    380     return result;
    381   }
    382 
    383   const childrenOf = new Map();
    384   for (const gs of Object.values(genGroups))
    385     for (const grp of gs) childrenOf.set(grp, childGroupsOf(grp));
    386 
    387   // parentsOf: reverse of childrenOf — needed for the downward sweep.
    388   const parentsOf = new Map();
    389   for (const [grp, ch] of childrenOf)
    390     for (const cg of ch) {
    391       if (!parentsOf.has(cg)) parentsOf.set(cg, []);
    392       parentsOf.get(cg).push(grp);
    393     }
    394 
    395   // groupChildSlot: slot (0 = left, 1 = right) through which parentGrp reaches childGrp.
    396   // Breaks ordering ties: parents of the left person of a couple sort left of
    397   // parents of the right person when both share the same leftmost child.
    398   const groupChildSlot = new Map();
    399   for (const pl of parentLinks) {
    400     const parentPs = pl.partnership_id != null
    401       ? partnerships.find(p => p.id === pl.partnership_id) : null;
    402     const parentGrp = parentPs
    403       ? personGroup.get(parentPs.person1_id)
    404       : (pl.parent_id != null ? personGroup.get(pl.parent_id) : null);
    405     if (!parentGrp) continue;
    406     const childGrp = personGroup.get(pl.child_id);
    407     if (!childGrp) continue;
    408     const slot = childGrp.indexOf(pl.child_id);
    409     if (slot === -1) continue;
    410     if (!groupChildSlot.has(parentGrp)) groupChildSlot.set(parentGrp, new Map());
    411     const m = groupChildSlot.get(parentGrp);
    412     if (!m.has(childGrp) || slot < m.get(childGrp)) m.set(childGrp, slot);
    413   }
    414 
    415   const maxG = Object.keys(genGroups).length
    416     ? Math.max(...Object.keys(genGroups).map(Number)) : 0;
    417 
    418   // ── Phase 1: crossing-minimisation (Sugiyama barycentric) ─────────────────
    419   // Start from DB insertion order and run 4 passes of alternating sweeps.
    420   // Downward sweep: each generation sorted by the leftmost-rank of its parent
    421   //   groups in the layer above — this correctly orders childless groups by
    422   //   where their parents are, not by where their (absent) children would be.
    423   // Upward sweep: each generation sorted by the leftmost-rank of its child
    424   //   groups in the layer below, with a slot tiebreaker so parents of the left
    425   //   person of a couple end up left of parents of the right person.
    426 
    427   const genOrdered = {};
    428   for (let g = 0; g <= maxG; g++) genOrdered[g] = [...(genGroups[g] || [])];
    429 
    430   for (let pass = 0; pass < 4; pass++) {
    431     // Downward sweep
    432     for (let g = 1; g <= maxG; g++) {
    433       if (!genOrdered[g].length) continue;
    434       const rank = new Map(genOrdered[g - 1].map((grp, i) => [grp, i]));
    435       genOrdered[g] = [...genOrdered[g]].sort((a, b) => {
    436         const pA = parentsOf.get(a) || [], pB = parentsOf.get(b) || [];
    437         const minA = pA.length ? Math.min(...pA.map(pg => rank.get(pg) ?? Infinity)) : Infinity;
    438         const minB = pB.length ? Math.min(...pB.map(pg => rank.get(pg) ?? Infinity)) : Infinity;
    439         if (minA !== minB) return minA - minB;
    440         // Tiebreak: a group whose rightmost parent is further right also goes right.
    441         // This handles the case where one group is a shared child of two parents
    442         // (one left, one right) while another group only has the left parent — the
    443         // shared child must sit right of the single-parent group to avoid crossings.
    444         const maxA = pA.length ? Math.max(...pA.map(pg => rank.get(pg) ?? -Infinity)) : -Infinity;
    445         const maxB = pB.length ? Math.max(...pB.map(pg => rank.get(pg) ?? -Infinity)) : -Infinity;
    446         return maxA - maxB;
    447       });
    448     }
    449     // Upward sweep
    450     for (let g = maxG - 1; g >= 0; g--) {
    451       if (!genOrdered[g].length) continue;
    452       const rank = new Map(genOrdered[g + 1].map((grp, i) => [grp, i]));
    453       const downRank = new Map(genOrdered[g].map((grp, i) => [grp, i]));
    454       genOrdered[g] = [...genOrdered[g]].sort((a, b) => {
    455         const cA = childrenOf.get(a) || [], cB = childrenOf.get(b) || [];
    456         // HIGHEST PRIORITY: if both share any child, use the slot (left/right)
    457         // of that shared child to immediately fix parent ordering. This fires
    458         // before any rank comparison so it cannot be blocked by childless groups.
    459         if (cA.length && cB.length) {
    460           const aSet = new Set(cA);
    461           for (const cg of cB) {
    462             if (aSet.has(cg)) {
    463               const sA = groupChildSlot.get(a)?.get(cg) ?? Infinity;
    464               const sB = groupChildSlot.get(b)?.get(cg) ?? Infinity;
    465               if (sA !== sB && sA !== Infinity && sB !== Infinity) return sA - sB;
    466             }
    467           }
    468         }
    469         // SECOND: leftmost child rank (only when both have children)
    470         if (cA.length && cB.length) {
    471           const rA = Math.min(...cA.map(cg => rank.get(cg) ?? Infinity));
    472           const rB = Math.min(...cB.map(cg => rank.get(cg) ?? Infinity));
    473           if (rA !== rB) return rA - rB;
    474         }
    475         // FALLBACK: preserve downward-sweep order (keeps childless groups near
    476         // their parents rather than drifting to the end of the layer).
    477         return (downRank.get(a) ?? 0) - (downRank.get(b) ?? 0);
    478       });
    479     }
    480   }
    481 
    482   // ── Phase 2: coordinate assignment ────────────────────────────────────────
    483   // Seed rough positions: equal spacing in the Phase-1 order.
    484   // These serve as the ideal fallback for groups with no children so the
    485   // bottom-up sweep never pushes them to the far right.
    486 
    487   const roughCx = new Map();
    488   for (let g = 0; g <= maxG; g++) {
    489     const groups = genOrdered[g] || [];
    490     const totalW = groups.reduce((s, grp, i) => s + grpW(grp) + (i > 0 ? MARGIN : 0), 0);
    491     let x = -totalW / 2;
    492     for (const grp of groups) {
    493       roughCx.set(grp, x + grpW(grp) / 2);
    494       x += grpW(grp) + MARGIN;
    495     }
    496   }
    497 
    498   // Refine bottom-up: centre each parent row over its children.
    499   const assignedCx = new Map(roughCx);
    500 
    501   for (let g = maxG - 1; g >= 0; g--) {
    502     const groups = genOrdered[g] || [];
    503     if (!groups.length) continue;
    504 
    505     const idealOf = new Map();
    506     for (const grp of groups) {
    507       const ch = (childrenOf.get(grp) || []).filter(cg => assignedCx.has(cg));
    508       if (ch.length) {
    509         const cxs = ch.map(cg => assignedCx.get(cg));
    510         idealOf.set(grp, (Math.min(...cxs) + Math.max(...cxs)) / 2);
    511       } else {
    512         idealOf.set(grp, roughCx.get(grp));
    513       }
    514     }
    515 
    516     // Sweep in Phase-1 order — never re-sort by ideal, that would undo the
    517     // crossing-free ordering. Only push groups right to avoid overlap.
    518     const result = [];
    519     let prevRight = null;
    520     for (const grp of groups) {
    521       const ideal = idealOf.get(grp);
    522       const half = grpW(grp) / 2;
    523       const cx = prevRight === null
    524         ? ideal
    525         : Math.max(ideal, prevRight + MARGIN + half);
    526       result.push([grp, cx]);
    527       prevRight = cx + half;
    528     }
    529 
    530     // Re-centre: shift so groups-with-children sit at their true ideal on average.
    531     const withCh = result.filter(([grp]) => (childrenOf.get(grp) || []).some(cg => assignedCx.has(cg)));
    532     if (withCh.length) {
    533       const trueIdeal = grp => {
    534         const cxs = (childrenOf.get(grp) || []).filter(cg => assignedCx.has(cg)).map(cg => assignedCx.get(cg));
    535         return (Math.min(...cxs) + Math.max(...cxs)) / 2;
    536       };
    537       const avgI = withCh.reduce((s, [grp]) => s + trueIdeal(grp), 0) / withCh.length;
    538       const avgP = withCh.reduce((s, [, cx]) => s + cx, 0) / withCh.length;
    539       const shift = avgI - avgP;
    540       for (const e of result) e[1] += shift;
    541     }
    542 
    543     for (const [grp, cx] of result) assignedCx.set(grp, cx);
    544   }
    545 
    546   // Translate group centers to per-person x positions.
    547   const assignedX = {};
    548   for (const [grp, cx] of assignedCx) {
    549     if (grp.length === 2) {
    550       assignedX[grp[0]] = cx - _NW / 2 - _CG / 2;
    551       assignedX[grp[1]] = cx + _NW / 2 + _CG / 2;
    552     } else {
    553       assignedX[grp[0]] = cx;
    554     }
    555   }
    556 
    557   for (const p of people) {
    558     pos[p.id] = { x: assignedX[p.id] ?? 0, y: gen[p.id] * _GY };
    559   }
    560   return pos;
    561 }
    562 
    563 function drawEdges(pos) {
    564   const g = gMain.append('g').attr('class', 'edges');
    565   const { partnerships, parentLinks } = state.graph;
    566 
    567   for (const ps of partnerships) {
    568     const p1 = pos[ps.person1_id], p2 = pos[ps.person2_id];
    569     if (!p1 || !p2) continue;
    570     const mx = (p1.x + p2.x) / 2;
    571     const midY = p1.y + NODE_H / 2;
    572 
    573     g.append('line').attr('class', 'edge-line')
    574       .attr('x1', p1.x + NODE_W / 2).attr('y1', midY)
    575       .attr('x2', p2.x - NODE_W / 2).attr('y2', midY);
    576 
    577     const dot = g.append('circle').attr('class', 'partner-dot')
    578       .attr('cx', mx).attr('cy', midY).attr('r', 6);
    579     dot.on('click', () => state.canEdit && openPartnershipDetail(ps));
    580 
    581     const children = parentLinks.filter(pl => pl.partnership_id === ps.id).map(pl => pl.child_id);
    582     if (children.length) {
    583       const dropY = midY + 40;
    584       const childXs = children.map(cid => pos[cid]?.x ?? mx);
    585       if (children.length === 1 && Math.abs(childXs[0] - mx) > 1) {
    586         // Single child not directly below this couple: draw a bezier curve so
    587         // the connector doesn't sit at the same horizontal level as sibling bars
    588         // from other families, which would make unrelated people look like siblings.
    589         const cp = pos[children[0]];
    590         if (cp) {
    591           g.append('path').attr('class', 'edge-line').attr('fill', 'none')
    592             .attr('d', `M${mx},${midY + 6} C${mx},${midY + 70} ${cp.x},${cp.y - 70} ${cp.x},${cp.y}`);
    593         }
    594       } else {
    595         // Standard comb: vertical stem → horizontal sibling bar → drops to children.
    596         g.append('line').attr('class', 'edge-line')
    597           .attr('x1', mx).attr('y1', midY + 6)
    598           .attr('x2', mx).attr('y2', dropY);
    599         if (children.length > 1) {
    600           const barMin = Math.min(mx, ...childXs);
    601           const barMax = Math.max(mx, ...childXs);
    602           g.append('line').attr('class', 'edge-line')
    603             .attr('x1', barMin).attr('y1', dropY)
    604             .attr('x2', barMax).attr('y2', dropY);
    605         }
    606         for (const cid of children) {
    607           const cp = pos[cid]; if (!cp) continue;
    608           g.append('line').attr('class', 'edge-line')
    609             .attr('x1', cp.x).attr('y1', dropY)
    610             .attr('x2', cp.x).attr('y2', cp.y);
    611         }
    612       }
    613     }
    614   }
    615 
    616   for (const pl of parentLinks.filter(p => p.parent_id)) {
    617     const pp = pos[pl.parent_id], cp = pos[pl.child_id];
    618     if (!pp || !cp) continue;
    619     g.append('path').attr('class', 'edge-line')
    620       .attr('d', `M${pp.x},${pp.y + NODE_H} C${pp.x},${pp.y + NODE_H + 40} ${cp.x},${cp.y - 40} ${cp.x},${cp.y}`);
    621   }
    622 }
    623 
    624 function drawNodes(pos) {
    625   const g = gMain.append('g').attr('class', 'nodes');
    626   for (const person of state.graph.people) {
    627     const { x, y } = pos[person.id] ?? { x: 0, y: 0 };
    628     const grp = g.append('g')
    629       .attr('transform', `translate(${x - NODE_W / 2},${y})`)
    630       .attr('cursor', 'pointer')
    631       .on('click', () => openDetailPanel(person.id));
    632 
    633     const rect = grp.append('rect')
    634       .attr('width', NODE_W).attr('height', NODE_H)
    635       .attr('rx', 8).attr('ry', 8)
    636       .attr('fill', 'var(--polar3)')
    637       .attr('stroke', person.id === state.selectedPersonId ? 'var(--lightblue)' : 'var(--polar4)')
    638       .attr('stroke-width', 1);
    639 
    640     grp.on('mouseenter', () => rect.attr('stroke', 'var(--lightblue)'))
    641        .on('mouseleave', () => rect.attr('stroke', person.id === state.selectedPersonId ? 'var(--lightblue)' : 'var(--polar4)'));
    642 
    643     const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ');
    644     grp.append('text')
    645       .attr('x', NODE_W / 2).attr('y', 22)
    646       .attr('text-anchor', 'middle')
    647       .attr('font-family', "'EB Garamond', Georgia, serif")
    648       .attr('font-size', 14).attr('font-weight', 600)
    649       .attr('fill', 'var(--snow3)')
    650       .text(truncate(fullName, 20));
    651 
    652     const dateStr = formatDateRange(person.birth_date, person.death_date);
    653     if (dateStr) {
    654       grp.append('text')
    655         .attr('x', NODE_W / 2).attr('y', 40)
    656         .attr('text-anchor', 'middle')
    657         .attr('font-family', "'Inter', sans-serif")
    658         .attr('font-size', 11).attr('fill', 'var(--snow1)').attr('opacity', 0.55)
    659         .text(dateStr);
    660     }
    661 
    662     if (person.burial_place) {
    663       grp.append('text')
    664         .attr('x', NODE_W / 2).attr('y', dateStr ? 54 : 40)
    665         .attr('text-anchor', 'middle')
    666         .attr('font-family', "'Inter', sans-serif")
    667         .attr('font-size', 10).attr('fill', 'var(--snow1)').attr('opacity', 0.35)
    668         .text('⚰ ' + truncate(person.burial_place, 18));
    669     }
    670   }
    671 }
    672 
    673 /* ── PDF export ── */
    674 function wrapText(text, maxChars, maxLines) {
    675   const words = text.split(/\s+/);
    676   const lines = [];
    677   let cur = '';
    678   for (const word of words) {
    679     const test = cur ? cur + ' ' + word : word;
    680     if (test.length <= maxChars) {
    681       cur = test;
    682     } else {
    683       if (cur) lines.push(cur);
    684       if (lines.length >= maxLines) break;
    685       cur = word.slice(0, maxChars);
    686     }
    687   }
    688   if (cur && lines.length < maxLines) lines.push(cur);
    689   return lines;
    690 }
    691 
    692 function drawPrintEdges(g, pos, { partnerships, parentLinks }, NW, NH) {
    693   const edgeG = g.append('g');
    694   const lineAttr = sel => sel.attr('stroke', '#444').attr('stroke-width', 1.5).attr('fill', 'none');
    695 
    696   for (const ps of partnerships) {
    697     const p1 = pos[ps.person1_id], p2 = pos[ps.person2_id];
    698     if (!p1 || !p2) continue;
    699     const mx = (p1.x + p2.x) / 2;
    700     const midY = p1.y + NH / 2;
    701 
    702     lineAttr(edgeG.append('line'))
    703       .attr('x1', p1.x + NW / 2).attr('y1', midY)
    704       .attr('x2', p2.x - NW / 2).attr('y2', midY);
    705 
    706     edgeG.append('circle').attr('cx', mx).attr('cy', midY).attr('r', 5).attr('fill', '#444');
    707 
    708     const children = parentLinks.filter(pl => pl.partnership_id === ps.id).map(pl => pl.child_id);
    709     if (children.length) {
    710       const childXs = children.map(cid => pos[cid]?.x ?? mx);
    711       const childYs = children.map(cid => pos[cid]?.y).filter(y => y !== undefined);
    712       const nearestChildY = childYs.length ? Math.min(...childYs) : p1.y + NH * 2;
    713       const dropY = p1.y + NH + Math.round((nearestChildY - p1.y - NH) * 0.35);
    714       if (children.length === 1 && Math.abs(childXs[0] - mx) > 1) {
    715         const cp = pos[children[0]];
    716         if (cp) lineAttr(edgeG.append('path'))
    717           .attr('d', `M${mx},${midY + 5} C${mx},${dropY + 50} ${cp.x},${cp.y - 50} ${cp.x},${cp.y}`);
    718       } else {
    719         lineAttr(edgeG.append('line')).attr('x1', mx).attr('y1', midY + 5).attr('x2', mx).attr('y2', dropY);
    720         if (children.length > 1) {
    721           lineAttr(edgeG.append('line'))
    722             .attr('x1', Math.min(mx, ...childXs)).attr('y1', dropY)
    723             .attr('x2', Math.max(mx, ...childXs)).attr('y2', dropY);
    724         }
    725         for (const cid of children) {
    726           const cp = pos[cid]; if (!cp) continue;
    727           lineAttr(edgeG.append('line')).attr('x1', cp.x).attr('y1', dropY).attr('x2', cp.x).attr('y2', cp.y);
    728         }
    729       }
    730     }
    731   }
    732 
    733   for (const pl of parentLinks.filter(p => p.parent_id)) {
    734     const pp = pos[pl.parent_id], cp = pos[pl.child_id];
    735     if (!pp || !cp) continue;
    736     lineAttr(edgeG.append('path'))
    737       .attr('d', `M${pp.x},${pp.y + NH} C${pp.x},${pp.y + NH + 55} ${cp.x},${cp.y - 55} ${cp.x},${cp.y}`);
    738   }
    739 }
    740 
    741 function drawPrintNodes(g, pos, people, NW, NH) {
    742   const nodeG = g.append('g');
    743   for (const person of people) {
    744     const { x, y } = pos[person.id] ?? { x: 0, y: 0 };
    745     const grp = nodeG.append('g').attr('transform', `translate(${x - NW / 2},${y})`);
    746 
    747     grp.append('rect')
    748       .attr('width', NW).attr('height', NH).attr('rx', 6).attr('ry', 6)
    749       .attr('fill', 'white').attr('stroke', '#333').attr('stroke-width', 1);
    750 
    751     const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ');
    752     grp.append('text')
    753       .attr('x', NW / 2).attr('y', 20)
    754       .attr('text-anchor', 'middle')
    755       .attr('font-family', "'EB Garamond', Georgia, serif")
    756       .attr('font-size', 14).attr('font-weight', 600).attr('fill', '#111')
    757       .text(fullName);
    758 
    759     grp.append('line')
    760       .attr('x1', 8).attr('y1', 26).attr('x2', NW - 8).attr('y2', 26)
    761       .attr('stroke', '#ccc').attr('stroke-width', 0.5);
    762 
    763     let lineY = 40;
    764     const addLine = (label, value, color = '#333') => {
    765       if (lineY > NH - 8) return;
    766       grp.append('text')
    767         .attr('x', 10).attr('y', lineY)
    768         .attr('font-family', 'Inter, sans-serif')
    769         .attr('font-size', 10).attr('fill', color)
    770         .text(label + value);
    771       lineY += 14;
    772     };
    773 
    774     const bornParts = [person.birth_date, person.birth_place].filter(Boolean);
    775     if (bornParts.length) addLine('* ', bornParts.join(' · '));
    776     if (person.death_date) addLine('† ', person.death_date);
    777     if (person.burial_place) addLine('⚰ ', truncate(person.burial_place, 26), '#555');
    778     if (person.bio && lineY <= NH - 14) {
    779       const linesAvail = Math.floor((NH - lineY - 4) / 12);
    780       if (linesAvail >= 1) {
    781         const bioLines = wrapText(person.bio, 28, linesAvail);
    782         for (const line of bioLines) {
    783           if (lineY > NH - 8) break;
    784           grp.append('text')
    785             .attr('x', 10).attr('y', lineY)
    786             .attr('font-family', 'Inter, sans-serif')
    787             .attr('font-size', 9).attr('fill', '#555').attr('font-style', 'italic')
    788             .text(line);
    789           lineY += 12;
    790         }
    791       }
    792     }
    793   }
    794 }
    795 
    796 function downloadPDF() {
    797   if (!state.activeTreeId) return;
    798   const tree = state.trees.find(t => t.id === state.activeTreeId);
    799   const NW = 225, NH = 130, GY = 280, CG = 52;
    800 
    801   const pos = computeLayout(state.graph, { nodeW: NW, genY: GY, coupleGap: CG, margin: 48 });
    802 
    803   const container = document.createElement('div');
    804   container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden';
    805   document.body.appendChild(container);
    806 
    807   const printSvg = d3.select(container).append('svg').attr('xmlns', 'http://www.w3.org/2000/svg');
    808   const printG = printSvg.append('g');
    809 
    810   drawPrintEdges(printG, pos, state.graph, NW, NH);
    811   drawPrintNodes(printG, pos, state.graph.people, NW, NH);
    812 
    813   const bb = printG.node().getBBox();
    814   const pad = 48;
    815   printSvg
    816     .attr('viewBox', `${bb.x - pad} ${bb.y - pad} ${bb.width + pad * 2} ${bb.height + pad * 2}`)
    817     .attr('width', bb.width + pad * 2)
    818     .attr('height', bb.height + pad * 2);
    819 
    820   const svgStr = new XMLSerializer().serializeToString(printSvg.node());
    821   document.body.removeChild(container);
    822 
    823   const html = `<!DOCTYPE html>
    824 <html>
    825 <head>
    826   <meta charset="utf-8">
    827   <title>Stammbaum</title>
    828   <link rel="preconnect" href="https://fonts.googleapis.com">
    829   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    830   <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=EB+Garamond:wght@400;600&display=swap" rel="stylesheet">
    831   <style>
    832     @page { size: landscape; margin: 0; }
    833     * { box-sizing: border-box; }
    834     body { margin: 8mm; background: white; display: flex; flex-direction: column; align-items: center; }
    835     h1 { font-family: 'EB Garamond', Georgia, serif; font-size: 22px; font-weight: 600; color: #111; margin: 0 0 6mm; }
    836     svg { width: 100%; height: auto; display: block; }
    837   </style>
    838 </head>
    839 <body>
    840   <h1>Stammbaum</h1>
    841   ${svgStr}
    842 </body>
    843 </html>`;
    844 
    845   const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
    846   const url = URL.createObjectURL(blob);
    847   const tab = window.open(url, '_blank');
    848   if (!tab) showError('Popup blocked — allow popups for this site.');
    849   setTimeout(() => URL.revokeObjectURL(url), 30000);
    850 }
    851 
    852 /* ── Detail panel ── */
    853 function openDetailPanel(personId) {
    854   state.selectedPersonId = personId;
    855   const person = state.graph.people.find(p => p.id === personId);
    856   if (!person) return;
    857 
    858   renderCanvas();
    859 
    860   const panel = document.getElementById('detailPanel');
    861   panel.classList.add('open');
    862 
    863   const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ');
    864   document.getElementById('detailName').textContent = fullName;
    865   document.getElementById('btnEditPerson').style.display = state.canEdit ? '' : 'none';
    866   document.getElementById('btnDeletePerson').style.display = state.canEdit ? '' : 'none';
    867 
    868   let html = '';
    869 
    870   if (person.birth_date || person.birth_place)
    871     html += field(t('born'), [person.birth_date, person.birth_place].filter(Boolean).join(' · '));
    872   if (person.death_date) html += field(t('died'), person.death_date);
    873   if (person.burial_place) html += field(t('buried'), person.burial_place);
    874   if (person.bio) html += field(t('biography'), person.bio);
    875 
    876   const myPartnerships = state.graph.partnerships.filter(
    877     ps => ps.person1_id === personId || ps.person2_id === personId
    878   );
    879   if (myPartnerships.length) {
    880     html += `<p class="detail-section-title">${t('partnerships')}</p>`;
    881     for (const ps of myPartnerships) {
    882       const otherId = ps.person1_id === personId ? ps.person2_id : ps.person1_id;
    883       const other = state.graph.people.find(p => p.id === otherId);
    884       const otherName = other ? [other.first_name, other.last_name].filter(Boolean).join(' ') : '—';
    885       const label = { married: t('rel-married'), separated: t('rel-separated'), partnered: t('rel-partner') }[ps.union_type] ?? ps.union_type;
    886       html += `<div class="detail-rel-item" onclick="openDetailPanel(${otherId})">
    887         <span>${esc(otherName)}</span>
    888         <span class="detail-rel-badge">${label}${ps.union_date ? ' ' + ps.union_date.slice(0, 4) : ''}</span>
    889       </div>`;
    890     }
    891   }
    892 
    893   const childIds = [...new Set([
    894     ...myPartnerships.flatMap(ps => state.graph.parentLinks.filter(pl => pl.partnership_id === ps.id).map(pl => pl.child_id)),
    895     ...state.graph.parentLinks.filter(pl => pl.parent_id === personId).map(pl => pl.child_id),
    896   ])];
    897   if (childIds.length) {
    898     html += `<p class="detail-section-title">${t('children')}</p>`;
    899     for (const cid of childIds) {
    900       const child = state.graph.people.find(p => p.id === cid);
    901       if (!child) continue;
    902       html += `<div class="detail-rel-item" onclick="openDetailPanel(${cid})">
    903         <span>${esc([child.first_name, child.last_name].filter(Boolean).join(' '))}</span>
    904       </div>`;
    905     }
    906   }
    907 
    908   const myParentLinks = state.graph.parentLinks.filter(pl => pl.child_id === personId);
    909   const parentPeople = [];
    910   for (const pl of myParentLinks) {
    911     if (pl.partnership_id) {
    912       const ps = state.graph.partnerships.find(p => p.id === pl.partnership_id);
    913       if (ps) [ps.person1_id, ps.person2_id].forEach(pid => { if (!parentPeople.includes(pid)) parentPeople.push(pid); });
    914     } else if (pl.parent_id && !parentPeople.includes(pl.parent_id)) {
    915       parentPeople.push(pl.parent_id);
    916     }
    917   }
    918   if (parentPeople.length) {
    919     html += `<p class="detail-section-title">${t('parents')}</p>`;
    920     for (const pid of parentPeople) {
    921       const parent = state.graph.people.find(p => p.id === pid);
    922       if (!parent) continue;
    923       html += `<div class="detail-rel-item" onclick="openDetailPanel(${pid})">
    924         <span>${esc([parent.first_name, parent.last_name].filter(Boolean).join(' '))}</span>
    925       </div>`;
    926     }
    927   }
    928 
    929   // Siblings: people who share a parent link with this person
    930   const siblingIds = [];
    931   for (const pl of myParentLinks) {
    932     const sharedLinks = state.graph.parentLinks.filter(spl =>
    933       spl.child_id !== personId &&
    934       ((pl.partnership_id && spl.partnership_id === pl.partnership_id) ||
    935        (pl.parent_id && spl.parent_id === pl.parent_id))
    936     );
    937     for (const spl of sharedLinks) {
    938       if (!siblingIds.includes(spl.child_id)) siblingIds.push(spl.child_id);
    939     }
    940   }
    941   if (siblingIds.length) {
    942     html += `<p class="detail-section-title">${t('siblings')}</p>`;
    943     for (const sid of siblingIds) {
    944       const sib = state.graph.people.find(p => p.id === sid);
    945       if (!sib) continue;
    946       html += `<div class="detail-rel-item" onclick="openDetailPanel(${sid})">
    947         <span>${esc([sib.first_name, sib.last_name].filter(Boolean).join(' '))}</span>
    948       </div>`;
    949     }
    950   }
    951 
    952   if (state.canEdit) {
    953     html += `<p class="detail-section-title">${t('add-relationship')}</p>`;
    954     if (!myPartnerships.length) {
    955       html += `<button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddPartnershipModal(${personId})">${t('add-partnership-btn')}</button>`;
    956     }
    957     html += `<button class="btn" style="width:100%;margin-bottom:0.5rem" onclick="openAddParentLinkModal(${personId},'child')">${t('set-parents-btn')}</button>
    958       <button class="btn" style="width:100%" onclick="openAddParentLinkModal(null,'parent',${personId})">${t('add-child-btn')}</button>`;
    959   }
    960 
    961   document.getElementById('detailBody').innerHTML = html;
    962 }
    963 
    964 function closeDetailPanel() {
    965   state.selectedPersonId = null;
    966   document.getElementById('detailPanel').classList.remove('open');
    967 }
    968 
    969 function field(label, value) {
    970   return `<div class="detail-field">
    971     <div class="detail-field-label">${esc(label)}</div>
    972     <div class="detail-field-value">${esc(String(value))}</div>
    973   </div>`;
    974 }
    975 
    976 /* ── Partnership detail (dot click) ── */
    977 function openPartnershipDetail(ps) {
    978   state._editingPartnershipId = ps.id;
    979   const p1 = state.graph.people.find(p => p.id === ps.person1_id);
    980   const p2 = state.graph.people.find(p => p.id === ps.person2_id);
    981   const n1 = p1 ? [p1.first_name, p1.last_name].filter(Boolean).join(' ') : '?';
    982   const n2 = p2 ? [p2.first_name, p2.last_name].filter(Boolean).join(' ') : '?';
    983   document.getElementById('modalPartnershipTitle').textContent = `${n1} & ${n2}`;
    984   document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
    985   document.getElementById('partnerType').closest('.form-group').style.display = '';
    986   document.getElementById('partnerStart').closest('.form-row').style.display = '';
    987   document.getElementById('btnPartnerSave').textContent = t('save');
    988   state._siblingMode = false;
    989   populatePersonSelects('partnerPerson1', 'partnerPerson2');
    990   document.getElementById('partnerPerson1').value = ps.person1_id;
    991   document.getElementById('partnerPerson2').value = ps.person2_id;
    992   document.getElementById('partnerType').value = ps.union_type;
    993   document.getElementById('partnerStart').value = ps.union_date ?? '';
    994   document.getElementById('partnerEnd').value = ps.union_end_date ?? '';
    995   openModal('modalPartnership');
    996 }
    997 
    998 /* ── Context menu ── */
    999 function openCtxMenu(e, treeId) {
   1000   e.preventDefault();
   1001   state._ctxTreeId = treeId;
   1002   const tree = state.trees.find(t => t.id === treeId);
   1003   const isOwner = tree?.role === 'owner';
   1004   const menu = document.getElementById('ctxMenu');
   1005   document.getElementById('ctxRename').style.display = isOwner ? '' : 'none';
   1006   document.getElementById('ctxDelete').style.display = isOwner ? '' : 'none';
   1007   document.getElementById('ctxLeave').style.display = isOwner ? 'none' : '';
   1008   menu.style.left = e.clientX + 'px';
   1009   menu.style.top = e.clientY + 'px';
   1010   menu.classList.add('open');
   1011 }
   1012 
   1013 document.addEventListener('click', () => document.getElementById('ctxMenu').classList.remove('open'));
   1014 
   1015 /* ── Modal helpers ── */
   1016 function openModal(id) { document.getElementById(id).classList.add('open'); }
   1017 function closeModal(id) { document.getElementById(id).classList.remove('open'); }
   1018 
   1019 function populatePersonSelects(...ids) {
   1020   const options = state.graph.people.map(p => {
   1021     const name = [p.first_name, p.last_name].filter(Boolean).join(' ');
   1022     return `<option value="${p.id}">${esc(name)}</option>`;
   1023   }).join('');
   1024   for (const id of ids) document.getElementById(id).innerHTML = options;
   1025 }
   1026 
   1027 /* ── Tree modals ── */
   1028 document.getElementById('btnNewTree').addEventListener('click', openNewTreeModal);
   1029 document.getElementById('emptyNewTree').addEventListener('click', openNewTreeModal);
   1030 
   1031 function openNewTreeModal() {
   1032   state._editingTreeId = null;
   1033   document.getElementById('modalTreeTitle').textContent = t('new-tree-title');
   1034   document.getElementById('btnTreeSave').textContent = t('create');
   1035   document.getElementById('treeNameInput').value = '';
   1036   openModal('modalTree');
   1037   setTimeout(() => document.getElementById('treeNameInput').focus(), 50);
   1038 }
   1039 
   1040 document.getElementById('btnTreeCancel').addEventListener('click', () => closeModal('modalTree'));
   1041 document.getElementById('btnTreeSave').addEventListener('click', async () => {
   1042   const name = document.getElementById('treeNameInput').value.trim();
   1043   if (!name) return;
   1044   try {
   1045     if (state._editingTreeId) {
   1046       await api('PATCH', `/api/trees/${state._editingTreeId}`, { name });
   1047     } else {
   1048       const t = await api('POST', '/api/trees', { name });
   1049       state.activeTreeId = t.id;
   1050     }
   1051     closeModal('modalTree');
   1052     await loadTrees();
   1053     if (state.activeTreeId) await loadGraph(state.activeTreeId);
   1054   } catch(e) { showError(e.message); }
   1055 });
   1056 
   1057 /* ── Context menu actions ── */
   1058 document.getElementById('ctxRename').addEventListener('click', () => {
   1059   const tree = state.trees.find(t => t.id === state._ctxTreeId);
   1060   if (!tree) return;
   1061   state._editingTreeId = tree.id;
   1062   document.getElementById('modalTreeTitle').textContent = t('rename-tree-title');
   1063   document.getElementById('btnTreeSave').textContent = t('save');
   1064   document.getElementById('treeNameInput').value = tree.name;
   1065   openModal('modalTree');
   1066 });
   1067 
   1068 document.getElementById('ctxMembers').addEventListener('click', () => openMembersModal(state._ctxTreeId));
   1069 
   1070 document.getElementById('ctxDelete').addEventListener('click', async () => {
   1071   if (!confirm(t('confirm-delete-tree'))) return;
   1072   try {
   1073     await api('DELETE', `/api/trees/${state._ctxTreeId}`);
   1074     if (state.activeTreeId === state._ctxTreeId) selectTree(null);
   1075     await loadTrees();
   1076   } catch(e) { showError(e.message); }
   1077 });
   1078 
   1079 document.getElementById('ctxLeave').addEventListener('click', async () => {
   1080   if (!confirm(t('confirm-leave-tree'))) return;
   1081   try {
   1082     await api('DELETE', `/api/trees/${state._ctxTreeId}/leave`);
   1083     if (state.activeTreeId === state._ctxTreeId) selectTree(null);
   1084     await loadTrees();
   1085   } catch(e) { showError(e.message); }
   1086 });
   1087 
   1088 /* ── Add/edit person modal ── */
   1089 document.getElementById('btnAddPerson').addEventListener('click', () => openPersonModal(null));
   1090 
   1091 function openPersonModal(person) {
   1092   state._editingPersonId = person?.id ?? null;
   1093   document.getElementById('modalPersonTitle').textContent = person ? t('edit-person-title') : t('add-person-title');
   1094   document.getElementById('personFirstName').value = person?.first_name ?? '';
   1095   document.getElementById('personLastName').value = person?.last_name ?? '';
   1096   document.getElementById('personBirthDate').value = person?.birth_date ?? '';
   1097   document.getElementById('personDeathDate').value = person?.death_date ?? '';
   1098   document.getElementById('personBirthPlace').value = person?.birth_place ?? '';
   1099   document.getElementById('personBurialPlace').value = person?.burial_place ?? '';
   1100   document.getElementById('personBio').value = person?.bio ?? '';
   1101   openModal('modalPerson');
   1102   setTimeout(() => document.getElementById('personFirstName').focus(), 50);
   1103 }
   1104 
   1105 document.getElementById('btnPersonCancel').addEventListener('click', () => closeModal('modalPerson'));
   1106 
   1107 document.getElementById('btnPersonSave').addEventListener('click', async () => {
   1108   const body = {
   1109     first_name: document.getElementById('personFirstName').value.trim(),
   1110     last_name: document.getElementById('personLastName').value.trim() || null,
   1111     birth_date: document.getElementById('personBirthDate').value.trim() || null,
   1112     death_date: document.getElementById('personDeathDate').value.trim() || null,
   1113     birth_place: document.getElementById('personBirthPlace').value.trim() || null,
   1114     burial_place: document.getElementById('personBurialPlace').value.trim() || null,
   1115     bio: document.getElementById('personBio').value.trim() || null,
   1116   };
   1117   if (!body.first_name) { alert(t('alert-first-name-req')); return; }
   1118   try {
   1119     const isNew = !state._editingPersonId;
   1120     let saved;
   1121     if (state._editingPersonId) {
   1122       saved = await api('PATCH', `/api/trees/${state.activeTreeId}/people/${state._editingPersonId}`, body);
   1123     } else {
   1124       saved = await api('POST', `/api/trees/${state.activeTreeId}/people`, body);
   1125     }
   1126     closeModal('modalPerson');
   1127     await loadGraph(state.activeTreeId);
   1128     // After creating (not editing) a person, offer to connect them if others exist
   1129     if (isNew && state.graph.people.length > 1) {
   1130       openConnectModal(saved.id);
   1131     }
   1132   } catch(e) {
   1133     showError('Could not save: ' + e.message);
   1134   }
   1135 });
   1136 
   1137 /* ── Edit / delete from detail panel ── */
   1138 document.getElementById('btnEditPerson').addEventListener('click', () => {
   1139   const person = state.graph.people.find(p => p.id === state.selectedPersonId);
   1140   if (person) openPersonModal(person);
   1141 });
   1142 
   1143 document.getElementById('btnDeletePerson').addEventListener('click', async () => {
   1144   if (!confirm(t('confirm-delete-person'))) return;
   1145   try {
   1146     await api('DELETE', `/api/trees/${state.activeTreeId}/people/${state.selectedPersonId}`);
   1147     closeDetailPanel();
   1148     await loadGraph(state.activeTreeId);
   1149   } catch(e) { showError(e.message); }
   1150 });
   1151 
   1152 document.getElementById('btnCloseDetail').addEventListener('click', closeDetailPanel);
   1153 
   1154 /* ── Connect modal ── */
   1155 function openConnectModal(personId) {
   1156   state._connectingPersonId = personId;
   1157   const person = state.graph.people.find(p => p.id === personId);
   1158   const name = person ? [person.first_name, person.last_name].filter(Boolean).join(' ') : '';
   1159   document.getElementById('connectPersonName').textContent = name;
   1160   openModal('modalConnect');
   1161 }
   1162 
   1163 document.getElementById('connectAsPartner').addEventListener('click', () => {
   1164   closeModal('modalConnect');
   1165   openAddPartnershipModal(state._connectingPersonId);
   1166 });
   1167 
   1168 document.getElementById('connectAsChild').addEventListener('click', () => {
   1169   closeModal('modalConnect');
   1170   openAddParentLinkModal(state._connectingPersonId, 'child');
   1171 });
   1172 
   1173 document.getElementById('connectAsParent').addEventListener('click', () => {
   1174   closeModal('modalConnect');
   1175   openAddParentLinkModal(null, 'parent', state._connectingPersonId);
   1176 });
   1177 
   1178 document.getElementById('connectAsSibling').addEventListener('click', () => {
   1179   closeModal('modalConnect');
   1180   openSiblingModal(state._connectingPersonId);
   1181 });
   1182 
   1183 document.getElementById('btnConnectSkip').addEventListener('click', () => closeModal('modalConnect'));
   1184 
   1185 /* ── Sibling modal: select an existing person → inherit their parent links ── */
   1186 function openSiblingModal(newPersonId) {
   1187   const others = state.graph.people.filter(p => p.id !== newPersonId);
   1188   if (!others.length) { showError('No other people in the tree.'); return; }
   1189   state._siblingMode = true;
   1190   state._siblingModePersonId = newPersonId;
   1191   document.getElementById('modalPartnershipTitle').textContent = t('sibling-of-title');
   1192   // Show only person1 select; hide partner/date fields
   1193   populatePersonSelects('partnerPerson1');
   1194   Array.from(document.getElementById('partnerPerson1').options)
   1195     .forEach(o => { if (Number(o.value) === newPersonId) o.remove(); });
   1196   document.getElementById('partnerPerson2').closest('.form-group').style.display = 'none';
   1197   document.getElementById('partnerType').closest('.form-group').style.display = 'none';
   1198   document.getElementById('partnerStart').closest('.form-row').style.display = 'none';
   1199   document.getElementById('btnPartnerSave').textContent = t('add-sibling-btn');
   1200   openModal('modalPartnership');
   1201 }
   1202 
   1203 /* ── Partnership modal ── */
   1204 function openAddPartnershipModal(preselectedPersonId) {
   1205   if (state.graph.people.length < 2) { alert(t('alert-add-two-people')); return; }
   1206   state._editingPartnershipId = null;
   1207   state._siblingMode = false;
   1208   document.getElementById('modalPartnershipTitle').textContent = t('add-partnership-title');
   1209   document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
   1210   document.getElementById('partnerType').closest('.form-group').style.display = '';
   1211   document.getElementById('partnerStart').closest('.form-row').style.display = '';
   1212   document.getElementById('btnPartnerSave').textContent = t('save');
   1213   populatePersonSelects('partnerPerson1', 'partnerPerson2');
   1214   if (preselectedPersonId) document.getElementById('partnerPerson1').value = preselectedPersonId;
   1215   document.getElementById('partnerType').value = 'partnered';
   1216   document.getElementById('partnerStart').value = '';
   1217   document.getElementById('partnerEnd').value = '';
   1218   openModal('modalPartnership');
   1219 }
   1220 
   1221 document.getElementById('btnPartnerCancel').addEventListener('click', () => {
   1222   state._siblingMode = false;
   1223   // Restore hidden fields
   1224   document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
   1225   document.getElementById('partnerType').closest('.form-group').style.display = '';
   1226   document.getElementById('partnerStart').closest('.form-row').style.display = '';
   1227   closeModal('modalPartnership');
   1228 });
   1229 
   1230 document.getElementById('btnPartnerSave').addEventListener('click', async () => {
   1231   if (state._siblingMode) {
   1232     const siblingId = parseInt(document.getElementById('partnerPerson1').value);
   1233     const newPersonId = state._siblingModePersonId;
   1234     state._siblingMode = false;
   1235     document.getElementById('partnerPerson2').closest('.form-group').style.display = '';
   1236     document.getElementById('partnerType').closest('.form-group').style.display = '';
   1237     document.getElementById('partnerStart').closest('.form-row').style.display = '';
   1238     closeModal('modalPartnership');
   1239     try {
   1240       const siblingLinks = state.graph.parentLinks.filter(pl => pl.child_id === siblingId);
   1241       if (!siblingLinks.length) {
   1242         showError(t('alert-no-parents'));
   1243         return;
   1244       }
   1245       for (const pl of siblingLinks) {
   1246         await api('POST', `/api/trees/${state.activeTreeId}/parentlinks`, {
   1247           child_id: newPersonId,
   1248           partnership_id: pl.partnership_id ?? null,
   1249           parent_id: pl.parent_id ?? null,
   1250         });
   1251       }
   1252       await loadGraph(state.activeTreeId);
   1253     } catch(e) { showError(e.message); }
   1254     return;
   1255   }
   1256 
   1257   const p1 = parseInt(document.getElementById('partnerPerson1').value);
   1258   const p2 = parseInt(document.getElementById('partnerPerson2').value);
   1259   if (p1 === p2) { alert(t('alert-select-diff')); return; }
   1260   const body = {
   1261     person1_id: p1, person2_id: p2,
   1262     union_type: document.getElementById('partnerType').value,
   1263     union_date: document.getElementById('partnerStart').value.trim() || null,
   1264     union_end_date: document.getElementById('partnerEnd').value.trim() || null,
   1265   };
   1266   try {
   1267     if (state._editingPartnershipId) {
   1268       await api('PATCH', `/api/trees/${state.activeTreeId}/partnerships/${state._editingPartnershipId}`, {
   1269         union_type: body.union_type, union_date: body.union_date, union_end_date: body.union_end_date,
   1270       });
   1271     } else {
   1272       await api('POST', `/api/trees/${state.activeTreeId}/partnerships`, body);
   1273     }
   1274     closeModal('modalPartnership');
   1275     await loadGraph(state.activeTreeId);
   1276     if (state.selectedPersonId) openDetailPanel(state.selectedPersonId);
   1277   } catch(e) { showError(e.message); }
   1278 });
   1279 
   1280 /* ── Parent link modal ── */
   1281 function openAddParentLinkModal(childPreselect, mode, parentPreselect) {
   1282   populatePersonSelects('linkChild', 'linkParent');
   1283 
   1284   if (mode === 'child' && childPreselect != null) {
   1285     Array.from(document.getElementById('linkParent').options)
   1286       .forEach(o => { if (Number(o.value) === childPreselect) o.remove(); });
   1287     document.getElementById('linkChild').value = childPreselect;
   1288   }
   1289   if (mode === 'parent' && parentPreselect != null) {
   1290     Array.from(document.getElementById('linkChild').options)
   1291       .forEach(o => { if (Number(o.value) === parentPreselect) o.remove(); });
   1292   }
   1293 
   1294   const psSelect = document.getElementById('linkPartnership');
   1295   psSelect.innerHTML = state.graph.partnerships.map(ps => {
   1296     const p1 = state.graph.people.find(p => p.id === ps.person1_id);
   1297     const p2 = state.graph.people.find(p => p.id === ps.person2_id);
   1298     const n1 = p1 ? [p1.first_name, p1.last_name].filter(Boolean).join(' ') : '?';
   1299     const n2 = p2 ? [p2.first_name, p2.last_name].filter(Boolean).join(' ') : '?';
   1300     return `<option value="${ps.id}">${esc(n1)} & ${esc(n2)}</option>`;
   1301   }).join('');
   1302 
   1303   if (!psSelect.options.length) {
   1304     document.getElementById('linkViaType').value = 'single';
   1305     document.getElementById('linkPartnershipGroup').style.display = 'none';
   1306     document.getElementById('linkParentGroup').style.display = '';
   1307     if (mode === 'parent' && parentPreselect != null)
   1308       document.getElementById('linkParent').value = parentPreselect;
   1309   } else {
   1310     document.getElementById('linkViaType').value = 'partnership';
   1311     document.getElementById('linkPartnershipGroup').style.display = '';
   1312     document.getElementById('linkParentGroup').style.display = 'none';
   1313   }
   1314 
   1315   openModal('modalParentLink');
   1316 }
   1317 
   1318 window.app = {
   1319   onLinkViaTypeChange() {
   1320     const via = document.getElementById('linkViaType').value;
   1321     document.getElementById('linkPartnershipGroup').style.display = via === 'partnership' ? '' : 'none';
   1322     document.getElementById('linkParentGroup').style.display = via === 'single' ? '' : 'none';
   1323   }
   1324 };
   1325 
   1326 document.getElementById('btnLinkCancel').addEventListener('click', () => closeModal('modalParentLink'));
   1327 document.getElementById('btnLinkSave').addEventListener('click', async () => {
   1328   const childId = parseInt(document.getElementById('linkChild').value);
   1329   const via = document.getElementById('linkViaType').value;
   1330   const body = { child_id: childId };
   1331   if (via === 'partnership') {
   1332     body.partnership_id = parseInt(document.getElementById('linkPartnership').value);
   1333   } else {
   1334     body.parent_id = parseInt(document.getElementById('linkParent').value);
   1335   }
   1336   try {
   1337     await api('POST', `/api/trees/${state.activeTreeId}/parentlinks`, body);
   1338     closeModal('modalParentLink');
   1339     await loadGraph(state.activeTreeId);
   1340     if (state.selectedPersonId) openDetailPanel(state.selectedPersonId);
   1341   } catch(e) { showError(e.message); }
   1342 });
   1343 
   1344 /* ── Members modal ── */
   1345 async function openMembersModal(treeId) {
   1346   state._ctxTreeId = treeId;
   1347   await loadTrees();
   1348   renderMembersModal(treeId);
   1349   openModal('modalMembers');
   1350 }
   1351 
   1352 function renderMembersModal(treeId) {
   1353   const tree = state.trees.find(t => t.id === treeId);
   1354   if (!tree) return;
   1355   const isOwner = tree.role === 'owner';
   1356 
   1357   document.getElementById('membersList').innerHTML = tree.members.map(m => `
   1358     <div class="member-row">
   1359       <span class="member-username">${esc(m.display_name || m.username)}</span>
   1360       <span class="member-role">${m.role}</span>
   1361       ${isOwner && m.role !== 'owner' ? `
   1362         <button class="btn-icon danger" title="${t('remove')}" onclick="removeMember(${treeId},'${esc(m.username)}')">
   1363           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
   1364         </button>` : ''}
   1365     </div>`).join('');
   1366 
   1367   document.getElementById('pendingList').innerHTML = tree.pending_invitations?.length ? (
   1368     `<p class="form-label" style="margin-top:1rem;margin-bottom:0.4rem">${t('pending')}</p>` +
   1369     tree.pending_invitations.map(inv => `
   1370       <div class="pending-row">
   1371         <span>${esc(inv.email)} <em style="opacity:0.5">(${inv.role})</em></span>
   1372         ${isOwner ? `<button class="btn-icon danger" onclick="revokeInvitation(${treeId},'${esc(inv.email)}')">
   1373           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
   1374         </button>` : ''}
   1375       </div>`).join('')
   1376   ) : '';
   1377 
   1378   document.querySelector('.members-invite-section').style.display = isOwner ? '' : 'none';
   1379 }
   1380 
   1381 window.removeMember = async (treeId, username) => {
   1382   if (!confirm(`${t('remove')} ${username}?`)) return;
   1383   try {
   1384     await api('DELETE', `/api/trees/${treeId}/members/${username}`);
   1385     await loadTrees(); renderMembersModal(treeId);
   1386   } catch(e) { showError(e.message); }
   1387 };
   1388 
   1389 window.revokeInvitation = async (treeId, email) => {
   1390   try {
   1391     await api('DELETE', `/api/trees/${treeId}/invitations/${encodeURIComponent(email)}`);
   1392     await loadTrees(); renderMembersModal(treeId);
   1393   } catch(e) { showError(e.message); }
   1394 };
   1395 
   1396 document.getElementById('btnInviteSend').addEventListener('click', async () => {
   1397   const username = document.getElementById('inviteUsername').value.trim();
   1398   const role = document.getElementById('inviteRole').value;
   1399   if (!username) return;
   1400   try {
   1401     await api('POST', `/api/trees/${state._ctxTreeId}/members`, { username, role });
   1402     document.getElementById('inviteUsername').value = '';
   1403     await loadTrees(); renderMembersModal(state._ctxTreeId);
   1404   } catch(e) { showError(e.message); }
   1405 });
   1406 
   1407 document.getElementById('btnDownloadPDF').addEventListener('click', downloadPDF);
   1408 
   1409 document.getElementById('btnMembersClose').addEventListener('click', () => closeModal('modalMembers'));
   1410 
   1411 /* ── Toolbar ── */
   1412 document.getElementById('btnZoomIn').addEventListener('click', () => svg?.transition().call(zoom.scaleBy, 1.3));
   1413 document.getElementById('btnZoomOut').addEventListener('click', () => svg?.transition().call(zoom.scaleBy, 1 / 1.3));
   1414 document.getElementById('btnFit').addEventListener('click', fitCanvas);
   1415 document.getElementById('sidebarToggle').addEventListener('click', () => {
   1416   document.getElementById('sidebar').classList.toggle('collapsed');
   1417 });
   1418 
   1419 /* ── Close modals on backdrop / Escape ── */
   1420 document.querySelectorAll('.modal-overlay').forEach(overlay => {
   1421   overlay.addEventListener('click', e => { if (e.target === overlay) overlay.classList.remove('open'); });
   1422 });
   1423 document.addEventListener('keydown', e => {
   1424   if (e.key === 'Escape') {
   1425     document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
   1426     document.getElementById('ctxMenu').classList.remove('open');
   1427   }
   1428 });
   1429 
   1430 /* ── Helpers ── */
   1431 function esc(s) {
   1432   return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
   1433 }
   1434 function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
   1435 function formatDateRange(birth, death) {
   1436   if (!birth && !death) return '';
   1437   const year = d => d.slice(-4);
   1438   const b = birth ? year(birth) : '?';
   1439   return death ? `${b} – ${year(death)}` : b;
   1440 }
   1441 
   1442 /* ── Language change ── */
   1443 document.addEventListener('langchange', () => {
   1444   if (state.trees.length) renderSidebar();
   1445   if (state.activeTreeId) renderCanvas();
   1446   if (state.selectedPersonId) openDetailPanel(state.selectedPersonId);
   1447 });
   1448 
   1449 /* ── Init ── */
   1450 showEmptyState();
   1451 boot().catch(console.error);