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]);
+};