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/GraphRenderer.tsx b/frontend/src/components/resourceMap/GraphRenderer.tsx index 7226e705a5..48b6d00fd4 100644 --- a/frontend/src/components/resourceMap/GraphRenderer.tsx +++ b/frontend/src/components/resourceMap/GraphRenderer.tsx @@ -15,6 +15,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Loader } from '../common'; import { KubeRelationEdge } from './edges/KubeRelationEdge'; +import { maxZoom, minZoom } from './graphConstants'; import { GraphControls } from './GraphControls'; import { GroupNodeComponent } from './nodes/GroupNode'; import { KubeGroupNodeComponent } from './nodes/KubeGroupNode'; @@ -81,8 +82,8 @@ export function GraphRenderer({ onBackgroundClick?.(); } }} - minZoom={0.1} - maxZoom={2.0} + minZoom={minZoom} + maxZoom={maxZoom} connectionMode={ConnectionMode.Loose} > diff --git a/frontend/src/components/resourceMap/GraphView.tsx b/frontend/src/components/resourceMap/GraphView.tsx index 97dd08c15e..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, @@ -45,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 { @@ -121,8 +114,6 @@ function GraphViewContent({ edges: [], }); - const flow = useReactFlow(); - // Apply filters const filteredGraph = useMemo(() => { const filters = [...defaultFilters]; @@ -146,54 +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 defaultViewportPaddingPx = 50; - - const topLeftOrigin = { x: defaultViewportPaddingPx, y: defaultViewportPaddingPx }; - const centerOrigin = { - x: reactFlowWidth / 2 - bounds.width / 2, - y: reactFlowHeight / 2 - bounds.height / 2, - }; - - const xFits = bounds.width + defaultViewportPaddingPx * 2 <= reactFlowWidth; - const yFits = bounds.height + defaultViewportPaddingPx * 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(() => { @@ -324,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/graphConstants.ts b/frontend/src/components/resourceMap/graphConstants.ts new file mode 100644 index 0000000000..aa34867dd7 --- /dev/null +++ b/frontend/src/components/resourceMap/graphConstants.ts @@ -0,0 +1,3 @@ +export const minZoom = 0.1; +export const maxZoom = 2.0; +export const viewportPaddingPx = 50; diff --git a/frontend/src/components/resourceMap/useGraphViewport.ts b/frontend/src/components/resourceMap/useGraphViewport.ts new file mode 100644 index 0000000000..82e901a61b --- /dev/null +++ b/frontend/src/components/resourceMap/useGraphViewport.ts @@ -0,0 +1,87 @@ +import { getNodesBounds, getViewportForBounds, Node, useReactFlow, useStore } from '@xyflow/react'; +import { useCallback, useMemo } from 'react'; +import { useLocalStorageState } from '../globalSearch/useLocalStorageState'; +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 [zoomMode, setZoomMode] = useLocalStorageState('map-zoom-mode', '100%'); + 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 = zoomMode, + }: { + /** 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; + }) => { + if (mode !== zoomMode) { + setZoomMode(() => mode); + } + + 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, zoomMode, reactFlowWidth, reactFlowHeight] + ); + + return useMemo(() => ({ updateViewport, aspectRatio }), [updateViewport, aspectRatio]); +};