inference-playground / src /lib /components /flow /FitViewOnResize.svelte
enzostvs's picture
enzostvs HF Staff
some fixes
beaa75f
<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>