From 66167b7df8663ce446e703b19233397f801cb15e Mon Sep 17 00:00:00 2001 From: Oleksandr Dubenko Date: Tue, 10 Dec 2024 12:39:53 +0100 Subject: [PATCH] frontend Map: Move viewport logic to useGrpahViewport hook Signed-off-by: Oleksandr Dubenko --- .../components/resourceMap/GraphControls.tsx | 5 +- .../src/components/resourceMap/GraphView.tsx | 76 +++++------------ .../resourceMap/useGraphViewport.ts | 81 +++++++++++++++++++ 3 files changed, 102 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/resourceMap/useGraphViewport.ts diff --git a/frontend/src/components/resourceMap/GraphControls.tsx b/frontend/src/components/resourceMap/GraphControls.tsx index 8aa4234a11..edb06b84d9 100644 --- a/frontend/src/components/resourceMap/GraphControls.tsx +++ b/frontend/src/components/resourceMap/GraphControls.tsx @@ -46,7 +46,7 @@ export function GraphControls({ children }: { children?: React.ReactNode }) { const { t } = useTranslation(); const minZoomReached = useStore(it => it.transform[2] <= it.minZoom); const maxZoomReached = useStore(it => it.transform[2] >= it.maxZoom); - const { zoomIn, zoomOut, fitView } = useReactFlow(); + const { zoomIn, zoomOut } = useReactFlow(); return ( @@ -72,9 +72,6 @@ export function GraphControls({ children }: { children?: React.ReactNode }) { - fitView()}> - - {children} ); diff --git a/frontend/src/components/resourceMap/GraphView.tsx b/frontend/src/components/resourceMap/GraphView.tsx index a34aba3060..88512fb683 100644 --- a/frontend/src/components/resourceMap/GraphView.tsx +++ b/frontend/src/components/resourceMap/GraphView.tsx @@ -2,15 +2,7 @@ import '@xyflow/react/dist/base.css'; import './GraphView.css'; import { Icon } from '@iconify/react'; import { Box, Chip, Theme, ThemeProvider } from '@mui/material'; -import { - Edge, - getNodesBounds, - Node, - Panel, - ReactFlowProvider, - useReactFlow, - useStore, -} from '@xyflow/react'; +import { Edge, Node, Panel, ReactFlowProvider } from '@xyflow/react'; import { createContext, ReactNode, @@ -38,7 +30,6 @@ import { } from './graph/graphGrouping'; import { applyGraphLayout } from './graph/graphLayout'; import { GraphNode, GraphSource, GroupNode, isGroup, KubeObjectNode } from './graph/graphModel'; -import { viewportPaddingPx } from './graphConstants'; import { GraphControlButton } from './GraphControls'; import { GraphRenderer } from './GraphRenderer'; import { NodeHighlight, useNodeHighlight } from './NodeHighlight'; @@ -46,6 +37,7 @@ import { ResourceSearch } from './search/ResourceSearch'; import { SelectionBreadcrumbs } from './SelectionBreadcrumbs'; import { allSources, GraphSourceManager, useSources } from './sources/GraphSources'; import { GraphSourcesView } from './sources/GraphSourcesView'; +import { useGraphViewport } from './useGraphViewport'; import { useQueryParamsState } from './useQueryParamsState'; interface GraphViewContent { @@ -122,8 +114,6 @@ function GraphViewContent({ edges: [], }); - const flow = useReactFlow(); - // Apply filters const filteredGraph = useMemo(() => { const filters = [...defaultFilters]; @@ -147,52 +137,18 @@ function GraphViewContent({ return { visibleGraph, fullGraph: graph }; }, [filteredGraph, groupBy, selectedNodeId, expandAll]); - // Apply layout to visible graph - const aspectRatio = useStore(it => it.width / it.height); - const reactFlowWidth = useStore(it => it.width); - const reactFlowHeight = useStore(it => it.height); - - /** - * Zooms the viewport to 100% zoom level - * It will center the nodes if they fit into view - * Or if they don't fit it: - * - align to top if they don't fit vertically - * - align to left if they don't fit horizontally - */ - const zoomTo100 = useCallback( - (nodes: Node[]) => { - const bounds = getNodesBounds(nodes); - - const topLeftOrigin = { x: viewportPaddingPx, y: viewportPaddingPx }; - const centerOrigin = { - x: reactFlowWidth / 2 - bounds.width / 2, - y: reactFlowHeight / 2 - bounds.height / 2, - }; - - const xFits = bounds.width + viewportPaddingPx * 2 <= reactFlowWidth; - const yFits = bounds.height + viewportPaddingPx * 2 <= reactFlowHeight; - - const defaultZoomViewport = { - x: xFits ? centerOrigin.x : topLeftOrigin.x, - y: yFits ? centerOrigin.y : topLeftOrigin.y, - zoom: 1, - }; - - flow.setViewport(defaultZoomViewport); - }, - [flow, reactFlowWidth, reactFlowHeight] - ); + const viewport = useGraphViewport(); useEffect(() => { - applyGraphLayout(visibleGraph, aspectRatio).then(layout => { + applyGraphLayout(visibleGraph, viewport.aspectRatio).then(layout => { setLayoutedGraph(layout); // Only fit bounds when user hasn't moved viewport manually if (!viewportMovedRef.current) { - zoomTo100(layout.nodes); + viewport.updateViewport({ nodes: layout.nodes }); } }); - }, [visibleGraph, aspectRatio, zoomTo100]); + }, [visibleGraph, viewport]); // Reset after view change useLayoutEffect(() => { @@ -323,12 +279,20 @@ function GraphViewContent({ } }} controlActions={ - zoomTo100(layoutedGraph.nodes)} - > - 100% - + <> + viewport.updateViewport({ mode: 'fit' })} + > + + + viewport.updateViewport({ mode: '100%' })} + > + 100% + + } > diff --git a/frontend/src/components/resourceMap/useGraphViewport.ts b/frontend/src/components/resourceMap/useGraphViewport.ts new file mode 100644 index 0000000000..48c742f7f3 --- /dev/null +++ b/frontend/src/components/resourceMap/useGraphViewport.ts @@ -0,0 +1,81 @@ +import { getNodesBounds, getViewportForBounds, Node, useReactFlow, useStore } from '@xyflow/react'; +import { useCallback, useMemo } from 'react'; +import { maxZoom, minZoom, viewportPaddingPx } from './graphConstants'; + +/** + * Zoom Mode represents different approaches to viewport calculation + * + * - 100% (default) + * Will try to fit nodes without exceeding 100% zoom + * Often results in content overflowing but keeps text readable + * + * - Fit + * Will show everything and zoom out as needed + */ +type zoomMode = '100%' | 'fit'; + +/** Helper hook to deal with viewport zooming */ +export const useGraphViewport = () => { + const reactFlowWidth = useStore(it => it.width); + const reactFlowHeight = useStore(it => it.height); + const aspectRatio = useStore(it => it.width / it.height); + const flow = useReactFlow(); + + const updateViewport = useCallback( + ({ + nodes = flow.getNodes(), + mode = '100%', + }: { + /** List of nodes, if not provided will use current nodes in the graph */ + nodes?: Node[]; + /** Zoom mode. More info in the type definition {@link zoomMode} */ + mode?: zoomMode; + }) => { + const bounds = getNodesBounds(nodes); + + if (mode === 'fit') { + const viewport = getViewportForBounds( + { + x: bounds.x - viewportPaddingPx, + y: bounds.y - viewportPaddingPx, + width: bounds.width + viewportPaddingPx * 2, + height: bounds.height + viewportPaddingPx * 2, + }, + reactFlowWidth, + reactFlowHeight, + minZoom, + maxZoom, + 0 + ); + + flow.setViewport(viewport); + return; + } + + if (mode === '100%') { + const topLeftOrigin = { x: viewportPaddingPx, y: viewportPaddingPx }; + const centerOrigin = { + x: reactFlowWidth / 2 - bounds.width / 2, + y: reactFlowHeight / 2 - bounds.height / 2, + }; + + const xFits = bounds.width + viewportPaddingPx * 2 <= reactFlowWidth; + const yFits = bounds.height + viewportPaddingPx * 2 <= reactFlowHeight; + + const defaultZoomViewport = { + x: xFits ? centerOrigin.x : topLeftOrigin.x, + y: yFits ? centerOrigin.y : topLeftOrigin.y, + zoom: 1, + }; + + flow.setViewport(defaultZoomViewport); + return; + } + + console.error('Unknown zoom mode', mode); + }, + [flow, reactFlowWidth, reactFlowHeight] + ); + + return useMemo(() => ({ updateViewport, aspectRatio }), [updateViewport, aspectRatio]); +};