chart.tsx 14 KB

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