44 * you may not use this file except in compliance with the Elastic License.
55 */
66
7+ import cytoscape from 'cytoscape' ;
8+ import dagre from 'cytoscape-dagre' ;
9+ import isEqual from 'lodash/isEqual' ;
710import React , {
811 createContext ,
912 CSSProperties ,
13+ memo ,
1014 ReactNode ,
1115 useEffect ,
1216 useRef ,
1317 useState ,
1418} from 'react' ;
15- import cytoscape from 'cytoscape' ;
16- import dagre from 'cytoscape-dagre' ;
17- import { debounce } from 'lodash' ;
1819import { useTheme } from '../../../hooks/useTheme' ;
19- import {
20- getAnimationOptions ,
21- getCytoscapeOptions ,
22- getNodeHeight ,
23- } from './cytoscapeOptions' ;
24- import { useUiTracker } from '../../../../../observability/public' ;
20+ import { getCytoscapeOptions } from './cytoscapeOptions' ;
21+ import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers' ;
2522
2623cytoscape . use ( dagre ) ;
2724
@@ -59,49 +56,7 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) {
5956 return [ ref , cy ] as [ React . MutableRefObject < any > , cytoscape . Core | undefined ] ;
6057}
6158
62- function getLayoutOptions ( nodeHeight : number ) : cytoscape . LayoutOptions {
63- return {
64- name : 'dagre' ,
65- fit : true ,
66- padding : nodeHeight ,
67- spacingFactor : 1.2 ,
68- // @ts -ignore
69- nodeSep : nodeHeight ,
70- edgeSep : 32 ,
71- rankSep : 128 ,
72- rankDir : 'LR' ,
73- ranker : 'network-simplex' ,
74- } ;
75- }
76-
77- /*
78- * @notice
79- * This product includes code in the function applyCubicBezierStyles that was
80- * inspired by a public Codepen, which was available under a "MIT" license.
81- *
82- * Copyright (c) 2020 by Guillaume (https://codepen.io/guillaumethomas/pen/xxbbBKO)
83- * MIT License http://www.opensource.org/licenses/mit-license
84- */
85- function applyCubicBezierStyles ( edges : cytoscape . EdgeCollection ) {
86- edges . forEach ( ( edge ) => {
87- const { x : x0 , y : y0 } = edge . source ( ) . position ( ) ;
88- const { x : x1 , y : y1 } = edge . target ( ) . position ( ) ;
89- const x = x1 - x0 ;
90- const y = y1 - y0 ;
91- const z = Math . sqrt ( x * x + y * y ) ;
92- const costheta = z === 0 ? 0 : x / z ;
93- const alpha = 0.25 ;
94- // Two values for control-point-distances represent a pair symmetric quadratic
95- // bezier curves joined in the middle as a seamless cubic bezier curve:
96- edge . style ( 'control-point-distances' , [
97- - alpha * y * costheta ,
98- alpha * y * costheta ,
99- ] ) ;
100- edge . style ( 'control-point-weights' , [ alpha , 1 - alpha ] ) ;
101- } ) ;
102- }
103-
104- export function Cytoscape ( {
59+ function CytoscapeComponent ( {
10560 children,
10661 elements,
10762 height,
@@ -113,131 +68,31 @@ export function Cytoscape({
11368 ...getCytoscapeOptions ( theme ) ,
11469 elements,
11570 } ) ;
71+ useCytoscapeEventHandlers ( { cy, serviceName, theme } ) ;
11672
117- const nodeHeight = getNodeHeight ( theme ) ;
118-
119- // Add the height to the div style. The height is a separate prop because it
120- // is required and can trigger rendering when changed.
121- const divStyle = { ...style , height } ;
122-
123- const trackApmEvent = useUiTracker ( { app : 'apm' } ) ;
124-
125- // Set up cytoscape event handlers
73+ // Add items from the elements prop to the cytoscape collection and remove
74+ // items that no longer are in the list, then trigger an event to notify
75+ // the handlers that data has changed.
12676 useEffect ( ( ) => {
127- const resetConnectedEdgeStyle = ( node ?: cytoscape . NodeSingular ) => {
128- if ( cy ) {
129- cy . edges ( ) . removeClass ( 'highlight' ) ;
130-
131- if ( node ) {
132- node . connectedEdges ( ) . addClass ( 'highlight' ) ;
133- }
134- }
135- } ;
77+ if ( cy && elements . length > 0 ) {
78+ // We do a fit if we're going from 0 to >0 elements
79+ const fit = cy . elements ( ) . length === 0 ;
13680
137- const dataHandler : cytoscape . EventHandler = ( event ) => {
138- if ( cy && cy . elements ( ) . length > 0 ) {
139- if ( serviceName ) {
140- resetConnectedEdgeStyle ( cy . getElementById ( serviceName ) ) ;
141- // Add the "primary" class to the node if its id matches the serviceName.
142- if ( cy . nodes ( ) . length > 0 ) {
143- cy . nodes ( ) . removeClass ( 'primary' ) ;
144- cy . getElementById ( serviceName ) . addClass ( 'primary' ) ;
145- }
146- } else {
147- resetConnectedEdgeStyle ( ) ;
148- }
149- cy . layout ( getLayoutOptions ( nodeHeight ) ) . run ( ) ;
150- }
151- } ;
152- let layoutstopDelayTimeout : NodeJS . Timeout ;
153- const layoutstopHandler : cytoscape . EventHandler = ( event ) => {
154- // This 0ms timer is necessary to prevent a race condition
155- // between the layout finishing rendering and viewport centering
156- layoutstopDelayTimeout = setTimeout ( ( ) => {
157- if ( serviceName ) {
158- event . cy . animate ( {
159- ...getAnimationOptions ( theme ) ,
160- fit : {
161- eles : event . cy . elements ( ) ,
162- padding : nodeHeight ,
163- } ,
164- center : {
165- eles : event . cy . getElementById ( serviceName ) ,
166- } ,
167- } ) ;
168- } else {
169- event . cy . fit ( undefined , nodeHeight ) ;
170- }
171- } , 0 ) ;
172- applyCubicBezierStyles ( event . cy . edges ( ) ) ;
173- } ;
174- // debounce hover tracking so it doesn't spam telemetry with redundant events
175- const trackNodeEdgeHover = debounce (
176- ( ) => trackApmEvent ( { metric : 'service_map_node_or_edge_hover' } ) ,
177- 1000
178- ) ;
179- const mouseoverHandler : cytoscape . EventHandler = ( event ) => {
180- trackNodeEdgeHover ( ) ;
181- event . target . addClass ( 'hover' ) ;
182- event . target . connectedEdges ( ) . addClass ( 'nodeHover' ) ;
183- } ;
184- const mouseoutHandler : cytoscape . EventHandler = ( event ) => {
185- event . target . removeClass ( 'hover' ) ;
186- event . target . connectedEdges ( ) . removeClass ( 'nodeHover' ) ;
187- } ;
188- const selectHandler : cytoscape . EventHandler = ( event ) => {
189- trackApmEvent ( { metric : 'service_map_node_select' } ) ;
190- resetConnectedEdgeStyle ( event . target ) ;
191- } ;
192- const unselectHandler : cytoscape . EventHandler = ( event ) => {
193- resetConnectedEdgeStyle (
194- serviceName ? event . cy . getElementById ( serviceName ) : undefined
195- ) ;
196- } ;
197- const debugHandler : cytoscape . EventHandler = ( event ) => {
198- const debugEnabled = sessionStorage . getItem ( 'apm_debug' ) === 'true' ;
199- if ( debugEnabled ) {
200- // eslint-disable-next-line no-console
201- console . debug ( 'cytoscape:' , event ) ;
202- }
203- } ;
204- const dragHandler : cytoscape . EventHandler = ( event ) => {
205- applyCubicBezierStyles ( event . target . connectedEdges ( ) ) ;
206- } ;
207-
208- if ( cy ) {
209- cy . on ( 'data layoutstop select unselect' , debugHandler ) ;
210- cy . on ( 'data' , dataHandler ) ;
211- cy . on ( 'layoutstop' , layoutstopHandler ) ;
212- cy . on ( 'mouseover' , 'edge, node' , mouseoverHandler ) ;
213- cy . on ( 'mouseout' , 'edge, node' , mouseoutHandler ) ;
214- cy . on ( 'select' , 'node' , selectHandler ) ;
215- cy . on ( 'unselect' , 'node' , unselectHandler ) ;
216- cy . on ( 'drag' , 'node' , dragHandler ) ;
217-
218- cy . remove ( cy . elements ( ) ) ;
21981 cy . add ( elements ) ;
220- cy . trigger ( 'data' ) ;
82+ // Remove any old elements that don't exist in the new set of elements.
83+ const elementIds = elements . map ( ( element ) => element . data . id ) ;
84+ cy . elements ( ) . forEach ( ( element ) => {
85+ if ( ! elementIds . includes ( element . data ( 'id' ) ) ) {
86+ cy . remove ( element ) ;
87+ }
88+ } ) ;
89+ cy . trigger ( 'custom:data' , [ fit ] ) ;
22190 }
91+ } , [ cy , elements ] ) ;
22292
223- return ( ) => {
224- if ( cy ) {
225- cy . removeListener (
226- 'data layoutstop select unselect' ,
227- undefined ,
228- debugHandler
229- ) ;
230- cy . removeListener ( 'data' , undefined , dataHandler ) ;
231- cy . removeListener ( 'layoutstop' , undefined , layoutstopHandler ) ;
232- cy . removeListener ( 'mouseover' , 'edge, node' , mouseoverHandler ) ;
233- cy . removeListener ( 'mouseout' , 'edge, node' , mouseoutHandler ) ;
234- cy . removeListener ( 'select' , 'node' , selectHandler ) ;
235- cy . removeListener ( 'unselect' , 'node' , unselectHandler ) ;
236- cy . removeListener ( 'drag' , 'node' , dragHandler ) ;
237- }
238- clearTimeout ( layoutstopDelayTimeout ) ;
239- } ;
240- } , [ cy , elements , height , serviceName , trackApmEvent , nodeHeight , theme ] ) ;
93+ // Add the height to the div style. The height is a separate prop because it
94+ // is required and can trigger rendering when changed.
95+ const divStyle = { ...style , height } ;
24196
24297 return (
24398 < CytoscapeContext . Provider value = { cy } >
@@ -247,3 +102,20 @@ export function Cytoscape({
247102 </ CytoscapeContext . Provider >
248103 ) ;
249104}
105+
106+ export const Cytoscape = memo ( CytoscapeComponent , ( prevProps , nextProps ) => {
107+ const prevElementIds = prevProps . elements
108+ . map ( ( element ) => element . data . id )
109+ . sort ( ) ;
110+ const nextElementIds = nextProps . elements
111+ . map ( ( element ) => element . data . id )
112+ . sort ( ) ;
113+
114+ const propsAreEqual =
115+ prevProps . height === nextProps . height &&
116+ prevProps . serviceName === nextProps . serviceName &&
117+ isEqual ( prevProps . style , nextProps . style ) &&
118+ isEqual ( prevElementIds , nextElementIds ) ;
119+
120+ return propsAreEqual ;
121+ } ) ;
0 commit comments