chart.tsx 15 KB

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