chart.tsx 17 KB

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