diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 0b00c8a8bf0934..41dacfd8b588af 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -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); @@ -59,49 +56,7 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) { return [ref, cy] as [React.MutableRefObject, 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, @@ -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 ( @@ -247,3 +102,20 @@ export function Cytoscape({ ); } + +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; +}); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx index 8291d94d91c48f..c4272d28690161 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -70,7 +70,7 @@ 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); } @@ -78,7 +78,7 @@ export function Popover({ focusedServiceName }: PopoverProps) { 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); } }; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.test.tsx new file mode 100644 index 00000000000000..4212d866c08536 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.test.tsx @@ -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); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.ts new file mode 100644 index 00000000000000..3f879196f2a4f7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/use_cytoscape_event_handlers.ts @@ -0,0 +1,188 @@ +/* + * 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 cytoscape from 'cytoscape'; +import debounce from 'lodash/debounce'; +import { useEffect } from 'react'; +import { EuiTheme, useUiTracker } from '../../../../../observability/public'; +import { getAnimationOptions, getNodeHeight } from './cytoscapeOptions'; + +/* + * @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]); + }); +} + +function getLayoutOptions({ + fit = false, + nodeHeight, + theme, +}: { + fit?: boolean; + nodeHeight: number; + theme: EuiTheme; +}): cytoscape.LayoutOptions { + const animationOptions = getAnimationOptions(theme); + + // @ts-expect-error Some of the dagre-specific layout options don't work with + // the types. + return { + animationDuration: animationOptions.duration, + animationEasing: animationOptions.easing, + fit, + name: 'dagre', + animate: !fit, + padding: nodeHeight, + spacingFactor: 1.2, + nodeSep: nodeHeight, + edgeSep: 32, + rankSep: 128, + rankDir: 'LR', + ranker: 'network-simplex', + }; +} + +export function useCytoscapeEventHandlers({ + cy, + serviceName, + theme, +}: { + cy?: cytoscape.Core; + serviceName?: string; + theme: EuiTheme; +}) { + const trackApmEvent = useUiTracker({ app: 'apm' }); + + useEffect(() => { + const nodeHeight = getNodeHeight(theme); + + const resetConnectedEdgeStyle = ( + cytoscapeInstance: cytoscape.Core, + node?: cytoscape.NodeSingular + ) => { + cytoscapeInstance.edges().removeClass('highlight'); + if (node) { + node.connectedEdges().addClass('highlight'); + } + }; + + const dataHandler: cytoscape.EventHandler = (event, fit) => { + if (serviceName) { + const node = event.cy.getElementById(serviceName); + resetConnectedEdgeStyle(event.cy, node); + // Add the "primary" class to the node if its id matches the serviceName. + if (event.cy.nodes().length > 0) { + event.cy.nodes().removeClass('primary'); + node.addClass('primary'); + } + } else { + resetConnectedEdgeStyle(event.cy); + } + + // Run the layout on nodes that are not selected and have not been manually + // positioned. + event.cy + .elements('[!hasBeenDragged]') + .difference('node:selected') + .layout(getLayoutOptions({ fit, nodeHeight, theme })) + .run(); + }; + + const layoutstopHandler: cytoscape.EventHandler = (event) => { + 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( + event.cy, + 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 (!event.target.data('hasBeenDragged')) { + event.target.data('hasBeenDragged', true); + } + }; + + if (cy) { + cy.on('custom:data drag layoutstop select unselect', debugHandler); + cy.on('custom: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); + } + + return () => { + if (cy) { + cy.removeListener( + 'custom:data drag layoutstop select unselect', + undefined, + debugHandler + ); + cy.removeListener('custom: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); + } + }; + }, [cy, serviceName, trackApmEvent, theme]); +}