mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
4155dc8eca
commit
db9c9b5354
@ -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"
|
||||||
|
@ -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>
|
||||||
|
@ -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'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -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 [];
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user