123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- 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 { useStyles2, useTheme2 } from '@grafana/ui';
- import { NodeDatum } from './types';
- import { statToString } from './utils';
- const nodeR = 40;
- const getStyles = (theme: GrafanaTheme2) => ({
- mainGroup: css`
- cursor: pointer;
- font-size: 10px;
- `,
- 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: boolean;
- }) {
- const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
- const styles = useStyles2(getStyles);
- 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} />
- {hovering && <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 - (hovering ? 100 : 35)} y={node.y - 15} width={hovering ? '200' : '70'} height="40">
- <div className={cx(styles.statsText, hovering && 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 - (hovering ? 100 : 50)}
- y={node.y + nodeR + 5}
- width={hovering ? '200' : '100'}
- height="40"
- >
- <div className={cx(styles.titleText, hovering && 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));
- }
|