chart.tsx 19 KB

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