baseChart.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. import 'echarts/lib/component/grid';
  2. import 'echarts/lib/component/graphic';
  3. import 'echarts/lib/component/toolbox';
  4. import 'zrender/lib/svg/svg';
  5. import {forwardRef, useMemo} from 'react';
  6. import {css, Global, useTheme} from '@emotion/react';
  7. import styled from '@emotion/styled';
  8. import type {
  9. AxisPointerComponentOption,
  10. ECharts,
  11. EChartsOption,
  12. GridComponentOption,
  13. LegendComponentOption,
  14. LineSeriesOption,
  15. SeriesOption,
  16. TooltipComponentFormatterCallback,
  17. TooltipComponentFormatterCallbackParams,
  18. TooltipComponentOption,
  19. VisualMapComponentOption,
  20. XAXisComponentOption,
  21. YAXisComponentOption,
  22. } from 'echarts';
  23. import * as echarts from 'echarts/core';
  24. import ReactEchartsCore from 'echarts-for-react/lib/core';
  25. import MarkLine from 'sentry/components/charts/components/markLine';
  26. import {IS_ACCEPTANCE_TEST} from 'sentry/constants';
  27. import space from 'sentry/styles/space';
  28. import {
  29. EChartChartReadyHandler,
  30. EChartClickHandler,
  31. EChartDataZoomHandler,
  32. EChartEventHandler,
  33. EChartFinishedHandler,
  34. EChartHighlightHandler,
  35. EChartMouseOutHandler,
  36. EChartMouseOverHandler,
  37. EChartRenderedHandler,
  38. EChartRestoreHandler,
  39. ReactEchartsRef,
  40. Series,
  41. } from 'sentry/types/echarts';
  42. import {defined} from 'sentry/utils';
  43. import type {Theme} from 'sentry/utils/theme';
  44. import Grid from './components/grid';
  45. import Legend from './components/legend';
  46. import Tooltip, {TooltipSubLabel} from './components/tooltip';
  47. import XAxis from './components/xAxis';
  48. import YAxis from './components/yAxis';
  49. import LineSeries from './series/lineSeries';
  50. import {getDiffInMinutes, getDimensionValue, lightenHexToRgb} from './utils';
  51. // TODO(ts): What is the series type? EChartOption.Series's data cannot have
  52. // `onClick` since it's typically an array.
  53. //
  54. // Handle series item clicks (e.g. Releases mark line or a single series
  55. // item) This is different than when you hover over an "axis" line on a chart
  56. // (e.g. if there are 2 series for an axis and you're not directly hovered
  57. // over an item)
  58. //
  59. // Calls "onClick" inside of series data
  60. const handleClick = (clickSeries: any, instance: ECharts) => {
  61. if (clickSeries.data) {
  62. clickSeries.data.onClick?.(clickSeries, instance);
  63. }
  64. };
  65. type ReactEchartProps = React.ComponentProps<typeof ReactEchartsCore>;
  66. type ReactEChartOpts = NonNullable<ReactEchartProps['opts']>;
  67. /**
  68. * Used for some properties that can be truncated
  69. */
  70. type Truncateable = {
  71. /**
  72. * Truncate the label / value some number of characters.
  73. * If true is passed, it will use truncate based on a default length.
  74. */
  75. truncate?: number | boolean;
  76. };
  77. interface TooltipOption
  78. extends Omit<TooltipComponentOption, 'valueFormatter'>,
  79. Truncateable {
  80. filter?: (value: number, seriesParam: TooltipComponentOption['formatter']) => boolean;
  81. formatAxisLabel?: (
  82. value: number,
  83. isTimestamp: boolean,
  84. utc: boolean,
  85. showTimeInTooltip: boolean,
  86. addSecondsToTimeFormat: boolean,
  87. bucketSize: number | undefined,
  88. seriesParamsOrParam: TooltipComponentFormatterCallbackParams
  89. ) => string;
  90. markerFormatter?: (marker: string, label?: string) => string;
  91. nameFormatter?: (name: string) => string;
  92. /**
  93. * Array containing data that is used to display indented sublabels.
  94. */
  95. subLabels?: TooltipSubLabel[];
  96. valueFormatter?: (
  97. value: number,
  98. label?: string,
  99. seriesParams?: TooltipComponentFormatterCallback<any>
  100. ) => string;
  101. }
  102. type Props = {
  103. /**
  104. * Additional Chart Series
  105. * This is to pass series to BaseChart bypassing the wrappers like LineChart, AreaChart etc.
  106. */
  107. additionalSeries?: LineSeriesOption[];
  108. /**
  109. * If true, ignores height value and auto-scales chart to fit container height.
  110. */
  111. autoHeightResize?: boolean;
  112. /**
  113. * Axis pointer options
  114. */
  115. axisPointer?: AxisPointerComponentOption;
  116. /**
  117. * Bucket size to display time range in chart tooltip
  118. */
  119. bucketSize?: number;
  120. /**
  121. * Array of color codes to use in charts. May also take a function which is
  122. * provided with the current theme
  123. */
  124. colors?: string[] | ((theme: Theme) => string[]);
  125. 'data-test-id'?: string;
  126. /**
  127. * DataZoom (allows for zooming of chart)
  128. */
  129. dataZoom?: EChartsOption['dataZoom'];
  130. devicePixelRatio?: ReactEChartOpts['devicePixelRatio'];
  131. /**
  132. * theme name
  133. * example theme: https://github.com/apache/incubator-echarts/blob/master/theme/dark.js
  134. */
  135. echartsTheme?: ReactEchartProps['theme'];
  136. /**
  137. * optional, used to determine how xAxis is formatted if `isGroupedByDate == true`
  138. */
  139. end?: Date;
  140. /**
  141. * Forwarded Ref
  142. */
  143. forwardedRef?: React.Ref<ReactEchartsCore>;
  144. /**
  145. * Graphic options
  146. */
  147. graphic?: EChartsOption['graphic'];
  148. /**
  149. * ECharts Grid options. multiple grids allow multiple sub-graphs.
  150. */
  151. grid?: GridComponentOption | GridComponentOption[];
  152. /**
  153. * Chart height
  154. */
  155. height?: ReactEChartOpts['height'];
  156. /**
  157. * If data is grouped by date; then apply default date formatting to x-axis
  158. * and tooltips.
  159. */
  160. isGroupedByDate?: boolean;
  161. /**
  162. * states whether not to update chart immediately
  163. */
  164. lazyUpdate?: boolean;
  165. /**
  166. * Chart legend
  167. */
  168. legend?: LegendComponentOption & Truncateable;
  169. /**
  170. * optional, threshold in minutes used to add seconds to the xAxis datetime format if `isGroupedByDate == true`
  171. */
  172. minutesThresholdToDisplaySeconds?: number;
  173. /**
  174. * states whether or not to merge with previous `option`
  175. */
  176. notMerge?: boolean;
  177. onChartReady?: EChartChartReadyHandler;
  178. onClick?: EChartClickHandler;
  179. onDataZoom?: EChartDataZoomHandler;
  180. onFinished?: EChartFinishedHandler;
  181. onHighlight?: EChartHighlightHandler;
  182. onLegendSelectChanged?: EChartEventHandler<{
  183. name: string;
  184. selected: Record<string, boolean>;
  185. type: 'legendselectchanged';
  186. }>;
  187. onMouseOut?: EChartMouseOutHandler;
  188. onMouseOver?: EChartMouseOverHandler;
  189. onRendered?: EChartRenderedHandler;
  190. /**
  191. * One example of when this is called is restoring chart from zoom levels
  192. */
  193. onRestore?: EChartRestoreHandler;
  194. options?: EChartsOption;
  195. /**
  196. * optional, used to determine how xAxis is formatted if `isGroupedByDate == true`
  197. */
  198. period?: string | null;
  199. /**
  200. * Custom chart props that are implemented by us (and not a feature of eCharts)
  201. *
  202. * Display previous period as a LineSeries
  203. */
  204. previousPeriod?: Series[];
  205. /**
  206. * Use `canvas` when dealing with large datasets
  207. * See: https://ecomfe.github.io/echarts-doc/public/en/tutorial.html#Render%20by%20Canvas%20or%20SVG
  208. */
  209. renderer?: ReactEChartOpts['renderer'];
  210. /**
  211. * Chart Series
  212. * This is different than the interface to higher level charts, these need to
  213. * be an array of ECharts "Series" components.
  214. */
  215. series?: SeriesOption[];
  216. /**
  217. * Format timestamp with date AND time
  218. */
  219. showTimeInTooltip?: boolean;
  220. /**
  221. * optional, used to determine how xAxis is formatted if `isGroupedByDate == true`
  222. */
  223. start?: Date;
  224. /**
  225. * Inline styles
  226. */
  227. style?: React.CSSProperties;
  228. /**
  229. * Toolbox options
  230. */
  231. toolBox?: EChartsOption['toolbox'];
  232. /**
  233. * Tooltip options
  234. */
  235. tooltip?: TooltipOption;
  236. /**
  237. * If true and there's only one datapoint in series.data, we show a bar chart to increase the visibility.
  238. * Especially useful with line / area charts, because you can't draw line with single data point and one alone point is hard to spot.
  239. */
  240. transformSinglePointToBar?: boolean;
  241. /**
  242. * If true and there's only one datapoint in series.data, we show a horizontal line to increase the visibility
  243. * Similarly to single point bar in area charts a flat line for line charts makes it easy to spot the single data point.
  244. */
  245. transformSinglePointToLine?: boolean;
  246. /**
  247. * Use short date formatting for xAxis
  248. */
  249. useShortDate?: boolean;
  250. /**
  251. * Formats dates as UTC?
  252. */
  253. utc?: boolean;
  254. /**
  255. * ECharts Visual Map Options.
  256. */
  257. visualMap?: VisualMapComponentOption | VisualMapComponentOption[];
  258. /**
  259. * Chart width
  260. */
  261. width?: ReactEChartOpts['width'];
  262. /**
  263. * Pass `true` to have 2 x-axes with default properties. Can pass an array
  264. * of multiple objects to customize xAxis properties
  265. */
  266. xAxes?: true | Props['xAxis'][];
  267. /**
  268. * Must be explicitly `null` to disable xAxis
  269. *
  270. * Additionally a `truncate` option
  271. */
  272. xAxis?: (XAXisComponentOption & Truncateable) | null;
  273. /**
  274. * Pass `true` to have 2 y-axes with default properties. Can pass an array of
  275. * objects to customize yAxis properties
  276. */
  277. yAxes?: true | Props['yAxis'][];
  278. /**
  279. * Must be explicitly `null` to disable yAxis
  280. */
  281. yAxis?: YAXisComponentOption | null;
  282. };
  283. function BaseChartUnwrapped({
  284. colors,
  285. grid,
  286. tooltip,
  287. legend,
  288. dataZoom,
  289. toolBox,
  290. graphic,
  291. axisPointer,
  292. previousPeriod,
  293. echartsTheme,
  294. devicePixelRatio,
  295. minutesThresholdToDisplaySeconds,
  296. showTimeInTooltip,
  297. useShortDate,
  298. start,
  299. end,
  300. period,
  301. utc,
  302. yAxes,
  303. xAxes,
  304. style,
  305. forwardedRef,
  306. onClick,
  307. onLegendSelectChanged,
  308. onHighlight,
  309. onMouseOut,
  310. onMouseOver,
  311. onDataZoom,
  312. onRestore,
  313. onFinished,
  314. onRendered,
  315. options = {},
  316. series = [],
  317. additionalSeries = [],
  318. yAxis = {},
  319. xAxis = {},
  320. autoHeightResize = false,
  321. height = 200,
  322. width,
  323. renderer = 'svg',
  324. notMerge = true,
  325. lazyUpdate = false,
  326. isGroupedByDate = false,
  327. transformSinglePointToBar = false,
  328. transformSinglePointToLine = false,
  329. onChartReady = () => {},
  330. 'data-test-id': dataTestId,
  331. }: Props) {
  332. const theme = useTheme();
  333. const hasSinglePoints = (series as LineSeriesOption[] | undefined)?.every(
  334. s => Array.isArray(s.data) && s.data.length <= 1
  335. );
  336. const resolveColors =
  337. colors !== undefined ? (Array.isArray(colors) ? colors : colors(theme)) : null;
  338. const color =
  339. resolveColors ||
  340. (series.length ? theme.charts.getColorPalette(series.length) : theme.charts.colors);
  341. const previousPeriodColors =
  342. previousPeriod && previousPeriod.length > 1 ? lightenHexToRgb(color) : undefined;
  343. const transformedSeries =
  344. (hasSinglePoints && transformSinglePointToBar
  345. ? (series as LineSeriesOption[] | undefined)?.map(s => ({
  346. ...s,
  347. type: 'bar',
  348. barWidth: 40,
  349. barGap: 0,
  350. itemStyle: {...(s.areaStyle ?? {})},
  351. }))
  352. : hasSinglePoints && transformSinglePointToLine
  353. ? (series as LineSeriesOption[] | undefined)?.map(s => ({
  354. ...s,
  355. type: 'line',
  356. itemStyle: {...(s.lineStyle ?? {})},
  357. markLine:
  358. s?.data?.[0]?.[1] !== undefined
  359. ? MarkLine({
  360. silent: true,
  361. lineStyle: {
  362. type: 'solid',
  363. width: 1.5,
  364. },
  365. data: [{yAxis: s?.data?.[0]?.[1]}],
  366. label: {
  367. show: false,
  368. },
  369. })
  370. : undefined,
  371. }))
  372. : series) ?? [];
  373. const transformedPreviousPeriod =
  374. previousPeriod?.map((previous, seriesIndex) =>
  375. LineSeries({
  376. name: previous.seriesName,
  377. data: previous.data.map(({name, value}) => [name, value]),
  378. lineStyle: {
  379. color: previousPeriodColors ? previousPeriodColors[seriesIndex] : theme.gray200,
  380. type: 'dotted',
  381. },
  382. itemStyle: {
  383. color: previousPeriodColors ? previousPeriodColors[seriesIndex] : theme.gray200,
  384. },
  385. stack: 'previous',
  386. animation: false,
  387. })
  388. ) ?? [];
  389. const resolvedSeries = !previousPeriod
  390. ? [...transformedSeries, ...additionalSeries]
  391. : [...transformedSeries, ...transformedPreviousPeriod, ...additionalSeries];
  392. const defaultAxesProps = {theme};
  393. const yAxisOrCustom = !yAxes
  394. ? yAxis !== null
  395. ? YAxis({theme, ...yAxis})
  396. : undefined
  397. : Array.isArray(yAxes)
  398. ? yAxes.map(axis => YAxis({...axis, theme}))
  399. : [YAxis(defaultAxesProps), YAxis(defaultAxesProps)];
  400. /**
  401. * If true seconds will be added to the time format in the tooltips and chart xAxis
  402. */
  403. const addSecondsToTimeFormat =
  404. isGroupedByDate && defined(minutesThresholdToDisplaySeconds)
  405. ? getDiffInMinutes({start, end, period}) <= minutesThresholdToDisplaySeconds
  406. : false;
  407. const xAxisOrCustom = !xAxes
  408. ? xAxis !== null
  409. ? XAxis({
  410. ...xAxis,
  411. theme,
  412. useShortDate,
  413. start,
  414. end,
  415. period,
  416. isGroupedByDate,
  417. addSecondsToTimeFormat,
  418. utc,
  419. })
  420. : undefined
  421. : Array.isArray(xAxes)
  422. ? xAxes.map(axis =>
  423. XAxis({
  424. ...axis,
  425. theme,
  426. useShortDate,
  427. start,
  428. end,
  429. period,
  430. isGroupedByDate,
  431. addSecondsToTimeFormat,
  432. utc,
  433. })
  434. )
  435. : [XAxis(defaultAxesProps), XAxis(defaultAxesProps)];
  436. const seriesData =
  437. Array.isArray(series?.[0]?.data) && series[0].data.length > 1
  438. ? series[0].data
  439. : undefined;
  440. const bucketSize = seriesData ? seriesData[1][0] - seriesData[0][0] : undefined;
  441. const isTooltipPortalled = tooltip?.appendToBody;
  442. const tooltipOrNone =
  443. tooltip !== null
  444. ? Tooltip({
  445. showTimeInTooltip,
  446. isGroupedByDate,
  447. addSecondsToTimeFormat,
  448. utc,
  449. bucketSize,
  450. ...tooltip,
  451. className: isTooltipPortalled
  452. ? `${tooltip?.className ?? ''} chart-tooltip-portal`
  453. : tooltip?.className,
  454. })
  455. : undefined;
  456. const chartOption = {
  457. ...options,
  458. animation: IS_ACCEPTANCE_TEST ? false : options.animation ?? true,
  459. useUTC: utc,
  460. color,
  461. grid: Array.isArray(grid) ? grid.map(Grid) : Grid(grid),
  462. tooltip: tooltipOrNone,
  463. legend: legend ? Legend({theme, ...legend}) : undefined,
  464. yAxis: yAxisOrCustom,
  465. xAxis: xAxisOrCustom,
  466. series: resolvedSeries,
  467. toolbox: toolBox,
  468. axisPointer,
  469. dataZoom,
  470. graphic,
  471. };
  472. const chartStyles = {
  473. height: autoHeightResize ? '100%' : getDimensionValue(height),
  474. width: getDimensionValue(width),
  475. ...style,
  476. };
  477. // XXX(epurkhiser): Echarts can become unhappy if one of these event handlers
  478. // causes the chart to re-render and be passed a whole different instance of
  479. // event handlers.
  480. //
  481. // We use React.useMemo to keep the value across renders
  482. //
  483. const eventsMap = useMemo(
  484. () =>
  485. ({
  486. click: (props, instance) => {
  487. handleClick(props, instance);
  488. onClick?.(props, instance);
  489. },
  490. highlight: (props, instance) => onHighlight?.(props, instance),
  491. mouseout: (props, instance) => onMouseOut?.(props, instance),
  492. mouseover: (props, instance) => onMouseOver?.(props, instance),
  493. datazoom: (props, instance) => onDataZoom?.(props, instance),
  494. restore: (props, instance) => onRestore?.(props, instance),
  495. finished: (props, instance) => onFinished?.(props, instance),
  496. rendered: (props, instance) => onRendered?.(props, instance),
  497. legendselectchanged: (props, instance) =>
  498. onLegendSelectChanged?.(props, instance),
  499. } as ReactEchartProps['onEvents']),
  500. [
  501. onClick,
  502. onHighlight,
  503. onLegendSelectChanged,
  504. onMouseOut,
  505. onMouseOver,
  506. onDataZoom,
  507. onRestore,
  508. onFinished,
  509. onRendered,
  510. ]
  511. );
  512. return (
  513. <ChartContainer autoHeightResize={autoHeightResize} data-test-id={dataTestId}>
  514. {isTooltipPortalled && <Global styles={getPortalledTooltipStyles({theme})} />}
  515. <ReactEchartsCore
  516. ref={forwardedRef}
  517. echarts={echarts}
  518. notMerge={notMerge}
  519. lazyUpdate={lazyUpdate}
  520. theme={echartsTheme}
  521. onChartReady={onChartReady}
  522. onEvents={eventsMap}
  523. style={chartStyles}
  524. opts={{
  525. height: autoHeightResize ? undefined : height,
  526. width,
  527. renderer,
  528. devicePixelRatio,
  529. }}
  530. option={chartOption}
  531. />
  532. </ChartContainer>
  533. );
  534. }
  535. // Tooltip styles shared for regular and portalled tooltips
  536. const getTooltipStyles = (p: {theme: Theme}) => css`
  537. /* Tooltip styling */
  538. .tooltip-series,
  539. .tooltip-date {
  540. color: ${p.theme.subText};
  541. font-family: ${p.theme.text.family};
  542. font-variant-numeric: tabular-nums;
  543. padding: ${space(1)} ${space(2)};
  544. border-radius: ${p.theme.borderRadius} ${p.theme.borderRadius} 0 0;
  545. }
  546. .tooltip-series {
  547. border-bottom: none;
  548. }
  549. .tooltip-series-solo {
  550. border-radius: ${p.theme.borderRadius};
  551. }
  552. .tooltip-label {
  553. margin-right: ${space(1)};
  554. }
  555. .tooltip-label strong {
  556. font-weight: normal;
  557. color: ${p.theme.textColor};
  558. }
  559. .tooltip-label-indent {
  560. margin-left: 18px;
  561. }
  562. .tooltip-series > div {
  563. display: flex;
  564. justify-content: space-between;
  565. align-items: baseline;
  566. }
  567. .tooltip-date {
  568. border-top: solid 1px ${p.theme.innerBorder};
  569. text-align: center;
  570. position: relative;
  571. width: auto;
  572. border-radius: ${p.theme.borderRadiusBottom};
  573. }
  574. .tooltip-arrow {
  575. top: 100%;
  576. left: 50%;
  577. position: absolute;
  578. pointer-events: none;
  579. border-left: 8px solid transparent;
  580. border-right: 8px solid transparent;
  581. border-top: 8px solid ${p.theme.backgroundElevated};
  582. margin-left: -8px;
  583. &:before {
  584. border-left: 8px solid transparent;
  585. border-right: 8px solid transparent;
  586. border-top: 8px solid ${p.theme.translucentBorder};
  587. content: '';
  588. display: block;
  589. position: absolute;
  590. top: -7px;
  591. left: -8px;
  592. z-index: -1;
  593. }
  594. }
  595. /* Tooltip description styling */
  596. .tooltip-description {
  597. color: ${p.theme.white};
  598. border-radius: ${p.theme.borderRadius};
  599. background: #000;
  600. opacity: 0.9;
  601. padding: 5px 10px;
  602. position: relative;
  603. font-weight: bold;
  604. font-size: ${p.theme.fontSizeSmall};
  605. line-height: 1.4;
  606. font-family: ${p.theme.text.family};
  607. max-width: 230px;
  608. min-width: 230px;
  609. white-space: normal;
  610. text-align: center;
  611. :after {
  612. content: '';
  613. position: absolute;
  614. top: 100%;
  615. left: 50%;
  616. width: 0;
  617. height: 0;
  618. border-left: 5px solid transparent;
  619. border-right: 5px solid transparent;
  620. border-top: 5px solid #000;
  621. transform: translateX(-50%);
  622. }
  623. }
  624. `;
  625. // Contains styling for chart elements as we can't easily style those
  626. // elements directly
  627. const ChartContainer = styled('div')<{autoHeightResize: boolean}>`
  628. ${p => p.autoHeightResize && 'height: 100%;'}
  629. .echarts-for-react div:first-of-type {
  630. width: 100% !important;
  631. }
  632. .echarts-for-react text {
  633. font-variant-numeric: tabular-nums !important;
  634. }
  635. ${p => getTooltipStyles(p)}
  636. `;
  637. const getPortalledTooltipStyles = (p: {theme: Theme}) => css`
  638. .chart-tooltip-portal {
  639. ${getTooltipStyles(p)};
  640. }
  641. `;
  642. const BaseChart = forwardRef<ReactEchartsRef, Props>((props, ref) => (
  643. <BaseChartUnwrapped forwardedRef={ref} {...props} />
  644. ));
  645. BaseChart.displayName = 'forwardRef(BaseChart)';
  646. export default BaseChart;