chart.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. import type {RefObject} from 'react';
  2. import {useEffect, useRef, useState} from 'react';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import type {LineSeriesOption} from 'echarts';
  6. import * as echarts from 'echarts/core';
  7. import type {
  8. MarkLineOption,
  9. TooltipFormatterCallback,
  10. TopLevelFormatterParams,
  11. XAXisOption,
  12. YAXisOption,
  13. } from 'echarts/types/dist/shared';
  14. import max from 'lodash/max';
  15. import min from 'lodash/min';
  16. import moment from 'moment';
  17. import type {AreaChartProps} from 'sentry/components/charts/areaChart';
  18. import {AreaChart} from 'sentry/components/charts/areaChart';
  19. import {BarChart} from 'sentry/components/charts/barChart';
  20. import BaseChart from 'sentry/components/charts/baseChart';
  21. import ChartZoom from 'sentry/components/charts/chartZoom';
  22. import type {FormatterOptions} from 'sentry/components/charts/components/tooltip';
  23. import {getFormatter} from 'sentry/components/charts/components/tooltip';
  24. import ErrorPanel from 'sentry/components/charts/errorPanel';
  25. import LineSeries from 'sentry/components/charts/series/lineSeries';
  26. import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
  27. import TransitionChart from 'sentry/components/charts/transitionChart';
  28. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  29. import {isChartHovered} from 'sentry/components/charts/utils';
  30. import LoadingIndicator from 'sentry/components/loadingIndicator';
  31. import {IconWarning} from 'sentry/icons';
  32. import type {
  33. EChartClickHandler,
  34. EChartDataZoomHandler,
  35. EChartEventHandler,
  36. EChartHighlightHandler,
  37. EChartMouseOutHandler,
  38. EChartMouseOverHandler,
  39. ReactEchartsRef,
  40. Series,
  41. } from 'sentry/types/echarts';
  42. import {
  43. axisLabelFormatter,
  44. getDurationUnit,
  45. tooltipFormatter,
  46. } from 'sentry/utils/discover/charts';
  47. import type {AggregationOutputType, RateUnit} from 'sentry/utils/discover/fields';
  48. import {aggregateOutputType} from 'sentry/utils/discover/fields';
  49. import usePageFilters from 'sentry/utils/usePageFilters';
  50. import useRouter from 'sentry/utils/useRouter';
  51. const STARFISH_CHART_GROUP = 'starfish_chart_group';
  52. export enum ChartType {
  53. BAR = 0,
  54. LINE = 1,
  55. AREA = 2,
  56. }
  57. type Props = {
  58. data: Series[];
  59. loading: boolean;
  60. type: ChartType;
  61. aggregateOutputFormat?: AggregationOutputType;
  62. chartColors?: string[];
  63. chartGroup?: string;
  64. dataMax?: number;
  65. definedAxisTicks?: number;
  66. disableXAxis?: boolean;
  67. durationUnit?: number;
  68. error?: Error | null;
  69. forwardedRef?: RefObject<ReactEchartsRef>;
  70. grid?: AreaChartProps['grid'];
  71. height?: number;
  72. hideYAxis?: boolean;
  73. hideYAxisSplitLine?: boolean;
  74. legendFormatter?: (name: string) => string;
  75. log?: boolean;
  76. markLine?: MarkLineOption;
  77. onClick?: EChartClickHandler;
  78. onDataZoom?: EChartDataZoomHandler;
  79. onHighlight?: EChartHighlightHandler;
  80. onLegendSelectChanged?: EChartEventHandler<{
  81. name: string;
  82. selected: Record<string, boolean>;
  83. type: 'legendselectchanged';
  84. }>;
  85. onMouseOut?: EChartMouseOutHandler;
  86. onMouseOver?: EChartMouseOverHandler;
  87. preserveIncompletePoints?: boolean;
  88. previousData?: Series[];
  89. rateUnit?: RateUnit;
  90. scatterPlot?: Series[];
  91. showLegend?: boolean;
  92. stacked?: boolean;
  93. throughput?: {count: number; interval: string}[];
  94. tooltipFormatterOptions?: FormatterOptions;
  95. };
  96. function Chart({
  97. data,
  98. dataMax,
  99. previousData,
  100. loading,
  101. height,
  102. grid,
  103. disableXAxis,
  104. definedAxisTicks,
  105. durationUnit,
  106. rateUnit,
  107. chartColors,
  108. type,
  109. stacked,
  110. log,
  111. hideYAxisSplitLine,
  112. showLegend,
  113. scatterPlot,
  114. throughput,
  115. aggregateOutputFormat,
  116. onClick,
  117. onMouseOver,
  118. onMouseOut,
  119. onHighlight,
  120. forwardedRef,
  121. chartGroup,
  122. tooltipFormatterOptions = {},
  123. error,
  124. onLegendSelectChanged,
  125. onDataZoom,
  126. legendFormatter,
  127. preserveIncompletePoints,
  128. }: Props) {
  129. const router = useRouter();
  130. const theme = useTheme();
  131. const pageFilters = usePageFilters();
  132. const {start, end, period, utc} = pageFilters.selection.datetime;
  133. const defaultRef = useRef<ReactEchartsRef>(null);
  134. const chartRef = forwardedRef || defaultRef;
  135. const echartsInstance = chartRef?.current?.getEchartsInstance?.();
  136. if (echartsInstance && !echartsInstance.group) {
  137. echartsInstance.group = chartGroup ?? STARFISH_CHART_GROUP;
  138. }
  139. const colors = chartColors ?? theme.charts.getColorPalette(4);
  140. const durationOnly =
  141. aggregateOutputFormat === 'duration' ||
  142. data.every(value => aggregateOutputType(value.seriesName) === 'duration');
  143. const percentOnly =
  144. aggregateOutputFormat === 'percentage' ||
  145. data.every(value => aggregateOutputType(value.seriesName) === 'percentage');
  146. if (!dataMax) {
  147. dataMax = durationOnly
  148. ? computeAxisMax(
  149. [...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])],
  150. stacked
  151. )
  152. : percentOnly
  153. ? computeMax([...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])])
  154. : undefined;
  155. // Fix an issue where max == 1 for duration charts would look funky cause we round
  156. if (dataMax === 1 && durationOnly) {
  157. dataMax += 1;
  158. }
  159. }
  160. let transformedThroughput: LineSeriesOption[] | undefined = undefined;
  161. const additionalAxis: YAXisOption[] = [];
  162. if (throughput && throughput.length > 1) {
  163. transformedThroughput = [
  164. LineSeries({
  165. name: 'Throughput',
  166. data: throughput.map(({interval, count}) => [interval, count]),
  167. yAxisIndex: 1,
  168. lineStyle: {type: 'dashed', width: 1, opacity: 0.5},
  169. animation: false,
  170. animationThreshold: 1,
  171. animationDuration: 0,
  172. }),
  173. ];
  174. additionalAxis.push({
  175. minInterval: durationUnit ?? getDurationUnit(data),
  176. splitNumber: definedAxisTicks,
  177. max: dataMax,
  178. type: 'value',
  179. axisLabel: {
  180. color: theme.chartLabel,
  181. formatter(value: number) {
  182. return axisLabelFormatter(value, 'number', true);
  183. },
  184. },
  185. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  186. });
  187. }
  188. const yAxes = [
  189. {
  190. minInterval: durationUnit ?? getDurationUnit(data),
  191. splitNumber: definedAxisTicks,
  192. max: dataMax,
  193. type: log ? 'log' : 'value',
  194. axisLabel: {
  195. color: theme.chartLabel,
  196. formatter(value: number) {
  197. return axisLabelFormatter(
  198. value,
  199. aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
  200. undefined,
  201. durationUnit ?? getDurationUnit(data),
  202. rateUnit
  203. );
  204. },
  205. },
  206. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  207. },
  208. ...additionalAxis,
  209. ];
  210. const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
  211. params,
  212. asyncTicket
  213. ) => {
  214. if (isChartHovered(chartRef?.current)) {
  215. // Return undefined to use default formatter
  216. return getFormatter({
  217. isGroupedByDate: true,
  218. showTimeInTooltip: true,
  219. utc: utc ?? false,
  220. valueFormatter: (value, seriesName) => {
  221. return tooltipFormatter(
  222. value,
  223. aggregateOutputFormat ?? aggregateOutputType(seriesName)
  224. );
  225. },
  226. ...tooltipFormatterOptions,
  227. })(params, asyncTicket);
  228. }
  229. // Return empty string, ie no tooltip
  230. return '';
  231. };
  232. const areaChartProps = {
  233. seriesOptions: {
  234. showSymbol: false,
  235. },
  236. grid,
  237. yAxes,
  238. utc,
  239. legend: showLegend
  240. ? {
  241. top: 0,
  242. right: 10,
  243. ...(legendFormatter ? {formatter: legendFormatter} : {}),
  244. }
  245. : undefined,
  246. isGroupedByDate: true,
  247. showTimeInTooltip: true,
  248. tooltip: {
  249. formatter,
  250. trigger: 'axis',
  251. axisPointer: {
  252. type: 'cross',
  253. label: {show: false},
  254. },
  255. valueFormatter: (value, seriesName) => {
  256. return tooltipFormatter(
  257. value,
  258. aggregateOutputFormat ??
  259. aggregateOutputType(data?.length ? data[0].seriesName : seriesName)
  260. );
  261. },
  262. nameFormatter(value: string) {
  263. return value === 'epm()' ? 'tpm()' : value;
  264. },
  265. },
  266. } as Omit<AreaChartProps, 'series'>;
  267. const series: Series[] = data.map((values, _) => ({
  268. ...values,
  269. yAxisIndex: 0,
  270. xAxisIndex: 0,
  271. }));
  272. // Trims off the last data point because it's incomplete
  273. const trimmedSeries =
  274. !preserveIncompletePoints && period && !start && !end
  275. ? series.map(serie => {
  276. return {
  277. ...serie,
  278. data: serie.data.slice(0, -1),
  279. };
  280. })
  281. : series;
  282. const xAxis: XAXisOption = disableXAxis
  283. ? {
  284. show: false,
  285. axisLabel: {show: true, margin: 0},
  286. axisLine: {show: false},
  287. }
  288. : {
  289. min: moment(trimmedSeries[0]?.data[0]?.name).unix() * 1000,
  290. max:
  291. moment(trimmedSeries[0]?.data[trimmedSeries[0].data.length - 1]?.name).unix() *
  292. 1000,
  293. };
  294. function getChart() {
  295. if (error) {
  296. return (
  297. <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
  298. <IconWarning color="gray300" size="lg" />
  299. </ErrorPanel>
  300. );
  301. }
  302. return (
  303. <ChartZoom
  304. router={router}
  305. saveOnZoom
  306. period={period}
  307. start={start}
  308. end={end}
  309. utc={utc}
  310. onDataZoom={onDataZoom}
  311. >
  312. {zoomRenderProps => {
  313. if (type === ChartType.LINE) {
  314. return (
  315. <BaseChart
  316. {...zoomRenderProps}
  317. ref={chartRef}
  318. height={height}
  319. previousPeriod={previousData}
  320. additionalSeries={transformedThroughput}
  321. xAxis={xAxis}
  322. yAxes={areaChartProps.yAxes}
  323. tooltip={areaChartProps.tooltip}
  324. colors={colors}
  325. grid={grid}
  326. legend={
  327. showLegend ? {top: 0, right: 10, formatter: legendFormatter} : undefined
  328. }
  329. onClick={onClick}
  330. onMouseOut={onMouseOut}
  331. onMouseOver={onMouseOver}
  332. onHighlight={onHighlight}
  333. series={[
  334. ...trimmedSeries.map(({seriesName, data: seriesData, ...options}) =>
  335. LineSeries({
  336. ...options,
  337. name: seriesName,
  338. data: seriesData?.map(({value, name}) => [name, value]),
  339. animation: false,
  340. animationThreshold: 1,
  341. animationDuration: 0,
  342. })
  343. ),
  344. ...(scatterPlot ?? []).map(
  345. ({seriesName, data: seriesData, ...options}) =>
  346. ScatterSeries({
  347. ...options,
  348. name: seriesName,
  349. data: seriesData?.map(({value, name}) => [name, value]),
  350. animation: false,
  351. })
  352. ),
  353. ]}
  354. />
  355. );
  356. }
  357. if (type === ChartType.BAR) {
  358. return (
  359. <BarChart
  360. height={height}
  361. series={trimmedSeries}
  362. xAxis={{
  363. type: 'category',
  364. axisTick: {show: true},
  365. truncate: Infinity, // Show axis labels
  366. axisLabel: {
  367. interval: 0, // Show _all_ axis labels
  368. },
  369. }}
  370. yAxis={{
  371. minInterval: durationUnit ?? getDurationUnit(data),
  372. splitNumber: definedAxisTicks,
  373. max: dataMax,
  374. axisLabel: {
  375. color: theme.chartLabel,
  376. formatter(value: number) {
  377. return axisLabelFormatter(
  378. value,
  379. aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
  380. undefined,
  381. durationUnit ?? getDurationUnit(data),
  382. rateUnit
  383. );
  384. },
  385. },
  386. }}
  387. tooltip={{
  388. valueFormatter: (value, seriesName) => {
  389. return tooltipFormatter(
  390. value,
  391. aggregateOutputFormat ??
  392. aggregateOutputType(
  393. data?.length ? data[0].seriesName : seriesName
  394. )
  395. );
  396. },
  397. }}
  398. colors={colors}
  399. grid={grid}
  400. legend={showLegend ? {top: 0, right: 10} : undefined}
  401. onClick={onClick}
  402. />
  403. );
  404. }
  405. return (
  406. <AreaChart
  407. forwardedRef={chartRef}
  408. height={height}
  409. {...zoomRenderProps}
  410. series={trimmedSeries}
  411. previousPeriod={previousData}
  412. additionalSeries={transformedThroughput}
  413. xAxis={xAxis}
  414. stacked={stacked}
  415. colors={colors}
  416. onClick={onClick}
  417. {...areaChartProps}
  418. onLegendSelectChanged={onLegendSelectChanged}
  419. />
  420. );
  421. }}
  422. </ChartZoom>
  423. );
  424. }
  425. return (
  426. <TransitionChart
  427. loading={loading}
  428. reloading={loading}
  429. height={height ? `${height}px` : undefined}
  430. >
  431. <LoadingScreen loading={loading} />
  432. {getChart()}
  433. </TransitionChart>
  434. );
  435. }
  436. export default Chart;
  437. function computeMax(data: Series[]) {
  438. const valuesDict = data.map(value => value.data.map(point => point.value));
  439. return max(valuesDict.map(max)) as number;
  440. }
  441. // adapted from https://stackoverflow.com/questions/11397239/rounding-up-for-a-graph-maximum
  442. export function computeAxisMax(data: Series[], stacked?: boolean) {
  443. // assumes min is 0
  444. let maxValue = 0;
  445. if (data.length > 1 && stacked) {
  446. for (let i = 0; i < data.length; i++) {
  447. maxValue += max(data[i].data.map(point => point.value)) as number;
  448. }
  449. } else {
  450. maxValue = computeMax(data);
  451. }
  452. if (maxValue <= 1) {
  453. return 1;
  454. }
  455. const power = Math.log10(maxValue);
  456. const magnitude = min([max([10 ** (power - Math.floor(power)), 0]), 10]) as number;
  457. let scale: number;
  458. if (magnitude <= 2.5) {
  459. scale = 0.2;
  460. } else if (magnitude <= 5) {
  461. scale = 0.5;
  462. } else if (magnitude <= 7.5) {
  463. scale = 1.0;
  464. } else {
  465. scale = 2.0;
  466. }
  467. const step = 10 ** Math.floor(power) * scale;
  468. return Math.ceil(Math.ceil(maxValue / step) * step);
  469. }
  470. export function useSynchronizeCharts(deps: boolean[] = []) {
  471. const [synchronized, setSynchronized] = useState<boolean>(false);
  472. useEffect(() => {
  473. if (deps.every(Boolean)) {
  474. echarts?.connect?.(STARFISH_CHART_GROUP);
  475. setSynchronized(true);
  476. }
  477. }, [deps, synchronized]);
  478. }
  479. const StyledTransparentLoadingMask = styled(props => (
  480. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  481. ))`
  482. display: flex;
  483. justify-content: center;
  484. align-items: center;
  485. `;
  486. export function LoadingScreen({loading}: {loading: boolean}) {
  487. if (!loading) {
  488. return null;
  489. }
  490. return (
  491. <StyledTransparentLoadingMask visible={loading}>
  492. <LoadingIndicator mini />
  493. </StyledTransparentLoadingMask>
  494. );
  495. }