chart.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  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)
  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. ...tooltipFormatterOptions,
  224. })(params, asyncTicket);
  225. }
  226. // Return empty string, ie no tooltip
  227. return '';
  228. };
  229. const areaChartProps = {
  230. seriesOptions: {
  231. showSymbol: false,
  232. },
  233. grid,
  234. yAxes,
  235. utc,
  236. legend: showLegend
  237. ? {
  238. top: 0,
  239. right: 10,
  240. }
  241. : undefined,
  242. isGroupedByDate: true,
  243. showTimeInTooltip: true,
  244. tooltip: {
  245. formatter,
  246. trigger: 'axis',
  247. axisPointer: {
  248. type: 'cross',
  249. label: {show: false},
  250. },
  251. valueFormatter: (value, seriesName) => {
  252. return tooltipFormatter(
  253. value,
  254. aggregateOutputFormat ??
  255. aggregateOutputType(data && data.length ? data[0].seriesName : seriesName)
  256. );
  257. },
  258. nameFormatter(value: string) {
  259. return value === 'epm()' ? 'tpm()' : value;
  260. },
  261. },
  262. } as Omit<AreaChartProps, 'series'>;
  263. if (loading) {
  264. if (isLineChart) {
  265. return <LineChart height={height} series={[]} {...areaChartProps} />;
  266. }
  267. if (isBarChart) {
  268. return <BarChart height={height} series={[]} {...areaChartProps} />;
  269. }
  270. return <AreaChart height={height} series={[]} {...areaChartProps} />;
  271. }
  272. const series: Series[] = data.map((values, _) => ({
  273. ...values,
  274. yAxisIndex: 0,
  275. xAxisIndex: 0,
  276. }));
  277. const xAxisInterval = getXAxisInterval(startTime, endTime);
  278. const xAxis: XAXisOption = disableXAxis
  279. ? {
  280. show: false,
  281. axisLabel: {show: true, margin: 0},
  282. axisLine: {show: false},
  283. }
  284. : {
  285. type: 'time',
  286. maxInterval: xAxisInterval,
  287. axisLabel: {
  288. formatter: function (value: number) {
  289. if (endTime.diff(startTime, 'days') > 30) {
  290. return moment(value).format('MMMM DD');
  291. }
  292. if (startTime.isSame(endTime, 'day')) {
  293. return moment(value).format('HH:mm');
  294. }
  295. return moment(value).format('MMMM DD HH:mm');
  296. },
  297. },
  298. };
  299. return (
  300. <ChartZoom router={router} period={statsPeriod} start={start} end={end} utc={utc}>
  301. {zoomRenderProps => {
  302. if (isLineChart) {
  303. return (
  304. <BaseChart
  305. {...zoomRenderProps}
  306. ref={chartRef}
  307. height={height}
  308. previousPeriod={previousData}
  309. additionalSeries={transformedThroughput}
  310. xAxis={xAxis}
  311. yAxes={areaChartProps.yAxes}
  312. tooltip={areaChartProps.tooltip}
  313. colors={colors}
  314. grid={grid}
  315. legend={showLegend ? {top: 0, right: 0} : undefined}
  316. onClick={onClick}
  317. series={[
  318. ...series.map(({seriesName, data: seriesData, ...options}) =>
  319. LineSeries({
  320. ...options,
  321. name: seriesName,
  322. data: seriesData?.map(({value, name}) => [name, value]),
  323. animation: false,
  324. animationThreshold: 1,
  325. animationDuration: 0,
  326. })
  327. ),
  328. ...(scatterPlot ?? []).map(({seriesName, data: seriesData, ...options}) =>
  329. ScatterSeries({
  330. ...options,
  331. name: seriesName,
  332. data: seriesData?.map(({value, name}) => [name, value]),
  333. animation: false,
  334. })
  335. ),
  336. ]}
  337. />
  338. );
  339. }
  340. if (isBarChart) {
  341. return (
  342. <BarChart
  343. height={height}
  344. series={series}
  345. xAxis={xAxis}
  346. additionalSeries={transformedThroughput}
  347. yAxes={areaChartProps.yAxes}
  348. tooltip={areaChartProps.tooltip}
  349. colors={colors}
  350. grid={grid}
  351. legend={showLegend ? {top: 0, right: 0} : undefined}
  352. />
  353. );
  354. }
  355. return (
  356. <AreaChart
  357. forwardedRef={chartRef}
  358. height={height}
  359. {...zoomRenderProps}
  360. series={series}
  361. previousPeriod={previousData}
  362. additionalSeries={transformedThroughput}
  363. xAxis={xAxis}
  364. stacked={stacked}
  365. {...areaChartProps}
  366. />
  367. );
  368. }}
  369. </ChartZoom>
  370. );
  371. }
  372. export default Chart;
  373. export function useSynchronizeCharts(deps: boolean[] = []) {
  374. const [synchronized, setSynchronized] = useState<boolean>(false);
  375. useEffect(() => {
  376. if (deps.every(Boolean)) {
  377. echarts.connect(STARFISH_CHART_GROUP);
  378. setSynchronized(true);
  379. }
  380. }, [deps, synchronized]);
  381. }
  382. const getXAxisInterval = (startTime: moment.Moment, endTime: moment.Moment) => {
  383. const dateRange = endTime.diff(startTime);
  384. if (dateRange >= 30 * DAY) {
  385. return 7 * DAY;
  386. }
  387. if (dateRange >= 3 * DAY) {
  388. return DAY;
  389. }
  390. if (dateRange >= 1 * DAY) {
  391. return 12 * HOUR;
  392. }
  393. return HOUR;
  394. };