pieChart.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import {Component, createRef} from 'react';
  2. import {Theme, withTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import type {PieSeriesOption} from 'echarts';
  5. import BaseChart, {BaseChartProps} from 'sentry/components/charts/baseChart';
  6. import PieSeries from 'sentry/components/charts/series/pieSeries';
  7. import CircleIndicator from 'sentry/components/circleIndicator';
  8. import {Tooltip} from 'sentry/components/tooltip';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {ReactEchartsRef, Series} from 'sentry/types/echarts';
  12. import {formatPercentage, getDuration} from 'sentry/utils/formatters';
  13. export interface PieChartSeries
  14. extends Series,
  15. Omit<PieSeriesOption, 'id' | 'color' | 'data'> {}
  16. interface Props extends Omit<BaseChartProps, 'series'> {
  17. // TODO improve type
  18. data: any;
  19. series: PieChartSeries[];
  20. theme: Theme;
  21. selectOnRender?: boolean;
  22. }
  23. class PieChart extends Component<Props> {
  24. componentDidMount() {
  25. const {selectOnRender} = this.props;
  26. if (!selectOnRender) {
  27. return;
  28. }
  29. // Timeout is because we need to wait for rendering animation to complete
  30. // And I haven't found a callback for this
  31. this.highlightTimeout = window.setTimeout(() => this.highlight(0), 1000);
  32. }
  33. componentWillUnmount() {
  34. window.clearTimeout(this.highlightTimeout);
  35. }
  36. highlightTimeout: number | undefined = undefined;
  37. isInitialSelected = true;
  38. selected = 0;
  39. chart = createRef<ReactEchartsRef>();
  40. pieChartSliceColors = [...this.props.theme.charts.getColorPalette(5)].reverse();
  41. // Select a series to highlight (e.g. shows details of series)
  42. // This is the same event as when you hover over a series in the chart
  43. highlight = dataIndex => {
  44. if (!this.chart.current) {
  45. return;
  46. }
  47. this.chart.current.getEchartsInstance().dispatchAction({
  48. type: 'highlight',
  49. seriesIndex: 0,
  50. dataIndex,
  51. });
  52. };
  53. // Opposite of `highlight`
  54. downplay = dataIndex => {
  55. if (!this.chart.current) {
  56. return;
  57. }
  58. this.chart.current.getEchartsInstance().dispatchAction({
  59. type: 'downplay',
  60. seriesIndex: 0,
  61. dataIndex,
  62. });
  63. };
  64. // echarts Legend does not have access to percentages (but tooltip does :/)
  65. getSeriesPercentages = (series: PieChartSeries) => {
  66. const total = series.data.reduce((acc, {value}) => acc + value, 0);
  67. return series.data
  68. .map(({name, value}) => [name, Math.round((value / total) * 10000) / 100])
  69. .reduce(
  70. (acc, [name, value]) => ({
  71. ...acc,
  72. [name]: value,
  73. }),
  74. {}
  75. );
  76. };
  77. getSpanOpDurationChange = (op: string) => {
  78. return this.props.data[op].oldBaseline / this.props.data[op].newBaseline - 1;
  79. };
  80. render() {
  81. const {series, theme, ...props} = this.props;
  82. if (!series || !series.length) {
  83. return null;
  84. }
  85. if (series.length > 1) {
  86. // eslint-disable-next-line no-console
  87. console.warn('PieChart only uses the first series!');
  88. }
  89. // Note, we only take the first series unit!
  90. const [firstSeries] = series;
  91. // Attach a color and index to each operation. This allows us to match custom legend indicator
  92. // colors to the op's pie chart color AND display the legend items sorted based on their
  93. // percentage changes.
  94. const operationToColorMap: {
  95. [key: string]: {color: string; index: number};
  96. } = {};
  97. firstSeries.data.forEach((seriesRow, index) => {
  98. operationToColorMap[seriesRow.name] = {
  99. color: this.pieChartSliceColors[index],
  100. index,
  101. };
  102. });
  103. return (
  104. <Wrapper>
  105. <LegendWrapper>
  106. {[...Object.keys(this.props.data)]
  107. .sort((a, b) => {
  108. return this.getSpanOpDurationChange(a) - this.getSpanOpDurationChange(b);
  109. })
  110. .map((op, index) => {
  111. const change = this.getSpanOpDurationChange(op);
  112. const oldValue = getDuration(
  113. this.props.data[op].oldBaseline / 1000,
  114. 2,
  115. true
  116. );
  117. const newValue = getDuration(
  118. this.props.data[op].newBaseline / 1000,
  119. 2,
  120. true
  121. );
  122. const percentage = this.props.data
  123. ? formatPercentage(Math.abs(change))
  124. : '';
  125. const percentageText = change < 0 ? t('up') : t('down');
  126. return (
  127. <StyledLegendWrapper
  128. key={index}
  129. onMouseEnter={() => this.highlight(operationToColorMap[op].index)}
  130. onMouseLeave={() => this.downplay(operationToColorMap[op].index)}
  131. >
  132. <span>
  133. <StyledColorIndicator
  134. color={operationToColorMap[op].color}
  135. size={10}
  136. />
  137. {op}
  138. </span>
  139. <Tooltip
  140. skipWrapper
  141. title={t(
  142. `Total time for %s went %s from %s to %s`,
  143. op,
  144. percentageText,
  145. oldValue,
  146. newValue
  147. )}
  148. >
  149. <SpanOpChange regressed={change < 0}>
  150. {percentageText} {percentage}
  151. </SpanOpChange>
  152. </Tooltip>
  153. </StyledLegendWrapper>
  154. );
  155. })}
  156. </LegendWrapper>
  157. <BaseChart
  158. ref={this.chart}
  159. colors={this.pieChartSliceColors}
  160. // when legend highlights it does NOT pass dataIndex :(
  161. onHighlight={({name}) => {
  162. if (
  163. !this.isInitialSelected ||
  164. !name ||
  165. firstSeries.data[this.selected].name === name
  166. ) {
  167. return;
  168. }
  169. // Unhighlight if not initial "highlight" event and
  170. // if name exists (i.e. not dispatched from cDM) and
  171. // highlighted series name is different than the initially selected series name
  172. this.downplay(this.selected);
  173. this.isInitialSelected = false;
  174. }}
  175. onMouseOver={({dataIndex}) => {
  176. if (!this.isInitialSelected) {
  177. return;
  178. }
  179. if (dataIndex === this.selected) {
  180. return;
  181. }
  182. this.downplay(this.selected);
  183. this.isInitialSelected = false;
  184. }}
  185. {...props}
  186. tooltip={{
  187. formatter: data => {
  188. return [
  189. '<div class="tooltip-series">',
  190. `<div><span class="tooltip-label">${data.marker}<strong>${data.name}</strong></span></div>`,
  191. '</div>',
  192. `<div class="tooltip-footer">${getDuration(
  193. this.props.data[data.name].oldBaseline / 1000,
  194. 2,
  195. true
  196. )} to ${getDuration(
  197. this.props.data[data.name].newBaseline / 1000,
  198. 2,
  199. true
  200. )}</div>`,
  201. '</div>',
  202. '<div class="tooltip-arrow"></div>',
  203. ].join('');
  204. },
  205. }}
  206. series={[
  207. PieSeries({
  208. name: firstSeries.seriesName,
  209. data: firstSeries.data,
  210. avoidLabelOverlap: false,
  211. label: {
  212. position: 'inside',
  213. formatter: params => {
  214. return `${params.name} ${Math.round(Number(params.percent))}%`;
  215. },
  216. show: true,
  217. color: theme.background,
  218. width: 40,
  219. overflow: 'break',
  220. },
  221. emphasis: {
  222. label: {
  223. show: true,
  224. },
  225. },
  226. labelLine: {
  227. show: false,
  228. },
  229. center: ['90', '100'],
  230. radius: ['45%', '85%'],
  231. itemStyle: {
  232. borderColor: theme.background,
  233. borderWidth: 2,
  234. },
  235. }),
  236. ]}
  237. xAxis={null}
  238. yAxis={null}
  239. />
  240. </Wrapper>
  241. );
  242. }
  243. }
  244. const Wrapper = styled('div')`
  245. position: relative;
  246. `;
  247. const LegendWrapper = styled('div')`
  248. position: absolute;
  249. top: 50%;
  250. transform: translateY(-60%);
  251. left: 195px;
  252. z-index: 100;
  253. `;
  254. const StyledLegendWrapper = styled('div')`
  255. display: flex;
  256. justify-content: space-between;
  257. align-items: center;
  258. gap: ${space(3)};
  259. `;
  260. const SpanOpChange = styled('span')<{regressed: boolean}>`
  261. color: ${p => (p.regressed ? p.theme.red300 : p.theme.green300)};
  262. text-decoration-line: underline;
  263. text-decoration-style: dotted;
  264. text-transform: capitalize;
  265. `;
  266. const StyledColorIndicator = styled(CircleIndicator)`
  267. margin-right: ${space(0.5)};
  268. `;
  269. export default withTheme(PieChart);