Node.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import { css } from '@emotion/css';
  2. import cx from 'classnames';
  3. import React, { MouseEvent, memo } from 'react';
  4. import tinycolor from 'tinycolor2';
  5. import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
  6. import { useStyles2, useTheme2 } from '@grafana/ui';
  7. import { NodeDatum } from './types';
  8. import { statToString } from './utils';
  9. const nodeR = 40;
  10. const getStyles = (theme: GrafanaTheme2) => ({
  11. mainGroup: css`
  12. cursor: pointer;
  13. font-size: 10px;
  14. `,
  15. mainCircle: css`
  16. fill: ${theme.components.panel.background};
  17. `,
  18. hoverCircle: css`
  19. opacity: 0.5;
  20. fill: transparent;
  21. stroke: ${theme.colors.primary.text};
  22. `,
  23. text: css`
  24. fill: ${theme.colors.text.primary};
  25. `,
  26. titleText: css`
  27. text-align: center;
  28. text-overflow: ellipsis;
  29. overflow: hidden;
  30. white-space: nowrap;
  31. background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String()};
  32. width: 100px;
  33. `,
  34. statsText: css`
  35. text-align: center;
  36. text-overflow: ellipsis;
  37. overflow: hidden;
  38. white-space: nowrap;
  39. width: 70px;
  40. `,
  41. textHovering: css`
  42. width: 200px;
  43. & span {
  44. background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.8).toHex8String()};
  45. }
  46. `,
  47. });
  48. export const Node = memo(function Node(props: {
  49. node: NodeDatum;
  50. onMouseEnter: (id: string) => void;
  51. onMouseLeave: (id: string) => void;
  52. onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
  53. hovering: boolean;
  54. }) {
  55. const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
  56. const styles = useStyles2(getStyles);
  57. if (!(node.x !== undefined && node.y !== undefined)) {
  58. return null;
  59. }
  60. return (
  61. <g
  62. data-node-id={node.id}
  63. className={styles.mainGroup}
  64. onMouseEnter={() => {
  65. onMouseEnter(node.id);
  66. }}
  67. onMouseLeave={() => {
  68. onMouseLeave(node.id);
  69. }}
  70. onClick={(event) => {
  71. onClick(event, node);
  72. }}
  73. aria-label={`Node: ${node.title}`}
  74. >
  75. <circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
  76. {hovering && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
  77. <ColorCircle node={node} />
  78. <g className={styles.text}>
  79. <foreignObject x={node.x - (hovering ? 100 : 35)} y={node.y - 15} width={hovering ? '200' : '70'} height="40">
  80. <div className={cx(styles.statsText, hovering && styles.textHovering)}>
  81. <span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span>
  82. <br />
  83. <span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span>
  84. </div>
  85. </foreignObject>
  86. <foreignObject
  87. x={node.x - (hovering ? 100 : 50)}
  88. y={node.y + nodeR + 5}
  89. width={hovering ? '200' : '100'}
  90. height="40"
  91. >
  92. <div className={cx(styles.titleText, hovering && styles.textHovering)}>
  93. <span>{node.title}</span>
  94. <br />
  95. <span>{node.subTitle}</span>
  96. </div>
  97. </foreignObject>
  98. </g>
  99. </g>
  100. );
  101. });
  102. /**
  103. * Shows the outer segmented circle with different colors based on the supplied data.
  104. */
  105. function ColorCircle(props: { node: NodeDatum }) {
  106. const { node } = props;
  107. const fullStat = node.arcSections.find((s) => s.values.get(node.dataFrameRowIndex) === 1);
  108. const theme = useTheme2();
  109. if (fullStat) {
  110. // Doing arc with path does not work well so it's better to just do a circle in that case
  111. return (
  112. <circle
  113. fill="none"
  114. stroke={theme.visualization.getColorByName(fullStat.config.color?.fixedColor || '')}
  115. strokeWidth={2}
  116. r={nodeR}
  117. cx={node.x}
  118. cy={node.y}
  119. />
  120. );
  121. }
  122. const nonZero = node.arcSections.filter((s) => s.values.get(node.dataFrameRowIndex) !== 0);
  123. if (nonZero.length === 0) {
  124. // Fallback if no arc is defined
  125. return (
  126. <circle
  127. fill="none"
  128. stroke={node.color ? getColor(node.color, node.dataFrameRowIndex, theme) : 'gray'}
  129. strokeWidth={2}
  130. r={nodeR}
  131. cx={node.x}
  132. cy={node.y}
  133. />
  134. );
  135. }
  136. const { elements } = nonZero.reduce(
  137. (acc, section) => {
  138. const color = section.config.color?.fixedColor || '';
  139. const value = section.values.get(node.dataFrameRowIndex);
  140. const el = (
  141. <ArcSection
  142. key={color}
  143. r={nodeR}
  144. x={node.x!}
  145. y={node.y!}
  146. startPercent={acc.percent}
  147. percent={value}
  148. color={theme.visualization.getColorByName(color)}
  149. strokeWidth={2}
  150. />
  151. );
  152. acc.elements.push(el);
  153. acc.percent = acc.percent + value;
  154. return acc;
  155. },
  156. { elements: [] as React.ReactNode[], percent: 0 }
  157. );
  158. return <>{elements}</>;
  159. }
  160. function ArcSection({
  161. r,
  162. x,
  163. y,
  164. startPercent,
  165. percent,
  166. color,
  167. strokeWidth = 2,
  168. }: {
  169. r: number;
  170. x: number;
  171. y: number;
  172. startPercent: number;
  173. percent: number;
  174. color: string;
  175. strokeWidth?: number;
  176. }) {
  177. const endPercent = startPercent + percent;
  178. const startXPos = x + Math.sin(2 * Math.PI * startPercent) * r;
  179. const startYPos = y - Math.cos(2 * Math.PI * startPercent) * r;
  180. const endXPos = x + Math.sin(2 * Math.PI * endPercent) * r;
  181. const endYPos = y - Math.cos(2 * Math.PI * endPercent) * r;
  182. const largeArc = percent > 0.5 ? '1' : '0';
  183. return (
  184. <path
  185. fill="none"
  186. d={`M ${startXPos} ${startYPos} A ${r} ${r} 0 ${largeArc} 1 ${endXPos} ${endYPos}`}
  187. stroke={color}
  188. strokeWidth={strokeWidth}
  189. />
  190. );
  191. }
  192. function getColor(field: Field, index: number, theme: GrafanaTheme2): string {
  193. if (!field.config.color) {
  194. return field.values.get(index);
  195. }
  196. return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
  197. }