chart.tsx 19 KB

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