chart.tsx 14 KB

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