import 'zrender/lib/svg/svg'; import * as React from 'react'; import {withTheme} from '@emotion/react'; import styled from '@emotion/styled'; import echarts, {EChartOption, ECharts} from 'echarts/lib/echarts'; import ReactEchartsCore from 'echarts-for-react/lib/core'; import {IS_ACCEPTANCE_TEST} from 'app/constants'; import space from 'app/styles/space'; import { EChartChartReadyHandler, EChartClickHandler, EChartDataZoomHandler, EChartEventHandler, EChartFinishedHandler, EChartHighlightHandler, EChartMouseOverHandler, EChartRenderedHandler, EChartRestoreHandler, ReactEchartsRef, Series, } from 'app/types/echarts'; import {Theme} from 'app/utils/theme'; import Grid from './components/grid'; import Legend from './components/legend'; import Tooltip from './components/tooltip'; import XAxis from './components/xAxis'; import YAxis from './components/yAxis'; import LineSeries from './series/lineSeries'; import {getDimensionValue} from './utils'; // TODO(ts): What is the series type? EChartOption.Series's data cannot have // `onClick` since it's typically an array. // // Handle series item clicks (e.g. Releases mark line or a single series // item) This is different than when you hover over an "axis" line on a chart // (e.g. if there are 2 series for an axis and you're not directly hovered // over an item) // // Calls "onClick" inside of series data const handleClick = (clickSeries: any, instance: ECharts) => { if (clickSeries.data) { clickSeries.data.onClick?.(clickSeries, instance); } }; type ReactEchartProps = React.ComponentProps; type ReactEChartOpts = NonNullable; /** * Used for some properties that can be truncated */ type Truncateable = { /** * Truncate the label / value some number of characters. * If true is passed, it will use truncate based on a default length. */ truncate?: number | boolean; }; type Props = { theme: Theme; options?: EChartOption; /** * Chart Series * This is different than the interface to higher level charts, these need to * be an array of ECharts "Series" components. */ series?: EChartOption.Series[]; /** * Array of color codes to use in charts. May also take a function which is * provided with the current theme */ colors?: string[] | ((theme: Theme) => string[]); /** * Must be explicitly `null` to disable xAxis * * Additionally a `truncate` option */ xAxis?: (EChartOption.XAxis & Truncateable) | null; /** * Must be explicitly `null` to disable yAxis */ yAxis?: EChartOption.YAxis | null; /** * Pass `true` to have 2 y-axes with default properties. Can pass an array of * objects to customize yAxis properties */ yAxes?: true | Props['yAxis'][]; /** * Pass `true` to have 2 x-axes with default properties. Can pass an array * of multiple objects to customize xAxis properties */ xAxes?: true | Props['xAxis'][]; /** * Tooltip options */ tooltip?: EChartOption.Tooltip & Truncateable & { filter?: (value: number, seriesParam: EChartOption.Tooltip.Format) => boolean; formatAxisLabel?: ( value: number, isTimestamp: boolean, utc: boolean, showTimeInTooltip: boolean ) => string; valueFormatter?: (value: number, label?: string) => string | number; nameFormatter?: (name: string) => string; }; /** * DataZoom (allows for zooming of chart) */ dataZoom?: EChartOption['dataZoom']; /** * Axis pointer options */ axisPointer?: EChartOption.AxisPointer; /** * Toolbox options */ toolBox?: EChartOption['toolbox']; /** * Graphic options */ graphic?: EChartOption['graphic']; /** * ECharts Grid options. multiple grids allow multiple sub-graphs. */ grid?: EChartOption.Grid | EChartOption.Grid[]; /** * ECharts Visual Map Options. */ visualMap?: EChartOption.VisualMap | EChartOption.VisualMap[]; /** * Chart legend */ legend?: EChartOption.Legend & Truncateable; /** * Chart height */ height?: ReactEChartOpts['height']; /** * Chart width */ width?: ReactEChartOpts['width']; /** * Use `canvas` when dealing with large datasets * See: https://ecomfe.github.io/echarts-doc/public/en/tutorial.html#Render%20by%20Canvas%20or%20SVG */ renderer?: ReactEChartOpts['renderer']; devicePixelRatio?: ReactEChartOpts['devicePixelRatio']; /** * theme name * example theme: https://github.com/apache/incubator-echarts/blob/master/theme/dark.js */ echartsTheme?: ReactEchartProps['theme']; /** * states whether or not to merge with previous `option` */ notMerge?: boolean; /** * states whether not to update chart immediately */ lazyUpdate?: boolean; onChartReady?: EChartChartReadyHandler; onHighlight?: EChartHighlightHandler; onMouseOver?: EChartMouseOverHandler; onClick?: EChartClickHandler; onDataZoom?: EChartDataZoomHandler; /** * One example of when this is called is restoring chart from zoom levels */ onRestore?: EChartRestoreHandler; onFinished?: EChartFinishedHandler; onRendered?: EChartRenderedHandler; onLegendSelectChanged?: EChartEventHandler<{ name: string; selected: Record; type: 'legendselectchanged'; }>; /** * Forwarded Ref */ forwardedRef?: React.Ref; /** * Custom chart props that are implemented by us (and not a feature of eCharts) * * Display previous period as a LineSeries */ previousPeriod?: Series[]; /** * If data is grouped by date; then apply default date formatting to x-axis * and tooltips. */ isGroupedByDate?: boolean; /** * Format timestamp with date AND time */ showTimeInTooltip?: boolean; /** * Use short date formatting for xAxis */ useShortDate?: boolean; /** * optional, used to determine how xAxis is formatted if `isGroupedByDate == true` */ start?: Date; /** * optional, used to determine how xAxis is formatted if `isGroupedByDate == true` */ end?: Date; /** * optional, used to determine how xAxis is formatted if `isGroupedByDate == true` */ period?: string; /** * Formats dates as UTC? */ utc?: boolean; /** * Bucket size to display time range in chart tooltip */ bucketSize?: number; /** * If true and there's only one datapoint in series.data, we show a bar chart to increase the visibility. * Especially useful with line / area charts, because you can't draw line with single data point and one alone point is hard to spot. */ transformSinglePointToBar?: boolean; /** * Inline styles */ style?: React.CSSProperties; }; function BaseChartUnwrapped({ theme, colors, grid, tooltip, legend, dataZoom, toolBox, graphic, axisPointer, previousPeriod, echartsTheme, devicePixelRatio, showTimeInTooltip, useShortDate, start, end, period, utc, yAxes, xAxes, style, forwardedRef, onClick, onLegendSelectChanged, onHighlight, onMouseOver, onDataZoom, onRestore, onFinished, onRendered, options = {}, series = [], yAxis = {}, xAxis = {}, height = 200, width = 'auto', renderer = 'svg', notMerge = true, lazyUpdate = false, isGroupedByDate = false, transformSinglePointToBar = false, onChartReady = () => {}, }: Props) { const hasSinglePoints = (series as EChartOption.SeriesLine[] | undefined)?.every( s => Array.isArray(s.data) && s.data.length <= 1 ); const transformedSeries = (hasSinglePoints && transformSinglePointToBar ? (series as EChartOption.SeriesLine[] | undefined)?.map(s => ({ ...s, type: 'bar', barWidth: 40, barGap: 0, itemStyle: {...(s.areaStyle ?? {})}, })) : series) ?? []; const transformedPreviousPeriod = previousPeriod?.map(previous => LineSeries({ name: previous.seriesName, data: previous.data.map(({name, value}) => [name, value]), lineStyle: {color: theme.gray200, type: 'dotted'}, itemStyle: {color: theme.gray200}, }) ) ?? []; const resolvedSeries = !previousPeriod ? transformedSeries : [...transformedSeries, ...transformedPreviousPeriod]; const defaultAxesProps = {theme}; const yAxisOrCustom = !yAxes ? yAxis !== null ? YAxis({theme, ...yAxis}) : undefined : Array.isArray(yAxes) ? yAxes.map(axis => YAxis({...axis, theme})) : [YAxis(defaultAxesProps), YAxis(defaultAxesProps)]; const xAxisOrCustom = !xAxes ? xAxis !== null ? XAxis({ ...xAxis, theme, useShortDate, start, end, period, isGroupedByDate, utc, }) : undefined : Array.isArray(xAxes) ? xAxes.map(axis => XAxis({...axis, theme, useShortDate, start, end, period, isGroupedByDate, utc}) ) : [XAxis(defaultAxesProps), XAxis(defaultAxesProps)]; // Maybe changing the series type to types/echarts Series[] would be a better // solution and can't use ignore for multiline blocks const seriesValid = series && series[0]?.data && series[0].data.length > 1; const seriesData = seriesValid ? series[0].data : undefined; const bucketSize = seriesData ? seriesData[1][0] - seriesData[0][0] : undefined; const tooltipOrNone = tooltip !== null ? Tooltip({ showTimeInTooltip, isGroupedByDate, utc, bucketSize, ...tooltip, }) : undefined; const resolveColors = colors !== undefined ? (Array.isArray(colors) ? colors : colors(theme)) : null; const color = resolveColors || (series.length ? theme.charts.getColorPalette(series.length) : theme.charts.colors); const chartOption = { ...options, animation: IS_ACCEPTANCE_TEST ? false : options.animation ?? true, useUTC: utc, color, grid: Array.isArray(grid) ? grid.map(Grid) : Grid(grid), tooltip: tooltipOrNone, legend: legend ? Legend({theme, ...legend}) : undefined, yAxis: yAxisOrCustom, xAxis: xAxisOrCustom, series: resolvedSeries, toolbox: toolBox, axisPointer, dataZoom, graphic, }; const chartStyles = { height: getDimensionValue(height), width: getDimensionValue(width), ...style, }; // XXX(epurkhiser): Echarts can become unhappy if one of these event handlers // causes the chart to re-render and be passed a whole different instance of // event handlers. // // We use React.useMemo to keep the value across renders // const eventsMap = React.useMemo( () => ({ click: (props, instance) => { handleClick(props, instance); onClick?.(props, instance); }, highlight: (props, instance) => onHighlight?.(props, instance), mouseover: (props, instance) => onMouseOver?.(props, instance), datazoom: (props, instance) => onDataZoom?.(props, instance), restore: (props, instance) => onRestore?.(props, instance), finished: (props, instance) => onFinished?.(props, instance), rendered: (props, instance) => onRendered?.(props, instance), legendselectchanged: (props, instance) => onLegendSelectChanged?.(props, instance), } as ReactEchartProps['onEvents']), [onclick, onHighlight, onMouseOver, onDataZoom, onRestore, onFinished, onRendered] ); return ( ); } // Contains styling for chart elements as we can't easily style those // elements directly const ChartContainer = styled('div')` /* Tooltip styling */ .tooltip-series, .tooltip-date { color: ${p => p.theme.gray300}; font-family: ${p => p.theme.text.family}; font-variant-numeric: tabular-nums; background: ${p => p.theme.gray500}; padding: ${space(1)} ${space(2)}; border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0; } .tooltip-series-solo { border-radius: ${p => p.theme.borderRadius}; } .tooltip-label { margin-right: ${space(1)}; } .tooltip-label strong { font-weight: normal; color: ${p => p.theme.white}; } .tooltip-series > div { display: flex; justify-content: space-between; align-items: baseline; } .tooltip-date { border-top: 1px solid ${p => p.theme.gray400}; text-align: center; position: relative; width: auto; border-radius: ${p => p.theme.borderRadiusBottom}; } .tooltip-arrow { top: 100%; left: 50%; border: 0px solid transparent; content: ' '; height: 0; width: 0; position: absolute; pointer-events: none; border-top-color: ${p => p.theme.gray500}; border-width: 8px; margin-left: -8px; } .echarts-for-react div:first-of-type { width: 100% !important; } .echarts-for-react tspan { font-variant-numeric: tabular-nums; } /* Tooltip description styling */ .tooltip-description { color: ${p => p.theme.white}; border-radius: ${p => p.theme.borderRadius}; background: #000; opacity: 0.9; padding: 5px 10px; position: relative; font-weight: bold; font-size: ${p => p.theme.fontSizeSmall}; line-height: 1.4; font-family: ${p => p.theme.text.family}; max-width: 230px; min-width: 230px; white-space: normal; text-align: center; :after { content: ''; position: absolute; top: 100%; left: 50%; width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid #000; transform: translateX(-50%); } } `; const BaseChartWithTheme = withTheme(BaseChartUnwrapped); const BaseChart = React.forwardRef>( (props, ref) => ); BaseChart.displayName = 'forwardRef(BaseChart)'; export default BaseChart;