7 Commits

Author SHA1 Message Date
305004d06a better edge layout 2025-06-27 10:31:55 +02:00
cb86748281 display id when checking relationship 2025-06-27 10:31:43 +02:00
cb47d30627 small edge change 2025-06-27 10:19:56 +02:00
3730e52361 remove duplicate spouses 2025-06-27 10:12:08 +02:00
0ce1b35b3f improve layout 2025-06-27 10:08:29 +02:00
5538435b6e improve render a bit 2025-06-27 09:51:48 +02:00
8d37fc0fe3 fix edge render 2025-06-27 09:12:25 +02:00
4 changed files with 389 additions and 214 deletions

View File

@@ -1,98 +1,141 @@
<script lang="ts">
import type { components } from '$lib/api/api.gen.ts';
import { child, spouse, parent, sibling } from '$lib/paraglide/messages';
import { getBezierPath, BaseEdge, type EdgeProps, Position } from '@xyflow/svelte';
let {
sourceX,
sourceY,
source,
sourcePosition,
target,
targetX,
targetY,
targetPosition,
markerEnd,
style,
data
}: EdgeProps = $props();
let edgeType = (
data as components['schemas']['FamilyRelationship'] & { type: string }
).type.toLowerCase();
let edgeLabel: string = $state(edgeType);
let edgeColor: string = $state('stroke: gray;');
let srcPos;
let tgtPos;
if (edgeType === 'spouse') {
edgeColor = 'stroke: red;';
edgeLabel = spouse();
if (sourceX < targetX) {
tgtPos = Position.Right;
srcPos = Position.Left;
} else {
tgtPos = Position.Left;
srcPos = Position.Right;
}
} else if (edgeType === 'child') {
edgeColor = 'stroke: blue;';
edgeLabel = child();
if (sourceY < targetY) {
tgtPos = Position.Bottom;
srcPos = Position.Top;
} else {
tgtPos = Position.Bottom;
srcPos = Position.Top;
}
} else if (edgeType === 'parent') {
edgeColor = 'stroke: green;';
edgeLabel = parent();
if (sourceY < targetY) {
tgtPos = Position.Bottom;
srcPos = Position.Top;
} else {
tgtPos = Position.Bottom;
srcPos = Position.Top;
}
} else if (edgeType === 'sibling') {
edgeColor = 'stroke: brown;';
edgeLabel = sibling();
if (sourceX < targetX) {
tgtPos = Position.Right;
srcPos = Position.Left;
} else {
tgtPos = Position.Left;
srcPos = Position.Right;
}
} else {
edgeColor = 'stroke: gray;';
edgeLabel = edgeType;
}
let [path, labelX, labelY] = $derived(
getBezierPath({
sourceX,
sourceY,
sourcePosition: srcPos,
targetX,
targetY,
targetPosition: tgtPos
})
);
edgeColor = edgeColor + 'stroke-opacity:unset; stroke-width=20;' + (style ?? '');
const onEdgeClick = () => {
window.dispatchEvent(
new CustomEvent('edge-click', {
detail: {
start: source,
end: target,
data: data as components['schemas']['FamilyRelationship'] & { type: string }
}
})
);
};
import type { components } from '$lib/api/api.gen.ts';
import { child, spouse, parent, sibling } from '$lib/paraglide/messages';
import { getSmoothStepPath, BaseEdge, type EdgeProps, Position } from '@xyflow/svelte';
let {
sourceX,
sourceY,
source,
sourcePosition,
sourceHandleId,
target,
targetX,
targetY,
targetPosition,
targetHandleId,
markerEnd,
style,
data
}: EdgeProps = $props();
let edgeType = (
data as components['schemas']['FamilyRelationship'] & { type: string }
).type.toLowerCase();
let edgeLabel: string = $state(edgeType);
let edgeColor: string = $state('stroke: gray;');
let srcPos: Position = $state(sourcePosition || Position.Bottom);
let tgtPos: Position = $state(targetPosition || Position.Top);
// Determine edge styling and positioning based on relationship type and handles
if (edgeType === 'spouse') {
edgeColor = 'stroke: red;';
edgeLabel = spouse();
// Use handle-based positioning for spouses
if (sourceHandleId === 'spouse-right') {
srcPos = Position.Right;
tgtPos = Position.Left;
} else if (sourceHandleId === 'spouse-left') {
srcPos = Position.Left;
tgtPos = Position.Right;
} else {
// Fallback to position-based logic
if (sourceX < targetX) {
srcPos = Position.Right;
tgtPos = Position.Left;
} else {
srcPos = Position.Left;
tgtPos = Position.Right;
}
}
} else if (edgeType === 'child') {
edgeColor = 'stroke: blue;';
edgeLabel = child();
// Parent-child: from parent's bottom (child handle) to child's top (parent handle)
srcPos = Position.Bottom;
tgtPos = Position.Top;
} else if (edgeType === 'parent') {
edgeColor = 'stroke: blue;';
edgeLabel = parent();
// Parent relationship: from child (top) to parent (bottom)
srcPos = Position.Top;
tgtPos = Position.Bottom;
} else if (edgeType === 'sibling') {
edgeColor = 'stroke: orange;';
edgeLabel = sibling();
// Use handle-based positioning for siblings
if (sourceHandleId === 'spouse-right') {
srcPos = Position.Right;
tgtPos = Position.Left;
} else if (sourceHandleId === 'spouse-left') {
srcPos = Position.Left;
tgtPos = Position.Right;
} else {
// Fallback to position-based logic
if (sourceX < targetX) {
srcPos = Position.Right;
tgtPos = Position.Left;
} else {
srcPos = Position.Left;
tgtPos = Position.Right;
}
}
} else {
edgeColor = 'stroke: gray;';
edgeLabel = edgeType;
// Keep original positions for unknown types
srcPos = sourcePosition || Position.Bottom;
tgtPos = targetPosition || Position.Top;
}
// Explicit handle overrides (these should take precedence)
if (sourceHandleId === 'child') {
srcPos = Position.Bottom;
}
if (targetHandleId === 'parent') {
tgtPos = Position.Top;
}
if (sourceHandleId === 'spouse-left') {
srcPos = Position.Left;
}
if (sourceHandleId === 'spouse-right') {
srcPos = Position.Right;
}
if (targetHandleId === 'spouse-left') {
tgtPos = Position.Left;
}
if (targetHandleId === 'spouse-right') {
tgtPos = Position.Right;
}
let [path, labelX, labelY] = $derived(
getSmoothStepPath({
sourceX,
sourceY,
sourcePosition: srcPos,
targetX,
targetY,
targetPosition: tgtPos,
borderRadius: 20, // Add some rounding to make paths smoother
offset: 20 // Add offset to avoid overlapping with nodes
})
);
// Fix the style string formatting
const finalStyle = `${edgeColor} stroke-width: 3; stroke-opacity: 0.8; ${style ?? ''}`;
const onEdgeClick = () => {
window.dispatchEvent(
new CustomEvent('edge-click', {
detail: {
start: source,
end: target,
data: data as components['schemas']['FamilyRelationship'] & { type: string }
}
})
);
};
</script>
<BaseEdge {path} {labelX} {labelY} {markerEnd} style={edgeColor} onclick={onEdgeClick} />
<BaseEdge {path} {labelX} {labelY} {markerEnd} style={finalStyle} onclick={onEdgeClick} />

