From 4c521f800936694e13c087e8dbe88efb561c0e4d Mon Sep 17 00:00:00 2001 From: Vargha Csongor Date: Mon, 28 Apr 2025 20:30:22 +0200 Subject: [PATCH] layout tree --- apps/app/src/lib/graph/PersonNode.svelte | 4 +- apps/app/src/lib/graph/layout.ts | 104 +++++++++++++++++++++++ apps/app/src/lib/graph/model.ts | 11 ++- apps/app/src/routes/+page.server.ts | 29 +++---- apps/app/src/routes/+page.svelte | 83 +++++++++++++----- apps/app/src/routes/page.stories.svelte | 7 -- 6 files changed, 188 insertions(+), 50 deletions(-) create mode 100644 apps/app/src/lib/graph/layout.ts delete mode 100644 apps/app/src/routes/page.stories.svelte diff --git a/apps/app/src/lib/graph/PersonNode.svelte b/apps/app/src/lib/graph/PersonNode.svelte index 8fa3695..65e0d74 100644 --- a/apps/app/src/lib/graph/PersonNode.svelte +++ b/apps/app/src/lib/graph/PersonNode.svelte @@ -27,7 +27,7 @@
{#if isConnecting && isTarget} {#if isConnecting && isTarget} { + 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 }; + } +} diff --git a/apps/app/src/lib/graph/model.ts b/apps/app/src/lib/graph/model.ts index ea9ad8b..394e68f 100644 --- a/apps/app/src/lib/graph/model.ts +++ b/apps/app/src/lib/graph/model.ts @@ -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; - Edges: Array; -} \ No newline at end of file + Nodes: Array; + Edges: Array; +}; diff --git a/apps/app/src/routes/+page.server.ts b/apps/app/src/routes/+page.server.ts index 01918da..c017f1e 100644 --- a/apps/app/src/routes/+page.server.ts +++ b/apps/app/src/routes/+page.server.ts @@ -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; } diff --git a/apps/app/src/routes/+page.svelte b/apps/app/src/routes/+page.svelte index 38e0f9d..333761c 100644 --- a/apps/app/src/routes/+page.svelte +++ b/apps/app/src/routes/+page.svelte @@ -1,8 +1,14 @@ @@ -115,6 +146,8 @@ {nodeTypes} fitView onlyRenderVisibleElements + connectionLineType={ConnectionLineType.SmoothStep} + defaultEdgeOptions={{ type: 'smoothstep' }} > @@ -127,7 +160,13 @@ /> {/if} {#if createPerson} - + { + createPerson = false; + }} + relationshipStartID={relationshipStart} + > {/if} {#if openPersonMenu !== undefined} diff --git a/apps/app/src/routes/page.stories.svelte b/apps/app/src/routes/page.stories.svelte deleted file mode 100644 index 02908c5..0000000 --- a/apps/app/src/routes/page.stories.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - -