Plugins: Improvements to NodeGraph (#76879)

This commit is contained in:
Fabrizio 2023-10-24 15:16:10 +02:00 committed by GitHub
parent 765defea1e
commit b8d005ae23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 138 additions and 52 deletions

View File

@ -7367,7 +7367,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"]
],
"public/app/plugins/panel/nodeGraph/NodeGraph.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],

View File

@ -108,6 +108,8 @@ Optional fields:
| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown |
| secondarystat | string/number | Same as mainStat, but shown right under it. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. |
| thickness | number | The thickness of the edge. Default: `1` |
| highlighted | boolean | Sets whether the edge should be highlighted. Useful, for example, to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
### Nodes data frame structure
@ -130,3 +132,4 @@ Optional fields:
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behaviour depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana built in icons are allowed (see the available icons [here](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)). |
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |

View File

@ -26,5 +26,12 @@ export enum NodeGraphDataFrameFieldNames {
// Prefix for fields which will be shown in a context menu [nodes + edges]
detail = 'detail__',
// Radius of the node [nodes]
nodeRadius = 'noderadius',
// Thickness of the edge [edges]
thickness = 'thickness',
// Whether the node or edge should be highlighted (e.g., shown in red) in the UI
highlighted = 'highlighted',
}

View File

@ -105,6 +105,10 @@ export function generateRandomNodes(count = 10) {
values: [],
type: FieldType.number,
},
[NodeGraphDataFrameFieldNames.highlighted]: {
values: [],
type: FieldType.boolean,
},
};
const nodeFrame = new MutableDataFrame({
@ -123,6 +127,8 @@ export function generateRandomNodes(count = 10) {
{ name: NodeGraphDataFrameFieldNames.source, values: [], type: FieldType.string, config: {} },
{ name: NodeGraphDataFrameFieldNames.target, values: [], type: FieldType.string, config: {} },
{ name: NodeGraphDataFrameFieldNames.mainStat, values: [], type: FieldType.number, config: {} },
{ name: NodeGraphDataFrameFieldNames.highlighted, values: [], type: FieldType.boolean, config: {} },
{ name: NodeGraphDataFrameFieldNames.thickness, values: [], type: FieldType.number, config: {} },
],
meta: { preferredVisualisationType: 'nodeGraph' },
length: 0,
@ -139,7 +145,8 @@ export function generateRandomNodes(count = 10) {
nodeFields.arc__errors.values.push(node.error);
const rnd = Math.random();
nodeFields[NodeGraphDataFrameFieldNames.icon].values.push(rnd > 0.9 ? 'database' : rnd < 0.1 ? 'cloud' : '');
nodeFields[NodeGraphDataFrameFieldNames.nodeRadius].values.push(rnd > 0.5 ? 30 : 40);
nodeFields[NodeGraphDataFrameFieldNames.nodeRadius].values.push(Math.max(rnd * 100, 30)); // ensure a minimum radius of 30 or icons will not fit well in the node
nodeFields[NodeGraphDataFrameFieldNames.highlighted].values.push(Math.random() > 0.5);
for (const edge of node.edges) {
const id = `${node.id}--${edge}`;
@ -152,6 +159,8 @@ export function generateRandomNodes(count = 10) {
edgesFrame.fields[1].values.push(node.id);
edgesFrame.fields[2].values.push(edge);
edgesFrame.fields[3].values.push(Math.random() * 100);
edgesFrame.fields[4].values.push(Math.random() > 0.5);
edgesFrame.fields[5].values.push(Math.ceil(Math.random() * 15));
}
}
edgesFrame.length = edgesFrame.fields[0].values.length;
@ -171,6 +180,7 @@ function makeRandomNode(index: number) {
stat1: Math.random(),
stat2: Math.random(),
edges: [],
highlighted: Math.random() > 0.5,
};
}

View File

@ -1,9 +1,13 @@
import React, { MouseEvent, memo } from 'react';
import { nodeR } from './Node';
import { EdgeArrowMarker } from './EdgeArrowMarker';
import { computeNodeCircumferenceStrokeWidth, nodeR } from './Node';
import { EdgeDatum, NodeDatum } from './types';
import { shortenLine } from './utils';
export const highlightedEdgeColor = '#a00';
export const defaultEdgeColor = '#999';
interface Props {
edge: EdgeDatum;
hovering: boolean;
@ -11,6 +15,7 @@ interface Props {
onMouseEnter: (id: string) => void;
onMouseLeave: (id: string) => void;
}
export const Edge = memo(function Edge(props: Props) {
const { edge, onClick, onMouseEnter, onMouseLeave, hovering } = props;
@ -21,6 +26,7 @@ export const Edge = memo(function Edge(props: Props) {
sourceNodeRadius: number;
targetNodeRadius: number;
};
const arrowHeadHeight = 10 + edge.thickness * 2; // resized value, just to make the UI nicer
// As the nodes have some radius we want edges to end outside of the node circle.
const line = shortenLine(
@ -30,39 +36,47 @@ export const Edge = memo(function Edge(props: Props) {
x2: target.x!,
y2: target.y!,
},
sourceNodeRadius || nodeR,
targetNodeRadius || nodeR
sourceNodeRadius + computeNodeCircumferenceStrokeWidth(sourceNodeRadius) / 2 || nodeR,
targetNodeRadius + computeNodeCircumferenceStrokeWidth(targetNodeRadius) / 2 || nodeR,
arrowHeadHeight
);
const markerId = `triangle-${edge.id}`;
const coloredMarkerId = `triangle-colored-${edge.id}`;
return (
<g
onClick={(event) => onClick(event, edge)}
style={{ cursor: 'pointer' }}
aria-label={`Edge from: ${source.id} to: ${target.id}`}
>
<line
strokeWidth={hovering ? 2 : 1}
stroke={'#999'}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
markerEnd="url(#triangle)"
/>
<line
stroke={'transparent'}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
strokeWidth={20}
onMouseEnter={() => {
onMouseEnter(edge.id);
}}
onMouseLeave={() => {
onMouseLeave(edge.id);
}}
/>
</g>
<>
<EdgeArrowMarker id={markerId} headHeight={arrowHeadHeight} />
<EdgeArrowMarker id={coloredMarkerId} fill={highlightedEdgeColor} headHeight={arrowHeadHeight} />
<g
onClick={(event) => onClick(event, edge)}
style={{ cursor: 'pointer' }}
aria-label={`Edge from: ${source.id} to: ${target.id}`}
>
<line
strokeWidth={(hovering ? 1 : 0) + (edge.highlighted ? 1 : 0) + edge.thickness}
stroke={edge.highlighted ? highlightedEdgeColor : defaultEdgeColor}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
markerEnd={`url(#${edge.highlighted ? coloredMarkerId : markerId})`}
/>
<line
stroke={'transparent'}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
strokeWidth={20}
onMouseEnter={() => {
onMouseEnter(edge.id);
}}
onMouseLeave={() => {
onMouseLeave(edge.id);
}}
/>
</g>
</>
);
});

View File

@ -1,23 +1,33 @@
import React from 'react';
import { defaultEdgeColor } from './Edge';
/**
* In SVG you need to supply this kind of marker that can be then referenced from a line segment as an ending of the
* line turning in into arrow. Needs to be included in the svg element and then referenced as markerEnd="url(#triangle)"
*/
export function EdgeArrowMarker() {
export function EdgeArrowMarker({
id = 'triangle',
fill = defaultEdgeColor,
headHeight = 10,
}: {
id?: string;
fill?: string;
headHeight?: number;
}) {
return (
<defs>
<marker
id="triangle"
id={id}
viewBox="0 0 10 10"
refX="8"
refX="1" // shift the arrow head slightly closer to the center of the line it will be attached to, to ensure no empty space is shown between the line and the arrow head
refY="5"
markerUnits="userSpaceOnUse"
markerWidth="10"
markerHeight="10"
markerWidth={headHeight} // equal to the height just for simplicily
markerHeight={headHeight}
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
<path d="M 0 0 L 10 5 L 0 10 z" fill={fill} />
</marker>
</defs>
);

View File

@ -69,4 +69,5 @@ const nodeDatum = {
mainStat: { name: 'stat', values: [1234], type: FieldType.number, config: {} },
secondaryStat: { name: 'stat2', values: [9876], type: FieldType.number, config: {} },
arcSections: [],
highlighted: false,
};

View File

@ -11,6 +11,7 @@ import { NodeDatum } from './types';
import { statToString } from './utils';
export const nodeR = 40;
export const highlightedNodeColor = '#a00';
const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
mainGroup: css`
@ -24,6 +25,10 @@ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
fill: ${theme.components.panel.background};
`,
filledCircle: css`
fill: ${highlightedNodeColor};
`,
hoverCircle: css`
opacity: 0.5;
fill: transparent;
@ -66,6 +71,8 @@ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
`,
});
export const computeNodeCircumferenceStrokeWidth = (nodeRadius: number) => Math.ceil(nodeRadius * 0.075);
export const Node = memo(function Node(props: {
node: NodeDatum;
hovering: HoverState;
@ -78,6 +85,7 @@ export const Node = memo(function Node(props: {
const styles = getStyles(theme, hovering);
const isHovered = hovering === 'active';
const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR;
const strokeWidth = computeNodeCircumferenceStrokeWidth(nodeRadius);
if (!(node.x !== undefined && node.y !== undefined)) {
return null;
@ -87,13 +95,13 @@ export const Node = memo(function Node(props: {
<g data-node-id={node.id} className={styles.mainGroup} aria-label={`Node: ${node.title}`}>
<circle
data-testid={`node-circle-${node.id}`}
className={styles.mainCircle}
className={node.highlighted ? styles.filledCircle : styles.mainCircle}
r={nodeRadius}
cx={node.x}
cy={node.y}
/>
{isHovered && (
<circle className={styles.hoverCircle} r={nodeRadius - 3} cx={node.x} cy={node.y} strokeWidth={2} />
<circle className={styles.hoverCircle} r={nodeRadius - 3} cx={node.x} cy={node.y} strokeWidth={strokeWidth} />
)}
<ColorCircle node={node} />
<g className={styles.text} style={{ pointerEvents: 'none' }}>
@ -172,14 +180,15 @@ function ColorCircle(props: { node: NodeDatum }) {
const fullStat = node.arcSections.find((s) => s.values[node.dataFrameRowIndex] >= 1);
const theme = useTheme2();
const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR;
const strokeWidth = computeNodeCircumferenceStrokeWidth(nodeRadius);
if (fullStat) {
// Doing arc with path does not work well so it's better to just do a circle in that case
// Drawing a full circle with a `path` tag does not work well, it's better to use a `circle` tag in that case
return (
<circle
fill="none"
stroke={theme.visualization.getColorByName(fullStat.config.color?.fixedColor || '')}
strokeWidth={2}
strokeWidth={strokeWidth}
r={nodeRadius}
cx={node.x}
cy={node.y}
@ -194,7 +203,7 @@ function ColorCircle(props: { node: NodeDatum }) {
<circle
fill="none"
stroke={node.color ? getColor(node.color, node.dataFrameRowIndex, theme) : 'gray'}
strokeWidth={2}
strokeWidth={strokeWidth}
r={nodeRadius}
cx={node.x}
cy={node.y}
@ -225,7 +234,7 @@ function ColorCircle(props: { node: NodeDatum }) {
: value
}
color={theme.visualization.getColorByName(color)}
strokeWidth={2}
strokeWidth={strokeWidth}
/>
);
acc.elements.push(el);

View File

@ -7,7 +7,6 @@ import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
import { Icon, Spinner, useStyles2 } from '@grafana/ui';
import { Edge } from './Edge';
import { EdgeArrowMarker } from './EdgeArrowMarker';
import { EdgeLabel } from './EdgeLabel';
import { Legend } from './Legend';
import { Marker } from './Marker';
@ -208,7 +207,6 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
className={styles.mainGroup}
style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }}
>
<EdgeArrowMarker />
{!config.gridLayout && (
<Edges
edges={edges}

View File

@ -74,6 +74,7 @@ function makeNode(index: number, incoming: number): NodeDatum {
dataFrameRowIndex: 0,
incoming,
arcSections: [],
highlighted: false,
};
}
@ -87,5 +88,7 @@ function makeEdge(source: number, target: number): EdgeDatum {
dataFrameRowIndex: 0,
sourceNodeRadius: 40,
targetNodeRadius: 40,
highlighted: false,
thickness: 1,
};
}

View File

@ -16,6 +16,7 @@ export type NodeDatum = SimulationNodeDatum & {
color?: Field;
icon?: IconName;
nodeRadius?: Field;
highlighted: boolean;
};
export type NodeDatumFromEdge = NodeDatum & { mainStatNumeric?: number; secondaryStatNumeric?: number };
@ -34,6 +35,8 @@ export type EdgeDatum = LinkDatum & {
dataFrameRowIndex: number;
sourceNodeRadius: number;
targetNodeRadius: number;
highlighted: boolean;
thickness: number;
};
// After layout is run D3 will change the string IDs for actual references to the nodes.

View File

@ -294,6 +294,7 @@ function makeNodeDatum(options: Partial<NodeDatum> = {}) {
],
color: colorField,
dataFrameRowIndex: 0,
highlighted: false,
id: '0',
incoming: 0,
mainStat: {
@ -334,6 +335,8 @@ function makeEdgeDatum(id: string, index: number, mainStat = '', secondaryStat =
target: id.split('--')[1],
sourceNodeRadius: 40,
targetNodeRadius: 40,
highlighted: false,
thickness: 1,
};
}
@ -346,5 +349,6 @@ function makeNodeFromEdgeDatum(options: Partial<NodeDatum> = {}): NodeDatum {
subTitle: '',
title: 'service:0',
...options,
highlighted: false,
};
}

View File

@ -15,19 +15,32 @@ import { EdgeDatum, GraphFrame, NodeDatum, NodeDatumFromEdge, NodeGraphOptions }
type Line = { x1: number; y1: number; x2: number; y2: number };
/**
* Makes line shorter while keeping the middle in he same place.
* Makes line shorter while keeping its middle in the same place.
* This is manly used to add some empty space between an edge line and its source and target nodes, to make it nicer.
*
* @param line a line, where x1 and y1 are the coordinates of the source node center, and x2 and y2 are the coordinates of the target node center
* @param sourceNodeRadius radius of the source node (possibly taking into account the thickness of the node circumference line, etc.)
* @param targetNodeRadius radius of the target node (possibly taking into account the thickness of the node circumference line, etc.)
* @param arrowHeadHeight height of the arrow head (in pixels)
*/
export function shortenLine(line: Line, sourceNodeRadius: number, targetNodeRadius: number): Line {
export function shortenLine(line: Line, sourceNodeRadius: number, targetNodeRadius: number, arrowHeadHeight = 1): Line {
const vx = line.x2 - line.x1;
const vy = line.y2 - line.y1;
const mag = Math.sqrt(vx * vx + vy * vy);
const cosine = (line.x2 - line.x1) / mag;
const sine = (line.y2 - line.y1) / mag;
const scaledThickness = arrowHeadHeight - arrowHeadHeight / 10;
// Reduce the line length (along its main direction) by:
// - the radius of the source node
// - the radius of the target node,
// - a constant value, just to add some empty space
// - the height of the arrow head; the bigger the arrow head, the better is to add even more empty space
return {
x1: line.x1 + cosine * (sourceNodeRadius + 5),
y1: line.y1 + sine * (sourceNodeRadius + 5),
x2: line.x2 - cosine * (targetNodeRadius + 5),
y2: line.y2 - sine * (targetNodeRadius + 5),
x2: line.x2 - cosine * (targetNodeRadius + 3 + scaledThickness),
y2: line.y2 - sine * (targetNodeRadius + 3 + scaledThickness),
};
}
@ -42,6 +55,7 @@ export type NodeFields = {
color?: Field;
icon?: Field;
nodeRadius?: Field;
highlighted?: Field;
};
export function getNodeFields(nodes: DataFrame): NodeFields {
@ -61,6 +75,7 @@ export function getNodeFields(nodes: DataFrame): NodeFields {
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
icon: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.icon),
nodeRadius: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.nodeRadius.toLowerCase()),
highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()),
};
}
@ -71,6 +86,8 @@ export type EdgeFields = {
mainStat?: Field;
secondaryStat?: Field;
details: Field[];
highlighted?: Field;
thickness?: Field;
};
export function getEdgeFields(edges: DataFrame): EdgeFields {
@ -86,6 +103,8 @@ export function getEdgeFields(edges: DataFrame): EdgeFields {
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()),
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()),
details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail.toLowerCase()),
highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()),
thickness: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.thickness.toLowerCase()),
};
}
@ -215,6 +234,8 @@ function processEdges(edges: DataFrame, edgeFields: EdgeFields, nodesMap: { [id:
secondaryStat: edgeFields.secondaryStat
? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values[index])
: '',
highlighted: edgeFields.highlighted?.values[index] || false,
thickness: edgeFields.thickness?.values[index] || 1,
};
});
}
@ -286,6 +307,7 @@ function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge {
dataFrameRowIndex: index,
incoming: 0,
arcSections: [],
highlighted: false,
};
}
@ -302,6 +324,7 @@ function makeNodeDatum(id: string, nodeFields: NodeFields, index: number): NodeD
color: nodeFields.color,
icon: nodeFields.icon?.values[index] || '',
nodeRadius: nodeFields.nodeRadius,
highlighted: nodeFields.highlighted?.values[index] || false,
};
}