chart.tsx 16 KB

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