View File

@@ -26,6 +26,38 @@
class={'card card-compact flex h-40 w-40 flex-col items-center justify-center rounded-full shadow-lg' +
nodeColor}
>
<Handle
class="customHandle"
id="spouse-left"
{isValidConnection}
position={Position.Left}
isConnectable={true}
type="source"
/>
<Handle
class="customHandle"
id="spouse-right"
{isValidConnection}
position={Position.Right}
isConnectable={true}
type="source"
/>
<Handle
class="customHandle"
id="spouse-left"
{isValidConnection}
position={Position.Left}
isConnectable={true}
type="target"
/>
<Handle
class="customHandle"
id="spouse-right"
{isValidConnection}
position={Position.Right}
isConnectable={true}
type="target"
/>
<Handle
class="customHandle"
id="child"
@@ -81,7 +113,7 @@
/>
<div class="avatar mb-2" style="z-index: 2; cursor: pointer;">
<div class={"w-24 rounded-full border-0 ring-offset-1"+nodeColor}>
<div class={'w-24 rounded-full border-0 ring-offset-1' + nodeColor}>
<img
src={data.profile_picture || 'https://cdn-icons-png.flaticon.com/512/10628/10628885.png'}
alt="Picture of {data.last_name} {data.first_name}"
@@ -90,7 +122,7 @@
</div>
<div class="px-2 text-center" style="z-index: 2; cursor: pointer;">
<h2 class="text-sm leading-tight font-semibold">
<h2 class="text-sm font-semibold leading-tight">
{data.first_name}
{data.middle_name ? data.middle_name : ''}
{data.last_name}

View File

