chart.tsx 14 KB

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