baseChart.tsx 18 KB

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