= ({ nodes, edges, interactive, ...rest }) => {
+ const layoutCalled = useRef(false);
+ const [isGraphLocked, setIsGraphLocked] = useState(interactive);
+ const { initialNodes, initialEdges } = useMemo(
+ () => processGraph(nodes, edges, isGraphLocked),
+ [nodes, edges, isGraphLocked]
+ );
+
+ const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes);
+ const [edgesState, _setEdges, onEdgesChange] = useEdgesState(initialEdges);
+
+ if (!layoutCalled.current) {
+ const { nodes: layoutedNodes } = layoutGraph(nodesState, edgesState);
+ setNodes(layoutedNodes);
+ layoutCalled.current = true;
+ }
+
+ const onInteractiveStateChange = useCallback(
+ (interactiveStatus: boolean): void => {
+ setIsGraphLocked(interactiveStatus);
+ setNodes((prevNodes) =>
+ prevNodes.map((node) => ({
+ ...node,
+ data: {
+ ...node.data,
+ interactive: interactiveStatus,
+ },
+ }))
+ );
+ },
+ [setNodes]
+ );
+
+ return (
+
+
+ {
+ window.requestAnimationFrame(() => xyflow.fitView());
+
+ // When the graph is not initialized as interactive, we need to fit the view on resize
+ if (!interactive) {
+ const resizeObserver = new ResizeObserver(() => {
+ xyflow.fitView();
+ });
+ resizeObserver.observe(document.querySelector('.react-flow') as Element);
+ return () => resizeObserver.disconnect();
+ }
+ }}
+ nodeTypes={nodeTypes}
+ edgeTypes={edgeTypes}
+ nodes={nodesState}
+ edges={edgesState}
+ onNodesChange={onNodesChange}
+ onEdgesChange={onEdgesChange}
+ proOptions={{ hideAttribution: true }}
+ panOnDrag={isGraphLocked}
+ zoomOnScroll={isGraphLocked}
+ zoomOnPinch={isGraphLocked}
+ zoomOnDoubleClick={isGraphLocked}
+ preventScrolling={isGraphLocked}
+ nodesDraggable={interactive && isGraphLocked}
+ maxZoom={1.3}
+ >
+ {interactive && }
+
+
+
+ );
+};
+
+const processGraph = (
+ nodesModel: NodeViewModel[],
+ edgesModel: EdgeViewModel[],
+ interactive: boolean
+): {
+ initialNodes: Array>;
+ initialEdges: Array>;
+} => {
+ const nodesById: { [key: string]: NodeViewModel } = {};
+
+ const initialNodes = nodesModel.map((nodeData) => {
+ nodesById[nodeData.id] = nodeData;
+
+ const node: Node = {
+ id: nodeData.id,
+ type: nodeData.shape,
+ data: { ...nodeData, interactive },
+ position: { x: 0, y: 0 }, // Default position, should be updated later
+ };
+
+ if (node.type === 'group' && nodeData.shape === 'group') {
+ node.sourcePosition = Position.Right;
+ node.targetPosition = Position.Left;
+ node.resizing = false;
+ node.focusable = false;
+ } else if (nodeData.shape === 'label' && nodeData.parentId) {
+ node.parentId = nodeData.parentId;
+ node.extent = 'parent';
+ node.expandParent = false;
+ node.draggable = false;
+ }
+
+ return node;
+ });
+
+ const initialEdges: Array> = edgesModel.map((edgeData) => {
+ const isIn =
+ nodesById[edgeData.source].shape !== 'label' && nodesById[edgeData.target].shape === 'group';
+ const isInside =
+ nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape === 'label';
+ const isOut =
+ nodesById[edgeData.source].shape === 'label' && nodesById[edgeData.target].shape === 'group';
+ const isOutside =
+ nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape !== 'label';
+
+ return {
+ id: edgeData.id,
+ type: 'default',
+ source: edgeData.source,
+ sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
+ target: edgeData.target,
+ targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
+ focusable: false,
+ selectable: false,
+ data: {
+ ...edgeData,
+ sourceShape: nodesById[edgeData.source].shape,
+ targetShape: nodesById[edgeData.target].shape,
+ },
+ };
+ });
+
+ return { initialNodes, initialEdges };
+};
diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts
index d9f637483c115..868461f99cdee 100644
--- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts
+++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts
@@ -6,18 +6,16 @@
*/
import Dagre from '@dagrejs/dagre';
-import type {
- EdgeDataModel,
- NodeDataModel,
-} from '@kbn/cloud-security-posture-common/types/graph/latest';
-import type { NodeViewModel, Size } from '../types';
+import type { Node, Edge } from '@xyflow/react';
+import type { EdgeViewModel, NodeViewModel, Size } from '../types';
import { calcLabelSize } from './utils';
+import { GroupStyleOverride, NODE_HEIGHT, NODE_WIDTH } from '../node/styles';
export const layoutGraph = (
- nodes: NodeDataModel[],
- edges: EdgeDataModel[]
-): { nodes: NodeViewModel[] } => {
- const nodesById: { [key: string]: NodeViewModel } = {};
+ nodes: Array>,
+ edges: Array>
+): { nodes: Array> } => {
+ const nodesById: { [key: string]: Node } = {};
const graphOpts = {
compound: true,
};
@@ -29,28 +27,27 @@ export const layoutGraph = (
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
nodes.forEach((node) => {
- let size = { width: 90, height: 90 };
- const position = { x: 0, y: 0 };
+ let size = { width: NODE_WIDTH, height: node.measured?.height ?? NODE_HEIGHT };
- if (node.shape === 'label') {
- size = calcLabelSize(node.label);
+ if (node.data.shape === 'label') {
+ size = calcLabelSize(node.data.label);
// TODO: waiting for a fix: https://github.com/dagrejs/dagre/issues/238
// if (node.parentId) {
// g.setParent(node.id, node.parentId);
// }
- } else if (node.shape === 'group') {
+ } else if (node.data.shape === 'group') {
const res = layoutGroupChildren(node, nodes);
size = res.size;
res.children.forEach((child) => {
- nodesById[child.id] = { ...child };
+ nodesById[child.data.id] = child;
});
}
if (!nodesById[node.id]) {
- nodesById[node.id] = { ...node, position };
+ nodesById[node.id] = node;
}
g.setNode(node.id, {
@@ -61,8 +58,8 @@ export const layoutGraph = (
Dagre.layout(g);
- const nodesViewModel: NodeViewModel[] = nodes.map((nodeData) => {
- const dagreNode = g.node(nodeData.id);
+ const layoutedNodes = nodes.map((node) => {
+ const dagreNode = g.node(node.data.id);
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
@@ -70,37 +67,43 @@ export const layoutGraph = (
const y = dagreNode.y - (dagreNode.height ?? 0) / 2;
// For grouped nodes, we want to keep the original position relative to the parent
- if (nodeData.shape === 'label' && nodeData.parentId) {
+ if (node.data.shape === 'label' && node.data.parentId) {
return {
- ...nodeData,
- position: nodesById[nodeData.id].position,
+ ...node,
+ position: nodesById[node.data.id].position,
};
- } else if (nodeData.shape === 'group') {
+ } else if (node.data.shape === 'group') {
return {
- ...nodeData,
+ ...node,
position: { x, y },
- size: {
+ style: GroupStyleOverride({
width: dagreNode.width,
height: dagreNode.height,
- },
+ }),
+ };
+ } else if (node.data.shape === 'label') {
+ return {
+ ...node,
+ position: { x, y },
+ };
+ } else {
+ // Align nodes to labels by shifting the node position by it's label height
+ return {
+ ...node,
+ position: { x, y: y + (dagreNode.height - NODE_HEIGHT) / 2 },
};
}
-
- return {
- ...nodeData,
- position: { x, y },
- };
});
- return { nodes: nodesViewModel };
+ return { nodes: layoutedNodes };
};
const layoutGroupChildren = (
- groupNode: NodeDataModel,
- nodes: NodeDataModel[]
-): { size: Size; children: NodeViewModel[] } => {
+ groupNode: Node,
+ nodes: Array>
+): { size: Size; children: Array