chart.tsx 19 KB

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