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( + + {}} + onMouseLeave={() => {}} + onClick={() => {}} + hovering={'default'} + /> + + ); + + 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({