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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 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);