grafana/public/app/plugins/panel/nodeGraph/Node.tsx
Connor Lindsey db9c9b5354
Node Graph: Emphasize hovered or connected nodes (#51925)
* Node Graph: Emphasize hovered or connected nodes

* Add tests and refactor into util functions
2022-07-12 06:14:45 -06:00

224 lines
6.0 KiB
TypeScript

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 { useTheme2 } from '@grafana/ui';
import { HoverState } from './NodeGraph';
import { NodeDatum } from './types';
import { statToString } from './utils';
const nodeR = 40;
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};
`,
hoverCircle: css`
opacity: 0.5;
fill: transparent;
stroke: ${theme.colors.primary.text};
`,
text: css`
fill: ${theme.colors.text.primary};
`,
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: 100px;
`,
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()};
}
`,
});
export const Node = memo(function Node(props: {
node: NodeDatum;
onMouseEnter: (id: string) => void;
onMouseLeave: (id: string) => void;
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
hovering: HoverState;
}) {
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
const theme = useTheme2();
const styles = getStyles(theme, hovering);
const isHovered = hovering === 'active';
if (!(node.x !== undefined && node.y !== undefined)) {
return null;
}
return (
<g
data-node-id={node.id}
className={styles.mainGroup}
onMouseEnter={() => {
onMouseEnter(node.id);
}}
onMouseLeave={() => {
onMouseLeave(node.id);
}}
onClick={(event) => {
onClick(event, node);
}}
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} />}
<ColorCircle node={node} />
<g className={styles.text}>
<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, node.dataFrameRowIndex)}</span>
<br />
<span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span>
</div>
</foreignObject>
<foreignObject
x={node.x - (isHovered ? 100 : 50)}
y={node.y + nodeR + 5}
width={isHovered ? '200' : '100'}
height="40"
>
<div className={cx(styles.titleText, isHovered && styles.textHovering)}>
<span>{node.title}</span>
<br />
<span>{node.subTitle}</span>
</div>
</foreignObject>
</g>
</g>
);
});
/**
* 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.get(node.dataFrameRowIndex) === 1);
const theme = useTheme2();
if (fullStat) {
// Doing arc with path does not work well so it's better to just do a circle in that case
return (
<circle
fill="none"
stroke={theme.visualization.getColorByName(fullStat.config.color?.fixedColor || '')}
strokeWidth={2}
r={nodeR}
cx={node.x}
cy={node.y}
/>
);
}
const nonZero = node.arcSections.filter((s) => s.values.get(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={2}
r={nodeR}
cx={node.x}
cy={node.y}
/>
);
}
const { elements } = nonZero.reduce(
(acc, section) => {
const color = section.config.color?.fixedColor || '';
const value = section.values.get(node.dataFrameRowIndex);
const el = (
<ArcSection
key={color}
r={nodeR}
x={node.x!}
y={node.y!}
startPercent={acc.percent}
percent={value}
color={theme.visualization.getColorByName(color)}
strokeWidth={2}
/>
);
acc.elements.push(el);
acc.percent = acc.percent + value;
return acc;
},
{ elements: [] as React.ReactNode[], 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.get(index);
}
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
}