feat: upgrade tree relationship lines with zoom-invariant coordinates and spouse connection support

This commit is contained in:
Eivind Røsstad Skeie 2026-09-03 17:14:31 +02:00
parent 200b51494b
commit 67f70916c3
2 changed files with 147 additions and 64 deletions

200
script.js
View file

@ -459,6 +459,7 @@
// Once DOM elements are placed, calculate and draw connecting branches // Once DOM elements are placed, calculate and draw connecting branches
requestAnimationFrame(() => { requestAnimationFrame(() => {
drawRelationshipLines(memberMap); 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) { function drawRelationshipLines(memberMap) {
treeSvg.innerHTML = ''; treeSvg.innerHTML = '';
const layerRect = treeNodesLayer.getBoundingClientRect(); if (!memberMap) {
if (layerRect.width === 0 || layerRect.height === 0) return; memberMap = new Map(familyMembers.map(m => [m.id, m]));
}
// Resize SVG coordinate canvas to match layer // Ensure SVG 1:1 pixel mapping without viewBox scaling distortion
treeSvg.setAttribute('width', treeNodesLayer.scrollWidth); treeSvg.removeAttribute('viewBox');
treeSvg.setAttribute('height', treeNodesLayer.scrollHeight); 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(); const familyGroups = new Map();
familyMembers.forEach(child => { familyMembers.forEach(child => {
@ -558,7 +606,7 @@
familyGroups.get(parentsKey).childrenIds.push(child.id); 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 => { familyGroups.forEach(group => {
const { parentIds, childrenIds } = group; const { parentIds, childrenIds } = group;
@ -569,80 +617,112 @@
if (parentIds.length === 2) { if (parentIds.length === 2) {
const card1 = document.getElementById(`card_${parentIds[0]}`); const card1 = document.getElementById(`card_${parentIds[0]}`);
const card2 = document.getElementById(`card_${parentIds[1]}`); const card2 = document.getElementById(`card_${parentIds[1]}`);
const box1 = getElementCanvasBox(card1);
const box2 = getElementCanvasBox(card2);
if (card1 && card2) { if (box1 && box2) {
const r1 = card1.getBoundingClientRect(); originX = (box1.centerX + box2.centerX) / 2;
const r2 = card2.getBoundingClientRect(); // Check if marriage symbol exists
const symbol = document.querySelector(`#couple_${parentIds[0]}_${parentIds[1]} .marriage-symbol`)
const x1 = (r1.left + r1.right) / 2 - layerRect.left; || document.querySelector(`#couple_${parentIds[1]}_${parentIds[0]} .marriage-symbol`);
const x2 = (r2.left + r2.right) / 2 - layerRect.left; const symbolBox = getElementCanvasBox(symbol);
const y1 = r1.bottom - layerRect.top; if (symbolBox) {
const y2 = r2.bottom - layerRect.top; originX = symbolBox.centerX;
originY = symbolBox.bottom;
originX = (x1 + x2) / 2; } else {
originY = Math.max(y1, y2); originY = Math.max(box1.bottom, box2.bottom);
} else if (card1 || card2) { }
const single = card1 || card2; } else if (box1 || box2) {
const r = single.getBoundingClientRect(); const single = box1 || box2;
originX = (r.left + r.right) / 2 - layerRect.left; originX = single.centerX;
originY = r.bottom - layerRect.top; originY = single.bottom;
} }
} else if (parentIds.length === 1) { } else if (parentIds.length === 1) {
const card = document.getElementById(`card_${parentIds[0]}`); const card = document.getElementById(`card_${parentIds[0]}`);
if (card) { const box = getElementCanvasBox(card);
const r = card.getBoundingClientRect(); if (box) {
originX = (r.left + r.right) / 2 - layerRect.left; originX = box.centerX;
originY = r.bottom - layerRect.top; originY = box.bottom;
} }
} }
if (!originX || !originY) return; if (!originX || !originY) return;
// Collect valid child card coordinates // Collect valid child card coordinates
const childCoords = []; const childBoxes = [];
childrenIds.forEach(cid => { childrenIds.forEach(cid => {
const cCard = document.getElementById(`card_${cid}`); const cCard = document.getElementById(`card_${cid}`);
if (cCard) { const cBox = getElementCanvasBox(cCard);
const cr = cCard.getBoundingClientRect(); if (cBox) {
childCoords.push({ childBoxes.push({ id: cid, box: cBox });
id: cid,
x: (cr.left + cr.right) / 2 - layerRect.left,
y: cr.top - layerRect.top
});
} }
}); });
if (childCoords.length === 0) return; if (childBoxes.length === 0) return;
// Compute children bus line level // Sort child cards left-to-right by centerX
const minChildY = Math.min(...childCoords.map(c => c.y)); childBoxes.sort((a, b) => a.box.centerX - b.box.centerX);
const busY = originY + (minChildY - originY) * 0.5;
// 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'); const stemPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
stemPath.setAttribute('class', 'relation-line'); stemPath.setAttribute('class', 'relation-line');
stemPath.setAttribute('d', `M ${originX} ${originY} L ${originX} ${busY}`); stemPath.setAttribute('d', `M ${originX} ${originY} L ${originX} ${busY}`);
treeSvg.appendChild(stemPath); treeSvg.appendChild(stemPath);
// Path 2: Horizontal bus bar covering all children and parent origin if (childBoxes.length === 1) {
const allXs = [...childCoords.map(c => c.x), originX]; const cBox = childBoxes[0].box;
const minX = Math.min(...allXs); if (Math.abs(originX - cBox.centerX) < 3) {
const maxX = Math.max(...allXs); 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'); const busPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
busPath.setAttribute('class', 'relation-line'); 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); treeSvg.appendChild(busPath);
}
// Path 3: Drop vertical lines from bus bar into each child card // Vertical drops into each child
childCoords.forEach(c => { childBoxes.forEach(c => {
const childPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const dropPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
childPath.setAttribute('class', 'relation-line'); dropPath.setAttribute('class', 'relation-line');
childPath.setAttribute('d', `M ${c.x} ${busY} L ${c.x} ${c.y}`); dropPath.setAttribute('d', `M ${c.box.centerX} ${busY} L ${c.box.centerX} ${c.box.top}`);
treeSvg.appendChild(childPath); 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; panX = (viewportRect.width - contentWidth * zoomScale) / 2;
panY = 40; panY = 40;
applyTransform(); applyTransform();
requestAnimationFrame(() => drawRelationshipLines());
} }
function centerOnPerson(personId) { function centerOnPerson(personId) {
@ -1053,14 +1134,11 @@
card.classList.add('search-match', 'selected'); card.classList.add('search-match', 'selected');
const viewportRect = treeViewport.getBoundingClientRect(); const viewportRect = treeViewport.getBoundingClientRect();
const cardRect = card.getBoundingClientRect(); const box = getElementCanvasBox(card);
const layerRect = treeNodesLayer.getBoundingClientRect(); if (!box) return;
const cardX = (cardRect.left + cardRect.width / 2 - layerRect.left); panX = viewportRect.width / 2 - box.centerX * zoomScale;
const cardY = (cardRect.top + cardRect.height / 2 - layerRect.top); panY = viewportRect.height / 2 - box.centerY * zoomScale;
panX = viewportRect.width / 2 - cardX * zoomScale;
panY = viewportRect.height / 2 - cardY * zoomScale;
treeCanvas.style.transition = 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)'; treeCanvas.style.transition = 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)';
applyTransform(); applyTransform();

View file

@ -403,14 +403,19 @@ html, body {
stroke-width: 2.5px; stroke-width: 2.5px;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
opacity: 0.75; opacity: 0.85;
transition: stroke 0.2s, stroke-width 0.2s, opacity 0.2s; transition: stroke 0.2s, stroke-width 0.2s, opacity 0.2s;
} }
.tree-svg path.relation-line.spouse { .tree-svg path.relation-line.spouse {
stroke: var(--tree-line-spouse); stroke: var(--tree-line-spouse);
stroke-width: 2.5px; 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 { .tree-svg path.relation-line.highlighted {