layout tree

This commit is contained in:
2025-04-28 20:30:22 +02:00
parent a71ca26b2a
commit 4c521f8009
6 changed files with 188 additions and 50 deletions

View File

@@ -27,7 +27,7 @@
<div class="avatar mb-2">
{#if isConnecting && isTarget}
<Handle
isValidConnection={isValidConnection}
{isValidConnection}
position={Position.Left}
type="target"
isConnectableStart={false}
@@ -39,7 +39,7 @@
>
{#if isConnecting && isTarget}
<Handle
isValidConnection={isValidConnection}
{isValidConnection}
position={Position.Left}
type="target"
isConnectableStart={false}

View File

@@ -0,0 +1,104 @@
import dagre from '@dagrejs/dagre';
import type { Layout } from './model';
import type { Edge, Node } from '@xyflow/svelte';
import { Position } from '@xyflow/svelte';
import PersonNode from './PersonNode.svelte';
export class FamilyTree extends dagre.graphlib.Graph {
constructor() {
super();
}
getLayoutedElements(
nodes: Node[],
edges: Edge[],
nodeWidth: number,
nodeHeight: number,
direction = 'TB'
): Layout {
const isHorizontal = direction === 'LR';
this.setGraph({ rankdir: direction });
nodes.forEach((node) => {
this.setNode(node.id, { width: nodeWidth, height: nodeHeight});
});
edges.forEach((edge) => {
if (edge.data?.type === 'child') {
this.setEdge(edge.source, edge.target);
}
});
dagre.layout(this);
edges.forEach((edge) => {
if (edge.data?.type === 'parent' || edge.data?.type === 'sibling') {
this.setEdge(edge.source, edge.target);
}
});
edges.forEach((edge) => {
if (edge.data?.type === 'spouse') {
const sourceNode = this.node(edge.source);
const targetNode = this.node(edge.target);
if (!sourceNode || !targetNode) {
return;
}
const padding = 50; // 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;
}
});
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = this.node(node.id);
node.targetPosition = isHorizontal ? Position.Left : Position.Top;
node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
return {
...node,
type: 'personNode',
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2
}
};
});
return { Nodes: layoutedNodes, Edges: edges };
}
}

View File

@@ -1,4 +1,7 @@
import type { Node, Edge } from '@xyflow/svelte';
import type { Node, Edge, NodeTypes } from '@xyflow/svelte';
import PersonNode from './PersonNode.svelte';
export const nodeTypes: NodeTypes = { personNode: PersonNode };
export type NodeMenu = {
onClick: () => void;
@@ -14,6 +17,6 @@ export type NodeMenu = {
};
export type Layout = {
Nodes: Array<Node>;
Edges: Array<Edge>;
}
Nodes: Array<Node>;
Edges: Array<Edge>;
};

View File

