chart.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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. };
  63. type Props = {
  64. data: Series[];
  65. loading: boolean;
  66. utc: boolean;
  67. aggregateOutputFormat?: AggregationOutputType;
  68. chartColors?: string[];
  69. chartGroup?: string;
  70. dataMax?: number;
  71. definedAxisTicks?: number;
  72. disableXAxis?: boolean;
  73. durationUnit?: number;
  74. errored?: boolean;
  75. forwardedRef?: RefObject<ReactEchartsRef>;
  76. grid?: AreaChartProps['grid'];
  77. height?: number;
  78. hideYAxis?: boolean;
  79. hideYAxisSplitLine?: boolean;
  80. isBarChart?: boolean;
  81. isLineChart?: boolean;
  82. legendFormatter?: (name: string) => string;
  83. log?: boolean;
  84. markLine?: MarkLineOption;
  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. rateUnit?: RateUnits;
  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. utc,
  141. loading,
  142. height,
  143. grid,
  144. disableXAxis,
  145. definedAxisTicks,
  146. durationUnit,
  147. rateUnit,
  148. chartColors,
  149. isBarChart,
  150. isLineChart,
  151. stacked,
  152. log,
  153. hideYAxisSplitLine,
  154. showLegend,
  155. scatterPlot,
  156. throughput,
  157. aggregateOutputFormat,
  158. onClick,
  159. onMouseOver,
  160. onMouseOut,
  161. onHighlight,
  162. forwardedRef,
  163. chartGroup,
  164. tooltipFormatterOptions = {},
  165. errored,
  166. onLegendSelectChanged,
  167. onDataZoom,
  168. legendFormatter,
  169. }: Props) {
  170. const router = useRouter();
  171. const theme = useTheme();
  172. const pageFilters = usePageFilters();
  173. const {start, end, period} = pageFilters.selection.datetime;
  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. rateUnit
  244. );
  245. },
  246. },
  247. splitLine: hideYAxisSplitLine ? {show: false} : undefined,
  248. },
  249. ...additionalAxis,
  250. ];
  251. const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
  252. params,
  253. asyncTicket
  254. ) => {
  255. // Kinda jank. Get hovered dom elements and check if any of them are the chart
  256. const hoveredEchartElement = Array.from(document.querySelectorAll(':hover')).find(
  257. element => {
  258. return element.classList.contains('echarts-for-react');
  259. }
  260. );
  261. if (hoveredEchartElement === chartRef?.current?.ele) {
  262. // Return undefined to use default formatter
  263. return getFormatter({
  264. isGroupedByDate: true,
  265. showTimeInTooltip: true,
  266. utc,
  267. valueFormatter: (value, seriesName) => {
  268. return tooltipFormatter(
  269. value,
  270. aggregateOutputFormat ?? aggregateOutputType(seriesName)
  271. );
  272. },
  273. ...tooltipFormatterOptions,
  274. })(params, asyncTicket);
  275. }
  276. // Return empty string, ie no tooltip
  277. return '';
  278. };
  279. const areaChartProps = {
  280. seriesOptions: {
  281. showSymbol: false,
  282. },
  283. grid,
  284. yAxes,
  285. utc,
  286. legend: showLegend
  287. ? {
  288. top: 0,
  289. right: 10,
  290. ...(legendFormatter ? {formatter: legendFormatter} : {}),
  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. // Trims off the last data point because it's incomplete
  320. const trimmedSeries =
  321. period && !start && !end
  322. ? series.map(serie => {
  323. return {
  324. ...serie,
  325. data: serie.data.slice(0, -1),
  326. };
  327. })
  328. : series;
  329. const xAxis: XAXisOption = disableXAxis
  330. ? {
  331. show: false,
  332. axisLabel: {show: true, margin: 0},
  333. axisLine: {show: false},
  334. }
  335. : {
  336. min: moment(trimmedSeries[0]?.data[0]?.name).unix() * 1000,
  337. max:
  338. moment(trimmedSeries[0]?.data[trimmedSeries[0].data.length - 1]?.name).unix() *
  339. 1000,
  340. };
  341. function getChart() {
  342. if (errored) {
  343. return (
  344. <ErrorPanel>
  345. <IconWarning color="gray300" size="lg" />
  346. </ErrorPanel>
  347. );
  348. }
  349. return (
  350. <ChartZoom
  351. router={router}
  352. saveOnZoom
  353. period={period}
  354. start={start}
  355. end={end}
  356. utc={utc}
  357. onDataZoom={onDataZoom}
  358. >
  359. {zoomRenderProps => {
  360. if (isLineChart) {
  361. return (
  362. <BaseChart
  363. {...zoomRenderProps}
  364. ref={chartRef}
  365. height={height}
  366. previousPeriod={previousData}
  367. additionalSeries={transformedThroughput}
  368. xAxis={xAxis}
  369. yAxes={areaChartProps.yAxes}
  370. tooltip={areaChartProps.tooltip}
  371. colors={colors}
  372. grid={grid}
  373. legend={showLegend ? {top: 0, right: 10} : undefined}
  374. onClick={onClick}
  375. onMouseOut={onMouseOut}
  376. onMouseOver={onMouseOver}
  377. onHighlight={onHighlight}
  378. series={[
  379. ...trimmedSeries.map(({seriesName, data: seriesData, ...options}) =>
  380. LineSeries({
  381. ...options,
  382. name: seriesName,
  383. data: seriesData?.map(({value, name}) => [name, value]),
  384. animation: false,
  385. animationThreshold: 1,
  386. animationDuration: 0,
  387. })
  388. ),
  389. ...(scatterPlot ?? []).map(
  390. ({seriesName, data: seriesData, ...options}) =>
  391. ScatterSeries({
  392. ...options,
  393. name: seriesName,
  394. data: seriesData?.map(({value, name}) => [name, value]),
  395. animation: false,
  396. })
  397. ),
  398. ]}
  399. />
  400. );
  401. }
  402. if (isBarChart) {
  403. return (
  404. <BarChart
  405. height={height}
  406. series={trimmedSeries}
  407. xAxis={xAxis}
  408. additionalSeries={transformedThroughput}
  409. yAxes={areaChartProps.yAxes}
  410. tooltip={areaChartProps.tooltip}
  411. colors={colors}
  412. grid={grid}
  413. legend={showLegend ? {top: 0, right: 10} : undefined}
  414. onClick={onClick}
  415. />
  416. );
  417. }
  418. return (
  419. <AreaChart
  420. forwardedRef={chartRef}
  421. height={height}
  422. {...zoomRenderProps}
  423. series={trimmedSeries}
  424. previousPeriod={previousData}
  425. additionalSeries={transformedThroughput}
  426. xAxis={xAxis}
  427. stacked={stacked}
  428. colors={colors}
  429. onClick={onClick}
  430. {...areaChartProps}
  431. onLegendSelectChanged={onLegendSelectChanged}
  432. />
  433. );
  434. }}
  435. </ChartZoom>
  436. );
  437. }
  438. return (
  439. <TransitionChart
  440. loading={loading}
  441. reloading={loading}
  442. height={height ? `${height}px` : undefined}
  443. >
  444. <LoadingScreen loading={loading} />
  445. {getChart()}
  446. </TransitionChart>
  447. );
  448. }
  449. export default Chart;
  450. export function useSynchronizeCharts(deps: boolean[] = []) {
  451. const [synchronized, setSynchronized] = useState<boolean>(false);
  452. useEffect(() => {
  453. if (deps.every(Boolean)) {
  454. echarts.connect(STARFISH_CHART_GROUP);
  455. setSynchronized(true);
  456. }
  457. }, [deps, synchronized]);
  458. }
  459. const StyledTransparentLoadingMask = styled(props => (
  460. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  461. ))`
  462. display: flex;
  463. justify-content: center;
  464. align-items: center;
  465. `;
  466. function LoadingScreen({loading}: {loading: boolean}) {
  467. if (!loading) {
  468. return null;
  469. }
  470. return (
  471. <StyledTransparentLoadingMask visible={loading}>
  472. <LoadingIndicator mini />
  473. </StyledTransparentLoadingMask>
  474. );
  475. }