Spaces:
Running
Running
| <script lang="ts"> | |
| import { tick } from 'svelte'; | |
| import type { Node, Edge } from '@xyflow/svelte'; | |
| import { useNodes, useEdges } from '@xyflow/svelte'; | |
| import { useSvelteFlow } from '@xyflow/svelte'; | |
| import { breakpointsState } from '$lib/state/breakpoints.svelte'; | |
| import { viewState } from '$lib/state/view.svelte'; | |
| let { initialNodes }: { initialNodes: Node[] } = $props(); | |
| const DEFAULT_WIDTH = breakpointsState.isMobile | |
| ? typeof window !== 'undefined' | |
| ? window.innerWidth - 32 | |
| : 390 | |
| : 600; | |
| const DEFAULT_HEIGHT = 100; | |
| const H_SPACING = 60; | |
| const V_SPACING = 60; | |
| const { fitView } = useSvelteFlow(); | |
| const nodesStore = useNodes(); | |
| const edgesStore = useEdges(); | |
| let lastLayoutKey = $state<string | null>(null); | |
| let isFirstLayout = $state(true); | |
| let lastDraggable = $state(viewState.draggable); | |
| let lastFitViewRequest = $state(0); | |
| // βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function getMeasuredHeight(node: Node): number { | |
| return node.measured?.height ?? DEFAULT_HEIGHT; | |
| } | |
| function getMeasuredWidth(node: Node): number { | |
| return node.measured?.width ?? DEFAULT_WIDTH; | |
| } | |
| /** Side children are placed to the RIGHT of their parent (same row). */ | |
| function isSideChild(nodeId: string, nodeMap: Map<string, Node>): boolean { | |
| return nodeMap.get(nodeId)?.type === 'user-follow-up'; | |
| } | |
| // βββ Layout key (triggers re-layout on structural / measurement changes) βββ | |
| const layoutKey = $derived( | |
| (() => { | |
| const nodes = nodesStore.current; | |
| const edges = edgesStore.current; | |
| const ns = nodes.length === 0 ? initialNodes : nodes; | |
| const es = nodes.length === 0 ? [] : edges; | |
| const nodeIds = ns | |
| .map((n) => n.id) | |
| .sort() | |
| .join(','); | |
| const edgeKeys = es | |
| .map((e) => `${e.source}-${e.target}`) | |
| .sort() | |
| .join(','); | |
| const dims = ns | |
| .map((n) => `${n.id}:${n.measured?.width ?? 0}x${n.measured?.height ?? 0}`) | |
| .sort() | |
| .join(','); | |
| return `${ns.length}-${es.length}-${nodeIds}-${edgeKeys}-${dims}`; | |
| })() | |
| ); | |
| // βββ Fit view helper ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function doFitView(opts?: { animate?: boolean; forceAnimate?: boolean; force?: boolean }) { | |
| if (!viewState.draggable && !opts?.force) return; | |
| const nodes = nodesStore.current; | |
| if (nodes.length === 0) return; | |
| const shouldAnimate = opts?.forceAnimate || (opts?.animate !== false && !isFirstLayout); | |
| fitView({ | |
| maxZoom: breakpointsState.isMobile ? 1 : 1.15, | |
| minZoom: 0.7, | |
| padding: breakpointsState.isMobile ? 0 : 0.15, | |
| ...(shouldAnimate ? { interpolate: 'smooth' as const, duration: 250 } : {}), | |
| nodes: nodes.map((n) => ({ id: n.id })) | |
| }); | |
| } | |
| $effect(() => { | |
| const key = layoutKey; | |
| if (key === lastLayoutKey) return; | |
| lastLayoutKey = key; | |
| const nodes = nodesStore.current; | |
| const edges = edgesStore.current; | |
| const ns = nodes.length === 0 ? initialNodes : nodes; | |
| const es = nodes.length === 0 ? [] : edges; | |
| runLayout(ns, es); | |
| }); | |
| // When draggable switches to true: fitView after DOM settles (no blink) | |
| $effect(() => { | |
| const draggable = viewState.draggable; | |
| if (draggable === lastDraggable) return; | |
| lastDraggable = draggable; | |
| if (!draggable) return; | |
| tick().then(() => { | |
| requestAnimationFrame(() => doFitView({ forceAnimate: true })); | |
| }); | |
| }); | |
| // Respond to explicit fitView requests (e.g. button click in PanelCanvasActions) | |
| $effect(() => { | |
| const req = viewState.fitViewRequest; | |
| if (req === 0 || req === lastFitViewRequest) return; | |
| lastFitViewRequest = req; | |
| tick().then(() => { | |
| requestAnimationFrame(() => doFitView({ forceAnimate: true, force: true })); | |
| }); | |
| }); | |
| // βββ Layout algorithm ββββββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Two-phase block-based tree layout that guarantees zero overlap. | |
| // | |
| // Each node's subtree is split into two non-overlapping horizontal zones: | |
| // | |
| // Block A (left) β user centered above assistants, shared user centered below. | |
| // Block B (right) β side-children (user-follow-up) and their full subtrees. | |
| // | |
| // βββββββββββββββBlock Aβββββββββββββββ ββββββBlock Bββββββ | |
| // β ββββββββββ β β ββββββββββββββ β | |
| // β β USER β β β β Side node β β | |
| // β ββββββββββ β β ββββββββββββββ β | |
| // β ββββββββββββ ββββββββββββ β β ββββββββββββ β | |
| // β β ASS 1 β β ASS 2 β β β β Side sub β β | |
| // β ββββββββββββ ββββββββββββ β β ββββββββββββ β | |
| // β ββββββββββ β βββββββββββββββββββ | |
| // β β USER β β | |
| // β ββββββββββ β | |
| // βββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Phase 1 (bottom-up): compute the bounding-box extent of every subtree. | |
| // Phase 2 (top-down): assign positions, centering parents above children. | |
| // | |
| type Extent = { | |
| /** Total bounding-box width of the entire subtree. */ | |
| width: number; | |
| /** Total bounding-box height of the entire subtree. */ | |
| height: number; | |
| /** Width of Block A only (node + below-children column). */ | |
| blockAWidth: number; | |
| }; | |
| function computeLayout(nodes: Node[], edges: Edge[]): Node[] { | |
| if (nodes.length === 0) return []; | |
| const nodeMap = new Map(nodes.map((n) => [n.id, n])); | |
| // Build parent β children adjacency from edges | |
| const childrenOf = new Map<string, string[]>(); | |
| const parentsOf = new Map<string, string[]>(); | |
| const hasParent = new Set<string>(); | |
| for (const e of edges) { | |
| hasParent.add(e.target); | |
| const list = childrenOf.get(e.source) ?? []; | |
| list.push(e.target); | |
| childrenOf.set(e.source, list); | |
| const parents = parentsOf.get(e.target) ?? []; | |
| parents.push(e.source); | |
| parentsOf.set(e.target, parents); | |
| } | |
| // Move shared children (user node with multiple assistant parents) to grandparent. | |
| // They get placed centered below the assistants row. | |
| const sharedBelowOf = new Map<string, string[]>(); | |
| for (const [childId, parents] of parentsOf) { | |
| if (parents.length < 2) continue; | |
| // All parents must share the same parent (grandparent) | |
| const firstParent = parents[0]; | |
| const grandparentId = (parentsOf.get(firstParent) ?? [])[0]; | |
| if (!grandparentId || !parents.every((p) => (parentsOf.get(p) ?? []).includes(grandparentId))) | |
| continue; | |
| const gpChildren = childrenOf.get(grandparentId) ?? []; | |
| if (!parents.every((p) => gpChildren.includes(p))) continue; | |
| // Remove child from each parent | |
| for (const p of parents) { | |
| const list = childrenOf.get(p) ?? []; | |
| childrenOf.set( | |
| p, | |
| list.filter((id) => id !== childId) | |
| ); | |
| } | |
| const list = sharedBelowOf.get(grandparentId) ?? []; | |
| list.push(childId); | |
| sharedBelowOf.set(grandparentId, list); | |
| } | |
| const roots = nodes.filter((n) => !hasParent.has(n.id)); | |
| // ββ Phase 1: compute extents bottom-up ββββββββββββββββββββββββββ | |
| const extents = new Map<string, Extent>(); | |
| function computeExtent(nodeId: string): Extent { | |
| if (extents.has(nodeId)) return extents.get(nodeId)!; | |
| const node = nodeMap.get(nodeId); | |
| const nodeW = node ? getMeasuredWidth(node) : DEFAULT_WIDTH; | |
| const nodeH = node ? getMeasuredHeight(node) : DEFAULT_HEIGHT; | |
| const children = childrenOf.get(nodeId) ?? []; | |
| const sideIds = children.filter((id) => isSideChild(id, nodeMap)); | |
| const belowIds = children.filter((id) => !isSideChild(id, nodeMap)); | |
| const sharedBelowIds = sharedBelowOf.get(nodeId) ?? []; | |
| // Block A β node width vs. combined below-children width | |
| let belowTotalW = 0; | |
| let belowMaxH = 0; | |
| for (let i = 0; i < belowIds.length; i++) { | |
| const ext = computeExtent(belowIds[i]); | |
| if (i > 0) belowTotalW += H_SPACING; | |
| belowTotalW += ext.width; | |
| belowMaxH = Math.max(belowMaxH, ext.height); | |
| } | |
| // Shared children (e.g. user below assistants) go centered under the row | |
| let sharedBelowW = 0; | |
| let sharedBelowH = 0; | |
| for (let i = 0; i < sharedBelowIds.length; i++) { | |
| const ext = computeExtent(sharedBelowIds[i]); | |
| if (i > 0) sharedBelowW += H_SPACING; | |
| sharedBelowW += ext.width; | |
| sharedBelowH = Math.max(sharedBelowH, ext.height); | |
| } | |
| const rowH = belowIds.length > 0 ? nodeH + V_SPACING + belowMaxH : nodeH; | |
| const blockAW = Math.max(nodeW, belowTotalW, sharedBelowIds.length > 0 ? sharedBelowW : 0); | |
| const blockAH = sharedBelowIds.length > 0 ? rowH + V_SPACING + sharedBelowH : rowH; | |
| // Block B β side-children and their subtrees | |
| let blockBW = 0; | |
| let blockBH = 0; | |
| for (let i = 0; i < sideIds.length; i++) { | |
| const ext = computeExtent(sideIds[i]); | |
| if (i > 0) blockBW += H_SPACING; | |
| blockBW += ext.width; | |
| blockBH = Math.max(blockBH, ext.height); | |
| } | |
| const totalW = sideIds.length > 0 ? blockAW + H_SPACING + blockBW : blockAW; | |
| const totalH = Math.max(blockAH, blockBH); | |
| const extent: Extent = { width: totalW, height: totalH, blockAWidth: blockAW }; | |
| extents.set(nodeId, extent); | |
| return extent; | |
| } | |
| // Compute all extents (roots first, then any orphans) | |
| for (const r of roots) computeExtent(r.id); | |
| for (const n of nodes) { | |
| if (!extents.has(n.id)) computeExtent(n.id); | |
| } | |
| // ββ Phase 2: assign positions top-down ββββββββββββββββββββββββββ | |
| const positions = new Map<string, { x: number; y: number }>(); | |
| function placeSubtree(nodeId: string, allocX: number, y: number) { | |
| const node = nodeMap.get(nodeId); | |
| const nodeW = node ? getMeasuredWidth(node) : DEFAULT_WIDTH; | |
| const nodeH = node ? getMeasuredHeight(node) : DEFAULT_HEIGHT; | |
| const children = childrenOf.get(nodeId) ?? []; | |
| const sideIds = children.filter((id) => isSideChild(id, nodeMap)); | |
| const belowIds = children.filter((id) => !isSideChild(id, nodeMap)); | |
| const { blockAWidth } = extents.get(nodeId)!; | |
| // Center the node horizontally within Block A | |
| positions.set(nodeId, { | |
| x: allocX + (blockAWidth - nodeW) / 2, | |
| y | |
| }); | |
| // Place below-children (assistants), centered within Block A | |
| if (belowIds.length > 0) { | |
| let belowTotalW = 0; | |
| for (let i = 0; i < belowIds.length; i++) { | |
| if (i > 0) belowTotalW += H_SPACING; | |
| belowTotalW += extents.get(belowIds[i])!.width; | |
| } | |
| const childY = y + nodeH + V_SPACING; | |
| let childX = allocX + (blockAWidth - belowTotalW) / 2; | |
| for (const cid of belowIds) { | |
| placeSubtree(cid, childX, childY); | |
| childX += extents.get(cid)!.width + H_SPACING; | |
| } | |
| } | |
| // Place shared below-children (user node) centered below the assistants row | |
| const sharedBelowIds = sharedBelowOf.get(nodeId) ?? []; | |
| if (sharedBelowIds.length > 0 && belowIds.length > 0) { | |
| let belowTotalW = 0; | |
| for (let i = 0; i < belowIds.length; i++) { | |
| if (i > 0) belowTotalW += H_SPACING; | |
| belowTotalW += extents.get(belowIds[i])!.width; | |
| } | |
| const rowY = y + nodeH + V_SPACING; | |
| const rowHeight = Math.max(...belowIds.map((id) => extents.get(id)!.height)); | |
| const sharedY = rowY + rowHeight + V_SPACING; | |
| let sharedTotalW = 0; | |
| for (let i = 0; i < sharedBelowIds.length; i++) { | |
| if (i > 0) sharedTotalW += H_SPACING; | |
| sharedTotalW += extents.get(sharedBelowIds[i])!.width; | |
| } | |
| const sharedCenterX = allocX + (blockAWidth - sharedTotalW) / 2; | |
| let sharedX = sharedCenterX; | |
| for (const sid of sharedBelowIds) { | |
| placeSubtree(sid, sharedX, sharedY); | |
| sharedX += extents.get(sid)!.width + H_SPACING; | |
| } | |
| } | |
| // Place side-children to the right of Block A (non-overlapping zone) | |
| let sideX = allocX + blockAWidth + H_SPACING; | |
| for (const sid of sideIds) { | |
| placeSubtree(sid, sideX, y); | |
| sideX += extents.get(sid)!.width + H_SPACING; | |
| } | |
| } | |
| // Lay out root nodes (non-welcome) side by side | |
| let rootX = 0; | |
| for (const r of roots) { | |
| placeSubtree(r.id, rootX, 0); | |
| rootX += extents.get(r.id)!.width + H_SPACING; | |
| } | |
| // Safety net for any disconnected / orphan nodes | |
| for (const node of nodes) { | |
| if (positions.has(node.id)) continue; | |
| const maxY = Math.max(0, ...Array.from(positions.values()).map((p) => p.y)); | |
| positions.set(node.id, { x: 0, y: maxY + DEFAULT_HEIGHT + V_SPACING }); | |
| } | |
| return nodes.map((n) => { | |
| const pos = positions.get(n.id) ?? { x: 0, y: 0 }; | |
| return { ...n, position: pos }; | |
| }); | |
| } | |
| // βββ Apply layout to the store βββββββββββββββββββββββββββββββββββββ | |
| function runLayout(nodes: Node[], edges: Edge[]) { | |
| const result = computeLayout(nodes, edges); | |
| const current = nodesStore.current; | |
| if (current.length === 0) { | |
| nodesStore.set(result); | |
| } else { | |
| const posMap = new Map(result.map((n) => [n.id, n.position])); | |
| const dataMap = new Map(result.map((n) => [n.id, n.data])); | |
| nodesStore.update((prev) => | |
| prev.map((n) => { | |
| const pos = posMap.get(n.id); | |
| const data = dataMap.get(n.id); | |
| const updated = pos ? { ...n, position: pos } : n; | |
| return data ? { ...updated, data } : updated; | |
| }) | |
| ); | |
| } | |
| // Only fit the view on the very first layout (component mount). | |
| // Subsequent layout updates (new nodes, resizes) reposition nodes | |
| // without moving the camera β the user stays in control of the viewport. | |
| if (isFirstLayout) { | |
| tick().then(() => { | |
| requestAnimationFrame(() => { | |
| doFitView({ animate: false }); | |
| isFirstLayout = false; | |
| }); | |
| }); | |
| } else { | |
| isFirstLayout = false; | |
| } | |
| } | |
| </script> | |