mirror of
https://github.com/vcscsvcscs/GenerationsHeritage.git
synced 2025-08-13 22:39:06 +02:00
layout tree
This commit is contained in:
@@ -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}
|
||||
|
104
apps/app/src/lib/graph/layout.ts
Normal file
104
apps/app/src/lib/graph/layout.ts
Normal 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 };
|
||||
}
|
||||
}
|
@@ -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>;
|
||||
};
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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!} />
|
||||
|
@@ -1,7 +0,0 @@
|
||||
<script module>
|
||||
import Page from './+page.svelte';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page />
|
||||
</template>
|
Reference in New Issue
Block a user