"use client"; import { useCallback, useEffect, useRef } from "react"; import { useReactFlow } from "@xyflow/react"; import { toast } from "sonner"; import { useGraphStore } from "../stores/useGraphStore"; import { computeLayout, terminateWorker, SOFT_CAP_NODE_COUNT, } from "../lib/elk-layout"; import type { ElkLayoutOptions, LayoutResult } from "../lib/elk-layout"; const DEBOUNCE_MS = 300; const LAYOUT_ANIMATION_MS = 200; const LOADING_INDICATOR_DELAY_MS = 200; export function useAutoLayout() { const { fitView } = useReactFlow(); const debounceRef = useRef | null>(null); const isFirstLayout = useRef(true); const nodeCount = useGraphStore((s) => s.nodeCount); const setNodes = useGraphStore((s) => s.setNodes); const setEdges = useGraphStore((s) => s.setEdges); const layoutDirection = useGraphStore((s) => s.layoutDirection); const edgeRouting = useGraphStore((s) => s.edgeRouting); const isLayouting = useGraphStore((s) => s.isLayouting); const setIsLayouting = useGraphStore((s) => s.setIsLayouting); const runLayout = useCallback( async (options?: Partial) => { const currentNodes = useGraphStore.getState().nodes; const currentEdges = useGraphStore.getState().edges; if (currentNodes.length === 0) return; if (currentNodes.length > SOFT_CAP_NODE_COUNT) { toast.warning( `This diagram has ${currentNodes.length} nodes (recommended max: ${SOFT_CAP_NODE_COUNT}). Consider splitting into smaller diagrams for better performance.`, ); } // Only show loading indicator if layout takes > 200ms (AC#2) const loadingTimer = setTimeout(() => { setIsLayouting(true); }, LOADING_INDICATOR_DELAY_MS); // Add layouting class for CSS transition animation const flowNodes = document.querySelectorAll(".react-flow__node"); flowNodes.forEach((el) => el.classList.add("layouting")); try { const result: LayoutResult = await computeLayout( currentNodes, currentEdges, { direction: options?.direction ?? useGraphStore.getState().layoutDirection, edgeRouting: options?.edgeRouting ?? useGraphStore.getState().edgeRouting, ...options, }, ); setNodes(result.nodes); if (result.edges) { setEdges(result.edges); } // Fit view after layout, with a small delay to let transition run setTimeout(() => { fitView({ duration: LAYOUT_ANIMATION_MS, padding: 0.1 }); }, LAYOUT_ANIMATION_MS); } catch (error) { // Ignore cancellations from single-flight pattern if ( error instanceof Error && (error.message === "Layout superseded" || error.message === "Layout cancelled") ) { return; } toast.error( `Layout failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } finally { clearTimeout(loadingTimer); // Remove layouting class after animation setTimeout(() => { const flowNodesFinal = document.querySelectorAll(".react-flow__node"); flowNodesFinal.forEach((el) => el.classList.remove("layouting")); setIsLayouting(false); }, LAYOUT_ANIMATION_MS); } }, [setNodes, setEdges, setIsLayouting, fitView], ); const triggerLayout = useCallback( (options?: Partial) => { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { runLayout(options); }, DEBOUNCE_MS); }, [runLayout], ); // Run layout on initial load when there are nodes useEffect(() => { if (isFirstLayout.current && nodeCount > 0) { isFirstLayout.current = false; // Check if all nodes are at default position (0,0) — meaning they need layout const currentNodes = useGraphStore.getState().nodes; const needsLayout = currentNodes.every( (n) => n.position.x === 0 && n.position.y === 0, ); if (needsLayout) { runLayout(); } } }, [nodeCount, runLayout]); // Re-layout when direction or routing changes useEffect(() => { if (!isFirstLayout.current && nodeCount > 0) { triggerLayout(); } // Only trigger on direction/routing changes, not on node count // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutDirection, edgeRouting]); // Re-layout on explicit request (e.g., AI-generated graph patch) const layoutRequestId = useGraphStore((s) => s.layoutRequestId); useEffect(() => { if (layoutRequestId > 0 && nodeCount > 0) { runLayout(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutRequestId]); // Cleanup worker on unmount useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } terminateWorker(); }; }, []); return { triggerLayout, isLayouting }; }