diff --git a/frontend/src/components/WecsTopology.tsx b/frontend/src/components/WecsTopology.tsx index e387c60a4..ee22452f4 100644 --- a/frontend/src/components/WecsTopology.tsx +++ b/frontend/src/components/WecsTopology.tsx @@ -44,9 +44,16 @@ import { isEqual } from 'lodash'; import { useTranslation } from 'react-i18next'; import { useWebSocket } from '../context/webSocketExports'; import useTheme from '../stores/themeStore'; +import useZoomStore from '../stores/zoomStore'; import WecsDetailsPanel from './wecs_details/WecsDetailsPanel'; import { FlowCanvas } from './wds_topology/FlowCanvas'; import ListViewComponent from '../components/ListViewComponent'; +import { + TREE_VIEW_NODE_WIDTH, + TREE_VIEW_NODE_HEIGHT, + TREE_VIEW_NODE_SEP, + TREE_VIEW_RANK_SEP, +} from './treeView/hooks/useTreeViewData'; import { api } from '../lib/api'; import useEdgeTypeStore from '../stores/edgeTypeStore'; @@ -263,26 +270,10 @@ const getLayoutedElements = ( nodes: CustomNode[], edges: CustomEdge[], direction = 'LR', - prevNodes: React.MutableRefObject + prevNodes: React.MutableRefObject, + currentZoom: number ) => { - // Use fixed layout values - let ReactFlow handle zoom visually - const NODE_WIDTH = 146; - const NODE_HEIGHT = 30; - const NODE_SEP = 60; - const RANK_SEP = 150; - const CHILD_SPACING = NODE_HEIGHT + 30; - - if (nodes.length === 0) { - return { nodes: [], edges: [] }; - } - - // Step 1: Initial Dagre layout - const dagreGraph = new dagre.graphlib.Graph(); - dagreGraph.setDefaultEdgeLabel(() => ({})); - dagreGraph.setGraph({ rankdir: direction, nodesep: NODE_SEP, ranksep: RANK_SEP }); - const nodeMap = new Map(); - const newNodes: CustomNode[] = []; // recalculate only if node count changes significantly or if this is first render const shouldRecalculate = @@ -292,34 +283,51 @@ const getLayoutedElements = ( prevNodes.current.forEach(node => nodeMap.set(node.id, node)); } + const clampedZoom = Math.max(0.5, Math.min(2.0, currentZoom)); + + let spacingScaleX = 1; + let spacingScaleY = 1; + + if (clampedZoom <= 0.6) { + const zoomRange = 0.6 - 0.5; + const zoomProgress = (0.6 - clampedZoom) / zoomRange; + spacingScaleX = 1 + zoomProgress * 2; + spacingScaleY = 1 + zoomProgress * 1; + } + + const effectiveNodeWidth = TREE_VIEW_NODE_WIDTH * clampedZoom; + const effectiveNodeHeight = TREE_VIEW_NODE_HEIGHT * clampedZoom; + const nodeSep = TREE_VIEW_NODE_SEP * clampedZoom * spacingScaleX; + const rankSep = TREE_VIEW_RANK_SEP * clampedZoom * spacingScaleY; + const CHILD_SPACING = effectiveNodeHeight + 30; + + if (nodes.length === 0) { + return { nodes: [], edges: [] }; + } + + // Step 1: Initial Dagre layout + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + dagreGraph.setGraph({ rankdir: direction, nodesep: nodeSep, ranksep: rankSep }); + nodes.forEach(node => { - const cachedNode = nodeMap.get(node.id); - if (!cachedNode || !isEqual(cachedNode, node) || shouldRecalculate) { - dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); - newNodes.push(node); - } else { - newNodes.push({ ...cachedNode, ...node }); - } + dagreGraph.setNode(node.id, { width: effectiveNodeWidth, height: effectiveNodeHeight }); }); - if (shouldRecalculate) { - edges.forEach(edge => { - dagreGraph.setEdge(edge.source, edge.target); - }); + edges.forEach(edge => { + dagreGraph.setEdge(edge.source, edge.target); + }); - dagre.layout(dagreGraph); - } else { - return { nodes: prevNodes.current, edges }; - } + dagre.layout(dagreGraph); - const layoutedNodes = newNodes.map(node => { + const layoutedNodes = nodes.map(node => { const dagreNode = dagreGraph.node(node.id); return dagreNode ? { ...node, position: { - x: dagreNode.x - NODE_WIDTH / 2 + 50, - y: dagreNode.y - NODE_HEIGHT / 2 + 50, + x: dagreNode.x - effectiveNodeWidth / 2 + 50, + y: dagreNode.y - effectiveNodeHeight / 2 + 50, }, } : node; @@ -396,7 +404,7 @@ const getLayoutedElements = ( const totalHeight = (children.length - 1) * CHILD_SPACING; // Center children around the parent - const parentY = parentNode.position.y + NODE_HEIGHT / 2; + const parentY = parentNode.position.y + effectiveNodeHeight / 2; const topY = parentY - totalHeight / 2; // Update positions of aligned children @@ -508,8 +516,8 @@ const getLayoutedElements = ( const currentNode = layoutedNodes[i]; const prevNode = layoutedNodes[i - 1]; - if (Math.abs(currentNode.position.x - prevNode.position.x) < NODE_WIDTH / 2) { - const minSpacing = NODE_HEIGHT + 10; // Reduced minimum spacing (was 10) + if (Math.abs(currentNode.position.x - prevNode.position.x) < effectiveNodeWidth / 2) { + const minSpacing = effectiveNodeHeight + 10; // Reduced minimum spacing (was 10) if (currentNode.position.y - prevNode.position.y < minSpacing) { layoutedNodes[i] = { ...currentNode, @@ -556,19 +564,31 @@ const WecsTreeview = () => { const nodeCache = useRef>(new Map()); const edgeIdCounter = useRef(0); const prevNodes = useRef([]); + const rawNodesRef = useRef([]); + const rawEdgesRef = useRef([]); const renderStartTime = useRef(0); const panelRef = useRef(null); const prevWecsData = useRef(null); const stateRef = useRef({ isCollapsed, isExpanded }); const [viewMode, setViewMode] = useState<'tiles' | 'list'>('tiles'); const containerRef = useRef(null); + const [rawDataVersion, setRawDataVersion] = useState(0); const { wecsIsConnected, hasValidWecsData, wecsData } = useWebSocket(); + const currentZoom = useZoomStore(state => state.currentZoom); + const [debouncedZoom, setDebouncedZoom] = useState(currentZoom); useEffect(() => { renderStartTime.current = performance.now(); }, []); + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedZoom(currentZoom); + }, 150); + return () => clearTimeout(timer); + }, [currentZoom]); + const updateNodeStyles = useCallback(() => { setNodes(currentNodes => { if (currentNodes.length === 0) return currentNodes; @@ -605,7 +625,7 @@ const WecsTreeview = () => { })) ); } - }, [edgeType]); + }, [edgeType, edges.length]); useEffect(() => { const timer = setTimeout(() => { @@ -1275,26 +1295,35 @@ const WecsTreeview = () => { }); } - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( - newNodes, - newEdges, - 'LR', - prevNodes - ); - - if (!isEqual(nodes, layoutedNodes)) { - setNodes(layoutedNodes); - setEdges(layoutedEdges); - } else if (!isEqual(edges, layoutedEdges)) { - setEdges(layoutedEdges); - } + rawNodesRef.current = newNodes; + rawEdgesRef.current = newEdges; + setRawDataVersion(v => v + 1); - prevNodes.current = layoutedNodes; setIsTransforming(false); }, [createNode, fetchAllClusterTimestamps] ); + const layoutedElements = useMemo(() => { + if (rawNodesRef.current.length === 0 && rawEdgesRef.current.length === 0) { + return { nodes: [], edges: [] }; + } + return getLayoutedElements( + rawNodesRef.current, + rawEdgesRef.current, + 'LR', + prevNodes, + debouncedZoom + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rawDataVersion, debouncedZoom]); + + useEffect(() => { + setNodes(layoutedElements.nodes); + setEdges(layoutedElements.edges); + prevNodes.current = layoutedElements.nodes; + }, [layoutedElements]); + // Memoize the data processing to avoid unnecessary re-renders const memoizedWecsData = useMemo(() => wecsData, [wecsData]); diff --git a/frontend/src/components/treeView/hooks/useTreeViewData.ts b/frontend/src/components/treeView/hooks/useTreeViewData.ts index 0e60965e4..1eb40faca 100644 --- a/frontend/src/components/treeView/hooks/useTreeViewData.ts +++ b/frontend/src/components/treeView/hooks/useTreeViewData.ts @@ -3,7 +3,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useWebSocket } from '../../../context/webSocketExports'; import { useLocation } from 'react-router-dom'; import * as dagre from 'dagre'; -import { isEqual } from 'lodash'; import useTheme from '../../../stores/themeStore'; import useZoomStore from '../../../stores/zoomStore'; import { @@ -15,6 +14,10 @@ import { } from '../types'; import { useTreeViewNodes } from '../TreeViewNodes'; import { useTreeViewEdges } from '../TreeViewEdges'; +export const TREE_VIEW_NODE_WIDTH = 146; +export const TREE_VIEW_NODE_HEIGHT = 30; +export const TREE_VIEW_NODE_SEP = 60; +export const TREE_VIEW_RANK_SEP = 150; interface UseTreeViewDataProps { filteredContext: string; @@ -50,9 +53,13 @@ export const useTreeViewData = ({ const [viewMode, setViewMode] = useState<'tiles' | 'list'>('tiles'); const prevNodes = useRef([]); + const rawNodesRef = useRef([]); + const rawEdgesRef = useRef([]); const renderStartTime = useRef(0); const isInitialRender = useRef(true); + const currentZoom = useZoomStore(state => state.currentZoom); + const [debouncedZoom, setDebouncedZoom] = useState(currentZoom); const queryClient = useQueryClient(); const NAMESPACE_QUERY_KEY = ['namespaces']; @@ -122,38 +129,55 @@ export const useTreeViewData = ({ } }, [location.search]); + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedZoom(currentZoom); + }, 150); + return () => clearTimeout(timer); + }, [currentZoom]); + const getLayoutedElements = useCallback( (nodes: CustomNode[], edges: CustomEdge[], direction = 'LR') => { - const { currentZoom } = useZoomStore.getState(); - const scaleFactor = Math.max(0.5, Math.min(2.0, currentZoom)); + const nodeMap = new Map(); + + // recalculate only if node count changes significantly or if this is first render + const shouldRecalculate = + prevNodes.current.length === 0 || Math.abs(nodes.length - prevNodes.current.length) > 5; + + if (!shouldRecalculate) { + prevNodes.current.forEach(node => nodeMap.set(node.id, node)); + } + + const clampedZoom = Math.max(0.5, Math.min(2.0, debouncedZoom)); + + let spacingScaleX = 1; + let spacingScaleY = 1; + + if (clampedZoom <= 0.6) { + const zoomRange = 0.6 - 0.5; + const zoomProgress = (0.6 - clampedZoom) / zoomRange; + spacingScaleX = 1 + zoomProgress * 2; + spacingScaleY = 1 + zoomProgress * 1; + } + + const effectiveNodeWidth = TREE_VIEW_NODE_WIDTH * clampedZoom; + const effectiveNodeHeight = TREE_VIEW_NODE_HEIGHT * clampedZoom; + const nodeSep = TREE_VIEW_NODE_SEP * clampedZoom * spacingScaleX; + const rankSep = TREE_VIEW_RANK_SEP * clampedZoom * spacingScaleY; const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); dagreGraph.setGraph({ rankdir: direction, - nodesep: 50 * scaleFactor, // Increased from 30 - ranksep: 130 * scaleFactor, // Increased from 60 + nodesep: nodeSep, + ranksep: rankSep, }); - const nodeMap = new Map(); - const newNodes: CustomNode[] = []; - - const shouldRecalculate = true; - if (!shouldRecalculate && Math.abs(nodes.length - prevNodes.current.length) <= 5) { - prevNodes.current.forEach(node => nodeMap.set(node.id, node)); - } - nodes.forEach(node => { - const cachedNode = nodeMap.get(node.id); - if (!cachedNode || !isEqual(cachedNode, node) || shouldRecalculate) { - dagreGraph.setNode(node.id, { - width: 146 * scaleFactor, - height: 30 * scaleFactor, // Match the actual node height from zoom store - }); - newNodes.push(node); - } else { - newNodes.push({ ...cachedNode, ...node }); - } + dagreGraph.setNode(node.id, { + width: effectiveNodeWidth, + height: effectiveNodeHeight, + }); }); edges.forEach(edge => { @@ -162,14 +186,14 @@ export const useTreeViewData = ({ dagre.layout(dagreGraph); - const layoutedNodes = newNodes.map(node => { + const layoutedNodes = nodes.map(node => { const dagreNode = dagreGraph.node(node.id); return dagreNode ? { ...node, position: { - x: dagreNode.x - 73 * scaleFactor + 50 * scaleFactor, - y: dagreNode.y - 15 * scaleFactor + 50 * scaleFactor, // This is correct: 30/2 = 15 + x: dagreNode.x - effectiveNodeWidth / 2 + 50, + y: dagreNode.y - effectiveNodeHeight / 2 + 50, }, } : node; @@ -177,10 +201,25 @@ export const useTreeViewData = ({ return { nodes: layoutedNodes, edges }; }, - // Include dependencies for useZoomStore.getState() and any other external values - [prevNodes] + [debouncedZoom] ); + const [rawDataVersion, setRawDataVersion] = useState(0); + + const layoutedElements = useMemo(() => { + if (rawNodesRef.current.length === 0 && rawEdgesRef.current.length === 0) { + return { nodes: [], edges: [] }; + } + return getLayoutedElements(rawNodesRef.current, rawEdgesRef.current, 'LR'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rawDataVersion, getLayoutedElements]); + + useEffect(() => { + setNodes(layoutedElements.nodes); + setEdges(layoutedElements.edges); + prevNodes.current = layoutedElements.nodes; + }, [layoutedElements]); + const transformDataToTree = useCallback( (data: NamespaceResource[]) => { setIsTransforming(true); @@ -327,19 +366,12 @@ export const useTreeViewData = ({ } } - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( - newNodes, - newEdges, - 'LR' - ); + rawNodesRef.current = newNodes; + rawEdgesRef.current = newEdges; + setRawDataVersion(v => v + 1); - // React 18 automatically batches updates, no need for unstable_batchedUpdates - setNodes(layoutedNodes); - setEdges(layoutedEdges); setIsTransforming(false); - prevNodes.current = layoutedNodes; - // Calculate resource counts const tempContextCounts: Record = {}; let tempTotalCount = 0; @@ -367,7 +399,7 @@ export const useTreeViewData = ({ setIsTransforming(false); } }, - [filteredContext, isCollapsed, isExpanded, createNode, clearNodeCache, getLayoutedElements] + [filteredContext, isCollapsed, isExpanded, createNode, clearNodeCache] ); useEffect(() => { diff --git a/frontend/src/components/wds_topology/ZoomControls.tsx b/frontend/src/components/wds_topology/ZoomControls.tsx index 08f7011b8..b9e39560e 100644 --- a/frontend/src/components/wds_topology/ZoomControls.tsx +++ b/frontend/src/components/wds_topology/ZoomControls.tsx @@ -352,9 +352,9 @@ export const ZoomControls = memo( animation: `${bounceIn} 0.6s ease-out`, width: 'fit-content', minWidth: 'fit-content', - maxWidth: 'calc(100vw - clamp(20px, 40px, 4vw))', + maxWidth: 'min(calc(100vw - clamp(20px, 40px, 4vw)), calc(100% - 16px))', zIndex: 5, - maxHeight: 'calc(100vh - clamp(20px, 40px, 4vh))', + maxHeight: 'min(calc(100vh - clamp(20px, 40px, 4vh)), calc(100% - 16px))', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain',