baseChart.tsx 14 KB

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