chart.tsx 16 KB

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