Files
turbostarter/apps/web/src/modules/diagram/hooks/useAutoLayout.ts
Alejandro Gutiérrez 6dcb4dcd6f feat: implement Story 3.2 — AI diagram generation from natural language
Add complete AI-powered diagram generation pipeline: natural language input
→ type inference → graph patch generation → validated canvas render with
ELK.js layout animation. Includes adversarial code review fixes for
diagramType enum validation, duplicate ID detection, tool part history
preservation, PATCH error handling, and graphData structural validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:34:46 +00:00

163 lines
5.1 KiB
TypeScript

"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<ReturnType<typeof setTimeout> | 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<ElkLayoutOptions>) => {
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<HTMLElement>(".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<HTMLElement>(".react-flow__node");
flowNodesFinal.forEach((el) => el.classList.remove("layouting"));
setIsLayouting(false);
}, LAYOUT_ANIMATION_MS);
}
},
[setNodes, setEdges, setIsLayouting, fitView],
);
const triggerLayout = useCallback(
(options?: Partial<ElkLayoutOptions>) => {
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 };
}