2022-04-22 08:33:13 -05:00
|
|
|
import { css } from '@emotion/css';
|
2021-03-31 10:56:15 -05:00
|
|
|
import cx from 'classnames';
|
2022-04-22 08:33:13 -05:00
|
|
|
import React, { MouseEvent, memo } from 'react';
|
|
|
|
import tinycolor from 'tinycolor2';
|
|
|
|
|
2021-05-18 09:30:27 -05:00
|
|
|
import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
|
2023-03-01 09:02:33 -06:00
|
|
|
import { Icon, useTheme2 } from '@grafana/ui';
|
2022-04-22 08:33:13 -05:00
|
|
|
|
2022-07-12 07:14:45 -05:00
|
|
|
import { HoverState } from './NodeGraph';
|
2021-03-31 10:56:15 -05:00
|
|
|
import { NodeDatum } from './types';
|
2021-05-12 09:04:21 -05:00
|
|
|
import { statToString } from './utils';
|
2021-01-19 09:34:43 -06:00
|
|
|
|
2023-09-25 09:55:52 -05:00
|
|
|
export const nodeR = 40;
|
2023-10-24 08:16:10 -05:00
|
|
|
export const highlightedNodeColor = '#a00';
|
2021-01-19 09:34:43 -06:00
|
|
|
|
2022-07-12 07:14:45 -05:00
|
|
|
const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
|
2021-01-19 09:34:43 -06:00
|
|
|
mainGroup: css`
|
|
|
|
cursor: pointer;
|
|
|
|
font-size: 10px;
|
2022-07-12 07:14:45 -05:00
|
|
|
transition: opacity 300ms;
|
|
|
|
opacity: ${hovering === 'inactive' ? 0.5 : 1};
|
2021-01-19 09:34:43 -06:00
|
|
|
`,
|
|
|
|
|
|
|
|
mainCircle: css`
|
2021-05-12 09:04:21 -05:00
|
|
|
fill: ${theme.components.panel.background};
|
2021-01-19 09:34:43 -06:00
|
|
|
`,
|
|
|
|
|
2023-10-24 08:16:10 -05:00
|
|
|
filledCircle: css`
|
|
|
|
fill: ${highlightedNodeColor};
|
|
|
|
`,
|
|
|
|
|
2021-01-19 09:34:43 -06:00
|
|
|
hoverCircle: css`
|
|
|
|
opacity: 0.5;
|
|
|
|
fill: transparent;
|
2021-05-12 09:04:21 -05:00
|
|
|
stroke: ${theme.colors.primary.text};
|
2021-01-19 09:34:43 -06:00
|
|
|
`,
|
|
|
|
|
|
|
|
text: css`
|
2021-05-12 09:04:21 -05:00
|
|
|
fill: ${theme.colors.text.primary};
|
2023-05-17 08:22:21 -05:00
|
|
|
pointer-events: none;
|
2021-01-19 09:34:43 -06:00
|
|
|
`,
|
|
|
|
|
|
|
|
titleText: css`
|
|
|
|
text-align: center;
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
overflow: hidden;
|
|
|
|
white-space: nowrap;
|
2021-05-12 09:04:21 -05:00
|
|
|
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String()};
|
2023-07-18 05:11:12 -05:00
|
|
|
width: 140px;
|
2021-03-31 10:56:15 -05:00
|
|
|
`,
|
|
|
|
|
|
|
|
statsText: css`
|
|
|
|
text-align: center;
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
overflow: hidden;
|
|
|
|
white-space: nowrap;
|
|
|
|
width: 70px;
|
|
|
|
`,
|
|
|
|
|
|
|
|
textHovering: css`
|
|
|
|
width: 200px;
|
|
|
|
& span {
|
2021-05-12 09:04:21 -05:00
|
|
|
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.8).toHex8String()};
|
2021-03-31 10:56:15 -05:00
|
|
|
}
|
2021-01-19 09:34:43 -06:00
|
|
|
`,
|
2023-05-17 08:22:21 -05:00
|
|
|
|
|
|
|
clickTarget: css`
|
|
|
|
fill: none;
|
|
|
|
stroke: none;
|
|
|
|
pointer-events: fill;
|
|
|
|
`,
|
2021-05-12 09:04:21 -05:00
|
|
|
});
|
2021-01-19 09:34:43 -06:00
|
|
|
|
2023-10-24 08:16:10 -05:00
|
|
|
export const computeNodeCircumferenceStrokeWidth = (nodeRadius: number) => Math.ceil(nodeRadius * 0.075);
|
|
|
|
|
2021-01-19 09:34:43 -06:00
|
|
|
export const Node = memo(function Node(props: {
|
|
|
|
node: NodeDatum;
|
2023-03-01 09:02:33 -06:00
|
|
|
hovering: HoverState;
|
2021-01-19 09:34:43 -06:00
|
|
|
onMouseEnter: (id: string) => void;
|
|
|
|
onMouseLeave: (id: string) => void;
|
|
|
|
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
|
|
|
}) {
|
|
|
|
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
|
2022-07-12 07:14:45 -05:00
|
|
|
const theme = useTheme2();
|
|
|
|
const styles = getStyles(theme, hovering);
|
|
|
|
const isHovered = hovering === 'active';
|
2023-09-25 09:55:52 -05:00
|
|
|
const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR;
|
2023-10-24 08:16:10 -05:00
|
|
|
const strokeWidth = computeNodeCircumferenceStrokeWidth(nodeRadius);
|
2021-01-19 09:34:43 -06:00
|
|
|
|
|
|
|
if (!(node.x !== undefined && node.y !== undefined)) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
2023-05-17 08:22:21 -05:00
|
|
|
<g data-node-id={node.id} className={styles.mainGroup} aria-label={`Node: ${node.title}`}>
|
2023-09-25 09:55:52 -05:00
|
|
|
<circle
|
|
|
|
data-testid={`node-circle-${node.id}`}
|
2023-10-24 08:16:10 -05:00
|
|
|
className={node.highlighted ? styles.filledCircle : styles.mainCircle}
|
2023-09-25 09:55:52 -05:00
|
|
|
r={nodeRadius}
|
|
|
|
cx={node.x}
|
|
|
|
cy={node.y}
|
|
|
|
/>
|
|
|
|
{isHovered && (
|
2023-10-24 08:16:10 -05:00
|
|
|
<circle className={styles.hoverCircle} r={nodeRadius - 3} cx={node.x} cy={node.y} strokeWidth={strokeWidth} />
|
2023-09-25 09:55:52 -05:00
|
|
|
)}
|
2021-03-31 10:56:15 -05:00
|
|
|
<ColorCircle node={node} />
|
2023-05-17 08:22:21 -05:00
|
|
|
<g className={styles.text} style={{ pointerEvents: 'none' }}>
|
2023-03-01 09:02:33 -06:00
|
|
|
<NodeContents node={node} hovering={hovering} />
|
2021-03-31 10:56:15 -05:00
|
|
|
<foreignObject
|
2023-07-18 05:11:12 -05:00
|
|
|
x={node.x - (isHovered ? 100 : 70)}
|
2023-09-25 09:55:52 -05:00
|
|
|
y={node.y + nodeRadius + 5}
|
2023-07-18 05:11:12 -05:00
|
|
|
width={isHovered ? '200' : '140'}
|
2021-11-07 01:13:14 -05:00
|
|
|
height="40"
|
2021-03-31 10:56:15 -05:00
|
|
|
>
|
2022-07-12 07:14:45 -05:00
|
|
|
<div className={cx(styles.titleText, isHovered && styles.textHovering)}>
|
2021-03-31 10:56:15 -05:00
|
|
|
<span>{node.title}</span>
|
|
|
|
<br />
|
|
|
|
<span>{node.subTitle}</span>
|
2021-01-19 09:34:43 -06:00
|
|
|
</div>
|
|
|
|
</foreignObject>
|
|
|
|
</g>
|
2023-05-17 08:22:21 -05:00
|
|
|
<rect
|
|
|
|
data-testid={`node-click-rect-${node.id}`}
|
|
|
|
onMouseEnter={() => {
|
|
|
|
onMouseEnter(node.id);
|
|
|
|
}}
|
|
|
|
onMouseLeave={() => {
|
|
|
|
onMouseLeave(node.id);
|
|
|
|
}}
|
|
|
|
onClick={(event) => {
|
|
|
|
onClick(event, node);
|
|
|
|
}}
|
|
|
|
className={styles.clickTarget}
|
2023-09-25 09:55:52 -05:00
|
|
|
x={node.x - nodeRadius - 5}
|
|
|
|
y={node.y - nodeRadius - 5}
|
|
|
|
width={nodeRadius * 2 + 10}
|
|
|
|
height={nodeRadius * 2 + 50}
|
2023-05-17 08:22:21 -05:00
|
|
|
/>
|
2021-01-19 09:34:43 -06:00
|
|
|
</g>
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
2023-03-01 09:02:33 -06:00
|
|
|
/**
|
|
|
|
* 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)}>
|
2023-04-17 16:46:29 -05:00
|
|
|
<span>{node.mainStat && statToString(node.mainStat.config, node.mainStat.values[node.dataFrameRowIndex])}</span>
|
2023-03-01 09:02:33 -06:00
|
|
|
<br />
|
|
|
|
<span>
|
|
|
|
{node.secondaryStat &&
|
2023-04-17 16:46:29 -05:00
|
|
|
statToString(node.secondaryStat.config, node.secondaryStat.values[node.dataFrameRowIndex])}
|
2023-03-01 09:02:33 -06:00
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</foreignObject>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-01-19 09:34:43 -06:00
|
|
|
/**
|
2021-03-31 10:56:15 -05:00
|
|
|
* Shows the outer segmented circle with different colors based on the supplied data.
|
2021-01-19 09:34:43 -06:00
|
|
|
*/
|
2021-03-31 10:56:15 -05:00
|
|
|
function ColorCircle(props: { node: NodeDatum }) {
|
2021-01-19 09:34:43 -06:00
|
|
|
const { node } = props;
|
2023-04-17 16:46:29 -05:00
|
|
|
const fullStat = node.arcSections.find((s) => s.values[node.dataFrameRowIndex] >= 1);
|
2021-05-18 09:30:27 -05:00
|
|
|
const theme = useTheme2();
|
2023-09-25 09:55:52 -05:00
|
|
|
const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR;
|
2023-10-24 08:16:10 -05:00
|
|
|
const strokeWidth = computeNodeCircumferenceStrokeWidth(nodeRadius);
|
2021-01-19 09:34:43 -06:00
|
|
|
|
|
|
|
if (fullStat) {
|
2023-10-24 08:16:10 -05:00
|
|
|
// Drawing a full circle with a `path` tag does not work well, it's better to use a `circle` tag in that case
|
2021-01-19 09:34:43 -06:00
|
|
|
return (
|
|
|
|
<circle
|
|
|
|
fill="none"
|
2021-05-18 09:30:27 -05:00
|
|
|
stroke={theme.visualization.getColorByName(fullStat.config.color?.fixedColor || '')}
|
2023-10-24 08:16:10 -05:00
|
|
|
strokeWidth={strokeWidth}
|
2023-09-25 09:55:52 -05:00
|
|
|
r={nodeRadius}
|
2021-01-19 09:34:43 -06:00
|
|
|
cx={node.x}
|
|
|
|
cy={node.y}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-04-17 16:46:29 -05:00
|
|
|
const nonZero = node.arcSections.filter((s) => s.values[node.dataFrameRowIndex] !== 0);
|
2021-03-31 10:56:15 -05:00
|
|
|
if (nonZero.length === 0) {
|
|
|
|
// Fallback if no arc is defined
|
2021-05-18 09:30:27 -05:00
|
|
|
return (
|
|
|
|
<circle
|
|
|
|
fill="none"
|
|
|
|
stroke={node.color ? getColor(node.color, node.dataFrameRowIndex, theme) : 'gray'}
|
2023-10-24 08:16:10 -05:00
|
|
|
strokeWidth={strokeWidth}
|
2023-09-25 09:55:52 -05:00
|
|
|
r={nodeRadius}
|
2021-05-18 09:30:27 -05:00
|
|
|
cx={node.x}
|
|
|
|
cy={node.y}
|
|
|
|
/>
|
|
|
|
);
|
2021-03-31 10:56:15 -05:00
|
|
|
}
|
2021-01-19 09:34:43 -06:00
|
|
|
|
2022-11-23 10:54:57 -06:00
|
|
|
const { elements } = nonZero.reduce<{
|
|
|
|
elements: React.ReactNode[];
|
|
|
|
percent: number;
|
|
|
|
}>(
|
2023-03-01 09:02:33 -06:00
|
|
|
(acc, section, index) => {
|
2021-05-12 09:04:21 -05:00
|
|
|
const color = section.config.color?.fixedColor || '';
|
2023-04-17 16:46:29 -05:00
|
|
|
const value = section.values[node.dataFrameRowIndex];
|
2022-10-24 04:28:49 -05:00
|
|
|
|
2021-01-19 09:34:43 -06:00
|
|
|
const el = (
|
|
|
|
<ArcSection
|
2023-03-01 09:02:33 -06:00
|
|
|
key={index}
|
2023-09-25 09:55:52 -05:00
|
|
|
r={nodeRadius}
|
2021-01-19 09:34:43 -06:00
|
|
|
x={node.x!}
|
|
|
|
y={node.y!}
|
|
|
|
startPercent={acc.percent}
|
2022-10-24 04:28:49 -05:00
|
|
|
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
|
|
|
|
}
|
2021-05-18 09:30:27 -05:00
|
|
|
color={theme.visualization.getColorByName(color)}
|
2023-10-24 08:16:10 -05:00
|
|
|
strokeWidth={strokeWidth}
|
2021-01-19 09:34:43 -06:00
|
|
|
/>
|
|
|
|
);
|
|
|
|
acc.elements.push(el);
|
2021-05-12 09:04:21 -05:00
|
|
|
acc.percent = acc.percent + value;
|
2021-01-19 09:34:43 -06:00
|
|
|
return acc;
|
|
|
|
},
|
2022-11-23 10:54:57 -06:00
|
|
|
{ elements: [], percent: 0 }
|
2021-01-19 09:34:43 -06:00
|
|
|
);
|
|
|
|
|
|
|
|
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}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
2021-05-18 09:30:27 -05:00
|
|
|
|
|
|
|
function getColor(field: Field, index: number, theme: GrafanaTheme2): string {
|
|
|
|
if (!field.config.color) {
|
2023-04-17 16:46:29 -05:00
|
|
|
return field.values[index];
|
2021-05-18 09:30:27 -05:00
|
|
|
}
|
|
|
|
|
2023-04-17 16:46:29 -05:00
|
|
|
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values[index]);
|
2021-05-18 09:30:27 -05:00
|
|
|
}
|