NodeGraph: Allow to set node radius in dataframe (#74963)
Allow to set node radius in dataframe
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user