Node Graph: Emphasize hovered or connected nodes (#51925)

* Node Graph: Emphasize hovered or connected nodes

* Add tests and refactor into util functions
This commit is contained in:
Connor Lindsey 2022-07-12 06:14:45 -06:00 committed by GitHub
parent 4155dc8eca
commit db9c9b5354
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 107 additions and 21 deletions

View File

@ -12,7 +12,7 @@ export function EdgeArrowMarker() {
viewBox="0 0 10 10" viewBox="0 0 10 10"
refX="8" refX="8"
refY="5" refY="5"
markerUnits="strokeWidth" markerUnits="userSpaceOnUse"
markerWidth="10" markerWidth="10"
markerHeight="10" markerHeight="10"
orient="auto" orient="auto"

View File

@ -4,17 +4,20 @@ import React, { MouseEvent, memo } from 'react';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data'; 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 { NodeDatum } from './types';
import { statToString } from './utils'; import { statToString } from './utils';
const nodeR = 40; const nodeR = 40;
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
mainGroup: css` mainGroup: css`
cursor: pointer; cursor: pointer;
font-size: 10px; font-size: 10px;
transition: opacity 300ms;
opacity: ${hovering === 'inactive' ? 0.5 : 1};
`, `,
mainCircle: css` mainCircle: css`
@ -61,10 +64,12 @@ export const Node = memo(function Node(props: {
onMouseEnter: (id: string) => void; onMouseEnter: (id: string) => void;
onMouseLeave: (id: string) => void; onMouseLeave: (id: string) => void;
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void; onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
hovering: boolean; hovering: HoverState;
}) { }) {
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props; 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)) { if (!(node.x !== undefined && node.y !== undefined)) {
return null; return null;
@ -86,23 +91,23 @@ export const Node = memo(function Node(props: {
aria-label={`Node: ${node.title}`} aria-label={`Node: ${node.title}`}
> >
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} /> <circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
{hovering && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />} {isHovered && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
<ColorCircle node={node} /> <ColorCircle node={node} />
<g className={styles.text}> <g className={styles.text}>
<foreignObject x={node.x - (hovering ? 100 : 35)} y={node.y - 15} width={hovering ? '200' : '70'} height="40"> <foreignObject x={node.x - (isHovered ? 100 : 35)} y={node.y - 15} width={isHovered ? '200' : '70'} height="40">
<div className={cx(styles.statsText, hovering && styles.textHovering)}> <div className={cx(styles.statsText, isHovered && styles.textHovering)}>
<span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span> <span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span>
<br /> <br />
<span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span> <span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span>
</div> </div>
</foreignObject> </foreignObject>
<foreignObject <foreignObject
x={node.x - (hovering ? 100 : 50)} x={node.x - (isHovered ? 100 : 50)}
y={node.y + nodeR + 5} y={node.y + nodeR + 5}
width={hovering ? '200' : '100'} width={isHovered ? '200' : '100'}
height="40" height="40"
> >
<div className={cx(styles.titleText, hovering && styles.textHovering)}> <div className={cx(styles.titleText, isHovered && styles.textHovering)}>
<span>{node.title}</span> <span>{node.title}</span>
<br /> <br />
<span>{node.subTitle}</span> <span>{node.subTitle}</span>

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import cx from 'classnames'; 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 useMeasure from 'react-use/lib/useMeasure';
import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
@ -21,7 +21,7 @@ import { useFocusPositionOnLayout } from './useFocusPositionOnLayout';
import { useHighlight } from './useHighlight'; import { useHighlight } from './useHighlight';
import { usePanning } from './usePanning'; import { usePanning } from './usePanning';
import { useZoom } from './useZoom'; import { useZoom } from './useZoom';
import { processNodes, Bounds } from './utils'; import { processNodes, Bounds, findConnectedNodesForEdge, findConnectedNodesForNode } from './utils';
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css` wrapper: css`
@ -120,10 +120,6 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
const [measureRef, { width, height }] = useMeasure(); const [measureRef, { width, height }] = useMeasure();
const [config, setConfig] = useState<Config>(defaultConfig); const [config, setConfig] = useState<Config>(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 firstNodesDataFrame = nodesDataFrames[0];
const firstEdgesDataFrame = edgesDataFrames[0]; const firstEdgesDataFrame = edgesDataFrames[0];
@ -136,6 +132,20 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
[firstEdgesDataFrame, firstNodesDataFrame, theme] [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<string[]>([]);
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. // This is used for navigation from grid to graph view. This node will be centered and briefly highlighted.
const [focusedNodeId, setFocusedNodeId] = useState<string>(); const [focusedNodeId, setFocusedNodeId] = useState<string>();
const setFocused = useCallback((e: MouseEvent, m: NodesMarker) => setFocusedNodeId(m.node.id), [setFocusedNodeId]); 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} onMouseEnter={setNodeHover}
onMouseLeave={clearNodeHover} onMouseLeave={clearNodeHover}
onClick={onNodeOpen} onClick={onNodeOpen}
hoveringId={nodeHover || highlightId} hoveringIds={hoveringIds || [highlightId]}
/> />
<Markers markers={markers || []} onClick={setFocused} /> <Markers markers={markers || []} onClick={setFocused} />
@ -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 { interface NodesProps {
nodes: NodeDatum[]; nodes: NodeDatum[];
onMouseEnter: (id: string) => void; onMouseEnter: (id: string) => void;
onMouseLeave: (id: string) => void; onMouseLeave: (id: string) => void;
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void; onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
hoveringId?: string; hoveringIds?: string[];
} }
const Nodes = memo(function Nodes(props: NodesProps) { const Nodes = memo(function Nodes(props: NodesProps) {
return ( return (
@ -293,7 +305,13 @@ const Nodes = memo(function Nodes(props: NodesProps) {
onMouseEnter={props.onMouseEnter} onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave} onMouseLeave={props.onMouseLeave}
onClick={props.onClick} onClick={props.onClick}
hovering={props.hoveringId === n.id} hovering={
!props.hoveringIds || props.hoveringIds.length === 0
? 'default'
: props.hoveringIds?.includes(n.id)
? 'active'
: 'inactive'
}
/> />
))} ))}
</> </>

View File

@ -2,6 +2,8 @@ import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from
import { NodeGraphOptions } from './types'; import { NodeGraphOptions } from './types';
import { import {
findConnectedNodesForEdge,
findConnectedNodesForNode,
getEdgeFields, getEdgeFields,
getNodeFields, getNodeFields,
getNodeGraphDataFrames, getNodeGraphDataFrames,
@ -359,3 +361,37 @@ describe('processNodes', () => {
expect(edgesFrame?.fields.find((f) => f.name === 'secondaryStat')?.config).toEqual({ unit: 'ft^2' }); 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']);
});
});

View File

@ -404,3 +404,30 @@ export const applyOptionsToFrames = (frames: DataFrame[], options: NodeGraphOpti
return frame; 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 [];
};