diff --git a/script.js b/script.js index 82a0b22..e3e8dcf 100644 --- a/script.js +++ b/script.js @@ -459,6 +459,7 @@ // Once DOM elements are placed, calculate and draw connecting branches requestAnimationFrame(() => { drawRelationshipLines(memberMap); + setTimeout(() => drawRelationshipLines(memberMap), 60); }); } @@ -528,19 +529,66 @@ } /** - * Draw SVG curved connecting branches between parents and children + * Get unscaled local coordinates of an element inside treeCanvas. + * Guaranteed to be zoom/pan invariant so SVG lines attach precisely to cards. + */ + function getElementCanvasBox(elem) { + if (!elem) return null; + const elemRect = elem.getBoundingClientRect(); + const canvasRect = treeCanvas.getBoundingClientRect(); + const scale = zoomScale || 1; + + const left = (elemRect.left - canvasRect.left) / scale; + const right = (elemRect.right - canvasRect.left) / scale; + const top = (elemRect.top - canvasRect.top) / scale; + const bottom = (elemRect.bottom - canvasRect.top) / scale; + + return { + left, + right, + top, + bottom, + centerX: (left + right) / 2, + centerY: (top + bottom) / 2, + width: right - left, + height: bottom - top + }; + } + + /** + * Draw SVG curved connecting branches between parents, spouses, and children */ function drawRelationshipLines(memberMap) { treeSvg.innerHTML = ''; - const layerRect = treeNodesLayer.getBoundingClientRect(); - if (layerRect.width === 0 || layerRect.height === 0) return; + if (!memberMap) { + memberMap = new Map(familyMembers.map(m => [m.id, m])); + } - // Resize SVG coordinate canvas to match layer - treeSvg.setAttribute('width', treeNodesLayer.scrollWidth); - treeSvg.setAttribute('height', treeNodesLayer.scrollHeight); + // Ensure SVG 1:1 pixel mapping without viewBox scaling distortion + treeSvg.removeAttribute('viewBox'); + treeSvg.removeAttribute('width'); + treeSvg.removeAttribute('height'); + treeSvg.style.width = '100%'; + treeSvg.style.height = '100%'; - // Group children by parent combinations + // 1. Draw horizontal connection line between married couples + const coupleContainers = document.querySelectorAll('.couple-container'); + coupleContainers.forEach(coupleDiv => { + const cards = coupleDiv.querySelectorAll('.person-card'); + if (cards.length >= 2) { + const box1 = getElementCanvasBox(cards[0]); + const box2 = getElementCanvasBox(cards[1]); + if (box1 && box2) { + const spousePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + spousePath.setAttribute('class', 'relation-line spouse'); + spousePath.setAttribute('d', `M ${box1.right} ${box1.centerY} L ${box2.left} ${box2.centerY}`); + treeSvg.appendChild(spousePath); + } + } + }); + + // 2. Group children by parent combinations const familyGroups = new Map(); familyMembers.forEach(child => { @@ -558,7 +606,7 @@ familyGroups.get(parentsKey).childrenIds.push(child.id); }); - // Draw connections for each parent group + // 3. Draw connections from each parent group down to their children familyGroups.forEach(group => { const { parentIds, childrenIds } = group; @@ -569,80 +617,112 @@ if (parentIds.length === 2) { const card1 = document.getElementById(`card_${parentIds[0]}`); const card2 = document.getElementById(`card_${parentIds[1]}`); + const box1 = getElementCanvasBox(card1); + const box2 = getElementCanvasBox(card2); - if (card1 && card2) { - const r1 = card1.getBoundingClientRect(); - const r2 = card2.getBoundingClientRect(); - - const x1 = (r1.left + r1.right) / 2 - layerRect.left; - const x2 = (r2.left + r2.right) / 2 - layerRect.left; - const y1 = r1.bottom - layerRect.top; - const y2 = r2.bottom - layerRect.top; - - originX = (x1 + x2) / 2; - originY = Math.max(y1, y2); - } else if (card1 || card2) { - const single = card1 || card2; - const r = single.getBoundingClientRect(); - originX = (r.left + r.right) / 2 - layerRect.left; - originY = r.bottom - layerRect.top; + if (box1 && box2) { + originX = (box1.centerX + box2.centerX) / 2; + // Check if marriage symbol exists + const symbol = document.querySelector(`#couple_${parentIds[0]}_${parentIds[1]} .marriage-symbol`) + || document.querySelector(`#couple_${parentIds[1]}_${parentIds[0]} .marriage-symbol`); + const symbolBox = getElementCanvasBox(symbol); + if (symbolBox) { + originX = symbolBox.centerX; + originY = symbolBox.bottom; + } else { + originY = Math.max(box1.bottom, box2.bottom); + } + } else if (box1 || box2) { + const single = box1 || box2; + originX = single.centerX; + originY = single.bottom; } } else if (parentIds.length === 1) { const card = document.getElementById(`card_${parentIds[0]}`); - if (card) { - const r = card.getBoundingClientRect(); - originX = (r.left + r.right) / 2 - layerRect.left; - originY = r.bottom - layerRect.top; + const box = getElementCanvasBox(card); + if (box) { + originX = box.centerX; + originY = box.bottom; } } if (!originX || !originY) return; // Collect valid child card coordinates - const childCoords = []; + const childBoxes = []; childrenIds.forEach(cid => { const cCard = document.getElementById(`card_${cid}`); - if (cCard) { - const cr = cCard.getBoundingClientRect(); - childCoords.push({ - id: cid, - x: (cr.left + cr.right) / 2 - layerRect.left, - y: cr.top - layerRect.top - }); + const cBox = getElementCanvasBox(cCard); + if (cBox) { + childBoxes.push({ id: cid, box: cBox }); } }); - if (childCoords.length === 0) return; + if (childBoxes.length === 0) return; - // Compute children bus line level - const minChildY = Math.min(...childCoords.map(c => c.y)); - const busY = originY + (minChildY - originY) * 0.5; + // Sort child cards left-to-right by centerX + childBoxes.sort((a, b) => a.box.centerX - b.box.centerX); - // Path 1: Down from parent(s) origin to bus bar + const minChildTop = Math.min(...childBoxes.map(c => c.box.top)); + // Bus bar Y: midway between parent bottom and child top + const busY = originY + (minChildTop - originY) * 0.5; + + // Vertical stem down from parent(s) to the bus bar const stemPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); stemPath.setAttribute('class', 'relation-line'); stemPath.setAttribute('d', `M ${originX} ${originY} L ${originX} ${busY}`); treeSvg.appendChild(stemPath); - // Path 2: Horizontal bus bar covering all children and parent origin - const allXs = [...childCoords.map(c => c.x), originX]; - const minX = Math.min(...allXs); - const maxX = Math.max(...allXs); + if (childBoxes.length === 1) { + const cBox = childBoxes[0].box; + if (Math.abs(originX - cBox.centerX) < 3) { + const directDrop = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + directDrop.setAttribute('class', 'relation-line'); + directDrop.setAttribute('d', `M ${originX} ${busY} L ${cBox.centerX} ${cBox.top}`); + treeSvg.appendChild(directDrop); + } else { + const singleBusPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + singleBusPath.setAttribute('class', 'relation-line'); + singleBusPath.setAttribute('d', `M ${originX} ${busY} L ${cBox.centerX} ${busY} L ${cBox.centerX} ${cBox.top}`); + treeSvg.appendChild(singleBusPath); + } + } else { + // Multiple children + const minChildX = childBoxes[0].box.centerX; + const maxChildX = childBoxes[childBoxes.length - 1].box.centerX; - if (maxX - minX > 2) { + const busLeft = Math.min(minChildX, originX); + const busRight = Math.max(maxChildX, originX); + + // Horizontal bus bar const busPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); busPath.setAttribute('class', 'relation-line'); - busPath.setAttribute('d', `M ${minX} ${busY} L ${maxX} ${busY}`); + busPath.setAttribute('d', `M ${busLeft} ${busY} L ${busRight} ${busY}`); treeSvg.appendChild(busPath); - } - // Path 3: Drop vertical lines from bus bar into each child card - childCoords.forEach(c => { - const childPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - childPath.setAttribute('class', 'relation-line'); - childPath.setAttribute('d', `M ${c.x} ${busY} L ${c.x} ${c.y}`); - treeSvg.appendChild(childPath); - }); + // Vertical drops into each child + childBoxes.forEach(c => { + const dropPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + dropPath.setAttribute('class', 'relation-line'); + dropPath.setAttribute('d', `M ${c.box.centerX} ${busY} L ${c.box.centerX} ${c.box.top}`); + treeSvg.appendChild(dropPath); + + const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + dot.setAttribute('cx', c.box.centerX); + dot.setAttribute('cy', busY); + dot.setAttribute('r', '3'); + dot.setAttribute('class', 'relation-joint'); + treeSvg.appendChild(dot); + }); + + // Dot at parent stem connection + const parentDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + parentDot.setAttribute('cx', originX); + parentDot.setAttribute('cy', busY); + parentDot.setAttribute('r', '3.5'); + parentDot.setAttribute('class', 'relation-joint'); + treeSvg.appendChild(parentDot); + } }); } @@ -1042,6 +1122,7 @@ panX = (viewportRect.width - contentWidth * zoomScale) / 2; panY = 40; applyTransform(); + requestAnimationFrame(() => drawRelationshipLines()); } function centerOnPerson(personId) { @@ -1053,14 +1134,11 @@ card.classList.add('search-match', 'selected'); const viewportRect = treeViewport.getBoundingClientRect(); - const cardRect = card.getBoundingClientRect(); - const layerRect = treeNodesLayer.getBoundingClientRect(); + const box = getElementCanvasBox(card); + if (!box) return; - const cardX = (cardRect.left + cardRect.width / 2 - layerRect.left); - const cardY = (cardRect.top + cardRect.height / 2 - layerRect.top); - - panX = viewportRect.width / 2 - cardX * zoomScale; - panY = viewportRect.height / 2 - cardY * zoomScale; + panX = viewportRect.width / 2 - box.centerX * zoomScale; + panY = viewportRect.height / 2 - box.centerY * zoomScale; treeCanvas.style.transition = 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)'; applyTransform(); diff --git a/style.css b/style.css index 65141c5..fe66de3 100644 --- a/style.css +++ b/style.css @@ -403,14 +403,19 @@ html, body { stroke-width: 2.5px; stroke-linecap: round; stroke-linejoin: round; - opacity: 0.75; + opacity: 0.85; transition: stroke 0.2s, stroke-width 0.2s, opacity 0.2s; } .tree-svg path.relation-line.spouse { stroke: var(--tree-line-spouse); stroke-width: 2.5px; - stroke-dasharray: 4 4; + stroke-dasharray: 5 4; +} + +.tree-svg circle.relation-joint { + fill: var(--tree-line); + opacity: 0.9; } .tree-svg path.relation-line.highlighted {