Skip to content

Commit

Permalink
feat(Canvas): Pan nodes into view.
Browse files Browse the repository at this point in the history
In order to preserve the nodes visible when opening the configuration
panel, this commit schedules a `panIntoView` command to the selected
node, preserving the zoom level while aiming for the less movement
possible to make the node visible.

relates: https://issues.redhat.com/browse/KTO-461
  • Loading branch information
lordrip committed Sep 23, 2024
1 parent d0a6677 commit 5bedff2
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 79 deletions.
160 changes: 86 additions & 74 deletions packages/ui/src/components/Visualization/Canvas/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Icon } from '@patternfly/react-core';
import { CatalogIcon } from '@patternfly/react-icons';
import {
GRAPH_LAYOUT_END_EVENT,
GraphLayoutEndEventListener,
Model,
SELECTION_EVENT,
SelectionEventListener,
TopologyControlBar,
TopologyControlButton,
TopologyView,
VisualizationProvider,
VisualizationSurface,
action,
createTopologyControlButtons,
defaultControlButtonsOptions,
useEventListener,
useVisualizationController,
} from '@patternfly/react-topology';
import {
FunctionComponent,
Expand All @@ -19,7 +23,6 @@ import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
Expand All @@ -35,7 +38,6 @@ import './Canvas.scss';
import { CanvasSideBar } from './CanvasSideBar';
import { CanvasDefaults } from './canvas.defaults';
import { CanvasEdge, CanvasNode, LayoutType } from './canvas.models';
import { ControllerService } from './controller.service';
import { FlowService } from './flow.service';

interface CanvasProps {
Expand All @@ -47,7 +49,6 @@ export const Canvas: FunctionComponent<PropsWithChildren<CanvasProps>> = ({ enti
/** State for @patternfly/react-topology */
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedNode, setSelectedNode] = useState<CanvasNode | undefined>(undefined);
const [nodes, setNodes] = useState<CanvasNode[]>([]);
const [activeLayout, setActiveLayout] = useLocalStorage(LocalStorageKeys.CanvasLayout, CanvasDefaults.DEFAULT_LAYOUT);
const [sidebarWidth, setSidebarWidth] = useLocalStorage(
LocalStorageKeys.CanvasSidebarWidth,
Expand All @@ -57,14 +58,78 @@ export const Canvas: FunctionComponent<PropsWithChildren<CanvasProps>> = ({ enti
/** Context to interact with the Canvas catalog */
const catalogModalContext = useContext(CatalogModalContext);

const controller = useMemo(() => ControllerService.createController(), []);
const controller = useVisualizationController();
const { visibleFlows } = useContext(VisibleFlowsContext)!;
const shouldShowEmptyState = useMemo(() => {
const areNoFlows = entities.length === 0;
const areAllFlowsHidden = Object.values(visibleFlows).every((visible) => !visible);
return areNoFlows || areAllFlowsHidden;
}, [entities.length, visibleFlows]);

/** Draw graph */
useEffect(() => {
const nodes: CanvasNode[] = [];
const edges: CanvasEdge[] = [];

entities.forEach((entity) => {
if (visibleFlows[entity.id]) {
const { nodes: childNodes, edges: childEdges } = FlowService.getFlowDiagram(entity.toVizNode());
nodes.push(...childNodes);
edges.push(...childEdges);
}
});

const model: Model = {
nodes,
edges,
graph: {
id: 'g1',
type: 'graph',
layout: activeLayout,
},
};

console.log('[RENDER] Canvas - Draw graph', nodes);
controller.fromModel(model, true);
}, [activeLayout, controller, entities, visibleFlows]);

useEventListener<SelectionEventListener>(SELECTION_EVENT, (ids) => {
setSelectedIds(ids);
});
useEventListener<GraphLayoutEndEventListener>(GRAPH_LAYOUT_END_EVENT, ({ graph }) => {
console.log('[RENDER] Canvas - Graph layout end');
setTimeout(
action(() => {
graph.fit(80);
}),
0,
);
});

/** Set select node and pan it into view */
useEffect(() => {
let resizeTimeout: number | undefined;

if (!selectedIds[0]) {
const selectedNode = controller.getNodeById(selectedIds[0]);
if (selectedNode) {
setSelectedNode(selectedNode as unknown as CanvasNode);
resizeTimeout = setTimeout(
action(() => {
controller.getGraph().panIntoView(selectedNode, { offset: 20, minimumVisible: 100 });
resizeTimeout = undefined;
}),
500,
) as unknown as number;
}
}
return () => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
};
}, [selectedIds, controller]);

const controlButtons = useMemo(() => {
const customButtons: TopologyControlButton[] = catalogModalContext
? [
Expand Down Expand Up @@ -129,57 +194,6 @@ export const Canvas: FunctionComponent<PropsWithChildren<CanvasProps>> = ({ enti
});
}, [catalogModalContext, controller, setActiveLayout]);

const handleSelection = useCallback(
(selectedIds: string[]) => {
setSelectedIds(selectedIds);

/** Current support for single selection at the moment */
const selectedId = selectedIds[0];
setSelectedNode(nodes.find((node) => node.id === selectedId));
},
[nodes],
);

/** Set up the controller one time */
useEffect(() => {
const localController = controller;
localController.addEventListener(SELECTION_EVENT, handleSelection);

return () => {
localController.removeEventListener(SELECTION_EVENT, handleSelection);
};
}, [controller, handleSelection]);

/** Draw graph */
useLayoutEffect(() => {
setSelectedNode(undefined);

const nodes: CanvasNode[] = [];
const edges: CanvasEdge[] = [];

entities.forEach((entity) => {
if (visibleFlows[entity.id]) {
const { nodes: childNodes, edges: childEdges } = FlowService.getFlowDiagram(entity.toVizNode());
nodes.push(...childNodes);
edges.push(...childEdges);
}
});

setNodes([...nodes]);

const model: Model = {
nodes,
edges,
graph: {
id: 'g1',
type: 'graph',
layout: activeLayout,
},
};

controller.fromModel(model, false);
}, [activeLayout, controller, entities, visibleFlows]);

const handleCloseSideBar = useCallback(() => {
setSelectedIds([]);
setSelectedNode(undefined);
Expand All @@ -188,23 +202,21 @@ export const Canvas: FunctionComponent<PropsWithChildren<CanvasProps>> = ({ enti
const isSidebarOpen = useMemo(() => selectedNode !== undefined, [selectedNode]);

return (
<VisualizationProvider controller={controller}>
<TopologyView
defaultSideBarSize={sidebarWidth + 'px'}
minSideBarSize="210px"
onSideBarResize={setSidebarWidth}
sideBarResizable
sideBarOpen={isSidebarOpen}
sideBar={<CanvasSideBar selectedNode={selectedNode} onClose={handleCloseSideBar} />}
contextToolbar={contextToolbar}
controlBar={<TopologyControlBar controlButtons={controlButtons} />}
>
{shouldShowEmptyState ? (
<VisualizationEmptyState data-testid="visualization-empty-state" entitiesNumber={entities.length} />
) : (
<VisualizationSurface state={{ selectedIds }} />
)}
</TopologyView>
</VisualizationProvider>
<TopologyView
defaultSideBarSize={sidebarWidth + 'px'}
minSideBarSize="210px"
onSideBarResize={setSidebarWidth}
sideBarResizable
sideBarOpen={isSidebarOpen}
sideBar={<CanvasSideBar selectedNode={selectedNode} onClose={handleCloseSideBar} />}
contextToolbar={contextToolbar}
controlBar={<TopologyControlBar controlButtons={controlButtons} />}
>
{shouldShowEmptyState ? (
<VisualizationEmptyState data-testid="visualization-empty-state" entitiesNumber={entities.length} />
) : (
<VisualizationSurface state={{ selectedIds }} />
)}
</TopologyView>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { VisualizationProvider } from '@patternfly/react-topology';
import { FunctionComponent, PropsWithChildren, ReactNode, useMemo } from 'react';
import { BaseVisualCamelEntity } from '../../../models/visualization/base-visual-entity';
import { Canvas } from './Canvas';
import './Canvas.scss';
import { ControllerService } from './controller.service';

interface CanvasProps {
contextToolbar?: ReactNode;
entities: BaseVisualCamelEntity[];
}

export const CanvasController: FunctionComponent<PropsWithChildren<CanvasProps>> = ({ entities }) => {
const controller = useMemo(() => ControllerService.createController(), []);

return (
<VisualizationProvider controller={controller}>
<Canvas entities={entities} />
</VisualizationProvider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export class ControllerService {
newController.registerLayoutFactory(this.baselineLayoutFactory);
newController.registerComponentFactory(this.baselineComponentFactory);
newController.registerElementFactory(this.baselineElementFactory);
newController.setFitToScreenOnLayout(true, 80);

return newController;
}
Expand Down
8 changes: 4 additions & 4 deletions packages/ui/src/components/Visualization/Visualization.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { FunctionComponent, PropsWithChildren, ReactNode, useMemo } from 'react';
import { BaseVisualCamelEntity } from '../../models/visualization/base-visual-entity';
import { CanvasFormTabsProvider } from '../../providers';
import { ErrorBoundary } from '../ErrorBoundary';
import { Canvas } from './Canvas';
import { CanvasController } from './Canvas/CanvasController';
import { CanvasFallback } from './CanvasFallback';
import './Visualization.scss';
import { ContextToolbar } from './ContextToolbar/ContextToolbar';
import { CanvasFormTabsProvider } from '../../providers';
import './Visualization.scss';

interface CanvasProps {
className?: string;
Expand All @@ -20,7 +20,7 @@ export const Visualization: FunctionComponent<PropsWithChildren<CanvasProps>> =
<div className={`canvas-surface ${props.className ?? ''}`}>
<ErrorBoundary key={lastUpdate} fallback={props.fallback ?? <CanvasFallback />}>
<CanvasFormTabsProvider>
<Canvas contextToolbar={<ContextToolbar />} entities={props.entities} />
<CanvasController contextToolbar={<ContextToolbar />} entities={props.entities} />
</CanvasFormTabsProvider>
</ErrorBoundary>
</div>
Expand Down

0 comments on commit 5bedff2

Please sign in to comment.