chart.tsx 14 KB

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