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
202
script.js
202
script.js
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue