chart.tsx 14 KB

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