chart.tsx 12 KB

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