Skip to content

Commit

Permalink
Map layout changes (#77132)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
smith committed Sep 15, 2020
1 parent 7f3ec3f commit f98bed4
Show file tree
Hide file tree
Showing 4 changed files with 299 additions and 173 deletions.
214 changes: 43 additions & 171 deletions x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/

import cytoscape from 'cytoscape';
import dagre from 'cytoscape-dagre';
import isEqual from 'lodash/isEqual';
import React, {
createContext,
CSSProperties,
memo,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import cytoscape from 'cytoscape';
import dagre from 'cytoscape-dagre';
import { debounce } from 'lodash';
import { useTheme } from '../../../hooks/useTheme';
import {
getAnimationOptions,
getCytoscapeOptions,
getNodeHeight,
} from './cytoscapeOptions';
import { useUiTracker } from '../../../../../observability/public';
import { getCytoscapeOptions } from './cytoscapeOptions';
import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers';

cytoscape.use(dagre);

Expand Down Expand Up @@ -59,49 +56,7 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) {
return [ref, cy] as [React.MutableRefObject<any>, cytoscape.Core | undefined];
}

function getLayoutOptions(nodeHeight: number): cytoscape.LayoutOptions {
return {
name: 'dagre',
fit: true,
padding: nodeHeight,
spacingFactor: 1.2,
// @ts-ignore
nodeSep: nodeHeight,
edgeSep: 32,
rankSep: 128,
rankDir: 'LR',
ranker: 'network-simplex',
};
}

/*
* @notice
* This product includes code in the function applyCubicBezierStyles that was
* inspired by a public Codepen, which was available under a "MIT" license.
*
* Copyright (c) 2020 by Guillaume (https://codepen.io/guillaumethomas/pen/xxbbBKO)
* MIT License http://www.opensource.org/licenses/mit-license
*/
function applyCubicBezierStyles(edges: cytoscape.EdgeCollection) {
edges.forEach((edge) => {
const { x: x0, y: y0 } = edge.source().position();
const { x: x1, y: y1 } = edge.target().position();
const x = x1 - x0;
const y = y1 - y0;
const z = Math.sqrt(x * x + y * y);
const costheta = z === 0 ? 0 : x / z;
const alpha = 0.25;
// Two values for control-point-distances represent a pair symmetric quadratic
// bezier curves joined in the middle as a seamless cubic bezier curve:
edge.style('control-point-distances', [
-alpha * y * costheta,
alpha * y * costheta,
]);
edge.style('control-point-weights', [alpha, 1 - alpha]);
});
}

export function Cytoscape({
function CytoscapeComponent({
children,
elements,
height,
Expand All @@ -113,131 +68,31 @@ export function Cytoscape({
...getCytoscapeOptions(theme),
elements,
});
useCytoscapeEventHandlers({ cy, serviceName, theme });

const nodeHeight = getNodeHeight(theme);

// Add the height to the div style. The height is a separate prop because it
// is required and can trigger rendering when changed.
const divStyle = { ...style, height };

const trackApmEvent = useUiTracker({ app: 'apm' });

// Set up cytoscape event handlers
// Add items from the elements prop to the cytoscape collection and remove
// items that no longer are in the list, then trigger an event to notify
// the handlers that data has changed.
useEffect(() => {
const resetConnectedEdgeStyle = (node?: cytoscape.NodeSingular) => {
if (cy) {
cy.edges().removeClass('highlight');

if (node) {
node.connectedEdges().addClass('highlight');
}
}
};
if (cy && elements.length > 0) {
// We do a fit if we're going from 0 to >0 elements
const fit = cy.elements().length === 0;

const dataHandler: cytoscape.EventHandler = (event) => {
if (cy && cy.elements().length > 0) {
if (serviceName) {
resetConnectedEdgeStyle(cy.getElementById(serviceName));
// Add the "primary" class to the node if its id matches the serviceName.
if (cy.nodes().length > 0) {
cy.nodes().removeClass('primary');
cy.getElementById(serviceName).addClass('primary');
}
} else {
resetConnectedEdgeStyle();
}
cy.layout(getLayoutOptions(nodeHeight)).run();
}
};
let layoutstopDelayTimeout: NodeJS.Timeout;
const layoutstopHandler: cytoscape.EventHandler = (event) => {
// This 0ms timer is necessary to prevent a race condition
// between the layout finishing rendering and viewport centering
layoutstopDelayTimeout = setTimeout(() => {
if (serviceName) {
event.cy.animate({
...getAnimationOptions(theme),
fit: {
eles: event.cy.elements(),
padding: nodeHeight,
},
center: {
eles: event.cy.getElementById(serviceName),
},
});
} else {
event.cy.fit(undefined, nodeHeight);
}
}, 0);
applyCubicBezierStyles(event.cy.edges());
};
// debounce hover tracking so it doesn't spam telemetry with redundant events
const trackNodeEdgeHover = debounce(
() => trackApmEvent({ metric: 'service_map_node_or_edge_hover' }),
1000
);
const mouseoverHandler: cytoscape.EventHandler = (event) => {
trackNodeEdgeHover();
event.target.addClass('hover');
event.target.connectedEdges().addClass('nodeHover');
};
const mouseoutHandler: cytoscape.EventHandler = (event) => {
event.target.removeClass('hover');
event.target.connectedEdges().removeClass('nodeHover');
};
const selectHandler: cytoscape.EventHandler = (event) => {
trackApmEvent({ metric: 'service_map_node_select' });
resetConnectedEdgeStyle(event.target);
};
const unselectHandler: cytoscape.EventHandler = (event) => {
resetConnectedEdgeStyle(
serviceName ? event.cy.getElementById(serviceName) : undefined
);
};
const debugHandler: cytoscape.EventHandler = (event) => {
const debugEnabled = sessionStorage.getItem('apm_debug') === 'true';
if (debugEnabled) {
// eslint-disable-next-line no-console
console.debug('cytoscape:', event);
}
};
const dragHandler: cytoscape.EventHandler = (event) => {
applyCubicBezierStyles(event.target.connectedEdges());
};

if (cy) {
cy.on('data layoutstop select unselect', debugHandler);
cy.on('data', dataHandler);
cy.on('layoutstop', layoutstopHandler);
cy.on('mouseover', 'edge, node', mouseoverHandler);
cy.on('mouseout', 'edge, node', mouseoutHandler);
cy.on('select', 'node', selectHandler);
cy.on('unselect', 'node', unselectHandler);
cy.on('drag', 'node', dragHandler);

cy.remove(cy.elements());
cy.add(elements);
cy.trigger('data');
// Remove any old elements that don't exist in the new set of elements.
const elementIds = elements.map((element) => element.data.id);
cy.elements().forEach((element) => {
if (!elementIds.includes(element.data('id'))) {
cy.remove(element);
}
});
cy.trigger('custom:data', [fit]);
}
}, [cy, elements]);

return () => {
if (cy) {
cy.removeListener(
'data layoutstop select unselect',
undefined,
debugHandler
);
cy.removeListener('data', undefined, dataHandler);
cy.removeListener('layoutstop', undefined, layoutstopHandler);
cy.removeListener('mouseover', 'edge, node', mouseoverHandler);
cy.removeListener('mouseout', 'edge, node', mouseoutHandler);
cy.removeListener('select', 'node', selectHandler);
cy.removeListener('unselect', 'node', unselectHandler);
cy.removeListener('drag', 'node', dragHandler);
}
clearTimeout(layoutstopDelayTimeout);
};
}, [cy, elements, height, serviceName, trackApmEvent, nodeHeight, theme]);
// Add the height to the div style. The height is a separate prop because it
// is required and can trigger rendering when changed.
const divStyle = { ...style, height };

return (
<CytoscapeContext.Provider value={cy}>
Expand All @@ -247,3 +102,20 @@ export function Cytoscape({
</CytoscapeContext.Provider>
);
}

export const Cytoscape = memo(CytoscapeComponent, (prevProps, nextProps) => {
const prevElementIds = prevProps.elements
.map((element) => element.data.id)
.sort();
const nextElementIds = nextProps.elements
.map((element) => element.data.id)
.sort();

const propsAreEqual =
prevProps.height === nextProps.height &&
prevProps.serviceName === nextProps.serviceName &&
isEqual(prevProps.style, nextProps.style) &&
isEqual(prevElementIds, nextElementIds);

return propsAreEqual;
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ export function Popover({ focusedServiceName }: PopoverProps) {
if (cy) {
cy.on('select', 'node', selectHandler);
cy.on('unselect', 'node', deselect);
cy.on('data viewport', deselect);
cy.on('viewport', deselect);
cy.on('drag', 'node', deselect);
}

return () => {
if (cy) {
cy.removeListener('select', 'node', selectHandler);
cy.removeListener('unselect', 'node', deselect);
cy.removeListener('data viewport', undefined, deselect);
cy.removeListener('viewport', undefined, deselect);
cy.removeListener('drag', 'node', deselect);
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { renderHook } from '@testing-library/react-hooks';
import cytoscape from 'cytoscape';
import { EuiTheme } from '../../../../../observability/public';
import { useCytoscapeEventHandlers } from './use_cytoscape_event_handlers';
import dagre from 'cytoscape-dagre';

cytoscape.use(dagre);

const theme = ({
eui: { avatarSizing: { l: { size: 10 } } },
} as unknown) as EuiTheme;

describe('useCytoscapeEventHandlers', () => {
describe('when cytoscape is undefined', () => {
it('runs', () => {
expect(() => {
renderHook(() => useCytoscapeEventHandlers({ cy: undefined, theme }));
}).not.toThrowError();
});
});

describe('when an element is dragged', () => {
it('sets the hasBeenDragged data', () => {
const cy = cytoscape({ elements: [{ data: { id: 'test' } }] });

renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
cy.getElementById('test').trigger('drag');

expect(cy.getElementById('test').data('hasBeenDragged')).toEqual(true);
});
});

describe('when a node is hovered', () => {
it('applies the hover class', () => {
const cy = cytoscape({
elements: [{ data: { id: 'test' } }],
});
const node = cy.getElementById('test');

renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
node.trigger('mouseover');

expect(node.hasClass('hover')).toEqual(true);
});
});

describe('when a node is un-hovered', () => {
it('removes the hover class', () => {
const cy = cytoscape({
elements: [{ data: { id: 'test' }, classes: 'hover' }],
});
const node = cy.getElementById('test');

renderHook(() => useCytoscapeEventHandlers({ cy, theme }));
node.trigger('mouseout');

expect(node.hasClass('hover')).toEqual(false);
});
});
});
Loading

0 comments on commit f98bed4

Please sign in to comment.