NodeGraph: Allow to set node radius in dataframe (#74963)

Allow to set node radius in dataframe
This commit is contained in:
Juan Luis Peña Wagner
2023-09-25 16:55:52 +02:00
committed by GitHub
parent 40a1f8434d
commit ffb15ef363
11 changed files with 105 additions and 33 deletions
@@ -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. |
@@ -25,4 +25,6 @@ export enum NodeGraphDataFrameFieldNames {
// Prefix for fields which will be shown in a context menu [nodes + edges]
detail = 'detail__',
nodeRadius = 'noderadius',
}
@@ -101,6 +101,10 @@ export function generateRandomNodes(count = 10) {
values: [],
type: FieldType.string,
},
[NodeGraphDataFrameFieldNames.nodeRadius]: {
values: [],
type: FieldType.number,
},
};
const nodeFrame = new MutableDataFrame({
+10 -2
View File
@@ -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 (
@@ -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 = {
@@ -40,6 +40,22 @@ describe('Node', () => {
expect(screen.getByTestId('node-icon-database')).toBeInTheDocument();
});
it('renders correct node radius', async () => {
render(
<svg>
<Node
node={{ ...nodeDatum, nodeRadius: { name: 'nodeRadius', values: [20], type: FieldType.number, config: {} } }}
onMouseEnter={() => {}}
onMouseLeave={() => {}}
onClick={() => {}}
hovering={'default'}
/>
</svg>
);
expect(screen.getByTestId('node-circle-1')).toHaveAttribute('r', '20');
});
});
const nodeDatum = {
+21 -11
View File
@@ -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 (
<g data-node-id={node.id} className={styles.mainGroup} aria-label={`Node: ${node.title}`}>
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
{isHovered && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
<circle
data-testid={`node-circle-${node.id}`}
className={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} />
)}
<ColorCircle node={node} />
<g className={styles.text} style={{ pointerEvents: 'none' }}>
<NodeContents node={node} hovering={hovering} />
<foreignObject
x={node.x - (isHovered ? 100 : 70)}
y={node.y + nodeR + 5}
y={node.y + nodeRadius + 5}
width={isHovered ? '200' : '140'}
height="40"
>
@@ -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}
/>
</g>
);
@@ -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 = (
<ArcSection
key={index}
r={nodeR}
r={nodeRadius}
x={node.x!}
y={node.y!}
startPercent={acc.percent}
@@ -85,5 +85,7 @@ function makeEdge(source: number, target: number): EdgeDatum {
mainStat: '',
secondaryStat: '',
dataFrameRowIndex: 0,
sourceNodeRadius: 40,
targetNodeRadius: 40,
};
}
@@ -15,6 +15,7 @@ export type NodeDatum = SimulationNodeDatum & {
arcSections: Field[];
color?: Field;
icon?: IconName;
nodeRadius?: Field;
};
export type NodeDatumFromEdge = NodeDatum & { mainStatNumeric?: number; secondaryStatNumeric?: number };
@@ -31,6 +32,8 @@ export type EdgeDatum = LinkDatum & {
mainStat: string;
secondaryStat: string;
dataFrameRowIndex: number;
sourceNodeRadius: number;
targetNodeRadius: number;
};
// After layout is run D3 will change the string IDs for actual references to the nodes.
@@ -132,6 +132,7 @@ describe('processNodes', () => {
{ 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<NodeDatum> = {}) {
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,
};
}
+27 -18
View File
@@ -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({