baseChart.tsx 18 KB

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