improve layout

This commit is contained in:
2025-06-27 10:08:29 +02:00
parent 5538435b6e
commit 0ce1b35b3f

View File

@@ -4,125 +4,197 @@ import type { Edge, Node } from '@xyflow/svelte';
import { Position } from '@xyflow/svelte';
export class FamilyTree extends dagre.graphlib.Graph {
constructor() {
super();
}
constructor() {
super();
}
getLayoutedElements(
nodes: Node[],
edges: Edge[],
nodeWidth: number,
nodeHeight: number,
direction = 'TB'
): Layout {
this.setGraph({
rankdir: direction,
nodesep: 80, // Increased horizontal spacing between nodes
ranksep: 120, // Increased vertical spacing between ranks
marginx: 50,
marginy: 50
});
this.setDefaultEdgeLabel(() => ({}));
getLayoutedElements(
nodes: Node[],
edges: Edge[],
nodeWidth: number,
nodeHeight: number,
direction = 'TB'
): Layout {
this.setGraph({
rankdir: direction,
nodesep: 80,
ranksep: 120,
marginx: 50,
marginy: 50
});
this.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
this.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
// Add nodes to dagre
nodes.forEach((node) => {
this.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
if (String(edge.data!.type).toLowerCase() === 'child') {
this.setEdge(edge.source, edge.target);
}
});
// Only add parent-child edges to dagre for hierarchical layout
edges.forEach((edge) => {
if (String(edge.data!.type).toLowerCase() === 'child') {
this.setEdge(edge.source, edge.target);
}
});
dagre.layout(this);
// Run dagre layout
dagre.layout(this);
let newEdges: Edge[] = [];
edges.forEach((edge) => {
let newEdge = { ...edge };
if (String(edge.data?.type).toLowerCase() === 'child') {
newEdge.sourceHandle = 'child';
newEdge.targetHandle = 'parent';
} else if (String(edge.data?.type).toLowerCase() === 'parent') {
return;
}
const sourceNode = this.node(edge.source);
const targetNode = this.node(edge.target);
if (!sourceNode || !targetNode) {
return;
}
if (String(edge.data?.type).toLowerCase() === 'sibling') {
const padding = 80; // Increased distance between sibling and source
const spouseWidth = nodeWidth;
const existingNodesAtLevel = nodes
.map((n) => ({ id: n.id, pos: this.node(n.id) }))
.filter(({ pos }) => Math.abs(pos.y - sourceNode.y) < nodeHeight / 2); // same horizontal band
// Collect taken x ranges
const takenXRanges = existingNodesAtLevel.map(({ pos }) => ({
from: pos.x - spouseWidth / 2,
to: pos.x + spouseWidth / 2
}));
// Try placing sibling to the right
let desiredX = sourceNode.x + nodeWidth + padding;
// Check for collision
const collides = (x: number) => {
return takenXRanges.some(({ from, to }) => x > from && x < to);
};
// If right side collides, try left
if (collides(desiredX)) {
desiredX = sourceNode.x - (nodeWidth + padding);
}
// If both sides collide, push right until free
while (collides(desiredX)) {
desiredX += nodeWidth + padding;
}
targetNode.x = desiredX;
targetNode.y = sourceNode.y;
}
if (String(edge.data?.type).toLowerCase() === 'spouse') {
const padding = 30; // Closer distance between spouse and source
const spouseWidth = nodeWidth;
const existingNodesAtLevel = nodes
.map((n) => ({ id: n.id, pos: this.node(n.id) }))
.filter(({ pos }) => Math.abs(pos.y - sourceNode.y) < nodeHeight / 2); // same horizontal band
// Collect taken x ranges
const takenXRanges = existingNodesAtLevel.map(({ pos }) => ({
from: pos.x - spouseWidth / 2,
to: pos.x + spouseWidth / 2
}));
// Try placing spouse to the right
let desiredX = sourceNode.x + nodeWidth + padding;
// Check for collision
const collides = (x: number) => {
return takenXRanges.some(({ from, to }) => x > from && x < to);
};
// If right side collides, try left
if (collides(desiredX)) {
desiredX = sourceNode.x - (nodeWidth + padding);
}
// If both sides collide, push right until free
while (collides(desiredX)) {
desiredX += nodeWidth + padding;
}
targetNode.x = desiredX;
targetNode.y = sourceNode.y;
}
newEdge.hidden = false;
newEdge.type = 'familyEdge';
newEdges.push(newEdge);
});
// Create maps for relationship analysis
const spouseMap = new Map<string, string>();
const siblingMap = new Map<string, string[]>();
const childrenMap = new Map<string, string[]>();
const parentsMap = new Map<string, string[]>();
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = this.node(node.id);
return {
...node,
type: 'personNode',
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2
}
};
});
// Build relationship maps
edges.forEach((edge) => {
const type = String(edge.data?.type).toLowerCase();
if (type === 'spouse') {
spouseMap.set(edge.source, edge.target);
spouseMap.set(edge.target, edge.source);
} else if (type === 'sibling') {
if (!siblingMap.has(edge.source)) siblingMap.set(edge.source, []);
if (!siblingMap.has(edge.target)) siblingMap.set(edge.target, []);
siblingMap.get(edge.source)!.push(edge.target);
siblingMap.get(edge.target)!.push(edge.source);
} else if (type === 'child') {
if (!childrenMap.has(edge.source)) childrenMap.set(edge.source, []);
if (!parentsMap.has(edge.target)) parentsMap.set(edge.target, []);
childrenMap.get(edge.source)!.push(edge.target);
parentsMap.get(edge.target)!.push(edge.source);
}
});
return { Nodes: layoutedNodes, Edges: newEdges };
}
// Helper function to check if position is occupied
const isPositionOccupied = (x: number, y: number, excludeId?: string): boolean => {
return nodes.some(node => {
if (excludeId && node.id === excludeId) return false;
const nodePos = this.node(node.id);
return Math.abs(nodePos.x - x) < nodeWidth && Math.abs(nodePos.y - y) < nodeHeight;
});
};
// Helper function to find free position near a reference point
const findFreePosition = (refX: number, refY: number, excludeId?: string): { x: number, y: number } => {
const padding = 30;
const positions = [
{ x: refX + nodeWidth + padding, y: refY }, // right
{ x: refX - nodeWidth - padding, y: refY }, // left
{ x: refX + (nodeWidth + padding) * 2, y: refY }, // far right
{ x: refX - (nodeWidth + padding) * 2, y: refY }, // far left
];
for (const pos of positions) {
if (!isPositionOccupied(pos.x, pos.y, excludeId)) {
return pos;
}
}
// If all positions are taken, just go further right
return { x: refX + (nodeWidth + padding) * 3, y: refY };
};
// Position spouses next to each other
const processedSpouses = new Set<string>();
spouseMap.forEach((spouse, person) => {
if (processedSpouses.has(person)) return;
const personNode = this.node(person);
const spouseNode = this.node(spouse);
// Determine who should be the anchor (prefer the one with children or hierarchically positioned)
const personHasChildren = childrenMap.has(person) && childrenMap.get(person)!.length > 0;
const spouseHasChildren = childrenMap.has(spouse) && childrenMap.get(spouse)!.length > 0;
let anchorNode, mobileNode, anchorId, mobileId;
if (personHasChildren && !spouseHasChildren) {
anchorNode = personNode;
mobileNode = spouseNode;
anchorId = person;
mobileId = spouse;
} else if (!personHasChildren && spouseHasChildren) {
anchorNode = spouseNode;
mobileNode = personNode;
anchorId = spouse;
mobileId = person;
} else {
// Both or neither have children, use alphabetical order
if (person < spouse) {
anchorNode = personNode;
mobileNode = spouseNode;
anchorId = person;
mobileId = spouse;
} else {
anchorNode = spouseNode;
mobileNode = personNode;
anchorId = spouse;
mobileId = person;
}
}
// Position mobile spouse next to anchor
const newPos = findFreePosition(anchorNode.x, anchorNode.y, mobileId);
mobileNode.x = newPos.x;
mobileNode.y = newPos.y;
processedSpouses.add(person);
processedSpouses.add(spouse);
});
// Position siblings
const processedSiblings = new Set<string>();
siblingMap.forEach((siblings, person) => {
if (processedSiblings.has(person)) return;
const personNode = this.node(person);
siblings.forEach((sibling) => {
if (processedSiblings.has(sibling)) return;
const siblingNode = this.node(sibling);
// If sibling is not a spouse of someone at the same level, position as sibling
const siblingSpouse = spouseMap.get(sibling);
if (!siblingSpouse || Math.abs(this.node(siblingSpouse).y - personNode.y) > nodeHeight) {
const newPos = findFreePosition(personNode.x, personNode.y, sibling);
siblingNode.x = newPos.x;
siblingNode.y = newPos.y;
}
processedSiblings.add(sibling);
});
processedSiblings.add(person);
});
// Create new edges
let newEdges: Edge[] = [];
edges.forEach((edge) => {
let newEdge = { ...edge };
if (String(edge.data?.type).toLowerCase() === 'child') {
newEdge.sourceHandle = 'child';
newEdge.targetHandle = 'parent';
} else if (String(edge.data?.type).toLowerCase() === 'parent') {
return;
}
newEdge.hidden = false;
newEdge.type = 'familyEdge';
newEdges.push(newEdge);
});
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = this.node(node.id);
return {
...node,
type: 'personNode',
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2
}
};
});
return { Nodes: layoutedNodes, Edges: newEdges };
}
}