mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
# 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:
parent
5ff80fd329
commit
91b6a29e69
14 changed files with 10831 additions and 166 deletions
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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]
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -5,7 +5,8 @@
|
|||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
"**/*.tsx",
|
||||
"**/mock/*.json",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue