[8.x] [Cloud Security] Improve graph component performance (#204983) (#205148)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Cloud Security] Improve graph component performance
(#204983)](https://github.com/elastic/kibana/pull/204983)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Kfir
Peled","email":"61654899+kfirpeled@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-12-24T18:52:26Z","message":"[Cloud
Security] Improve graph component performance (#204983)\n\n##
Summary\r\n\r\nApply some graph optimizations and added the first graph
benchmark\r\nstorybook.\r\n\r\nYou can read the detailed investigation
process\r\n[here](https://github.com/elastic/kibana/issues/204982#issuecomment-2559715178).\r\n\r\nI
kept the dashed lines until a new design will be
introduced.\r\n\r\nBefore:\r\n\r\n\r\n3c13cae5-85d2-481b-8fc6-cec08ecee0d9\r\n\r\n\r\n**How
to test this PR**\r\n\r\nTo test this PR you can run\r\n\r\n```\r\nyarn
storybook cloud_security_posture_packages\r\n```\r\n\r\nOr open the
storybook link attached to the build message\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"d5fe9290e80a1b7ac05861804e0fa0e42f05572d","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","Team:Cloud
Security","backport:prev-minor","ci:build-storybooks"],"title":"[Cloud
Security] Improve graph component
performance","number":204983,"url":"https://github.com/elastic/kibana/pull/204983","mergeCommit":{"message":"[Cloud
Security] Improve graph component performance (#204983)\n\n##
Summary\r\n\r\nApply some graph optimizations and added the first graph
benchmark\r\nstorybook.\r\n\r\nYou can read the detailed investigation
process\r\n[here](https://github.com/elastic/kibana/issues/204982#issuecomment-2559715178).\r\n\r\nI
kept the dashed lines until a new design will be
introduced.\r\n\r\nBefore:\r\n\r\n\r\n3c13cae5-85d2-481b-8fc6-cec08ecee0d9\r\n\r\n\r\n**How
to test this PR**\r\n\r\nTo test this PR you can run\r\n\r\n```\r\nyarn
storybook cloud_security_posture_packages\r\n```\r\n\r\nOr open the
storybook link attached to the build message\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"d5fe9290e80a1b7ac05861804e0fa0e42f05572d"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/204983","number":204983,"mergeCommit":{"message":"[Cloud
Security] Improve graph component performance (#204983)\n\n##
Summary\r\n\r\nApply some graph optimizations and added the first graph
benchmark\r\nstorybook.\r\n\r\nYou can read the detailed investigation
process\r\n[here](https://github.com/elastic/kibana/issues/204982#issuecomment-2559715178).\r\n\r\nI
kept the dashed lines until a new design will be
introduced.\r\n\r\nBefore:\r\n\r\n\r\n3c13cae5-85d2-481b-8fc6-cec08ecee0d9\r\n\r\n\r\n**How
to test this PR**\r\n\r\nTo test this PR you can run\r\n\r\n```\r\nyarn
storybook cloud_security_posture_packages\r\n```\r\n\r\nOr open the
storybook link attached to the build message\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"d5fe9290e80a1b7ac05861804e0fa0e42f05572d"}}]}]
BACKPORT-->

Co-authored-by: Kfir Peled <61654899+kfirpeled@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-12-25 07:37:31 +11:00 committed by GitHub
parent 5ff80fd329
commit 91b6a29e69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 10831 additions and 166 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { memo } from 'react';
import { BaseEdge, getSmoothStepPath } from '@xyflow/react';
import { useEuiTheme } from '@elastic/eui';
import type { EdgeProps, EdgeViewModel } from '../types';
@ -14,55 +14,71 @@ import { getMarkerStart, getMarkerEnd } from './markers';
type EdgeColor = EdgeViewModel['color'];
export function DefaultEdge({
id,
label,
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
data,
}: EdgeProps) {
const { euiTheme } = useEuiTheme();
const color: EdgeColor = data?.color ?? 'primary';
const dashedStyle = {
strokeDasharray: '2 2',
};
const [edgePath] = getSmoothStepPath({
// sourceX and targetX are adjusted to account for the shape handle position
sourceX: sourceX - getShapeHandlePosition(data?.sourceShape),
const NODES_WITHOUT_MARKER = ['label', 'group'];
export const DefaultEdge = memo(
({
id,
label,
sourceX,
sourceY,
sourcePosition,
targetX: targetX + getShapeHandlePosition(data?.targetShape),
targetX,
targetY,
targetPosition,
borderRadius: 15,
offset: 0,
});
data,
}: EdgeProps) => {
const { euiTheme } = useEuiTheme();
const color: EdgeColor = data?.color || 'primary';
const sourceMargin = getShapeHandlePosition(data?.sourceShape);
const targetMargin = getShapeHandlePosition(data?.targetShape);
const markerStart =
!data?.sourceShape || !NODES_WITHOUT_MARKER.includes(data?.sourceShape)
? getMarkerStart(color)
: undefined;
const markerEnd =
!data?.targetShape || !NODES_WITHOUT_MARKER.includes(data?.targetShape)
? getMarkerEnd(color)
: undefined;
return (
<>
<BaseEdge
path={edgePath}
style={{
stroke: euiTheme.colors[color],
}}
css={
(!data?.type || data?.type === 'dashed') && {
strokeDasharray: '2,2',
}
}
markerStart={
data?.sourceShape !== 'label' && data?.sourceShape !== 'group'
? getMarkerStart(color)
: undefined
}
markerEnd={
data?.targetShape !== 'label' && data?.targetShape !== 'group'
? getMarkerEnd(color)
: undefined
}
/>
</>
);
}
const sX = Math.round(sourceX - sourceMargin);
const sY = Math.round(sourceY);
const tX = Math.round(targetX + targetMargin);
const tY = Math.round(targetY);
const [edgePath] = getSmoothStepPath({
// sourceX and targetX are adjusted to account for the shape handle position
sourceX: sX,
sourceY: sY,
sourcePosition,
targetX: tX,
targetY: tY,
targetPosition,
borderRadius: 15,
offset: 0,
});
return (
<>
<BaseEdge
id={id}
path={edgePath}
interactionWidth={0}
style={{
stroke: euiTheme.colors[color],
// Defaults to dashed when type is not available
...(!data?.type || data?.type === 'dashed' ? dashedStyle : {}),
}}
markerStart={markerStart}
markerEnd={markerEnd}
/>
</>
);
}
);
DefaultEdge.displayName = 'DefaultEdge';

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* Whether or not to instruct the graph component to only render nodes and edges that would be visible in the viewport.
*/
export const ONLY_RENDER_VISIBLE_ELEMENTS = true as const;

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useRef, useState } from 'react';
import { CommonProps } from '@elastic/eui';
export const FpsTrendline: React.FC<CommonProps> = (props: CommonProps) => {
const [fpsSamples, setFpsSamples] = useState<number[]>([]);
const frameCount = useRef(0);
const lastTimestamp = useRef(performance.now());
useEffect(() => {
let animationFrameId: number;
const calculateFPS = (timestamp: number) => {
frameCount.current += 1;
const delta = timestamp - lastTimestamp.current;
if (delta >= 1000) {
const fps = (frameCount.current * 1000) / delta;
setFpsSamples((prevSamples) => {
const updatedSamples = [...prevSamples, fps];
return updatedSamples.slice(-20);
});
frameCount.current = 0;
lastTimestamp.current = timestamp;
}
animationFrameId = requestAnimationFrame(calculateFPS);
};
animationFrameId = requestAnimationFrame(calculateFPS);
return () => cancelAnimationFrame(animationFrameId);
}, []);
const getBarColor = (fps: number): string => {
if (fps >= 50) return '#4caf50'; // Green
if (fps >= 30) return '#ffeb3b'; // Yellow
return '#f44336'; // Red
};
return (
<div {...props}>
<strong>{'FPS:'}</strong> {Math.round(fpsSamples[fpsSamples.length - 1])} <br />
<div
css={{
display: 'flex',
alignItems: 'flex-end',
height: '30px',
padding: '5px',
}}
>
{fpsSamples.map((fps, index) => (
<div
key={index}
css={{
height: `${Math.min(fps, 60) * (100 / 60)}%`,
width: '5%',
backgroundColor: getBarColor(fps),
marginRight: '2px',
}}
title={`${fps.toFixed(2)} FPS`}
>
<div
css={{
fontSize: '8px',
padding: '2px',
left: `${index * 5 + 5}%`,
}}
>
{fps.toFixed(0)}
</div>
</div>
))}
</div>
</div>
);
};

View file

@ -10,6 +10,11 @@ import React from 'react';
import { Graph, type GraphProps } from './graph';
import { TestProviders } from '../mock/test_providers';
// Turn off the optimization that hides elements that are not visible in the viewport
jest.mock('./constants', () => ({
ONLY_RENDER_VISIBLE_ELEMENTS: false,
}));
const renderGraphPreview = (props: GraphProps) =>
render(
<TestProviders>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import React, { useState, useCallback, useEffect, useRef, memo } from 'react';
import { size, isEmpty, isEqual, xorWith } from 'lodash';
import {
Background,
@ -31,6 +31,7 @@ import {
import { layoutGraph } from './layout_graph';
import { DefaultEdge } from '../edge';
import type { EdgeViewModel, NodeViewModel } from '../types';
import { ONLY_RENDER_VISIBLE_ELEMENTS } from './constants';
import '@xyflow/react/dist/style.css';
@ -82,97 +83,106 @@ const edgeTypes = {
*
* @returns {JSX.Element} The rendered Graph component.
*/
export const Graph = ({ nodes, edges, interactive, isLocked = false, ...rest }: GraphProps) => {
const backgroundId = useGeneratedHtmlId();
const fitViewRef = useRef<
((fitViewOptions?: FitViewOptions<Node> | undefined) => Promise<boolean>) | null
>(null);
const currNodesRef = useRef<NodeViewModel[]>([]);
const currEdgesRef = useRef<EdgeViewModel[]>([]);
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);
export const Graph = memo<GraphProps>(
({ nodes, edges, interactive, isLocked = false, ...rest }: GraphProps) => {
const backgroundId = useGeneratedHtmlId();
const fitViewRef = useRef<
((fitViewOptions?: FitViewOptions<Node> | undefined) => Promise<boolean>) | null
>(null);
const currNodesRef = useRef<NodeViewModel[]>([]);
const currEdgesRef = useRef<EdgeViewModel[]>([]);
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);
useEffect(() => {
// On nodes or edges changes reset the graph and re-layout
if (
!isArrayOfObjectsEqual(nodes, currNodesRef.current) ||
!isArrayOfObjectsEqual(edges, currEdgesRef.current)
) {
const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive);
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);
useEffect(() => {
// On nodes or edges changes reset the graph and re-layout
if (
!isArrayOfObjectsEqual(nodes, currNodesRef.current) ||
!isArrayOfObjectsEqual(edges, currEdgesRef.current)
) {
const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive);
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);
setNodes(layoutedNodes);
setEdges(initialEdges);
currNodesRef.current = nodes;
currEdgesRef.current = edges;
setTimeout(() => {
fitViewRef.current?.();
}, 30);
}
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);
const onInteractiveStateChange = useCallback(
(interactiveStatus: boolean): void => {
setIsGraphInteractive(interactiveStatus);
setNodes((currNodes) =>
currNodes.map((node) => ({
...node,
data: {
...node.data,
interactive: interactiveStatus,
},
}))
);
},
[setNodes]
);
const onInitCallback = useCallback(
(xyflow: ReactFlowInstance<Node<NodeViewModel>, Edge<EdgeViewModel>>) => {
window.requestAnimationFrame(() => xyflow.fitView());
fitViewRef.current = 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();
setNodes(layoutedNodes);
setEdges(initialEdges);
currNodesRef.current = nodes;
currEdgesRef.current = edges;
setTimeout(() => {
fitViewRef.current?.();
}, 30);
}
},
[interactive]
);
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);
return (
<div {...rest}>
<SvgDefsMarker />
<ReactFlow
fitView={true}
onInit={onInitCallback}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodesState}
edges={edgesState}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
proOptions={{ hideAttribution: true }}
panOnDrag={isGraphInteractive && !isLocked}
zoomOnScroll={isGraphInteractive && !isLocked}
zoomOnPinch={isGraphInteractive && !isLocked}
zoomOnDoubleClick={isGraphInteractive && !isLocked}
preventScrolling={interactive}
nodesDraggable={interactive && isGraphInteractive && !isLocked}
maxZoom={1.3}
minZoom={0.1}
>
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
<Background id={backgroundId} />
</ReactFlow>
</div>
);
};
const onInteractiveStateChange = useCallback(
(interactiveStatus: boolean): void => {
setIsGraphInteractive(interactiveStatus);
setNodes((currNodes) =>
currNodes.map((node) => ({
...node,
data: {
...node.data,
interactive: interactiveStatus,
},
}))
);
},
[setNodes]
);
const onInitCallback = useCallback(
(xyflow: ReactFlowInstance<Node<NodeViewModel>, Edge<EdgeViewModel>>) => {
window.requestAnimationFrame(() => xyflow.fitView());
fitViewRef.current = 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();
}
},
[interactive]
);
return (
<div {...rest}>
<SvgDefsMarker />
<ReactFlow
fitView={true}
onInit={onInitCallback}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodesState}
edges={edgesState}
nodesConnectable={false}
edgesFocusable={false}
onlyRenderVisibleElements={ONLY_RENDER_VISIBLE_ELEMENTS}
snapToGrid={true} // Snap to grid is enabled to avoid sub-pixel positioning
snapGrid={[1, 1]}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
proOptions={{ hideAttribution: true }}
panOnDrag={isGraphInteractive && !isLocked}
zoomOnScroll={isGraphInteractive && !isLocked}
zoomOnPinch={isGraphInteractive && !isLocked}
zoomOnDoubleClick={isGraphInteractive && !isLocked}
preventScrolling={interactive}
nodesDraggable={interactive && isGraphInteractive && !isLocked}
maxZoom={1.3}
minZoom={0.1}
>
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
<Background id={backgroundId} />
</ReactFlow>
</div>
);
}
);
Graph.displayName = 'Graph';
const processGraph = (
nodesModel: NodeViewModel[],
@ -234,6 +244,7 @@ const processGraph = (
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
focusable: false,
selectable: false,
deletable: false,
data: {
...edgeData,
sourceShape: nodesById[edgeData.source].shape,

View file

@ -0,0 +1,214 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { css, ThemeProvider } from '@emotion/react';
import { Story } from '@storybook/react';
import { EuiListGroup, EuiHorizontalRule } from '@elastic/eui';
import type { NodeProps, NodeViewModel } from '..';
import { Graph } from '..';
import { GraphPopover } from './graph_popover';
import { ExpandButtonClickCallback } from '../types';
import { useGraphPopover } from './use_graph_popover';
import { ExpandPopoverListItem } from '../styles';
import largeGraph from '../mock/large_graph.json';
import { GraphPerfMonitor } from './graph_perf_monitor';
export default {
title: 'Graph Benchmark',
description: 'CDR - Graph visualization',
argTypes: {},
};
const useExpandButtonPopover = () => {
const { id, state, actions } = useGraphPopover('node-expand-popover');
const { openPopover, closePopover } = actions;
const selectedNode = useRef<NodeProps | null>(null);
const unToggleCallbackRef = useRef<(() => void) | null>(null);
const [pendingOpen, setPendingOpen] = useState<{
node: NodeProps;
el: HTMLElement;
unToggleCallback: () => void;
} | null>(null);
const onNodeExpandButtonClick: ExpandButtonClickCallback = useCallback(
(e, node, unToggleCallback) => {
const lastPopoverId = selectedNode.current?.id;
// If the same node is clicked again, close the popover
selectedNode.current = null;
unToggleCallbackRef.current?.();
unToggleCallbackRef.current = null;
closePopover();
if (lastPopoverId !== node.id) {
// Set the pending open state
setPendingOpen({ node, el: e.currentTarget, unToggleCallback });
}
},
[closePopover]
);
useEffect(() => {
if (!state.isOpen && pendingOpen) {
const { node, el, unToggleCallback } = pendingOpen;
selectedNode.current = node;
unToggleCallbackRef.current = unToggleCallback;
openPopover(el);
setPendingOpen(null);
}
}, [state.isOpen, pendingOpen, openPopover]);
const closePopoverHandler = useCallback(() => {
selectedNode.current = null;
unToggleCallbackRef.current?.();
unToggleCallbackRef.current = null;
closePopover();
}, [closePopover]);
// eslint-disable-next-line react/display-name
const PopoverComponent = memo(() => (
<GraphPopover
panelPaddingSize="s"
anchorPosition="rightCenter"
isOpen={state.isOpen}
anchorElement={state.anchorElement}
closePopover={closePopoverHandler}
>
<EuiListGroup color="primary" gutterSize="none" bordered={false} flush={true}>
<ExpandPopoverListItem
iconType="visTagCloud"
label="Explore related entities"
onClick={() => {}}
/>
<ExpandPopoverListItem
iconType="users"
label="Show actions by this entity"
onClick={() => {}}
/>
<ExpandPopoverListItem
iconType="storage"
label="Show actions on this entity"
onClick={() => {}}
/>
<EuiHorizontalRule margin="xs" />
<ExpandPopoverListItem iconType="expand" label="View entity details" onClick={() => {}} />
</EuiListGroup>
</GraphPopover>
));
const actionsWithClose = useMemo(
() => ({
...actions,
closePopover: closePopoverHandler,
}),
[actions, closePopoverHandler]
);
return useMemo(
() => ({
onNodeExpandButtonClick,
Popover: PopoverComponent,
id,
actions: actionsWithClose,
state,
}),
[PopoverComponent, actionsWithClose, id, onNodeExpandButtonClick, state]
);
};
const useNodePopover = () => {
const { id, state, actions } = useGraphPopover('node-popover');
// eslint-disable-next-line react/display-name
const PopoverComponent = memo(() => (
<GraphPopover
panelPaddingSize="s"
anchorPosition="upCenter"
isOpen={state.isOpen}
anchorElement={state.anchorElement}
closePopover={actions.closePopover}
>
{'TODO'}
</GraphPopover>
));
return useMemo(
() => ({
onNodeClick: (e: React.MouseEvent<HTMLElement>) => actions.openPopover(e.currentTarget),
Popover: PopoverComponent,
id,
actions,
state,
}),
[PopoverComponent, actions, id, state]
);
};
const Template: Story = () => {
const expandNodePopover = useExpandButtonPopover();
const nodePopover = useNodePopover();
const popovers = [expandNodePopover, nodePopover];
const isPopoverOpen = popovers.some((popover) => popover.state.isOpen);
const popoverOpenWrapper = useCallback((cb: Function, ...args: unknown[]) => {
[expandNodePopover.actions.closePopover, nodePopover.actions.closePopover].forEach(
(closePopover) => {
closePopover();
}
);
cb(...args);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const expandButtonClickHandler = useCallback(
(...args: unknown[]) => popoverOpenWrapper(expandNodePopover.onNodeExpandButtonClick, ...args),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const nodeClickHandler = useCallback(
(...args: unknown[]) => popoverOpenWrapper(nodePopover.onNodeClick, ...args),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const nodes = useMemo(() => {
return largeGraph.nodes.map((node) => {
// @ts-expect-error
const nodeViewModel: NodeViewModel = { ...node };
if (nodeViewModel.shape !== 'group') {
nodeViewModel.nodeClick = nodeClickHandler;
nodeViewModel.expandButtonClick = expandButtonClickHandler;
}
return nodeViewModel;
});
}, [expandButtonClickHandler, nodeClickHandler]);
return (
<ThemeProvider theme={{ darkMode: false }}>
<GraphPerfMonitor />
<Graph
css={css`
height: 100%;
width: 100%;
`}
nodes={nodes}
// @ts-expect-error
edges={largeGraph.edges}
interactive={true}
isLocked={isPopoverOpen}
/>
{popovers?.map((popover) => popover.Popover && <popover.Popover key={popover.id} />)}
</ThemeProvider>
);
};
export const LargeGraphWithPopovers = Template.bind({});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { css } from '@emotion/react';
import { FpsTrendline } from './fps_trendline';
export const GraphPerfMonitor: React.FC = () => {
return (
<div
css={{
padding: '10px',
position: 'fixed',
}}
>
<strong>{'Nodes:'}</strong> {document.getElementsByClassName('react-flow__node').length}{' '}
<strong>{'Edges:'}</strong> {document.getElementsByClassName('react-flow__edge').length}
<FpsTrendline
css={css`
width: 300px;
`}
/>
</div>
);
};

View file

@ -36,22 +36,17 @@ const useExpandButtonPopover = () => {
const onNodeExpandButtonClick: ExpandButtonClickCallback = useCallback(
(e, node, unToggleCallback) => {
if (selectedNode.current?.id === node.id) {
// If the same node is clicked again, close the popover
selectedNode.current = null;
unToggleCallbackRef.current?.();
unToggleCallbackRef.current = null;
closePopover();
} else {
// Close the current popover if open
selectedNode.current = null;
unToggleCallbackRef.current?.();
unToggleCallbackRef.current = null;
const lastExpandedNode = selectedNode.current?.id;
// If the same node is clicked again, close the popover
selectedNode.current = null;
unToggleCallbackRef.current?.();
unToggleCallbackRef.current = null;
closePopover();
if (lastExpandedNode !== node.id) {
// Set the pending open state
setPendingOpen({ node, el: e.currentTarget, unToggleCallback });
closePopover();
}
},
[closePopover]

View file

@ -75,8 +75,9 @@ export const layoutGraph = (
// 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).
const x = dagreNode.x - (dagreNode.width ?? 0) / 2;
const y = dagreNode.y - (dagreNode.height ?? 0) / 2;
// We also need to round the position to avoid subpixel rendering
const x = Math.round(dagreNode.x - (dagreNode.width ?? 0) / 2);
const y = Math.round(dagreNode.y - (dagreNode.height ?? 0) / 2);
if (node.data.shape === 'group') {
return {

View file

@ -38,13 +38,12 @@ export const useGraphLabelExpandPopover = ({
const onLabelExpandButtonClick: ExpandButtonClickCallback = useCallback(
(e, node, unToggleCallback) => {
if (selectedNode.current?.id === node.id) {
// If the same node is clicked again, close the popover
closePopoverHandler();
} else {
// Close the current popover if open
closePopoverHandler();
const lastExpandedNode = selectedNode.current?.id;
// Close the current popover if open
closePopoverHandler();
if (lastExpandedNode !== node.id) {
// Set the pending open state
setPendingOpen({ node, el: e.currentTarget, unToggleCallback });
}

View file

@ -47,10 +47,12 @@ export const useGraphNodeExpandPopover = ({
*/
const onNodeExpandButtonClick: ExpandButtonClickCallback = useCallback(
(e, node, unToggleCallback) => {
const lastExpandedNode = selectedNode.current?.id;
// Close the current popover if open
closePopoverHandler();
if (selectedNode.current?.id !== node.id) {
if (lastExpandedNode !== node.id) {
// Set the pending open state
setPendingOpen({ node, el: e.currentTarget, unToggleCallback });
}

View file

@ -5,9 +5,9 @@
* 2.0.
*/
import React from 'react';
import { composeStories } from '@storybook/testing-react';
import { render } from '@testing-library/react';
import React from 'react';
import * as stories from './graph_layout.stories';
const { GraphLargeStackedEdgeCases } = composeStories(stories);
@ -46,6 +46,11 @@ const rectIntersect = (rect1: Rect, rect2: Rect) => {
);
};
// Turn off the optimization that hides elements that are not visible in the viewport
jest.mock('./graph/constants', () => ({
ONLY_RENDER_VISIBLE_ELEMENTS: false,
}));
describe('GraphLargeStackedEdgeCases story', () => {
it('all labels should be visible', async () => {
const { getAllByText } = render(<GraphLargeStackedEdgeCases />);
@ -59,13 +64,10 @@ describe('GraphLargeStackedEdgeCases story', () => {
for (const { label } of labels ?? []) {
// Get all label nodes that contains the label's text
const allLabelElements = getAllByText(
(_content, element) => element?.textContent === `${label!}`,
{
exact: true,
selector: 'div.react-flow__node-label',
}
);
const allLabelElements = getAllByText((_content, element) => element?.textContent === label, {
exact: true,
selector: 'div.react-flow__node-label',
});
expect(allLabelElements.length).toBeGreaterThan(0);
for (const labelElm of allLabelElements) {

View file

@ -5,7 +5,8 @@
},
"include": [
"**/*.ts",
"**/*.tsx"
"**/*.tsx",
"**/mock/*.json",
],
"exclude": [
"target/**/*"