chart.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import {RefObject, useEffect, useRef, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import {LineSeriesOption} from 'echarts';
  4. import * as echarts from 'echarts/core';
  5. import {
  6. TooltipFormatterCallback,
  7. TopLevelFormatterParams,
  8. YAXisOption,
  9. } from 'echarts/types/dist/shared';
  10. import max from 'lodash/max';
  11. import min from 'lodash/min';
  12. import {AreaChart, AreaChartProps} from 'sentry/components/charts/areaChart';
  13. import {BarChart} from 'sentry/components/charts/barChart';
  14. import BaseChart from 'sentry/components/charts/baseChart';
  15. import ChartZoom from 'sentry/components/charts/chartZoom';
  16. import {getFormatter} from 'sentry/components/charts/components/tooltip';
  17. import {LineChart} from 'sentry/components/charts/lineChart';
  18. import LineSeries from 'sentry/components/charts/series/lineSeries';
  19. import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
  20. import {DateString} from 'sentry/types';
  21. import {EChartClickHandler, ReactEchartsRef, Series} from 'sentry/types/echarts';
  22. import {
  23. axisLabelFormatter,
  24. getDurationUnit,
  25. tooltipFormatter,
  26. } from 'sentry/utils/discover/charts';
  27. import {aggregateOutputType} from 'sentry/utils/discover/fields';
  28. import useRouter from 'sentry/utils/useRouter';
  29. const STARFISH_CHART_GROUP = 'starfish_chart_group';
  30. type Props = {
  31. data: Series[];
  32. end: DateString;
  33. loading: boolean;
  34. start: DateString;
  35. statsPeriod: string | null | undefined;
  36. utc: boolean;
  37. aggregateOutputFormat?: 'number' | 'percentage' | 'duration';
  38. chartColors?: string[];
  39. chartGroup?: string;
  40. definedAxisTicks?: number;
  41. disableXAxis?: boolean;
  42. forwardedRef?: RefObject<ReactEchartsRef>;
  43. grid?: AreaChartProps['grid'];
  44. height?: number;
  45. hideYAxisSplitLine?: boolean;
  46. isBarChart?: boolean;
  47. isLineChart?: boolean;
  48. log?: boolean;
  49. onClick?: EChartClickHandler;
  50. previousData?: Series[];
  51. scatterPlot?: Series[];
  52. showLegend?: boolean;
  53. stacked?: boolean;
  54. throughput?: {count: number; interval: string}[];
  55. };
  56. function computeMax(data: Series[]) {
  57. const valuesDict = data.map(value => value.data.map(point => point.value));
  58. return max(valuesDict.map(max)) as number;
  59. }
  60. // adapted from https://stackoverflow.com/questions/11397239/rounding-up-for-a-graph-maximum
  61. function computeAxisMax(data: Series[]) {
  62. // assumes min is 0
  63. let maxValue = 0;
  64. if (data.length > 2) {
  65. for (let i = 0; i < data.length; i++) {
  66. maxValue += max(data[i].data.map(point => point.value)) as number;
  67. }
  68. } else {
  69. maxValue = computeMax(data);
  70. }
  71. if (maxValue <= 1) {
  72. return 1;
  73. }
  74. const power = Math.log10(maxValue);
  75. const magnitude = min([max([10 ** (power - Math.floor(power)), 0]), 10]) as number;
  76. let scale: number;
  77. if (magnitude <= 2.5) {
  78. scale = 0.2;
  79. } else if (magnitude <= 5) {
  80. scale = 0.5;
  81. } else if (magnitude <= 7.5) {
  82. scale = 1.0;
  83. } else {
  84. scale = 2.0;
  85. }
  86. const step = 10 ** Math.floor(power) * scale;
  87. return Math.round(Math.ceil(maxValue / step) * step);
  88. }
  89. function Chart({
  90. data,
  91. previousData,
  92. statsPeriod,
  93. start,
  94. end,
  95. utc,
  96. loading,
  97. height,
  98. grid,
  99. disableXAxis,
  100. definedAxisTicks,
  101. chartColors,
  102. isBarChart,
  103. isLineChart,
  104. stacked,
  105. log,
  106. hideYAxisSplitLine,
  107. showLegend,
  108. scatterPlot,
  109. throughput,
  110. aggregateOutputFormat,
  111. onClick,
  112. forwardedRef,
  113. chartGroup,
  114. }: Props) {
  115. const router = useRouter();
  116. const theme = useTheme();
  117. const defaultRef = useRef<ReactEchartsRef>(null);
  118. const chartRef = forwardedRef || defaultRef;
  119. const echartsInstance = chartRef?.current?.getEchartsInstance();
  120. if (echartsInstance && !echartsInstance.group) {
  121. echartsInstance.group = chartGroup ?? STARFISH_CHART_GROUP;
  122. }
  123. if (!data || data.length <= 0) {
  124. return null;
  125. }
  126. const colors = chartColors ?? theme.charts.getColorPalette(4);
  127. const durationOnly =
  128. aggregateOutputFormat === 'duration' ||
  129. data.every(value => aggregateOutputType(value.seriesName) === 'duration');
  130. const percentOnly =
  131. aggregateOutputFormat === 'percentage' ||
  132. data.every(value => aggregateOutputType(value.seriesName) === 'percentage');
  133. let dataMax = durationOnly
  134. ? computeAxisMax([...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])])
  135. : percentOnly
  136. ? computeMax(data)
  137. : undefined;
  138. // Fix an issue where max == 1 for duration charts would look funky cause we round
  139. if (dataMax === 1 && durationOnly) {
  140. dataMax += 1;
  141. }
  142. const durationUnit = getDurationUnit(data);
  143. let transformedThroughput: LineSeriesOption[] | undefined = undefined;
  144. const additionalAxis: YAXisOption[] = [];
  145. if (throughput && throughput.length > 1) {
  146. transformedThroughput = [
  147. LineSeries({
  148. name: 'Throughput',
  149. data: throughput.map(({interval, count}) => [interval, count]),
  150. yAxisIndex: 1,
  151. lineStyle: {type: 'dashed', width: 1, opacity: 0.5},
  152. animation: false,
  153. animationThreshold: 1,
  154. animationDuration: 0,
  155. }),
  156. ];
  157. additionalAxis.push({
  158. minInterval: durationUnit,
  159. splitNumber: definedAxisTicks,
  160. max: dataMax,
  161. type: 'value',
  162. axisLabel: {
  163. color: theme.chartLabel,
  164. formatter(value: number) {
  165. return axisLabelFormatter(value, 'number', true);
  166. },
  167. },
  168. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  169. });
  170. }
  171. const yAxes = [
  172. {
  173. minInterval: durationUnit,
  174. splitNumber: definedAxisTicks,
  175. max: dataMax,
  176. type: log ? 'log' : 'value',
  177. axisLabel: {
  178. color: theme.chartLabel,
  179. formatter(value: number) {
  180. return axisLabelFormatter(
  181. value,
  182. aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
  183. undefined,
  184. durationUnit
  185. );
  186. },
  187. },
  188. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  189. },
  190. ...additionalAxis,
  191. ];
  192. const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
  193. params,
  194. asyncTicket
  195. ) => {
  196. // Kinda jank. Get hovered dom elements and check if any of them are the chart
  197. const hoveredEchartElement = Array.from(document.querySelectorAll(':hover')).find(
  198. element => {
  199. return element.classList.contains('echarts-for-react');
  200. }
  201. );
  202. if (hoveredEchartElement === chartRef?.current?.ele) {
  203. // Return undefined to use default formatter
  204. return getFormatter({
  205. isGroupedByDate: true,
  206. showTimeInTooltip: true,
  207. utc,
  208. })(params, asyncTicket);
  209. }
  210. // Return empty string, ie no tooltip
  211. return '';
  212. };
  213. const areaChartProps = {
  214. seriesOptions: {
  215. showSymbol: false,
  216. },
  217. grid,
  218. yAxes,
  219. utc,
  220. legend: showLegend
  221. ? {
  222. top: 0,
  223. right: 10,
  224. }
  225. : undefined,
  226. isGroupedByDate: true,
  227. showTimeInTooltip: true,
  228. tooltip: {
  229. formatter,
  230. trigger: 'axis',
  231. axisPointer: {
  232. type: 'cross',
  233. label: {show: false},
  234. },
  235. valueFormatter: (value, seriesName) => {
  236. return tooltipFormatter(
  237. value,
  238. aggregateOutputFormat ??
  239. aggregateOutputType(data && data.length ? data[0].seriesName : seriesName)
  240. );
  241. },
  242. nameFormatter(value: string) {
  243. return value === 'epm()' ? 'tpm()' : value;
  244. },
  245. },
  246. } as Omit<AreaChartProps, 'series'>;
  247. if (loading) {
  248. if (isLineChart) {
  249. return <LineChart height={height} series={[]} {...areaChartProps} />;
  250. }
  251. if (isBarChart) {
  252. return <BarChart height={height} series={[]} {...areaChartProps} />;
  253. }
  254. return <AreaChart height={height} series={[]} {...areaChartProps} />;
  255. }
  256. const series = data.map((values, _) => ({
  257. ...values,
  258. yAxisIndex: 0,
  259. xAxisIndex: 0,
  260. }));
  261. const xAxis = disableXAxis
  262. ? {
  263. show: false,
  264. axisLabel: {show: true, margin: 0},
  265. axisLine: {show: false},
  266. }
  267. : undefined;
  268. return (
  269. <ChartZoom router={router} period={statsPeriod} start={start} end={end} utc={utc}>
  270. {zoomRenderProps => {
  271. if (isLineChart) {
  272. return (
  273. <BaseChart
  274. {...zoomRenderProps}
  275. ref={chartRef}
  276. height={height}
  277. previousPeriod={previousData}
  278. additionalSeries={transformedThroughput}
  279. xAxis={xAxis}
  280. yAxes={areaChartProps.yAxes}
  281. tooltip={areaChartProps.tooltip}
  282. colors={colors}
  283. grid={grid}
  284. legend={showLegend ? {top: 0, right: 0} : undefined}
  285. onClick={onClick}
  286. series={[
  287. ...series.map(({seriesName, data: seriesData, ...options}) =>
  288. LineSeries({
  289. ...options,
  290. name: seriesName,
  291. data: seriesData?.map(({value, name}) => [name, value]),
  292. animation: false,
  293. animationThreshold: 1,
  294. animationDuration: 0,
  295. })
  296. ),
  297. ...(scatterPlot ?? []).map(({seriesName, data: seriesData, ...options}) =>
  298. ScatterSeries({
  299. ...options,
  300. name: seriesName,
  301. data: seriesData?.map(({value, name}) => [name, value]),
  302. animation: false,
  303. })
  304. ),
  305. ]}
  306. />
  307. );
  308. }
  309. if (isBarChart) {
  310. return (
  311. <BarChart
  312. height={height}
  313. series={series}
  314. xAxis={xAxis}
  315. additionalSeries={transformedThroughput}
  316. yAxes={areaChartProps.yAxes}
  317. tooltip={areaChartProps.tooltip}
  318. colors={colors}
  319. grid={grid}
  320. legend={showLegend ? {top: 0, right: 0} : undefined}
  321. />
  322. );
  323. }
  324. return (
  325. <AreaChart
  326. forwardedRef={chartRef}
  327. height={height}
  328. {...zoomRenderProps}
  329. series={series}
  330. previousPeriod={previousData}
  331. additionalSeries={transformedThroughput}
  332. xAxis={xAxis}
  333. stacked={stacked}
  334. {...areaChartProps}
  335. />
  336. );
  337. }}
  338. </ChartZoom>
  339. );
  340. }
  341. export default Chart;
  342. export function useSynchronizeCharts(deps: boolean[] = []) {
  343. const [synchronized, setSynchronized] = useState<boolean>(false);
  344. useEffect(() => {
  345. if (deps.every(Boolean)) {
  346. echarts.connect(STARFISH_CHART_GROUP);
  347. setSynchronized(true);
  348. }
  349. }, [deps, synchronized]);
  350. }