diff --git a/docs/sources/panels-visualizations/visualizations/node-graph/index.md b/docs/sources/panels-visualizations/visualizations/node-graph/index.md
index 9b4122b9582..d4ac4fc5d4a 100644
--- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md
+++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md
@@ -129,3 +129,4 @@ Optional fields:
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
| 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. |
diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts
index a366555e623..ce063f6fa43 100644
--- a/packages/grafana-data/src/utils/nodeGraph.ts
+++ b/packages/grafana-data/src/utils/nodeGraph.ts
@@ -25,4 +25,6 @@ export enum NodeGraphDataFrameFieldNames {
// Prefix for fields which will be shown in a context menu [nodes + edges]
detail = 'detail__',
+
+ nodeRadius = 'noderadius',
}
diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts
index 42c50fe563a..c0630dd1c4b 100644
--- a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts
+++ b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts
@@ -101,6 +101,10 @@ export function generateRandomNodes(count = 10) {
values: [],
type: FieldType.string,
},
+ [NodeGraphDataFrameFieldNames.nodeRadius]: {
+ values: [],
+ type: FieldType.number,
+ },
};
const nodeFrame = new MutableDataFrame({
diff --git a/public/app/plugins/panel/nodeGraph/Edge.tsx b/public/app/plugins/panel/nodeGraph/Edge.tsx
index 263ea88ec33..ae2e7572b1e 100644
--- a/public/app/plugins/panel/nodeGraph/Edge.tsx
+++ b/public/app/plugins/panel/nodeGraph/Edge.tsx
@@ -1,5 +1,6 @@
import React, { MouseEvent, memo } from 'react';
+import { nodeR } from './Node';
import { EdgeDatum, NodeDatum } from './types';
import { shortenLine } from './utils';
@@ -12,8 +13,14 @@ interface Props {
}
export const Edge = memo(function Edge(props: Props) {
const { edge, onClick, onMouseEnter, onMouseLeave, hovering } = props;
+
// Not great typing but after we do layout these properties are full objects not just references
- const { source, target } = edge as { source: NodeDatum; target: NodeDatum };
+ const { source, target, sourceNodeRadius, targetNodeRadius } = edge as {
+ source: NodeDatum;
+ target: NodeDatum;
+ sourceNodeRadius: number;
+ targetNodeRadius: number;
+ };
// As the nodes have some radius we want edges to end outside of the node circle.
const line = shortenLine(
@@ -23,7 +30,8 @@ export const Edge = memo(function Edge(props: Props) {
x2: target.x!,
y2: target.y!,
},
- 90
+ sourceNodeRadius || nodeR,
+ targetNodeRadius || nodeR
);
return (
diff --git a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx
index ef9569b1819..c9c03a70beb 100644
--- a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx
+++ b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx
@@ -4,6 +4,7 @@ import React, { memo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
+import { nodeR } from './Node';
import { EdgeDatum, NodeDatum } from './types';
import { shortenLine } from './utils';
@@ -30,7 +31,12 @@ interface Props {
export const EdgeLabel = memo(function EdgeLabel(props: Props) {
const { edge } = props;
// Not great typing, but after we do layout these properties are full objects not just references
- const { source, target } = edge as { source: NodeDatum; target: NodeDatum };
+ const { source, target, sourceNodeRadius, targetNodeRadius } = edge as {
+ source: NodeDatum;
+ target: NodeDatum;
+ sourceNodeRadius: number;
+ targetNodeRadius: number;
+ };
// As the nodes have some radius we want edges to end outside the node circle.
const line = shortenLine(
@@ -40,7 +46,8 @@ export const EdgeLabel = memo(function EdgeLabel(props: Props) {
x2: target.x!,
y2: target.y!,
},
- 90
+ sourceNodeRadius || nodeR,
+ targetNodeRadius || nodeR
);
const middle = {
diff --git a/public/app/plugins/panel/nodeGraph/Node.test.tsx b/public/app/plugins/panel/nodeGraph/Node.test.tsx
index ca37bd10e86..a831e673b6b 100644
--- a/public/app/plugins/panel/nodeGraph/Node.test.tsx
+++ b/public/app/plugins/panel/nodeGraph/Node.test.tsx
@@ -40,6 +40,22 @@ describe('Node', () => {
expect(screen.getByTestId('node-icon-database')).toBeInTheDocument();
});
+
+ it('renders correct node radius', async () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('node-circle-1')).toHaveAttribute('r', '20');
+ });
});
const nodeDatum = {
diff --git a/public/app/plugins/panel/nodeGraph/Node.tsx b/public/app/plugins/panel/nodeGraph/Node.tsx
index 0b96f20a491..f111a8de786 100644
--- a/public/app/plugins/panel/nodeGraph/Node.tsx
+++ b/public/app/plugins/panel/nodeGraph/Node.tsx
@@ -10,7 +10,7 @@ import { HoverState } from './NodeGraph';
import { NodeDatum } from './types';
import { statToString } from './utils';
-const nodeR = 40;
+export const nodeR = 40;
const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
mainGroup: css`
@@ -77,6 +77,7 @@ export const Node = memo(function Node(props: {
const theme = useTheme2();
const styles = getStyles(theme, hovering);
const isHovered = hovering === 'active';
+ const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR;
if (!(node.x !== undefined && node.y !== undefined)) {
return null;
@@ -84,14 +85,22 @@ export const Node = memo(function Node(props: {
return (
-
- {isHovered && }
+
+ {isHovered && (
+
+ )}
@@ -114,10 +123,10 @@ export const Node = memo(function Node(props: {
onClick(event, node);
}}
className={styles.clickTarget}
- x={node.x - nodeR - 5}
- y={node.y - nodeR - 5}
- width={nodeR * 2 + 10}
- height={nodeR * 2 + 50}
+ x={node.x - nodeRadius - 5}
+ y={node.y - nodeRadius - 5}
+ width={nodeRadius * 2 + 10}
+ height={nodeRadius * 2 + 50}
/>
);
@@ -162,6 +171,7 @@ function ColorCircle(props: { node: NodeDatum }) {
const { node } = props;
const fullStat = node.arcSections.find((s) => s.values[node.dataFrameRowIndex] >= 1);
const theme = useTheme2();
+ const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR;
if (fullStat) {
// Doing arc with path does not work well so it's better to just do a circle in that case
@@ -170,7 +180,7 @@ function ColorCircle(props: { node: NodeDatum }) {
fill="none"
stroke={theme.visualization.getColorByName(fullStat.config.color?.fixedColor || '')}
strokeWidth={2}
- r={nodeR}
+ r={nodeRadius}
cx={node.x}
cy={node.y}
/>
@@ -185,7 +195,7 @@ function ColorCircle(props: { node: NodeDatum }) {
fill="none"
stroke={node.color ? getColor(node.color, node.dataFrameRowIndex, theme) : 'gray'}
strokeWidth={2}
- r={nodeR}
+ r={nodeRadius}
cx={node.x}
cy={node.y}
/>
@@ -203,7 +213,7 @@ function ColorCircle(props: { node: NodeDatum }) {
const el = (
{
{ name: 'SUBTITLE', type: FieldType.string, values: ['subTitle'] },
{ name: 'mainstat', type: FieldType.string, values: ['mainStat'] },
{ name: 'seconDarysTat', type: FieldType.string, values: ['secondaryStat'] },
+ { name: 'nodeRadius', type: FieldType.number, values: [20] },
],
});
@@ -312,6 +313,13 @@ function makeNodeDatum(options: Partial = {}) {
subTitle: 'service',
title: 'service:0',
icon: 'database',
+ nodeRadius: {
+ config: {},
+ index: 9,
+ name: 'noderadius',
+ type: 'number',
+ values: [40, 40, 40],
+ },
...options,
};
}
@@ -324,6 +332,8 @@ function makeEdgeDatum(id: string, index: number, mainStat = '', secondaryStat =
secondaryStat,
source: id.split('--')[0],
target: id.split('--')[1],
+ sourceNodeRadius: 40,
+ targetNodeRadius: 40,
};
}
diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts
index a0d533b72f7..d6d801298f7 100644
--- a/public/app/plugins/panel/nodeGraph/utils.ts
+++ b/public/app/plugins/panel/nodeGraph/utils.ts
@@ -9,6 +9,7 @@ import {
NodeGraphDataFrameFieldNames,
} from '@grafana/data';
+import { nodeR } from './Node';
import { EdgeDatum, GraphFrame, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } from './types';
type Line = { x1: number; y1: number; x2: number; y2: number };
@@ -16,22 +17,17 @@ type Line = { x1: number; y1: number; x2: number; y2: number };
/**
* Makes line shorter while keeping the middle in he same place.
*/
-export function shortenLine(line: Line, length: number): Line {
+export function shortenLine(line: Line, sourceNodeRadius: number, targetNodeRadius: number): Line {
const vx = line.x2 - line.x1;
const vy = line.y2 - line.y1;
const mag = Math.sqrt(vx * vx + vy * vy);
- const ratio = Math.max((mag - length) / mag, 0);
- const vx2 = vx * ratio;
- const vy2 = vy * ratio;
- const xDiff = vx - vx2;
- const yDiff = vy - vy2;
- const newx1 = line.x1 + xDiff / 2;
- const newy1 = line.y1 + yDiff / 2;
+ const cosine = (line.x2 - line.x1) / mag;
+ const sine = (line.y2 - line.y1) / mag;
return {
- x1: newx1,
- y1: newy1,
- x2: newx1 + vx2,
- y2: newy1 + vy2,
+ x1: line.x1 + cosine * (sourceNodeRadius + 5),
+ y1: line.y1 + sine * (sourceNodeRadius + 5),
+ x2: line.x2 - cosine * (targetNodeRadius + 5),
+ y2: line.y2 - sine * (targetNodeRadius + 5),
};
}
@@ -45,6 +41,7 @@ export type NodeFields = {
details: Field[];
color?: Field;
icon?: Field;
+ nodeRadius?: Field;
};
export function getNodeFields(nodes: DataFrame): NodeFields {
@@ -63,6 +60,7 @@ export function getNodeFields(nodes: DataFrame): NodeFields {
details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail),
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
icon: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.icon),
+ nodeRadius: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.nodeRadius.toLowerCase()),
};
}
@@ -127,7 +125,7 @@ export function processNodes(
}
// We may not have edges in case of single node
- let edgeDatums: EdgeDatum[] = edges ? processEdges(edges, getEdgeFields(edges)) : [];
+ let edgeDatums: EdgeDatum[] = edges ? processEdges(edges, getEdgeFields(edges), nodesMap) : [];
for (const e of edgeDatums) {
// We are adding incoming edges count, so we can later on find out which nodes are the roots
@@ -153,11 +151,9 @@ export function processNodes(
const nodesMap: { [id: string]: NodeDatumFromEdge } = {};
const edgeFields = getEdgeFields(edges);
- let edgeDatums = processEdges(edges, edgeFields);
// Turn edges into reasonable filled in nodes
- for (let i = 0; i < edgeDatums.length; i++) {
- const edge = edgeDatums[i];
+ for (let i = 0; i < edges.length; i++) {
const { source, target } = makeNodeDatumsFromEdge(edgeFields, i);
nodesMap[target.id] = nodesMap[target.id] || target;
@@ -176,9 +172,11 @@ export function processNodes(
}
// We are adding incoming edges count, so we can later on find out which nodes are the roots
- nodesMap[edge.target].incoming++;
+ nodesMap[target.id].incoming++;
}
+ let edgeDatums = processEdges(edges, edgeFields, nodesMap);
+
// It is expected for stats to be Field, so we have to create them.
const nodes = normalizeStatsForNodes(nodesMap, edgeFields);
@@ -194,7 +192,7 @@ export function processNodes(
* @param edges
* @param edgeFields
*/
-function processEdges(edges: DataFrame, edgeFields: EdgeFields): EdgeDatum[] {
+function processEdges(edges: DataFrame, edgeFields: EdgeFields, nodesMap: { [id: string]: NodeDatum }): EdgeDatum[] {
if (!edgeFields.id) {
throw new Error('id field is required for edges data frame.');
}
@@ -203,11 +201,16 @@ function processEdges(edges: DataFrame, edgeFields: EdgeFields): EdgeDatum[] {
const target = edgeFields.target?.values[index];
const source = edgeFields.source?.values[index];
+ const sourceNode = nodesMap[source];
+ const targetNode = nodesMap[target];
+
return {
id,
dataFrameRowIndex: index,
source,
target,
+ sourceNodeRadius: !sourceNode.nodeRadius ? nodeR : sourceNode.nodeRadius.values[sourceNode.dataFrameRowIndex],
+ targetNodeRadius: !targetNode.nodeRadius ? nodeR : targetNode.nodeRadius.values[targetNode.dataFrameRowIndex],
mainStat: edgeFields.mainStat ? statToString(edgeFields.mainStat.config, edgeFields.mainStat.values[index]) : '',
secondaryStat: edgeFields.secondaryStat
? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values[index])
@@ -298,6 +301,7 @@ function makeNodeDatum(id: string, nodeFields: NodeFields, index: number): NodeD
arcSections: nodeFields.arc,
color: nodeFields.color,
icon: nodeFields.icon?.values[index] || '',
+ nodeRadius: nodeFields.nodeRadius,
};
}
@@ -338,6 +342,7 @@ function makeNode(index: number) {
secondarystat: 2,
color: 0.5,
icon: 'database',
+ noderadius: 40,
};
}
@@ -382,6 +387,10 @@ function nodesFrame() {
values: [],
type: FieldType.string,
},
+ [NodeGraphDataFrameFieldNames.nodeRadius]: {
+ values: [],
+ type: FieldType.number,
+ },
};
return new MutableDataFrame({