chart.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  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 area and 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. createIngestionSeries(serie as MetricSeries, ingestionBuckets, metricChartType)
  229. );
  230. [series, incompleteSeries] = seriesToShow.reduce(
  231. (acc, serie, index) => {
  232. const [trimmed, incomplete] = acc;
  233. const {markLine: _, ...incompleteSerie} = serie[1] ?? {};
  234. return [
  235. [...trimmed, {...serie[0], color: colors[index]}],
  236. [
  237. ...incomplete,
  238. ...(Object.keys(incompleteSerie).length > 0 ? [incompleteSerie] : []),
  239. ],
  240. ];
  241. },
  242. [[], []] as [MetricSeries[], MetricSeries[]]
  243. );
  244. }
  245. const yAxes = [
  246. {
  247. minInterval: durationUnit ?? getDurationUnit(data),
  248. splitNumber: definedAxisTicks,
  249. max: dataMax,
  250. type: log ? 'log' : 'value',
  251. axisLabel: {
  252. color: theme.chartLabel,
  253. formatter(value: number) {
  254. return axisLabelFormatter(
  255. value,
  256. aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
  257. undefined,
  258. durationUnit ?? getDurationUnit(data),
  259. rateUnit
  260. );
  261. },
  262. },
  263. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  264. },
  265. ...additionalAxis,
  266. ];
  267. const xAxis: XAXisOption = disableXAxis
  268. ? {
  269. show: false,
  270. axisLabel: {show: true, margin: 0},
  271. axisLine: {show: false},
  272. }
  273. : {};
  274. const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
  275. params,
  276. asyncTicket
  277. ) => {
  278. // Only show the tooltip if the current chart is hovered
  279. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  280. if (!isChartHovered(chartRef?.current)) {
  281. return '';
  282. }
  283. let deDupedParams = params;
  284. if (Array.isArray(params)) {
  285. const uniqueSeries = new Set<string>();
  286. deDupedParams = params.filter(param => {
  287. // Filter null values from tooltip
  288. if (param.value[1] === null) {
  289. return false;
  290. }
  291. if (uniqueSeries.has(param.seriesName)) {
  292. return false;
  293. }
  294. uniqueSeries.add(param.seriesName);
  295. return true;
  296. });
  297. }
  298. // Return undefined to use default formatter
  299. return getFormatter({
  300. isGroupedByDate: true,
  301. showTimeInTooltip: true,
  302. utc: utc ?? false,
  303. valueFormatter: (value, seriesName) => {
  304. return tooltipFormatter(
  305. value,
  306. aggregateOutputFormat ?? aggregateOutputType(seriesName)
  307. );
  308. },
  309. ...tooltipFormatterOptions,
  310. })(deDupedParams, asyncTicket);
  311. };
  312. const areaChartProps = {
  313. seriesOptions: {
  314. showSymbol: false,
  315. },
  316. grid,
  317. yAxes,
  318. utc,
  319. legend: isLegendVisible ? {top: 0, right: 10, formatter: legendFormatter} : undefined,
  320. isGroupedByDate: true,
  321. showTimeInTooltip: true,
  322. tooltip: {
  323. formatter,
  324. trigger: 'axis',
  325. axisPointer: {
  326. type: 'cross',
  327. label: {show: false},
  328. },
  329. valueFormatter: (value, seriesName) => {
  330. return tooltipFormatter(
  331. value,
  332. aggregateOutputFormat ??
  333. aggregateOutputType(data?.length ? data[0].seriesName : seriesName)
  334. );
  335. },
  336. nameFormatter(value: string) {
  337. return value === 'epm()' ? 'tpm()' : value;
  338. },
  339. },
  340. } as Omit<AreaChartProps, 'series'>;
  341. function getChartWithSeries(
  342. zoomRenderProps: ZoomRenderProps,
  343. releaseSeries?: Series[]
  344. ) {
  345. if (error) {
  346. return (
  347. <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
  348. <IconWarning color="gray300" size="lg" />
  349. </ErrorPanel>
  350. );
  351. }
  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. isLegendVisible ? {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(({seriesName, data: seriesData, ...options}) =>
  384. ScatterSeries({
  385. ...options,
  386. name: seriesName,
  387. data: seriesData?.map(({value, name}) => [name, value]),
  388. animation: false,
  389. })
  390. ),
  391. ...incompleteSeries.map(({seriesName, data: seriesData, ...options}) =>
  392. LineSeries({
  393. ...options,
  394. name: seriesName,
  395. data: seriesData?.map(({value, name}) => [name, value]),
  396. animation: false,
  397. animationThreshold: 1,
  398. animationDuration: 0,
  399. })
  400. ),
  401. ...(releaseSeries ?? []).map(({seriesName, data: seriesData, ...options}) =>
  402. LineSeries({
  403. ...options,
  404. name: seriesName,
  405. data: seriesData?.map(({value, name}) => [name, value]),
  406. animation: false,
  407. animationThreshold: 1,
  408. animationDuration: 0,
  409. })
  410. ),
  411. ]}
  412. />
  413. );
  414. }
  415. if (type === ChartType.BAR) {
  416. return (
  417. <BarChart
  418. height={height}
  419. series={series}
  420. xAxis={{
  421. type: 'category',
  422. axisTick: {show: true},
  423. truncate: Infinity, // Show axis labels
  424. axisLabel: {
  425. interval: 0, // Show _all_ axis labels
  426. },
  427. }}
  428. yAxis={{
  429. minInterval: durationUnit ?? getDurationUnit(data),
  430. splitNumber: definedAxisTicks,
  431. max: dataMax,
  432. axisLabel: {
  433. color: theme.chartLabel,
  434. formatter(value: number) {
  435. return axisLabelFormatter(
  436. value,
  437. aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
  438. undefined,
  439. durationUnit ?? getDurationUnit(data),
  440. rateUnit
  441. );
  442. },
  443. },
  444. }}
  445. tooltip={{
  446. valueFormatter: (value, seriesName) => {
  447. return tooltipFormatter(
  448. value,
  449. aggregateOutputFormat ??
  450. aggregateOutputType(data?.length ? data[0].seriesName : seriesName)
  451. );
  452. },
  453. }}
  454. colors={colors}
  455. grid={grid}
  456. legend={
  457. isLegendVisible ? {top: 0, right: 10, formatter: legendFormatter} : undefined
  458. }
  459. onClick={onClick}
  460. />
  461. );
  462. }
  463. return (
  464. <AreaChart
  465. forwardedRef={chartRef}
  466. height={height}
  467. {...zoomRenderProps}
  468. series={[...series, ...incompleteSeries, ...(releaseSeries ?? [])]}
  469. previousPeriod={previousData}
  470. additionalSeries={transformedThroughput}
  471. xAxis={xAxis}
  472. stacked={stacked}
  473. colors={colors}
  474. onClick={onClick}
  475. {...areaChartProps}
  476. onLegendSelectChanged={onLegendSelectChanged}
  477. />
  478. );
  479. }
  480. function getChart() {
  481. if (error) {
  482. return (
  483. <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
  484. <IconWarning color="gray300" size="lg" />
  485. </ErrorPanel>
  486. );
  487. }
  488. // add top-padding to the chart in full screen so that the legend
  489. // and graph do not overlap
  490. if (renderingContext?.isFullscreen) {
  491. grid = {...grid, top: '20px'};
  492. }
  493. // overlay additional series data such as releases and issues on top of the original insights chart
  494. return (
  495. <ChartZoom
  496. saveOnZoom
  497. period={period}
  498. start={start}
  499. end={end}
  500. utc={utc}
  501. onDataZoom={onDataZoom}
  502. >
  503. {zoomRenderProps =>
  504. renderingContext?.isFullscreen ? (
  505. <ReleaseSeries
  506. start={start}
  507. end={end}
  508. queryExtra={undefined}
  509. period={period}
  510. utc={utc}
  511. projects={projects}
  512. environments={environments}
  513. >
  514. {({releaseSeries}) => {
  515. return getChartWithSeries(zoomRenderProps, releaseSeries);
  516. }}
  517. </ReleaseSeries>
  518. ) : (
  519. getChartWithSeries(zoomRenderProps)
  520. )
  521. }
  522. </ChartZoom>
  523. );
  524. }
  525. return (
  526. <TransitionChart
  527. loading={loading}
  528. reloading={loading}
  529. height={height ? `${height}px` : undefined}
  530. >
  531. <LoadingScreen loading={loading} />
  532. {getChart()}
  533. </TransitionChart>
  534. );
  535. }
  536. export default Chart;
  537. function computeMax(data: Series[]) {
  538. const valuesDict = data.map(value => value.data.map(point => point.value));
  539. return max(valuesDict.map(max)) as number;
  540. }
  541. // adapted from https://stackoverflow.com/questions/11397239/rounding-up-for-a-graph-maximum
  542. export function computeAxisMax(data: Series[], stacked?: boolean) {
  543. // assumes min is 0
  544. let maxValue = 0;
  545. if (data.length > 1 && stacked) {
  546. for (let i = 0; i < data.length; i++) {
  547. maxValue += max(data[i].data.map(point => point.value)) as number;
  548. }
  549. } else {
  550. maxValue = computeMax(data);
  551. }
  552. if (maxValue <= 1) {
  553. return 1;
  554. }
  555. const power = Math.log10(maxValue);
  556. const magnitude = min([max([10 ** (power - Math.floor(power)), 0]), 10]) as number;
  557. let scale: number;
  558. if (magnitude <= 2.5) {
  559. scale = 0.2;
  560. } else if (magnitude <= 5) {
  561. scale = 0.5;
  562. } else if (magnitude <= 7.5) {
  563. scale = 1.0;
  564. } else {
  565. scale = 2.0;
  566. }
  567. const step = 10 ** Math.floor(power) * scale;
  568. return Math.ceil(Math.ceil(maxValue / step) * step);
  569. }
  570. export function useSynchronizeCharts(
  571. charts: number,
  572. ready: boolean,
  573. group: string = STARFISH_CHART_GROUP
  574. ) {
  575. // Tries to connect all the charts under the same group so the cursor is shared.
  576. const [, forceUpdate] = useReducer(x => x + 1, 0);
  577. useEffect(() => {
  578. if (charts && ready) {
  579. echarts?.connect?.(group);
  580. // need to force a re-render otherwise only the currently visible charts
  581. // in the group will end up connected
  582. forceUpdate();
  583. }
  584. }, [
  585. charts, // this re-connects when new charts are added/removed
  586. ready, // this waits until the chart data has loaded before attempting to connect
  587. group,
  588. ]);
  589. }
  590. const StyledTransparentLoadingMask = styled(props => (
  591. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  592. ))`
  593. display: flex;
  594. justify-content: center;
  595. align-items: center;
  596. `;
  597. export function LoadingScreen({loading}: {loading: boolean}) {
  598. if (!loading) {
  599. return null;
  600. }
  601. return (
  602. <StyledTransparentLoadingMask visible={loading}>
  603. <LoadingIndicator mini />
  604. </StyledTransparentLoadingMask>
  605. );
  606. }