123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531 |
- 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<typeof ReactEchartsCore>;
- type ReactEChartOpts = NonNullable<ReactEchartProps['opts']>;
- /**
- * 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<string, boolean>;
- type: 'legendselectchanged';
- }>;
- /**
- * Forwarded Ref
- */
- forwardedRef?: React.Ref<ReactEchartsCore>;
- /**
- * 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 (
- <ChartContainer>
- <ReactEchartsCore
- ref={forwardedRef}
- echarts={echarts}
- notMerge={notMerge}
- lazyUpdate={lazyUpdate}
- theme={echartsTheme}
- onChartReady={onChartReady}
- onEvents={eventsMap}
- style={chartStyles}
- opts={{height, width, renderer, devicePixelRatio}}
- option={chartOption}
- />
- </ChartContainer>
- );
- }
- // 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<ReactEchartsRef, Omit<Props, 'theme'>>(
- (props, ref) => <BaseChartWithTheme forwardedRef={ref} {...props} />
- );
- BaseChart.displayName = 'forwardRef(BaseChart)';
- export default BaseChart;
|