diff --git a/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx b/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx index 56193e54784..a56a97c85d3 100644 --- a/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx +++ b/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx @@ -12,7 +12,7 @@ export function EdgeArrowMarker() { viewBox="0 0 10 10" refX="8" refY="5" - markerUnits="strokeWidth" + markerUnits="userSpaceOnUse" markerWidth="10" markerHeight="10" orient="auto" diff --git a/public/app/plugins/panel/nodeGraph/Node.tsx b/public/app/plugins/panel/nodeGraph/Node.tsx index 61aae2373fc..1dcdf97828c 100644 --- a/public/app/plugins/panel/nodeGraph/Node.tsx +++ b/public/app/plugins/panel/nodeGraph/Node.tsx @@ -4,17 +4,20 @@ import React, { MouseEvent, memo } from 'react'; import tinycolor from 'tinycolor2'; import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, useTheme2 } from '@grafana/ui'; +import { useTheme2 } from '@grafana/ui'; +import { HoverState } from './NodeGraph'; import { NodeDatum } from './types'; import { statToString } from './utils'; const nodeR = 40; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({ mainGroup: css` cursor: pointer; font-size: 10px; + transition: opacity 300ms; + opacity: ${hovering === 'inactive' ? 0.5 : 1}; `, mainCircle: css` @@ -61,10 +64,12 @@ export const Node = memo(function Node(props: { onMouseEnter: (id: string) => void; onMouseLeave: (id: string) => void; onClick: (event: MouseEvent, node: NodeDatum) => void; - hovering: boolean; + hovering: HoverState; }) { const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props; - const styles = useStyles2(getStyles); + const theme = useTheme2(); + const styles = getStyles(theme, hovering); + const isHovered = hovering === 'active'; if (!(node.x !== undefined && node.y !== undefined)) { return null; @@ -86,23 +91,23 @@ export const Node = memo(function Node(props: { aria-label={`Node: ${node.title}`} > - {hovering && } + {isHovered && } - -
+ +
{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}
{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}
-
+
{node.title}
{node.subTitle} diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx index 0c851b95a76..d421e8c8aad 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import cx from 'classnames'; -import React, { memo, MouseEvent, MutableRefObject, useCallback, useMemo, useState } from 'react'; +import React, { memo, MouseEvent, MutableRefObject, useCallback, useEffect, useMemo, useState } from 'react'; import useMeasure from 'react-use/lib/useMeasure'; import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; @@ -21,7 +21,7 @@ import { useFocusPositionOnLayout } from './useFocusPositionOnLayout'; import { useHighlight } from './useHighlight'; import { usePanning } from './usePanning'; import { useZoom } from './useZoom'; -import { processNodes, Bounds } from './utils'; +import { processNodes, Bounds, findConnectedNodesForEdge, findConnectedNodesForNode } from './utils'; const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` @@ -120,10 +120,6 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { const [measureRef, { width, height }] = useMeasure(); const [config, setConfig] = useState(defaultConfig); - // We need hover state here because for nodes we also highlight edges and for edges have labels separate to make - // sure they are visible on top of everything else - const { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover } = useHover(); - const firstNodesDataFrame = nodesDataFrames[0]; const firstEdgesDataFrame = edgesDataFrames[0]; @@ -136,6 +132,20 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { [firstEdgesDataFrame, firstNodesDataFrame, theme] ); + // We need hover state here because for nodes we also highlight edges and for edges have labels separate to make + // sure they are visible on top of everything else + const { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover } = useHover(); + const [hoveringIds, setHoveringIds] = useState([]); + useEffect(() => { + let linked: string[] = []; + if (nodeHover) { + linked = findConnectedNodesForNode(processed.nodes, processed.edges, nodeHover); + } else if (edgeHover) { + linked = findConnectedNodesForEdge(processed.nodes, processed.edges, edgeHover); + } + setHoveringIds(linked); + }, [nodeHover, edgeHover, processed]); + // This is used for navigation from grid to graph view. This node will be centered and briefly highlighted. const [focusedNodeId, setFocusedNodeId] = useState(); const setFocused = useCallback((e: MouseEvent, m: NodesMarker) => setFocusedNodeId(m.node.id), [setFocusedNodeId]); @@ -216,7 +226,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { onMouseEnter={setNodeHover} onMouseLeave={clearNodeHover} onClick={onNodeOpen} - hoveringId={nodeHover || highlightId} + hoveringIds={hoveringIds || [highlightId]} /> @@ -274,14 +284,16 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { ); } -// These components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom. +// Active -> emphasized, inactive -> de-emphasized, and default -> normal styling +export type HoverState = 'active' | 'inactive' | 'default'; +// These components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom. interface NodesProps { nodes: NodeDatum[]; onMouseEnter: (id: string) => void; onMouseLeave: (id: string) => void; onClick: (event: MouseEvent, node: NodeDatum) => void; - hoveringId?: string; + hoveringIds?: string[]; } const Nodes = memo(function Nodes(props: NodesProps) { return ( @@ -293,7 +305,13 @@ const Nodes = memo(function Nodes(props: NodesProps) { onMouseEnter={props.onMouseEnter} onMouseLeave={props.onMouseLeave} onClick={props.onClick} - hovering={props.hoveringId === n.id} + hovering={ + !props.hoveringIds || props.hoveringIds.length === 0 + ? 'default' + : props.hoveringIds?.includes(n.id) + ? 'active' + : 'inactive' + } /> ))} diff --git a/public/app/plugins/panel/nodeGraph/utils.test.ts b/public/app/plugins/panel/nodeGraph/utils.test.ts index 8e382a38829..edba930b1c6 100644 --- a/public/app/plugins/panel/nodeGraph/utils.test.ts +++ b/public/app/plugins/panel/nodeGraph/utils.test.ts @@ -2,6 +2,8 @@ import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from import { NodeGraphOptions } from './types'; import { + findConnectedNodesForEdge, + findConnectedNodesForNode, getEdgeFields, getNodeFields, getNodeGraphDataFrames, @@ -359,3 +361,37 @@ describe('processNodes', () => { expect(edgesFrame?.fields.find((f) => f.name === 'secondaryStat')?.config).toEqual({ unit: 'ft^2' }); }); }); + +describe('finds connections', () => { + const theme = createTheme(); + + it('finds connected nodes given an edge id', () => { + const { nodes, edges } = processNodes( + makeNodesDataFrame(3), + makeEdgesDataFrame([ + [0, 1], + [0, 2], + [1, 2], + ]), + theme + ); + + const linked = findConnectedNodesForEdge(nodes, edges, edges[0].id); + expect(linked).toEqual(['0', '1']); + }); + + it('finds connected nodes given a node id', () => { + const { nodes, edges } = processNodes( + makeNodesDataFrame(4), + makeEdgesDataFrame([ + [0, 1], + [0, 2], + [1, 2], + ]), + theme + ); + + const linked = findConnectedNodesForNode(nodes, edges, nodes[0].id); + expect(linked).toEqual(['0', '1', '2']); + }); +}); diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts index 593c6263450..1c735e56432 100644 --- a/public/app/plugins/panel/nodeGraph/utils.ts +++ b/public/app/plugins/panel/nodeGraph/utils.ts @@ -404,3 +404,30 @@ export const applyOptionsToFrames = (frames: DataFrame[], options: NodeGraphOpti return frame; }); }; + +// Returns an array of node ids which are connected to a given edge +export const findConnectedNodesForEdge = (nodes: NodeDatum[], edges: EdgeDatum[], edgeId: string): string[] => { + const edge = edges.find((edge) => edge.id === edgeId); + if (edge) { + return [ + ...new Set(nodes.filter((node) => edge.source === node.id || edge.target === node.id).map((node) => node.id)), + ]; + } + return []; +}; + +// Returns an array of node ids which are connected to a given node +export const findConnectedNodesForNode = (nodes: NodeDatum[], edges: EdgeDatum[], nodeId: string): string[] => { + const node = nodes.find((node) => node.id === nodeId); + if (node) { + const linkedEdges = edges.filter((edge) => edge.source === node.id || edge.target === node.id); + return [ + ...new Set( + linkedEdges.flatMap((edge) => + nodes.filter((n) => edge.source === n.id || edge.target === n.id).map((n) => n.id) + ) + ), + ]; + } + return []; +};