chart.tsx 14 KB

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