@@ -4,137 +4,229 @@ 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 });
this.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
this.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
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(() => ({}));
edges.forEach((edge) => {
if (String(edge.data!.type).toLowerCase() === 'child') {
this.setEdge(edge.source, edge.target);
}
});
// Add nodes to dagre
nodes.forEach((node) => {
this.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
dagre.layout(this);
// 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);
}
});
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;
}
// Run dagre layout
dagre.layout(this);
const sourceNode = this.node(edge.source);
const targetNode = this.node(edge.target);
if (!sourceNode || !targetNode) {
return;
}
// 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[]>();
if (String(edge.data?.type).toLowerCase() === 'sibling') {
const padding = 50; // distance between sibling and source
const spouseWidth = nodeWidth;
// 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);
}
});
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
// 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;
});
};
// Collect taken x ranges
const takenXRanges = existingNodesAtLevel.map(({ pos }) => ({
from: pos.x - spouseWidth / 2,
to: pos.x + spouseWidth / 2
}));
// 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
];
// Try placing spouse to the right
let desiredX = sourceNode.x + nodeWidth + padding;
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 };
};
// Check for collision
const collides = (x: number) => {
return takenXRanges.some(({ from, to }) => x > from && x < to);
};
// 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;
}
}
// If right side collides, try left
if (collides(desiredX)) {
desiredX = sourceNode.x - (nodeWidth + padding);
}
// Position mobile spouse next to anchor
const newPos = findFreePosition(anchorNode.x, anchorNode.y, mobileId);
mobileNode.x = newPos.x;
mobileNode.y = newPos.y;
// If both sides collide, push right until free
while (collides(desiredX)) {
desiredX += nodeWidth + padding;
}
processedSpouses.add(person);
processedSpouses.add(spouse);
});
targetNode.x = desiredX;
targetNode.y = sourceNode.y;
}
// 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);
});
if (String(edge.data?.type).toLowerCase() === 'spouse') {
const padding = 50; // distance between spouse and source
const spouseWidth = nodeWidth;
// Create new edges
let newEdges: Edge[] = [];
const processedSpouseEdges = new Set<string>();
edges.forEach((edge) => {
let newEdge = { ...edge };
if (String(edge.data?.type).toLowerCase() === 'child') {
// Parent to child: source (parent) uses 'child' handle (bottom), target (child) uses 'parent' handle (top)
newEdge.sourceHandle = 'child';
newEdge.targetHandle = 'parent';
} else if (String(edge.data?.type).toLowerCase() === 'parent') {
return;
} else if (String(edge.data?.type).toLowerCase() === 'spouse') {
// Avoid duplicate spouse edges by creating a unique key
const spouseKey = [edge.source, edge.target].sort().join('-');
if (processedSpouseEdges.has(spouseKey)) {
return; // Skip this duplicate spouse edge
}
processedSpouseEdges.add(spouseKey);
// Set spouse handles based on position
const sourceNode = this.node(edge.source);
const targetNode = this.node(edge.target);
if (sourceNode.x < targetNode.x) {
newEdge.sourceHandle = 'spouse-right';
newEdge.targetHandle = 'spouse-left';
} else {
newEdge.sourceHandle = 'spouse-left';
newEdge.targetHandle = 'spouse-right';
}
} else if (String(edge.data?.type).toLowerCase() === 'sibling') {
// Set sibling handles based on position
const sourceNode = this.node(edge.source);
const targetNode = this.node(edge.target);
if (sourceNode.x < targetNode.x) {
newEdge.sourceHandle = 'spouse-right';
newEdge.targetHandle = 'spouse-left';
} else {
newEdge.sourceHandle = 'spouse-left';
newEdge.targetHandle = 'spouse-right';
}
}
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
newEdge.hidden = false;
newEdge.type = 'familyEdge';
newEdges.push(newEdge);
});
// Collect taken x ranges
const takenXRanges = existingNodesAtLevel.map(({ pos }) => ({
from: pos.x - spouseWidth / 2,
to: pos.x + spouseWidth / 2
}));
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
}
};
});
// 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);
});
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 };
}
}
return { Nodes: layoutedNodes, Edges: newEdges };
}
}

View File

@@ -189,6 +189,14 @@
</div>
{/if}
{#if !createRelationship}
<div class="form-control mt-2">
<p>
<strong>{id()} 1:</strong> {startNode}
</p>
<p>
<strong>{id()} 2:</strong> {endNode}
</p>
</div>
<!-- Editor mode: show all existing relationships -->
{#each relationships as r, index}
<div class="border-base-300 mt-4 rounded border p-4">