Skip to content

Commit f024a22

Browse files
committed
Map layout changes (#77132)
These changes make the map work better with auto-refresh and with dragged nodes. Extract all event handling out of the Cytoscape component into a hook. Use React.memo to only render when the list of element ids has changed. Only do a fit on the layout if we're going from 0 to more than 0 elements. Instead of removing all the nodes on rerender, only remove the ones that aren't in the new list. Trigger a custom:data event instead of data when we receive fetched data. Before we triggered a data event which would trigger a layout if you called data() on an element. Don't trigger a deselect when we get new data, so popovers stay open when we get new data. Animate the layout on changes. When we do a layout, exclude selected nodes and nodes that have been dragged. When we set the time range to something low (like the default of 15m) and a fast refresh interval (1-3s) the edges we get back from the API are not consistent, so you can see the map changing frequently. See this video for an example: https://www.dropbox.com/s/jsq2bffxdw61xhu/77132.mov?dl=0 Fixes #73156. Fixes #76936.
1 parent 585ae33 commit f024a22

4 files changed

Lines changed: 299 additions & 173 deletions

File tree

x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx

Lines changed: 43 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,21 @@
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';
710
import 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';
1819
import { 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

2623
cytoscape.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+
});

x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,15 @@ export function Popover({ focusedServiceName }: PopoverProps) {
7070
if (cy) {
7171
cy.on('select', 'node', selectHandler);
7272
cy.on('unselect', 'node', deselect);
73-
cy.on('data viewport', deselect);
73+
cy.on('viewport', deselect);
7474
cy.on('drag', 'node', deselect);
7575
}
7676

7777
return () => {
7878
if (cy) {
7979
cy.removeListener('select', 'node', selectHandler);
8080
cy.removeListener('unselect', 'node', deselect);
81-
cy.removeListener('data viewport', undefined, deselect);
81+
cy.removeListener('viewport', undefined, deselect);
8282
cy.removeListener('drag', 'node', deselect);
8383
}
8484
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { renderHook } from '@testing-library/react-hooks';
8+
import cytoscape from 'cytoscape';
9+
import { EuiTheme } from '../../../../../observability/public';
10+
import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers';
11+
import dagre from 'cytoscape-dagre';
12+
13+
cytoscape.use(dagre);
14+
15+
const theme = ({
16+
eui: { avatarSizing: { l: { size: 10 } } },
17+
} as unknown) as EuiTheme;
18+
19+
describe('useCytoscapeEventHandlers', () => {
20+
describe('when cytoscape is undefined', () => {
21+
it('runs', () => {
22+
expect(() => {
23+
renderHook(() => useCytoscapeEventHandlers({ cy: undefined, theme }));
24+
}).not.toThrowError();
25+
});
26+
});
27+
28+
describe('when an element is dragged', () => {
29+
it('sets the hasBeenDragged data', () => {
30+
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });
31+
32+
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
33+
cy.getElementById('test').trigger('drag');
34+
35+
expect(cy.getElementById('test').data('hasBeenDragged')).toEqual(true);
36+
});
37+
});
38+
39+
describe('when a node is hovered', () => {
40+
it('applies the hover class', () => {
41+
const cy = cytoscape({
42+
elements: [{ data: { id: 'test' } }],
43+
});
44+
const node = cy.getElementById('test');
45+
46+
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
47+
node.trigger('mouseover');
48+
49+
expect(node.hasClass('hover')).toEqual(true);
50+
});
51+
});
52+
53+
describe('when a node is un-hovered', () => {
54+
it('removes the hover class', () => {
55+
const cy = cytoscape({
56+
elements: [{ data: { id: 'test' }, classes: 'hover' }],
57+
});
58+
const node = cy.getElementById('test');
59+
60+
renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
61+
node.trigger('mouseout');
62+
63+
expect(node.hasClass('hover')).toEqual(false);
64+
});
65+
});
66+
});

0 commit comments

Comments
 (0)