PieChart.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import { css } from '@emotion/css';
  2. import { localPoint } from '@visx/event';
  3. import { RadialGradient } from '@visx/gradient';
  4. import { Group } from '@visx/group';
  5. import Pie, { PieArcDatum, ProvidedProps } from '@visx/shape/lib/shapes/Pie';
  6. import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
  7. import { UseTooltipParams } from '@visx/tooltip/lib/hooks/useTooltip';
  8. import React, { FC, useCallback } from 'react';
  9. import tinycolor from 'tinycolor2';
  10. import {
  11. FieldDisplay,
  12. FALLBACK_COLOR,
  13. formattedValueToString,
  14. GrafanaTheme2,
  15. DataHoverClearEvent,
  16. DataHoverEvent,
  17. } from '@grafana/data';
  18. import { selectors } from '@grafana/e2e-selectors';
  19. import { VizTooltipOptions } from '@grafana/schema';
  20. import {
  21. useTheme2,
  22. useStyles2,
  23. SeriesTableRowProps,
  24. DataLinksContextMenu,
  25. SeriesTable,
  26. usePanelContext,
  27. } from '@grafana/ui';
  28. import { getTooltipContainerStyles } from '@grafana/ui/src/themes/mixins';
  29. import { useComponentInstanceId } from '@grafana/ui/src/utils/useComponetInstanceId';
  30. import { PieChartType, PieChartLabels } from './types';
  31. import { filterDisplayItems, sumDisplayItemsReducer } from './utils';
  32. /**
  33. * @beta
  34. */
  35. interface PieChartProps {
  36. height: number;
  37. width: number;
  38. fieldDisplayValues: FieldDisplay[];
  39. pieType: PieChartType;
  40. highlightedTitle?: string;
  41. displayLabels?: PieChartLabels[];
  42. useGradients?: boolean; // not used?
  43. tooltipOptions: VizTooltipOptions;
  44. }
  45. export const PieChart: FC<PieChartProps> = ({
  46. fieldDisplayValues,
  47. pieType,
  48. width,
  49. height,
  50. highlightedTitle,
  51. displayLabels = [],
  52. tooltipOptions,
  53. }) => {
  54. const theme = useTheme2();
  55. const componentInstanceId = useComponentInstanceId('PieChart');
  56. const styles = useStyles2(getStyles);
  57. const tooltip = useTooltip<SeriesTableRowProps[]>();
  58. const { containerRef, TooltipInPortal } = useTooltipInPortal({
  59. detectBounds: true,
  60. scroll: true,
  61. });
  62. const filteredFieldDisplayValues = fieldDisplayValues.filter(filterDisplayItems);
  63. const getValue = (d: FieldDisplay) => d.display.numeric;
  64. const getGradientId = (color: string) => `${componentInstanceId}-${tinycolor(color).toHex()}`;
  65. const getGradientColor = (color: string) => {
  66. return `url(#${getGradientId(color)})`;
  67. };
  68. const showLabel = displayLabels.length > 0;
  69. const showTooltip = tooltipOptions.mode !== 'none' && tooltip.tooltipOpen;
  70. const total = filteredFieldDisplayValues.reduce(sumDisplayItemsReducer, 0);
  71. const layout = getPieLayout(width, height, pieType);
  72. const colors = [
  73. ...new Set(
  74. filteredFieldDisplayValues.map((fieldDisplayValue) => fieldDisplayValue.display.color ?? FALLBACK_COLOR)
  75. ),
  76. ];
  77. return (
  78. <div className={styles.container}>
  79. <svg width={layout.size} height={layout.size} ref={containerRef}>
  80. <Group top={layout.position} left={layout.position}>
  81. {colors.map((color) => {
  82. return (
  83. <RadialGradient
  84. key={color}
  85. id={getGradientId(color)}
  86. from={getGradientColorFrom(color, theme)}
  87. to={getGradientColorTo(color, theme)}
  88. fromOffset={layout.gradientFromOffset}
  89. toOffset="1"
  90. gradientUnits="userSpaceOnUse"
  91. cx={0}
  92. cy={0}
  93. radius={layout.outerRadius}
  94. />
  95. );
  96. })}
  97. <Pie
  98. data={filteredFieldDisplayValues}
  99. pieValue={getValue}
  100. outerRadius={layout.outerRadius}
  101. innerRadius={layout.innerRadius}
  102. cornerRadius={3}
  103. padAngle={0.005}
  104. >
  105. {(pie) => (
  106. <>
  107. {pie.arcs.map((arc) => {
  108. const color = arc.data.display.color ?? FALLBACK_COLOR;
  109. const highlightState = getHighlightState(highlightedTitle, arc);
  110. if (arc.data.hasLinks && arc.data.getLinks) {
  111. return (
  112. <DataLinksContextMenu config={arc.data.field} key={arc.index} links={arc.data.getLinks}>
  113. {(api) => (
  114. <PieSlice
  115. tooltip={tooltip}
  116. highlightState={highlightState}
  117. arc={arc}
  118. pie={pie}
  119. fill={getGradientColor(color)}
  120. openMenu={api.openMenu}
  121. tooltipOptions={tooltipOptions}
  122. />
  123. )}
  124. </DataLinksContextMenu>
  125. );
  126. } else {
  127. return (
  128. <PieSlice
  129. key={arc.index}
  130. highlightState={highlightState}
  131. tooltip={tooltip}
  132. arc={arc}
  133. pie={pie}
  134. fill={getGradientColor(color)}
  135. tooltipOptions={tooltipOptions}
  136. />
  137. );
  138. }
  139. })}
  140. {showLabel &&
  141. pie.arcs.map((arc) => {
  142. const highlightState = getHighlightState(highlightedTitle, arc);
  143. return (
  144. <PieLabel
  145. arc={arc}
  146. key={arc.index}
  147. highlightState={highlightState}
  148. outerRadius={layout.outerRadius}
  149. innerRadius={layout.innerRadius}
  150. displayLabels={displayLabels}
  151. total={total}
  152. color={theme.colors.text.primary}
  153. />
  154. );
  155. })}
  156. </>
  157. )}
  158. </Pie>
  159. </Group>
  160. </svg>
  161. {showTooltip ? (
  162. <TooltipInPortal
  163. key={Math.random()}
  164. top={tooltip.tooltipTop}
  165. className={styles.tooltipPortal}
  166. left={tooltip.tooltipLeft}
  167. unstyled={true}
  168. applyPositionStyle={true}
  169. >
  170. <SeriesTable series={tooltip.tooltipData!} />
  171. </TooltipInPortal>
  172. ) : null}
  173. </div>
  174. );
  175. };
  176. interface SliceProps {
  177. arc: PieArcDatum<FieldDisplay>;
  178. pie: ProvidedProps<FieldDisplay>;
  179. highlightState: HighLightState;
  180. fill: string;
  181. tooltip: UseTooltipParams<SeriesTableRowProps[]>;
  182. tooltipOptions: VizTooltipOptions;
  183. openMenu?: (event: React.MouseEvent<SVGElement>) => void;
  184. }
  185. function PieSlice({ arc, pie, highlightState, openMenu, fill, tooltip, tooltipOptions }: SliceProps) {
  186. const theme = useTheme2();
  187. const styles = useStyles2(getStyles);
  188. const { eventBus } = usePanelContext();
  189. const onMouseOut = useCallback(
  190. (event: any) => {
  191. eventBus?.publish({
  192. type: DataHoverClearEvent.type,
  193. payload: {
  194. raw: event,
  195. x: 0,
  196. y: 0,
  197. dataId: arc.data.display.title,
  198. },
  199. });
  200. tooltip.hideTooltip();
  201. },
  202. [eventBus, arc, tooltip]
  203. );
  204. const onMouseMoveOverArc = useCallback(
  205. (event: any) => {
  206. eventBus?.publish({
  207. type: DataHoverEvent.type,
  208. payload: {
  209. raw: event,
  210. x: 0,
  211. y: 0,
  212. dataId: arc.data.display.title,
  213. },
  214. });
  215. const coords = localPoint(event.target.ownerSVGElement, event);
  216. tooltip.showTooltip({
  217. tooltipLeft: coords!.x,
  218. tooltipTop: coords!.y,
  219. tooltipData: getTooltipData(pie, arc, tooltipOptions),
  220. });
  221. },
  222. [eventBus, arc, tooltip, pie, tooltipOptions]
  223. );
  224. const pieStyle = getSvgStyle(highlightState, styles);
  225. return (
  226. <g
  227. key={arc.data.display.title}
  228. className={pieStyle}
  229. onMouseMove={tooltipOptions.mode !== 'none' ? onMouseMoveOverArc : undefined}
  230. onMouseOut={onMouseOut}
  231. onClick={openMenu}
  232. aria-label={selectors.components.Panels.Visualization.PieChart.svgSlice}
  233. >
  234. <path d={pie.path({ ...arc })!} fill={fill} stroke={theme.colors.background.primary} strokeWidth={1} />
  235. </g>
  236. );
  237. }
  238. interface LabelProps {
  239. arc: PieArcDatum<FieldDisplay>;
  240. outerRadius: number;
  241. innerRadius: number;
  242. displayLabels: PieChartLabels[];
  243. highlightState: HighLightState;
  244. total: number;
  245. color: string;
  246. }
  247. function PieLabel({ arc, outerRadius, innerRadius, displayLabels, total, color, highlightState }: LabelProps) {
  248. const styles = useStyles2(getStyles);
  249. const labelRadius = innerRadius === 0 ? outerRadius / 6 : innerRadius;
  250. const [labelX, labelY] = getLabelPos(arc, outerRadius, labelRadius);
  251. const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.3;
  252. if (!hasSpaceForLabel) {
  253. return null;
  254. }
  255. let labelFontSize = displayLabels.includes(PieChartLabels.Name)
  256. ? Math.min(Math.max((outerRadius / 150) * 14, 12), 30)
  257. : Math.min(Math.max((outerRadius / 100) * 14, 12), 36);
  258. return (
  259. <g className={getSvgStyle(highlightState, styles)}>
  260. <text
  261. fill={color}
  262. x={labelX}
  263. y={labelY}
  264. dy=".33em"
  265. fontSize={labelFontSize}
  266. fontWeight={500}
  267. textAnchor="middle"
  268. pointerEvents="none"
  269. >
  270. {displayLabels.includes(PieChartLabels.Name) && (
  271. <tspan x={labelX} dy="1.2em">
  272. {arc.data.display.title}
  273. </tspan>
  274. )}
  275. {displayLabels.includes(PieChartLabels.Value) && (
  276. <tspan x={labelX} dy="1.2em">
  277. {formattedValueToString(arc.data.display)}
  278. </tspan>
  279. )}
  280. {displayLabels.includes(PieChartLabels.Percent) && (
  281. <tspan x={labelX} dy="1.2em">
  282. {((arc.data.display.numeric / total) * 100).toFixed(arc.data.field.decimals ?? 0) + '%'}
  283. </tspan>
  284. )}
  285. </text>
  286. </g>
  287. );
  288. }
  289. function getTooltipData(
  290. pie: ProvidedProps<FieldDisplay>,
  291. arc: PieArcDatum<FieldDisplay>,
  292. tooltipOptions: VizTooltipOptions
  293. ) {
  294. if (tooltipOptions.mode === 'multi') {
  295. return pie.arcs.map((pieArc) => {
  296. return {
  297. color: pieArc.data.display.color ?? FALLBACK_COLOR,
  298. label: pieArc.data.display.title,
  299. value: formattedValueToString(pieArc.data.display),
  300. isActive: pieArc.index === arc.index,
  301. };
  302. });
  303. }
  304. return [
  305. {
  306. color: arc.data.display.color ?? FALLBACK_COLOR,
  307. label: arc.data.display.title,
  308. value: formattedValueToString(arc.data.display),
  309. },
  310. ];
  311. }
  312. function getLabelPos(arc: PieArcDatum<FieldDisplay>, outerRadius: number, innerRadius: number) {
  313. const r = (outerRadius + innerRadius) / 2;
  314. const a = (+arc.startAngle + +arc.endAngle) / 2 - Math.PI / 2;
  315. return [Math.cos(a) * r, Math.sin(a) * r];
  316. }
  317. function getGradientColorFrom(color: string, theme: GrafanaTheme2) {
  318. return tinycolor(color)
  319. .darken(20 * (theme.isDark ? 1 : -0.7))
  320. .spin(4)
  321. .toRgbString();
  322. }
  323. function getGradientColorTo(color: string, theme: GrafanaTheme2) {
  324. return tinycolor(color)
  325. .darken(10 * (theme.isDark ? 1 : -0.7))
  326. .spin(-4)
  327. .toRgbString();
  328. }
  329. interface PieLayout {
  330. position: number;
  331. size: number;
  332. outerRadius: number;
  333. innerRadius: number;
  334. gradientFromOffset: number;
  335. }
  336. function getPieLayout(height: number, width: number, pieType: PieChartType, margin = 16): PieLayout {
  337. const size = Math.min(width, height);
  338. const outerRadius = (size - margin * 2) / 2;
  339. const donutThickness = pieType === PieChartType.Pie ? outerRadius : Math.max(outerRadius / 3, 20);
  340. const innerRadius = outerRadius - donutThickness;
  341. const centerOffset = (size - margin * 2) / 2;
  342. // for non donut pie charts shift gradient out a bit
  343. const gradientFromOffset = 1 - (outerRadius - innerRadius) / outerRadius;
  344. return {
  345. position: centerOffset + margin,
  346. size: size,
  347. outerRadius: outerRadius,
  348. innerRadius: innerRadius,
  349. gradientFromOffset: gradientFromOffset,
  350. };
  351. }
  352. enum HighLightState {
  353. Highlighted,
  354. Deemphasized,
  355. Normal,
  356. }
  357. function getHighlightState(highlightedTitle: string | undefined, arc: PieArcDatum<FieldDisplay>) {
  358. if (highlightedTitle) {
  359. if (highlightedTitle === arc.data.display.title) {
  360. return HighLightState.Highlighted;
  361. } else {
  362. return HighLightState.Deemphasized;
  363. }
  364. }
  365. return HighLightState.Normal;
  366. }
  367. function getSvgStyle(
  368. highlightState: HighLightState,
  369. styles: {
  370. svgArg: { normal: string; highlighted: string; deemphasized: string };
  371. }
  372. ) {
  373. switch (highlightState) {
  374. case HighLightState.Highlighted:
  375. return styles.svgArg.highlighted;
  376. case HighLightState.Deemphasized:
  377. return styles.svgArg.deemphasized;
  378. case HighLightState.Normal:
  379. default:
  380. return styles.svgArg.normal;
  381. }
  382. }
  383. const getStyles = (theme: GrafanaTheme2) => {
  384. return {
  385. container: css`
  386. width: 100%;
  387. height: 100%;
  388. display: flex;
  389. align-items: center;
  390. justify-content: center;
  391. `,
  392. svgArg: {
  393. normal: css`
  394. transition: all 200ms ease-in-out;
  395. `,
  396. highlighted: css`
  397. transition: all 200ms ease-in-out;
  398. transform: scale3d(1.03, 1.03, 1);
  399. `,
  400. deemphasized: css`
  401. transition: all 200ms ease-in-out;
  402. fill-opacity: 0.5;
  403. `,
  404. },
  405. tooltipPortal: css`
  406. ${getTooltipContainerStyles(theme)}
  407. `,
  408. };
  409. };