chart.tsx 17 KB

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