chart.tsx 15 KB

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