chart.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. import type {RefObject} from 'react';
  2. import {createContext, useContext, useEffect, useMemo, useReducer, useRef} from 'react';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import type {LegendComponentOption, 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, {type ZoomRenderProps} 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 ReleaseSeries from 'sentry/components/charts/releaseSeries';
  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 {
  32. createIngestionSeries,
  33. getIngestionDelayBucketCount,
  34. } from 'sentry/components/metrics/chart/chart';
  35. import type {Series as MetricSeries} from 'sentry/components/metrics/chart/types';
  36. import {IconWarning} from 'sentry/icons';
  37. import type {
  38. EChartClickHandler,
  39. EChartDataZoomHandler,
  40. EChartEventHandler,
  41. EChartHighlightHandler,
  42. EChartMouseOutHandler,
  43. EChartMouseOverHandler,
  44. ReactEchartsRef,
  45. Series,
  46. } from 'sentry/types/echarts';
  47. import {
  48. axisLabelFormatter,
  49. getDurationUnit,
  50. tooltipFormatter,
  51. } from 'sentry/utils/discover/charts';
  52. import type {AggregationOutputType, RateUnit} from 'sentry/utils/discover/fields';
  53. import {aggregateOutputType} from 'sentry/utils/discover/fields';
  54. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  55. import usePageFilters from 'sentry/utils/usePageFilters';
  56. const STARFISH_CHART_GROUP = 'starfish_chart_group';
  57. type PairOfMetricSeries = [MetricSeries[], MetricSeries[]];
  58. export enum ChartType {
  59. BAR = 0,
  60. LINE = 1,
  61. AREA = 2,
  62. }
  63. export interface ChartRenderingProps {
  64. height: number;
  65. isFullscreen: boolean;
  66. }
  67. export const ChartRenderingContext = createContext<ChartRenderingProps | null>(null);
  68. type Props = {
  69. data: Series[];
  70. loading: boolean;
  71. type: ChartType;
  72. aggregateOutputFormat?: AggregationOutputType;
  73. chartColors?: string[] | readonly string[];
  74. chartGroup?: string;
  75. dataMax?: number;
  76. definedAxisTicks?: number;
  77. disableXAxis?: boolean;
  78. durationUnit?: number;
  79. error?: Error | null;
  80. forwardedRef?: RefObject<ReactEchartsRef>;
  81. grid?: AreaChartProps['grid'];
  82. height?: number;
  83. hideYAxis?: boolean;
  84. hideYAxisSplitLine?: boolean;
  85. legendFormatter?: (name: string) => string;
  86. legendOptions?: LegendComponentOption;
  87. log?: boolean;
  88. markLine?: MarkLineOption;
  89. onClick?: EChartClickHandler;
  90. onDataZoom?: EChartDataZoomHandler;
  91. onHighlight?: EChartHighlightHandler;
  92. onLegendSelectChanged?: EChartEventHandler<{
  93. name: string;
  94. selected: Record<string, boolean>;
  95. type: 'legendselectchanged';
  96. }>;
  97. onMouseOut?: EChartMouseOutHandler;
  98. onMouseOver?: EChartMouseOverHandler;
  99. previousData?: Series[];
  100. rateUnit?: RateUnit;
  101. scatterPlot?: Series[];
  102. showLegend?: boolean;
  103. stacked?: boolean;
  104. throughput?: Array<{count: number; interval: string}>;
  105. tooltipFormatterOptions?: FormatterOptions;
  106. };
  107. function Chart({
  108. data,
  109. dataMax,
  110. previousData,
  111. loading,
  112. height: chartHeight,
  113. grid,
  114. disableXAxis,
  115. definedAxisTicks,
  116. durationUnit,
  117. rateUnit,
  118. chartColors,
  119. type,
  120. stacked,
  121. log,
  122. hideYAxisSplitLine,
  123. showLegend,
  124. scatterPlot,
  125. throughput,
  126. aggregateOutputFormat,
  127. onClick,
  128. onMouseOver,
  129. onMouseOut,
  130. onHighlight,
  131. forwardedRef,
  132. chartGroup,
  133. tooltipFormatterOptions = {},
  134. error,
  135. onLegendSelectChanged,
  136. onDataZoom,
  137. legendOptions,
  138. legendFormatter,
  139. }: Props) {
  140. const theme = useTheme();
  141. const pageFilters = usePageFilters();
  142. const {start, end, period, utc} = pageFilters.selection.datetime;
  143. const {projects, environments} = pageFilters.selection;
  144. const renderingContext = useContext(ChartRenderingContext);
  145. const height = renderingContext?.height ?? chartHeight;
  146. const isLegendVisible = renderingContext?.isFullscreen ?? showLegend;
  147. const defaultRef = useRef<ReactEchartsRef>(null);
  148. const chartRef = forwardedRef || defaultRef;
  149. const echartsInstance = chartRef?.current?.getEchartsInstance?.();
  150. if (echartsInstance && !echartsInstance.group) {
  151. echartsInstance.group = chartGroup ?? STARFISH_CHART_GROUP;
  152. }
  153. const colors = chartColors ?? theme.charts.getColorPalette(4) ?? [];
  154. const durationOnly =
  155. aggregateOutputFormat === 'duration' ||
  156. data.every(value => aggregateOutputType(value.seriesName) === 'duration');
  157. const percentOnly =
  158. aggregateOutputFormat === 'percentage' ||
  159. data.every(value => aggregateOutputType(value.seriesName) === 'percentage');
  160. if (!dataMax) {
  161. dataMax = durationOnly
  162. ? computeAxisMax(
  163. [...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])],
  164. stacked
  165. )
  166. : percentOnly
  167. ? computeMax([...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])])
  168. : undefined;
  169. // Fix an issue where max == 1 for duration charts would look funky cause we round
  170. if (dataMax === 1 && durationOnly) {
  171. dataMax += 1;
  172. }
  173. }
  174. let transformedThroughput: LineSeriesOption[] | undefined = undefined;
  175. const additionalAxis: YAXisOption[] = [];
  176. if (throughput && throughput.length > 1) {
  177. transformedThroughput = [
  178. LineSeries({
  179. name: 'Throughput',
  180. data: throughput.map(({interval, count}) => [interval, count]),
  181. yAxisIndex: 1,
  182. lineStyle: {type: 'dashed', width: 1, opacity: 0.5},
  183. animation: false,
  184. animationThreshold: 1,
  185. animationDuration: 0,
  186. }),
  187. ];
  188. additionalAxis.push({
  189. minInterval: durationUnit ?? getDurationUnit(data),
  190. splitNumber: definedAxisTicks,
  191. max: dataMax,
  192. type: 'value',
  193. axisLabel: {
  194. color: theme.chartLabel,
  195. formatter(value: number) {
  196. return axisLabelFormatter(value, 'number', true);
  197. },
  198. },
  199. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  200. });
  201. }
  202. let series: Series[] = data.map((values, index) => ({
  203. ...values,
  204. yAxisIndex: 0,
  205. xAxisIndex: 0,
  206. id: values.seriesName,
  207. color: colors[index],
  208. }));
  209. let incompleteSeries: Series[] = [];
  210. const bucketSize =
  211. new Date(series[0]?.data[1]?.name!).getTime() -
  212. new Date(series[0]?.data[0]?.name!).getTime();
  213. const lastBucketTimestamp = new Date(
  214. series[0]?.data?.[series[0]?.data?.length - 1]?.name!
  215. ).getTime();
  216. const ingestionBuckets = useMemo(() => {
  217. if (isNaN(bucketSize) || isNaN(lastBucketTimestamp)) {
  218. return 1;
  219. }
  220. return getIngestionDelayBucketCount(bucketSize, lastBucketTimestamp);
  221. }, [bucketSize, lastBucketTimestamp]);
  222. // TODO: Support bar charts
  223. if (type === ChartType.LINE || type === ChartType.AREA) {
  224. const metricChartType =
  225. type === ChartType.AREA ? MetricDisplayType.AREA : MetricDisplayType.LINE;
  226. const seriesToShow = series.map(serie => {
  227. const ingestionSeries = createIngestionSeries(
  228. serie as MetricSeries,
  229. ingestionBuckets,
  230. metricChartType
  231. );
  232. // this helper causes all the incomplete series to stack, here we remove the stacking
  233. if (!stacked) {
  234. for (const s of ingestionSeries) {
  235. delete s.stack;
  236. }
  237. }
  238. return ingestionSeries;
  239. });
  240. [series, incompleteSeries] = seriesToShow.reduce<PairOfMetricSeries>(
  241. (acc: PairOfMetricSeries, serie: MetricSeries[], index: number) => {
  242. const [trimmed, incomplete] = acc;
  243. const {markLine: _, ...incompleteSerie} = serie[1] ?? {};
  244. return [
  245. [...trimmed, {...serie[0]!, color: colors[index]!}],
  246. [
  247. ...incomplete,
  248. ...(Object.keys(incompleteSerie).length > 0 ? [incompleteSerie] : []),
  249. ],
  250. ] as PairOfMetricSeries;
  251. },
  252. [[], []] as PairOfMetricSeries
  253. );
  254. }
  255. const yAxes = [
  256. {
  257. minInterval: durationUnit ?? getDurationUnit(data),
  258. splitNumber: definedAxisTicks,
  259. max: dataMax,
  260. type: log ? 'log' : 'value',
  261. axisLabel: {
  262. color: theme.chartLabel,
  263. formatter(value: number) {
  264. return axisLabelFormatter(
  265. value,
  266. aggregateOutputFormat ?? aggregateOutputType(data[0]!.seriesName),
  267. true,
  268. durationUnit ?? getDurationUnit(data),
  269. rateUnit
  270. );
  271. },
  272. },
  273. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  274. },
  275. ...additionalAxis,
  276. ];
  277. const xAxis: XAXisOption = disableXAxis
  278. ? {
  279. show: false,
  280. axisLabel: {show: true, margin: 0},
  281. axisLine: {show: false},
  282. }
  283. : {};
  284. const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
  285. params,
  286. asyncTicket
  287. ) => {
  288. // Only show the tooltip if the current chart is hovered
  289. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  290. if (!isChartHovered(chartRef?.current)) {
  291. return '';
  292. }
  293. let deDupedParams = params;
  294. if (Array.isArray(params)) {
  295. const uniqueSeries = new Set<string>();
  296. deDupedParams = params.filter(param => {
  297. // Filter null values from tooltip
  298. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  299. if (param.value[1] === null) {
  300. return false;
  301. }
  302. // @ts-expect-error TS(2345): Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message
  303. if (uniqueSeries.has(param.seriesName)) {
  304. return false;
  305. }
  306. // @ts-expect-error TS(2345): Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message
  307. uniqueSeries.add(param.seriesName);
  308. return true;
  309. });
  310. }
  311. // Return undefined to use default formatter
  312. return getFormatter({
  313. isGroupedByDate: true,
  314. showTimeInTooltip: true,
  315. truncate: true,
  316. utc: utc ?? false,
  317. valueFormatter: (value, seriesName) => {
  318. return tooltipFormatter(
  319. value,
  320. aggregateOutputFormat ?? aggregateOutputType(seriesName)
  321. );
  322. },
  323. ...tooltipFormatterOptions,
  324. })(deDupedParams, asyncTicket);
  325. };
  326. const legend = isLegendVisible
  327. ? {
  328. top: 0,
  329. right: 10,
  330. truncate: true,
  331. formatter: legendFormatter,
  332. ...legendOptions,
  333. }
  334. : undefined;
  335. const areaChartProps = {
  336. seriesOptions: {
  337. showSymbol: false,
  338. },
  339. grid,
  340. yAxes,
  341. utc,
  342. legend,
  343. isGroupedByDate: true,
  344. showTimeInTooltip: true,
  345. tooltip: {
  346. formatter,
  347. trigger: 'axis',
  348. axisPointer: {
  349. type: 'cross',
  350. label: {show: false},
  351. },
  352. valueFormatter: (value, seriesName) => {
  353. return tooltipFormatter(
  354. value,
  355. aggregateOutputFormat ??
  356. aggregateOutputType(data?.length ? data[0]!.seriesName : seriesName)
  357. );
  358. },
  359. nameFormatter(value: string) {
  360. return value === 'epm()' ? 'tpm()' : value;
  361. },
  362. },
  363. } as Omit<AreaChartProps, 'series'>;
  364. function getChartWithSeries(
  365. zoomRenderProps: ZoomRenderProps,
  366. releaseSeries?: Series[]
  367. ) {
  368. if (error) {
  369. return (
  370. <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
  371. <IconWarning color="gray300" size="lg" />
  372. </ErrorPanel>
  373. );
  374. }
  375. if (type === ChartType.LINE) {
  376. return (
  377. <BaseChart
  378. {...zoomRenderProps}
  379. ref={chartRef}
  380. height={height}
  381. previousPeriod={previousData}
  382. additionalSeries={transformedThroughput}
  383. xAxis={xAxis}
  384. yAxes={areaChartProps.yAxes}
  385. tooltip={areaChartProps.tooltip}
  386. colors={colors}
  387. grid={grid}
  388. legend={legend}
  389. onClick={onClick}
  390. onMouseOut={onMouseOut}
  391. onMouseOver={onMouseOver}
  392. onHighlight={onHighlight}
  393. series={[
  394. ...series.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. ...(scatterPlot ?? []).map(({seriesName, data: seriesData, ...options}) =>
  405. ScatterSeries({
  406. ...options,
  407. name: seriesName,
  408. data: seriesData?.map(({value, name}) => [name, value]),
  409. animation: false,
  410. })
  411. ),
  412. ...incompleteSeries.map(({seriesName, data: seriesData, ...options}) =>
  413. LineSeries({
  414. ...options,
  415. name: seriesName,
  416. data: seriesData?.map(({value, name}) => [name, value]),
  417. animation: false,
  418. animationThreshold: 1,
  419. animationDuration: 0,
  420. })
  421. ),
  422. ...(releaseSeries ?? []).map(({seriesName, data: seriesData, ...options}) =>
  423. LineSeries({
  424. ...options,
  425. name: seriesName,
  426. data: seriesData?.map(({value, name}) => [name, value]),
  427. animation: false,
  428. animationThreshold: 1,
  429. animationDuration: 0,
  430. })
  431. ),
  432. ]}
  433. />
  434. );
  435. }
  436. if (type === ChartType.BAR) {
  437. return (
  438. <BarChart
  439. {...zoomRenderProps}
  440. height={height}
  441. series={series}
  442. xAxis={xAxis}
  443. yAxis={{
  444. minInterval: durationUnit ?? getDurationUnit(data),
  445. splitNumber: definedAxisTicks,
  446. max: dataMax,
  447. axisLabel: {
  448. color: theme.chartLabel,
  449. formatter(value: number) {
  450. return axisLabelFormatter(
  451. value,
  452. aggregateOutputFormat ?? aggregateOutputType(data[0]!.seriesName),
  453. true,
  454. durationUnit ?? getDurationUnit(data),
  455. rateUnit
  456. );
  457. },
  458. },
  459. }}
  460. tooltip={{
  461. valueFormatter: (value, seriesName) => {
  462. return tooltipFormatter(
  463. value,
  464. aggregateOutputFormat ??
  465. aggregateOutputType(data?.length ? data[0]!.seriesName : seriesName)
  466. );
  467. },
  468. }}
  469. colors={colors}
  470. grid={grid}
  471. legend={legend}
  472. onClick={onClick}
  473. />
  474. );
  475. }
  476. return (
  477. <AreaChart
  478. forwardedRef={chartRef}
  479. height={height}
  480. {...zoomRenderProps}
  481. series={[...series, ...incompleteSeries, ...(releaseSeries ?? [])]}
  482. previousPeriod={previousData}
  483. additionalSeries={transformedThroughput}
  484. xAxis={xAxis}
  485. stacked={stacked}
  486. colors={colors}
  487. onClick={onClick}
  488. {...areaChartProps}
  489. onLegendSelectChanged={onLegendSelectChanged}
  490. />
  491. );
  492. }
  493. function getChart() {
  494. if (error) {
  495. return (
  496. <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
  497. <IconWarning color="gray300" size="lg" />
  498. </ErrorPanel>
  499. );
  500. }
  501. // add top-padding to the chart in full screen so that the legend
  502. // and graph do not overlap
  503. if (renderingContext?.isFullscreen) {
  504. grid = {...grid, top: '20px'};
  505. }
  506. // overlay additional series data such as releases and issues on top of the original insights chart
  507. return (
  508. <ChartZoom
  509. saveOnZoom
  510. period={period}
  511. start={start}
  512. end={end}
  513. utc={utc}
  514. onDataZoom={onDataZoom}
  515. >
  516. {zoomRenderProps =>
  517. renderingContext?.isFullscreen ? (
  518. <ReleaseSeries
  519. start={start}
  520. end={end}
  521. queryExtra={undefined}
  522. period={period}
  523. utc={utc}
  524. projects={projects}
  525. environments={environments}
  526. >
  527. {({releaseSeries}) => {
  528. return getChartWithSeries(zoomRenderProps, releaseSeries);
  529. }}
  530. </ReleaseSeries>
  531. ) : (
  532. getChartWithSeries(zoomRenderProps)
  533. )
  534. }
  535. </ChartZoom>
  536. );
  537. }
  538. return (
  539. <TransitionChart
  540. loading={loading}
  541. reloading={loading}
  542. height={height ? `${height}px` : undefined}
  543. >
  544. <LoadingScreen loading={loading} />
  545. {getChart()}
  546. </TransitionChart>
  547. );
  548. }
  549. export default Chart;
  550. function computeMax(data: Series[]) {
  551. const valuesDict = data.map(value => value.data.map(point => point.value));
  552. return max(valuesDict.map(max)) as number;
  553. }
  554. // adapted from https://stackoverflow.com/questions/11397239/rounding-up-for-a-graph-maximum
  555. export function computeAxisMax(data: Series[], stacked?: boolean) {
  556. // assumes min is 0
  557. let maxValue = 0;
  558. if (data.length > 1 && stacked) {
  559. for (let i = 0; i < data.length; i++) {
  560. maxValue += max(data[i]!.data.map(point => point.value)) as number;
  561. }
  562. } else {
  563. maxValue = computeMax(data);
  564. }
  565. if (maxValue <= 1) {
  566. return 1;
  567. }
  568. const power = Math.log10(maxValue);
  569. const magnitude = min([max([10 ** (power - Math.floor(power)), 0]), 10]) as number;
  570. let scale: number;
  571. if (magnitude <= 2.5) {
  572. scale = 0.2;
  573. } else if (magnitude <= 5) {
  574. scale = 0.5;
  575. } else if (magnitude <= 7.5) {
  576. scale = 1.0;
  577. } else {
  578. scale = 2.0;
  579. }
  580. const step = 10 ** Math.floor(power) * scale;
  581. return Math.ceil(Math.ceil(maxValue / step) * step);
  582. }
  583. export function useSynchronizeCharts(
  584. charts: number,
  585. ready: boolean,
  586. group: string = STARFISH_CHART_GROUP
  587. ) {
  588. // Tries to connect all the charts under the same group so the cursor is shared.
  589. const [, forceUpdate] = useReducer(x => x + 1, 0);
  590. useEffect(() => {
  591. if (charts && ready) {
  592. echarts?.connect?.(group);
  593. // need to force a re-render otherwise only the currently visible charts
  594. // in the group will end up connected
  595. forceUpdate();
  596. }
  597. }, [
  598. charts, // this re-connects when new charts are added/removed
  599. ready, // this waits until the chart data has loaded before attempting to connect
  600. group,
  601. ]);
  602. }
  603. const StyledTransparentLoadingMask = styled((props: any) => (
  604. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  605. ))`
  606. display: flex;
  607. justify-content: center;
  608. align-items: center;
  609. `;
  610. export function LoadingScreen({loading}: {loading: boolean}) {
  611. if (!loading) {
  612. return null;
  613. }
  614. return (
  615. <StyledTransparentLoadingMask visible={loading}>
  616. <LoadingIndicator mini />
  617. </StyledTransparentLoadingMask>
  618. );
  619. }