baseChart.tsx 15 KB

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