Skip to content

Commit

Permalink
frontend Map: Move viewport logic to useGrpahViewport hook
Browse files Browse the repository at this point in the history
Signed-off-by: Oleksandr Dubenko <[email protected]>
  • Loading branch information
sniok committed Dec 10, 2024
1 parent da2a758 commit 66167b7
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 60 deletions.
5 changes: 1 addition & 4 deletions frontend/src/components/resourceMap/GraphControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box display="flex" gap={1} flexDirection="column">
Expand All @@ -72,9 +72,6 @@ export function GraphControls({ children }: { children?: React.ReactNode }) {
<Icon icon="mdi:minus" />
</GraphControlButton>
</ButtonGroup>
<GraphControlButton title={t('Fit to screen')} onClick={() => fitView()}>
<Icon icon="mdi:fit-to-screen" />
</GraphControlButton>
{children}
</Box>
);
Expand Down
76 changes: 20 additions & 56 deletions frontend/src/components/resourceMap/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -38,14 +30,14 @@ 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';
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 {
Expand Down Expand Up @@ -122,8 +114,6 @@ function GraphViewContent({
edges: [],
});

const flow = useReactFlow();

// Apply filters
const filteredGraph = useMemo(() => {
const filters = [...defaultFilters];
Expand All @@ -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(() => {
Expand Down Expand Up @@ -323,12 +279,20 @@ function GraphViewContent({
}
}}
controlActions={
<GraphControlButton
title={t('Zoom to 100%')}
onClick={() => zoomTo100(layoutedGraph.nodes)}
>
100%
</GraphControlButton>
<>
<GraphControlButton
title={t('Fit to screen')}
onClick={() => viewport.updateViewport({ mode: 'fit' })}
>
<Icon icon="mdi:fit-to-screen" />
</GraphControlButton>
<GraphControlButton
title={t('Zoom to 100%')}
onClick={() => viewport.updateViewport({ mode: '100%' })}
>
100%
</GraphControlButton>
</>
}
>
<Panel position="top-left">
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/components/resourceMap/useGraphViewport.ts
Original file line number Diff line number Diff line change
@@ -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]);
};

0 comments on commit 66167b7

Please sign in to comment.