@@ -1,6 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
import { redirect } from '@sveltejs/kit';
import { parseFamilyTree } from '$lib/graph/fetch_family_tree';
import type { components } from '$lib/api/api.gen';
import type { RequestEvent } from './$types';
import { browser } from '$app/environment';
export async function load(event: RequestEvent) {
@@ -13,19 +14,17 @@ export async function load(event: RequestEvent) {
return {};
}
const response = await client
.GET('/family-tree-with-spouses', {
params: {
header: { 'X-User-ID': event.locals.session.userId },
}
})
const response = await event.fetch('/api/family_tree?with_out_spouse=false', {
method: 'GET'
});
if (response.response.status === 200) {
return response.data;
} else {
return fail(response.response.status, {
message: response.error?.msg || 'An error occurred'
});
if (response.status !== 200) {
throw new Error(await response.text());
}
const data = (await response.json()) as components['schemas']['FamilyTree'];
let layout = parseFamilyTree(data)
return layout;
}

View File

@@ -1,8 +1,14 @@
<script lang="ts">
import type { PageProps } from './$types';
import { nodeTypes } from '$lib/graph/model';
import { title, family_tree } from '$lib/paraglide/messages.js';
import { SvelteFlowProvider, SvelteFlow, Controls, MiniMap } from '@xyflow/svelte';
import {
SvelteFlowProvider,
SvelteFlow,
Controls,
MiniMap,
ConnectionLineType
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import type { Node, Edge, NodeTypes, NodeEventWithPointer } from '@xyflow/svelte';
@@ -15,34 +21,34 @@
import type { NodeMenu } from '$lib/graph/model';
import { handleNodeClick } from '$lib/graph/node_click';
import { onMount } from 'svelte';
import { FamilyTree } from '$lib/graph/layout';
import { tailwindClassToPixels } from '$lib/tailwindSizeToPx';
import type { Layout } from '$lib/graph/model';
let { data }: { data: Layout } = $props();
let { data, form }: PageProps = $props();
const nodeTypes: NodeTypes = { personNode: PersonNode };
let selectedPerson: components['schemas']['PersonProperties'] & { id: number | null } = $state({
id: null
});
let openPersonPanel = $state(false);
let openPersonMenu: NodeMenu | undefined = $state(undefined);
let with_out_spouse = $state(false);
let ppl = data.people;
if (ppl === undefined) {
ppl = [];
}
if (ppl[0].id === undefined) {
ppl[0].id = 0;
}
let nodes = $state.raw<Node[]>([
{
id: String(ppl[0].id),
type: 'personNode',
data: ppl[0] as components['schemas']['PersonProperties'],
position: { x: 0, y: 0 }
}
]);
let familyTreeDAG = new FamilyTree();
let nodes = $state.raw<Node[]>([]);
let edges = $state.raw<Edge[]>([]);
let layout = familyTreeDAG.getLayoutedElements(
data.Nodes,
data.Edges,
tailwindClassToPixels('w-40') || 160,
tailwindClassToPixels('h-40') || 160,
'TB'
);
nodes = layout.Nodes;
edges = layout.Edges;
let relationshipStart: number | null ;
let relationshipStart: number | null = $state(null);
let createPerson = $state(false);
let clientWidth: number | undefined = $state();
@@ -60,18 +66,24 @@
openPersonMenu = undefined;
},
deleteNode: () => {
relationshipStart = Number(node.id);
openPersonMenu = undefined;
},
createRelationshipAndNode: () => {
relationshipStart = Number(node.id);
createPerson = true;
openPersonMenu = undefined;
},
addRelationship: () => {
relationshipStart = Number(node.id);
openPersonMenu = undefined;
},
addAdmin: () => {
relationshipStart = Number(node.id);
openPersonMenu = undefined;
},
addRecipe: () => {
relationshipStart = Number(node.id);
openPersonMenu = undefined;
},
top: event.clientY < clientHeight - 200 ? event.clientY : undefined,
@@ -81,6 +93,24 @@
};
};
let onCreation = (newNodes: Array<Node> | null, newEdges: Array<Edge> | null) => {
if (newNodes !== null) {
nodes = [...nodes, ...newNodes];
}
if (newEdges !== null) {
edges = [...edges, ...newEdges];
}
familyTreeDAG.getLayoutedElements(
nodes,
edges,
tailwindClassToPixels('w-40') || 160,
tailwindClassToPixels('h-40') || 160,
'TB'
);
};
let handleNodeClickFunc = handleNodeClick(
(
person: components['schemas']['PersonProperties'] & {
@@ -96,6 +126,7 @@
let handlePaneClick = ({ event }: { event: MouseEvent }) => {
openPersonPanel = false;
openPersonMenu = undefined;
relationshipStart = null;
};
</script>
@@ -115,6 +146,8 @@
{nodeTypes}
fitView
onlyRenderVisibleElements
connectionLineType={ConnectionLineType.SmoothStep}
defaultEdgeOptions={{ type: 'smoothstep' }}
>
<MiniMap class="!bg-base-300" />
<Controls class="!bg-base-300" />
@@ -127,7 +160,13 @@
/>
{/if}
{#if createPerson}
<CreatePerson relationship={relationshipStart}></CreatePerson>
<CreatePerson
{onCreation}
closeModal={() => {
createPerson = false;
}}
relationshipStartID={relationshipStart}
></CreatePerson>
{/if}
{#if openPersonMenu !== undefined}
<PersonMenu {...openPersonMenu!} />

View File

@@ -1,7 +0,0 @@
<script module>
import Page from './+page.svelte';
</script>
<template>
<Page />
</template>