chart.tsx 11 KB

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