feat: upgrade tree relationship lines with zoom-invariant coordinates and spouse connection support
This commit is contained in:
parent
200b51494b
commit
67f70916c3
2 changed files with 147 additions and 64 deletions
200
script.js
200
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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue