chart.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import type {RefObject} from 'react';
  2. import {useEffect, 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 moment from 'moment';
  17. import type {AreaChartProps} from 'sentry/components/charts/areaChart';
  18. import {AreaChart} from 'sentry/components/charts/areaChart';
  19. import {BarChart} from 'sentry/components/charts/barChart';
  20. import BaseChart from 'sentry/components/charts/baseChart';
  21. import ChartZoom from 'sentry/components/charts/chartZoom';
  22. import type {FormatterOptions} from 'sentry/components/charts/components/tooltip';
  23. import {getFormatter} from 'sentry/components/charts/components/tooltip';
  24. import ErrorPanel from 'sentry/components/charts/errorPanel';
  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 {IconWarning} from 'sentry/icons';
  32. import type {
  33. EChartClickHandler,
  34. EChartDataZoomHandler,
  35. EChartEventHandler,
  36. EChartHighlightHandler,
  37. EChartMouseOutHandler,
  38. EChartMouseOverHandler,
  39. ReactEchartsRef,
  40. Series,
  41. } from 'sentry/types/echarts';
  42. import {
  43. axisLabelFormatter,
  44. getDurationUnit,
  45. tooltipFormatter,
  46. } from 'sentry/utils/discover/charts';
  47. import type {AggregationOutputType, RateUnit} from 'sentry/utils/discover/fields';
  48. import {aggregateOutputType} from 'sentry/utils/discover/fields';
  49. import usePageFilters from 'sentry/utils/usePageFilters';
  50. import useRouter from 'sentry/utils/useRouter';
  51. import {SpanMetricsField} from 'sentry/views/starfish/types';
  52. const STARFISH_CHART_GROUP = 'starfish_chart_group';
  53. export const STARFISH_FIELDS: Record<string, {outputType: AggregationOutputType}> = {
  54. [SpanMetricsField.SPAN_DURATION]: {
  55. outputType: 'duration',
  56. },
  57. [SpanMetricsField.SPAN_SELF_TIME]: {
  58. outputType: 'duration',
  59. },
  60. [SpanMetricsField.HTTP_RESPONSE_TRANSFER_SIZE]: {
  61. outputType: 'size',
  62. },
  63. [SpanMetricsField.HTTP_DECODED_RESPONSE_CONTENT_LENGTH]: {
  64. outputType: 'size',
  65. },
  66. [SpanMetricsField.HTTP_RESPONSE_CONTENT_LENGTH]: {
  67. outputType: 'size',
  68. },
  69. };
  70. export enum ChartType {
  71. BAR,
  72. LINE,
  73. AREA,
  74. }
  75. type Props = {
  76. data: Series[];
  77. loading: boolean;
  78. type: ChartType;
  79. aggregateOutputFormat?: AggregationOutputType;
  80. chartColors?: string[];
  81. chartGroup?: string;
  82. dataMax?: number;
  83. definedAxisTicks?: number;
  84. disableXAxis?: boolean;
  85. durationUnit?: number;
  86. error?: Error | null;
  87. forwardedRef?: RefObject<ReactEchartsRef>;
  88. grid?: AreaChartProps['grid'];
  89. height?: number;
  90. hideYAxis?: boolean;
  91. hideYAxisSplitLine?: boolean;
  92. legendFormatter?: (name: string) => string;
  93. log?: boolean;
  94. markLine?: MarkLineOption;
  95. onClick?: EChartClickHandler;
  96. onDataZoom?: EChartDataZoomHandler;
  97. onHighlight?: EChartHighlightHandler;
  98. onLegendSelectChanged?: EChartEventHandler<{
  99. name: string;
  100. selected: Record<string, boolean>;
  101. type: 'legendselectchanged';
  102. }>;
  103. onMouseOut?: EChartMouseOutHandler;
  104. onMouseOver?: EChartMouseOverHandler;
  105. preserveIncompletePoints?: boolean;
  106. previousData?: Series[];
  107. rateUnit?: RateUnit;
  108. scatterPlot?: Series[];
  109. showLegend?: boolean;
  110. stacked?: boolean;
  111. throughput?: {count: number; interval: string}[];
  112. tooltipFormatterOptions?: FormatterOptions;
  113. };
  114. function Chart({
  115. data,
  116. dataMax,
  117. previousData,
  118. loading,
  119. height,
  120. grid,
  121. disableXAxis,
  122. definedAxisTicks,
  123. durationUnit,
  124. rateUnit,
  125. chartColors,
  126. type,
  127. stacked,
  128. log,
  129. hideYAxisSplitLine,
  130. showLegend,
  131. scatterPlot,
  132. throughput,
  133. aggregateOutputFormat,
  134. onClick,
  135. onMouseOver,
  136. onMouseOut,
  137. onHighlight,
  138. forwardedRef,
  139. chartGroup,
  140. tooltipFormatterOptions = {},
  141. error,
  142. onLegendSelectChanged,
  143. onDataZoom,
  144. legendFormatter,
  145. preserveIncompletePoints,
  146. }: Props) {
  147. const router = useRouter();
  148. const theme = useTheme();
  149. const pageFilters = usePageFilters();
  150. const {start, end, period, utc} = pageFilters.selection.datetime;
  151. const defaultRef = useRef<ReactEchartsRef>(null);
  152. const chartRef = forwardedRef || defaultRef;
  153. const echartsInstance = chartRef?.current?.getEchartsInstance?.();
  154. if (echartsInstance && !echartsInstance.group) {
  155. echartsInstance.group = chartGroup ?? STARFISH_CHART_GROUP;
  156. }
  157. const colors = chartColors ?? theme.charts.getColorPalette(4);
  158. const durationOnly =
  159. aggregateOutputFormat === 'duration' ||
  160. data.every(value => aggregateOutputType(value.seriesName) === 'duration');
  161. const percentOnly =
  162. aggregateOutputFormat === 'percentage' ||
  163. data.every(value => aggregateOutputType(value.seriesName) === 'percentage');
  164. if (!dataMax) {
  165. dataMax = durationOnly
  166. ? computeAxisMax(
  167. [...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])],
  168. stacked
  169. )
  170. : percentOnly
  171. ? computeMax([...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])])
  172. : undefined;
  173. // Fix an issue where max == 1 for duration charts would look funky cause we round
  174. if (dataMax === 1 && durationOnly) {
  175. dataMax += 1;
  176. }
  177. }
  178. let transformedThroughput: LineSeriesOption[] | undefined = undefined;
  179. const additionalAxis: YAXisOption[] = [];
  180. if (throughput && throughput.length > 1) {
  181. transformedThroughput = [
  182. LineSeries({
  183. name: 'Throughput',
  184. data: throughput.map(({interval, count}) => [interval, count]),
  185. yAxisIndex: 1,
  186. lineStyle: {type: 'dashed', width: 1, opacity: 0.5},
  187. animation: false,
  188. animationThreshold: 1,
  189. animationDuration: 0,
  190. }),
  191. ];
  192. additionalAxis.push({
  193. minInterval: durationUnit ?? getDurationUnit(data),
  194. splitNumber: definedAxisTicks,
  195. max: dataMax,
  196. type: 'value',
  197. axisLabel: {
  198. color: theme.chartLabel,
  199. formatter(value: number) {
  200. return axisLabelFormatter(value, 'number', true);
  201. },
  202. },
  203. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  204. });
  205. }
  206. const yAxes = [
  207. {
  208. minInterval: durationUnit ?? getDurationUnit(data),
  209. splitNumber: definedAxisTicks,
  210. max: dataMax,
  211. type: log ? 'log' : 'value',
  212. axisLabel: {
  213. color: theme.chartLabel,
  214. formatter(value: number) {
  215. return axisLabelFormatter(
  216. value,
  217. aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
  218. undefined,
  219. durationUnit ?? getDurationUnit(data),
  220. rateUnit
  221. );
  222. },
  223. },
  224. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  225. },
  226. ...additionalAxis,
  227. ];
  228. const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
  229. params,
  230. asyncTicket
  231. ) => {
  232. if (isChartHovered(chartRef?.current)) {
  233. // Return undefined to use default formatter
  234. return getFormatter({
  235. isGroupedByDate: true,
  236. showTimeInTooltip: true,
  237. utc: utc ?? false,
  238. valueFormatter: (value, seriesName) => {
  239. return tooltipFormatter(
  240. value,
  241. aggregateOutputFormat ?? aggregateOutputType(seriesName)
  242. );
  243. },
  244. ...tooltipFormatterOptions,
  245. })(params, asyncTicket);
  246. }
  247. // Return empty string, ie no tooltip
  248. return '';
  249. };
  250. const areaChartProps = {
  251. seriesOptions: {
  252. showSymbol: false,
  253. },
  254. grid,
  255. yAxes,
  256. utc,
  257. legend: showLegend
  258. ? {
  259. top: 0,
  260. right: 10,
  261. ...(legendFormatter ? {formatter: legendFormatter} : {}),
  262. }
  263. : undefined,
  264. isGroupedByDate: true,
  265. showTimeInTooltip: true,
  266. tooltip: {
  267. formatter,
  268. trigger: 'axis',
  269. axisPointer: {
  270. type: 'cross',
  271. label: {show: false},
  272. },
  273. valueFormatter: (value, seriesName) => {
  274. return tooltipFormatter(
  275. value,
  276. aggregateOutputFormat ??
  277. aggregateOutputType(data?.length ? data[0].seriesName : seriesName)
  278. );
  279. },
  280. nameFormatter(value: string) {
  281. return value === 'epm()' ? 'tpm()' : value;
  282. },
  283. },
  284. } as Omit<AreaChartProps, 'series'>;
  285. const series: Series[] = data.map((values, _) => ({
  286. ...values,
  287. yAxisIndex: 0,
  288. xAxisIndex: 0,
  289. }));
  290. // Trims off the last data point because it's incomplete
  291. const trimmedSeries =
  292. !preserveIncompletePoints && period && !start && !end
  293. ? series.map(serie => {
  294. return {
  295. ...serie,
  296. data: serie.data.slice(0, -1),
  297. };
  298. })
  299. : series;
  300. const xAxis: XAXisOption = disableXAxis
  301. ? {
  302. show: false,
  303. axisLabel: {show: true, margin: 0},
  304. axisLine: {show: false},
  305. }
  306. : {
  307. min: moment(trimmedSeries[0]?.data[0]?.name).unix() * 1000,
  308. max:
  309. moment(trimmedSeries[0]?.data[trimmedSeries[0].data.length - 1]?.name).unix() *
  310. 1000,
  311. };
  312. function getChart() {
  313. if (error) {
  314. return (
  315. <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
  316. <IconWarning color="gray300" size="lg" />
  317. </ErrorPanel>
  318. );
  319. }
  320. return (
  321. <ChartZoom
  322. router={router}
  323. saveOnZoom
  324. period={period}
  325. start={start}
  326. end={end}
  327. utc={utc}
  328. onDataZoom={onDataZoom}
  329. >
  330. {zoomRenderProps => {
  331. if (type === ChartType.LINE) {
  332. return (
  333. <BaseChart
  334. {...zoomRenderProps}
  335. ref={chartRef}
  336. height={height}
  337. previousPeriod={previousData}
  338. additionalSeries={transformedThroughput}
  339. xAxis={xAxis}
  340. yAxes={areaChartProps.yAxes}
  341. tooltip={areaChartProps.tooltip}
  342. colors={colors}
  343. grid={grid}
  344. legend={
  345. showLegend ? {top: 0, right: 10, formatter: legendFormatter} : undefined
  346. }
  347. onClick={onClick}
  348. onMouseOut={onMouseOut}
  349. onMouseOver={onMouseOver}
  350. onHighlight={onHighlight}
  351. series={[
  352. ...trimmedSeries.map(({seriesName, data: seriesData, ...options}) =>
  353. LineSeries({
  354. ...options,
  355. name: seriesName,
  356. data: seriesData?.map(({value, name}) => [name, value]),
  357. animation: false,
  358. animationThreshold: 1,
  359. animationDuration: 0,
  360. })
  361. ),
  362. ...(scatterPlot ?? []).map(
  363. ({seriesName, data: seriesData, ...options}) =>
  364. ScatterSeries({
  365. ...options,
  366. name: seriesName,
  367. data: seriesData?.map(({value, name}) => [name, value]),
  368. animation: false,
  369. })
  370. ),
  371. ]}
  372. />
  373. );
  374. }
  375. if (type === ChartType.BAR) {
  376. return (
  377. <BarChart
  378. height={height}
  379. series={trimmedSeries}
  380. xAxis={{
  381. type: 'category',
  382. axisTick: {show: true},
  383. truncate: Infinity, // Show axis labels
  384. axisLabel: {
  385. interval: 0, // Show _all_ axis labels
  386. },
  387. }}
  388. yAxis={{
  389. minInterval: durationUnit ?? getDurationUnit(data),
  390. splitNumber: definedAxisTicks,
  391. max: dataMax,
  392. axisLabel: {
  393. color: theme.chartLabel,
  394. formatter(value: number) {
  395. return axisLabelFormatter(
  396. value,
  397. aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
  398. undefined,
  399. durationUnit ?? getDurationUnit(data),
  400. rateUnit
  401. );
  402. },
  403. },
  404. }}
  405. tooltip={{
  406. valueFormatter: (value, seriesName) => {
  407. return tooltipFormatter(
  408. value,
  409. aggregateOutputFormat ??
  410. aggregateOutputType(
  411. data?.length ? data[0].seriesName : seriesName
  412. )
  413. );
  414. },
  415. }}
  416. colors={colors}
  417. grid={grid}
  418. legend={showLegend ? {top: 0, right: 10} : undefined}
  419. onClick={onClick}
  420. />
  421. );
  422. }
  423. return (
  424. <AreaChart
  425. forwardedRef={chartRef}
  426. height={height}
  427. {...zoomRenderProps}
  428. series={trimmedSeries}
  429. previousPeriod={previousData}
  430. additionalSeries={transformedThroughput}
  431. xAxis={xAxis}
  432. stacked={stacked}
  433. colors={colors}
  434. onClick={onClick}
  435. {...areaChartProps}
  436. onLegendSelectChanged={onLegendSelectChanged}
  437. />
  438. );
  439. }}
  440. </ChartZoom>
  441. );
  442. }
  443. return (
  444. <TransitionChart
  445. loading={loading}
  446. reloading={loading}
  447. height={height ? `${height}px` : undefined}
  448. >
  449. <LoadingScreen loading={loading} />
  450. {getChart()}
  451. </TransitionChart>
  452. );
  453. }
  454. export default Chart;
  455. function computeMax(data: Series[]) {
  456. const valuesDict = data.map(value => value.data.map(point => point.value));
  457. return max(valuesDict.map(max)) as number;
  458. }
  459. // adapted from https://stackoverflow.com/questions/11397239/rounding-up-for-a-graph-maximum
  460. export function computeAxisMax(data: Series[], stacked?: boolean) {
  461. // assumes min is 0
  462. let maxValue = 0;
  463. if (data.length > 1 && stacked) {
  464. for (let i = 0; i < data.length; i++) {
  465. maxValue += max(data[i].data.map(point => point.value)) as number;
  466. }
  467. } else {
  468. maxValue = computeMax(data);
  469. }
  470. if (maxValue <= 1) {
  471. return 1;
  472. }
  473. const power = Math.log10(maxValue);
  474. const magnitude = min([max([10 ** (power - Math.floor(power)), 0]), 10]) as number;
  475. let scale: number;
  476. if (magnitude <= 2.5) {
  477. scale = 0.2;
  478. } else if (magnitude <= 5) {
  479. scale = 0.5;
  480. } else if (magnitude <= 7.5) {
  481. scale = 1.0;
  482. } else {
  483. scale = 2.0;
  484. }
  485. const step = 10 ** Math.floor(power) * scale;
  486. return Math.ceil(Math.ceil(maxValue / step) * step);
  487. }
  488. export function useSynchronizeCharts(deps: boolean[] = []) {
  489. const [synchronized, setSynchronized] = useState<boolean>(false);
  490. useEffect(() => {
  491. if (deps.every(Boolean)) {
  492. echarts?.connect?.(STARFISH_CHART_GROUP);
  493. setSynchronized(true);
  494. }
  495. }, [deps, synchronized]);
  496. }
  497. const StyledTransparentLoadingMask = styled(props => (
  498. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  499. ))`
  500. display: flex;
  501. justify-content: center;
  502. align-items: center;
  503. `;
  504. export function LoadingScreen({loading}: {loading: boolean}) {
  505. if (!loading) {
  506. return null;
  507. }
  508. return (
  509. <StyledTransparentLoadingMask visible={loading}>
  510. <LoadingIndicator mini />
  511. </StyledTransparentLoadingMask>
  512. );
  513. }