import { css } from '@emotion/css'; import cx from 'classnames'; import { 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', fontSize: '10px', [theme.transitions.handleMotion('no-preference', 'reduce')]: { 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, pointerEvents: 'none', }), titleText: css({ textAlign: 'center', textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', backgroundColor: tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String(), width: '140px', }), statsText: css({ textAlign: 'center', textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', width: '70px', }), textHovering: css({ width: '200px', '& span': { backgroundColor: tinycolor(theme.colors.background.primary).setAlpha(0.8).toHex8String(), }, }), clickTarget: css({ fill: 'none', stroke: 'none', pointerEvents: '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, 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 ( {isHovered && ( )}
{node.title}
{node.subTitle}
{ 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} />
); }); /** * 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 ? (
) : (
{node.mainStat && statToString(node.mainStat.config, node.mainStat.values[node.dataFrameRowIndex])}
{node.secondaryStat && statToString(node.secondaryStat.config, node.secondaryStat.values[node.dataFrameRowIndex])}
); } /** * 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 ( ); } const nonZero = node.arcSections.filter((s) => s.values[node.dataFrameRowIndex] !== 0); if (nonZero.length === 0) { // Fallback if no arc is defined return ( ); } 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 = ( 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 ( ); } 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]); }