chart.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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. legendFormatter?: (name: string) => string;
  85. log?: boolean;
  86. onClick?: EChartClickHandler;
  87. onDataZoom?: EChartDataZoomHandler;
  88. onHighlight?: EChartHighlightHandler;
  89. onLegendSelectChanged?: EChartEventHandler<{
  90. name: string;
  91. selected: Record<string, boolean>;
  92. type: 'legendselectchanged';
  93. }>;
  94. onMouseOut?: EChartMouseOutHandler;
  95. onMouseOver?: EChartMouseOverHandler;
  96. previousData?: Series[];
  97. scatterPlot?: Series[];
  98. showLegend?: boolean;
  99. stacked?: boolean;
  100. throughput?: {count: number; interval: string}[];
  101. tooltipFormatterOptions?: FormatterOptions;
  102. };
  103. function computeMax(data: Series[]) {
  104. const valuesDict = data.map(value => value.data.map(point => point.value));
  105. return max(valuesDict.map(max)) as number;
  106. }
  107. // adapted from https://stackoverflow.com/questions/11397239/rounding-up-for-a-graph-maximum
  108. export function computeAxisMax(data: Series[], stacked?: boolean) {
  109. // assumes min is 0
  110. let maxValue = 0;
  111. if (data.length > 1 && stacked) {
  112. for (let i = 0; i < data.length; i++) {
  113. maxValue += max(data[i].data.map(point => point.value)) as number;
  114. }
  115. } else {
  116. maxValue = computeMax(data);
  117. }
  118. if (maxValue <= 1) {
  119. return 1;
  120. }
  121. const power = Math.log10(maxValue);
  122. const magnitude = min([max([10 ** (power - Math.floor(power)), 0]), 10]) as number;
  123. let scale: number;
  124. if (magnitude <= 2.5) {
  125. scale = 0.2;
  126. } else if (magnitude <= 5) {
  127. scale = 0.5;
  128. } else if (magnitude <= 7.5) {
  129. scale = 1.0;
  130. } else {
  131. scale = 2.0;
  132. }
  133. const step = 10 ** Math.floor(power) * scale;
  134. return Math.ceil(Math.ceil(maxValue / step) * step);
  135. }
  136. function Chart({
  137. data,
  138. dataMax,
  139. previousData,
  140. statsPeriod,
  141. start,
  142. end,
  143. utc,
  144. loading,
  145. height,
  146. grid,
  147. disableXAxis,
  148. definedAxisTicks,
  149. durationUnit,
  150. chartColors,
  151. isBarChart,
  152. isLineChart,
  153. stacked,
  154. log,
  155. hideYAxisSplitLine,
  156. showLegend,
  157. scatterPlot,
  158. throughput,
  159. aggregateOutputFormat,
  160. onClick,
  161. onMouseOver,
  162. onMouseOut,
  163. onHighlight,
  164. forwardedRef,
  165. chartGroup,
  166. tooltipFormatterOptions = {},
  167. errored,
  168. onLegendSelectChanged,
  169. onDataZoom,
  170. legendFormatter,
  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. ...(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 && 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. const xAxis: XAXisOption = disableXAxis
  321. ? {
  322. show: false,
  323. axisLabel: {show: true, margin: 0},
  324. axisLine: {show: false},
  325. }
  326. : {
  327. min: startTime.unix() * 1000,
  328. max: endTime.unix() * 1000,
  329. };
  330. function getChart() {
  331. if (errored) {
  332. return (
  333. <ErrorPanel>
  334. <IconWarning color="gray300" size="lg" />
  335. </ErrorPanel>
  336. );
  337. }
  338. // Trims off the last data point because it's incomplete
  339. const trimmedSeries =
  340. statsPeriod && !start && !end
  341. ? series.map(serie => {
  342. return {
  343. ...serie,
  344. data: serie.data.slice(0, -1),
  345. };
  346. })
  347. : series;
  348. return (
  349. <ChartZoom
  350. router={router}
  351. saveOnZoom
  352. period={statsPeriod}
  353. start={start}
  354. end={end}
  355. utc={utc}
  356. onDataZoom={onDataZoom}
  357. >
  358. {zoomRenderProps => {
  359. if (isLineChart) {
  360. return (
  361. <BaseChart
  362. {...zoomRenderProps}
  363. ref={chartRef}
  364. height={height}
  365. previousPeriod={previousData}
  366. additionalSeries={transformedThroughput}
  367. xAxis={xAxis}
  368. yAxes={areaChartProps.yAxes}
  369. tooltip={areaChartProps.tooltip}
  370. colors={colors}
  371. grid={grid}
  372. legend={showLegend ? {top: 0, right: 10} : undefined}
  373. onClick={onClick}
  374. onMouseOut={onMouseOut}
  375. onMouseOver={onMouseOver}
  376. onHighlight={onHighlight}
  377. series={[
  378. ...trimmedSeries.map(({seriesName, data: seriesData, ...options}) =>
  379. LineSeries({
  380. ...options,
  381. name: seriesName,
  382. data: seriesData?.map(({value, name}) => [name, value]),
  383. animation: false,
  384. animationThreshold: 1,
  385. animationDuration: 0,
  386. })
  387. ),
  388. ...(scatterPlot ?? []).map(
  389. ({seriesName, data: seriesData, ...options}) =>
  390. ScatterSeries({
  391. ...options,
  392. name: seriesName,
  393. data: seriesData?.map(({value, name}) => [name, value]),
  394. animation: false,
  395. })
  396. ),
  397. ]}
  398. />
  399. );
  400. }
  401. if (isBarChart) {
  402. return (
  403. <BarChart
  404. height={height}
  405. series={trimmedSeries}
  406. xAxis={xAxis}
  407. additionalSeries={transformedThroughput}
  408. yAxes={areaChartProps.yAxes}
  409. tooltip={areaChartProps.tooltip}
  410. colors={colors}
  411. grid={grid}
  412. legend={showLegend ? {top: 0, right: 10} : undefined}
  413. onClick={onClick}
  414. />
  415. );
  416. }
  417. return (
  418. <AreaChart
  419. forwardedRef={chartRef}
  420. height={height}
  421. {...zoomRenderProps}
  422. series={trimmedSeries}
  423. previousPeriod={previousData}
  424. additionalSeries={transformedThroughput}
  425. xAxis={xAxis}
  426. stacked={stacked}
  427. onClick={onClick}
  428. {...areaChartProps}
  429. onLegendSelectChanged={onLegendSelectChanged}
  430. />
  431. );
  432. }}
  433. </ChartZoom>
  434. );
  435. }
  436. return (
  437. <TransitionChart
  438. loading={loading}
  439. reloading={loading}
  440. height={height ? `${height}px` : undefined}
  441. >
  442. <LoadingScreen loading={loading} />
  443. {getChart()}
  444. </TransitionChart>
  445. );
  446. }
  447. export default Chart;
  448. export function useSynchronizeCharts(deps: boolean[] = []) {
  449. const [synchronized, setSynchronized] = useState<boolean>(false);
  450. useEffect(() => {
  451. if (deps.every(Boolean)) {
  452. echarts.connect(STARFISH_CHART_GROUP);
  453. setSynchronized(true);
  454. }
  455. }, [deps, synchronized]);
  456. }
  457. const StyledTransparentLoadingMask = styled(props => (
  458. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  459. ))`
  460. display: flex;
  461. justify-content: center;
  462. align-items: center;
  463. `;
  464. function LoadingScreen({loading}: {loading: boolean}) {
  465. if (!loading) {
  466. return null;
  467. }
  468. return (
  469. <StyledTransparentLoadingMask visible={loading}>
  470. <LoadingIndicator mini />
  471. </StyledTransparentLoadingMask>
  472. );
  473. }