Skip to content

Commit

Permalink
Merge pull request #2649 from headlamp-k8s/map-zoom-mode
Browse files Browse the repository at this point in the history
frontend Map: Remember zoom mode. Move viewport logic to useGraphViewport hook
  • Loading branch information
joaquimrocha authored Dec 10, 2024
2 parents a11add3 + 859c864 commit b334efb
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 63 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
5 changes: 3 additions & 2 deletions frontend/src/components/resourceMap/GraphRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,8 +82,8 @@ export function GraphRenderer({
onBackgroundClick?.();
}
}}
minZoom={0.1}
maxZoom={2.0}
minZoom={minZoom}
maxZoom={maxZoom}
connectionMode={ConnectionMode.Loose}
>
<Background variant={BackgroundVariant.Dots} style={{ color: theme.palette.divider }} />
Expand Down
77 changes: 20 additions & 57 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 @@ -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 {
Expand Down Expand Up @@ -121,8 +114,6 @@ function GraphViewContent({
edges: [],
});

const flow = useReactFlow();

// Apply filters
const filteredGraph = useMemo(() => {
const filters = [...defaultFilters];
Expand All @@ -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(() => {
Expand Down Expand Up @@ -324,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
3 changes: 3 additions & 0 deletions frontend/src/components/resourceMap/graphConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const minZoom = 0.1;
export const maxZoom = 2.0;
export const viewportPaddingPx = 50;
87 changes: 87 additions & 0 deletions frontend/src/components/resourceMap/useGraphViewport.ts
Original file line number Diff line number Diff line change
@@ -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<zoomMode>('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]);
};

0 comments on commit b334efb

Please sign in to comment.