The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/plugins/panel/nodeGraph/Node.tsx

289 lines
8.3 KiB

import { css } from '@emotion/css';
import cx from 'classnames';
import React, { MouseEvent, memo } from 'react';
import tinycolor from 'tinycolor2';
import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
import { Icon, useTheme2 } from '@grafana/ui';
import { HoverState } from './NodeGraph';
import { NodeDatum } from './types';
import { statToString } from './utils';
export const nodeR = 40;
export const highlightedNodeColor = '#a00';
const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
mainGroup: css`
cursor: pointer;
font-size: 10px;
transition: opacity 300ms;
opacity: ${hovering === 'inactive' ? 0.5 : 1};
`,
mainCircle: css`
fill: ${theme.components.panel.background};
`,
filledCircle: css`
fill: ${highlightedNodeColor};
`,
hoverCircle: css`
opacity: 0.5;
fill: transparent;
stroke: ${theme.colors.primary.text};
`,
text: css`
fill: ${theme.colors.text.primary};
pointer-events: none;
`,
titleText: css`
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String()};
width: 140px;
`,
statsText: css`
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 70px;
`,
textHovering: css`
width: 200px;
& span {
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.8).toHex8String()};
}
`,
clickTarget: css`
fill: none;
stroke: none;
pointer-events: fill;
`,
});
export const computeNodeCircumferenceStrokeWidth = (nodeRadius: number) => Math.ceil(nodeRadius * 0.075);
export const Node = memo(function Node(props: {
node: NodeDatum;
hovering: HoverState;
onMouseEnter: (id: string) => void;
onMouseLeave: (id: string) => void;
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
}) {
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
const theme = useTheme2();
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;
}
return (
<g data-node-id={node.id} className={styles.mainGroup} aria-label={`Node: ${node.title}`}>
<circle
data-testid={`node-circle-${node.id}`}
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={strokeWidth} />
)}
<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 + nodeRadius + 5}
width={isHovered ? '200' : '140'}
height="40"
>
<div className={cx(styles.titleText, isHovered && styles.textHovering)}>
<span>{node.title}</span>
<br />
<span>{node.subTitle}</span>
</div>
</foreignObject>
</g>
<rect
data-testid={`node-click-rect-${node.id}`}
onMouseEnter={() => {
onMouseEnter(node.id);
}}
onMouseLeave={() => {
onMouseLeave(node.id);
}}
onClick={(event) => {
onClick(event, node);
}}
className={styles.clickTarget}
x={node.x - nodeRadius - 5}
y={node.y - nodeRadius - 5}
width={nodeRadius * 2 + 10}
height={nodeRadius * 2 + 50}
/>
</g>
);
});
/**
* Shows contents of the node which can be either an Icon or a main and secondary stat values.
*/
function NodeContents({ node, hovering }: { node: NodeDatum; hovering: HoverState }) {
const theme = useTheme2();
const styles = getStyles(theme, hovering);
const isHovered = hovering === 'active';
if (!(node.x !== undefined && node.y !== undefined)) {
return null;
}
return node.icon ? (
<foreignObject x={node.x - 35} y={node.y - 20} width="70" height="40">
<div style={{ width: 70, overflow: 'hidden', display: 'flex', justifyContent: 'center', marginTop: -4 }}>
<Icon data-testid={`node-icon-${node.icon}`} name={node.icon} size={'xxxl'} />
</div>
</foreignObject>
) : (
<foreignObject x={node.x - (isHovered ? 100 : 35)} y={node.y - 15} width={isHovered ? '200' : '70'} height="40">
<div className={cx(styles.statsText, isHovered && styles.textHovering)}>
<span>{node.mainStat && statToString(node.mainStat.config, node.mainStat.values[node.dataFrameRowIndex])}</span>
<br />
<span>
{node.secondaryStat &&
statToString(node.secondaryStat.config, node.secondaryStat.values[node.dataFrameRowIndex])}
</span>
</div>
</foreignObject>
);
}
/**
* Shows the outer segmented circle with different colors based on the supplied data.
*/
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;
const strokeWidth = computeNodeCircumferenceStrokeWidth(nodeRadius);
if (fullStat) {
// 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={strokeWidth}
r={nodeRadius}
cx={node.x}
cy={node.y}
/>
);
}
const nonZero = node.arcSections.filter((s) => s.values[node.dataFrameRowIndex] !== 0);
if (nonZero.length === 0) {
// Fallback if no arc is defined
return (
<circle
fill="none"
stroke={node.color ? getColor(node.color, node.dataFrameRowIndex, theme) : 'gray'}
strokeWidth={strokeWidth}
r={nodeRadius}
cx={node.x}
cy={node.y}
/>
);
}
const { elements } = nonZero.reduce<{
elements: React.ReactNode[];
percent: number;
}>(
(acc, section, index) => {
const color = section.config.color?.fixedColor || '';
const value = section.values[node.dataFrameRowIndex];
const el = (
<ArcSection
key={index}
r={nodeRadius}
x={node.x!}
y={node.y!}
startPercent={acc.percent}
percent={
value + acc.percent > 1
? // If the values aren't correct and add up to more than 100% lets still render correctly the amounts we
// already have and cap it at 100%
1 - acc.percent
: value
}
color={theme.visualization.getColorByName(color)}
strokeWidth={strokeWidth}
/>
);
acc.elements.push(el);
acc.percent = acc.percent + value;
return acc;
},
{ elements: [], percent: 0 }
);
return <>{elements}</>;
}
function ArcSection({
r,
x,
y,
startPercent,
percent,
color,
strokeWidth = 2,
}: {
r: number;
x: number;
y: number;
startPercent: number;
percent: number;
color: string;
strokeWidth?: number;
}) {
const endPercent = startPercent + percent;
const startXPos = x + Math.sin(2 * Math.PI * startPercent) * r;
const startYPos = y - Math.cos(2 * Math.PI * startPercent) * r;
const endXPos = x + Math.sin(2 * Math.PI * endPercent) * r;
const endYPos = y - Math.cos(2 * Math.PI * endPercent) * r;
const largeArc = percent > 0.5 ? '1' : '0';
return (
<path
fill="none"
d={`M ${startXPos} ${startYPos} A ${r} ${r} 0 ${largeArc} 1 ${endXPos} ${endYPos}`}
stroke={color}
strokeWidth={strokeWidth}
/>
);
}
function getColor(field: Field, index: number, theme: GrafanaTheme2): string {
if (!field.config.color) {
return field.values[index];
}
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values[index]);
}