import 'echarts/lib/component/grid'; import 'echarts/lib/component/graphic'; import 'echarts/lib/component/toolbox'; import 'zrender/lib/svg/svg'; import {forwardRef, useMemo} from 'react'; import {css, Global, Theme, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import type { AxisPointerComponentOption, ECharts, EChartsOption, GridComponentOption, LegendComponentOption, LineSeriesOption, SeriesOption, TooltipComponentFormatterCallback, TooltipComponentFormatterCallbackParams, TooltipComponentOption, VisualMapComponentOption, XAXisComponentOption, YAXisComponentOption, } from 'echarts'; import {AriaComponent} from 'echarts/components'; import * as echarts from 'echarts/core'; import ReactEchartsCore from 'echarts-for-react/lib/core'; import MarkLine from 'sentry/components/charts/components/markLine'; import {space} from 'sentry/styles/space'; import { EChartChartReadyHandler, EChartClickHandler, EChartDataZoomHandler, EChartEventHandler, EChartFinishedHandler, EChartHighlightHandler, EChartMouseOutHandler, EChartMouseOverHandler, EChartRenderedHandler, EChartRestoreHandler, ReactEchartsRef, Series, } from 'sentry/types/echarts'; import {defined} from 'sentry/utils'; import Grid from './components/grid'; import Legend from './components/legend'; import {ChartTooltip, TooltipSubLabel} from './components/tooltip'; import XAxis from './components/xAxis'; import YAxis from './components/yAxis'; import LineSeries from './series/lineSeries'; import { getDiffInMinutes, getDimensionValue, lightenHexToRgb, useEchartsAriaLabels, } 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); } }; echarts.use(AriaComponent); 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; }; interface TooltipOption extends Omit, Truncateable { filter?: (value: number, seriesParam: TooltipComponentOption['formatter']) => boolean; formatAxisLabel?: ( value: number, isTimestamp: boolean, utc: boolean, showTimeInTooltip: boolean, addSecondsToTimeFormat: boolean, bucketSize: number | undefined, seriesParamsOrParam: TooltipComponentFormatterCallbackParams ) => string; markerFormatter?: (marker: string, label?: string) => string; nameFormatter?: (name: string) => string; /** * Array containing data that is used to display indented sublabels. */ subLabels?: TooltipSubLabel[]; valueFormatter?: ( value: number, label?: string, seriesParams?: TooltipComponentFormatterCallback ) => string; } export interface BaseChartProps { /** * Additional Chart Series * This is to pass series to BaseChart bypassing the wrappers like LineChart, AreaChart etc. */ additionalSeries?: LineSeriesOption[]; /** * If true, ignores height value and auto-scales chart to fit container height. */ autoHeightResize?: boolean; /** * Axis pointer options */ axisPointer?: AxisPointerComponentOption; /** * Bucket size to display time range in chart tooltip */ bucketSize?: number; /** * 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[]); 'data-test-id'?: string; /** * DataZoom (allows for zooming of chart) */ dataZoom?: EChartsOption['dataZoom']; devicePixelRatio?: ReactEChartOpts['devicePixelRatio']; /** * theme name * example theme: https://github.com/apache/incubator-echarts/blob/master/theme/dark.js */ echartsTheme?: ReactEchartProps['theme']; /** * optional, used to determine how xAxis is formatted if `isGroupedByDate == true` */ end?: Date; /** * Forwarded Ref */ forwardedRef?: React.Ref; /** * Graphic options */ graphic?: EChartsOption['graphic']; /** * ECharts Grid options. multiple grids allow multiple sub-graphs. */ grid?: GridComponentOption | GridComponentOption[]; /** * Chart height */ height?: ReactEChartOpts['height']; /** * If data is grouped by date; then apply default date formatting to x-axis * and tooltips. */ isGroupedByDate?: boolean; /** * states whether not to update chart immediately */ lazyUpdate?: boolean; /** * Chart legend */ legend?: LegendComponentOption & Truncateable; /** * optional, threshold in minutes used to add seconds to the xAxis datetime format if `isGroupedByDate == true` */ minutesThresholdToDisplaySeconds?: number; /** * states whether or not to merge with previous `option` */ notMerge?: boolean; onChartReady?: EChartChartReadyHandler; onClick?: EChartClickHandler; onDataZoom?: EChartDataZoomHandler; onFinished?: EChartFinishedHandler; onHighlight?: EChartHighlightHandler; onLegendSelectChanged?: EChartEventHandler<{ name: string; selected: Record; type: 'legendselectchanged'; }>; onMouseOut?: EChartMouseOutHandler; onMouseOver?: EChartMouseOverHandler; onRendered?: EChartRenderedHandler; /** * One example of when this is called is restoring chart from zoom levels */ onRestore?: EChartRestoreHandler; options?: EChartsOption; /** * optional, used to determine how xAxis is formatted if `isGroupedByDate == true` */ period?: string | null; /** * Custom chart props that are implemented by us (and not a feature of eCharts) * * Display previous period as a LineSeries */ previousPeriod?: Series[]; /** * 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']; /** * Chart Series * This is different than the interface to higher level charts, these need to * be an array of ECharts "Series" components. */ series?: SeriesOption[]; /** * Format timestamp with date AND time */ showTimeInTooltip?: boolean; /** * optional, used to determine how xAxis is formatted if `isGroupedByDate == true` */ start?: Date; /** * Inline styles */ style?: React.CSSProperties; /** * Toolbox options */ toolBox?: EChartsOption['toolbox']; /** * Tooltip options */ tooltip?: TooltipOption; /** * 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; /** * If true and there's only one datapoint in series.data, we show a horizontal line to increase the visibility * Similarly to single point bar in area charts a flat line for line charts makes it easy to spot the single data point. */ transformSinglePointToLine?: boolean; /** * Use short date formatting for xAxis */ useShortDate?: boolean; /** * Formats dates as UTC? */ utc?: boolean; /** * ECharts Visual Map Options. */ visualMap?: VisualMapComponentOption | VisualMapComponentOption[]; /** * Chart width */ width?: ReactEChartOpts['width']; /** * Pass `true` to have 2 x-axes with default properties. Can pass an array * of multiple objects to customize xAxis properties */ xAxes?: true | BaseChartProps['xAxis'][]; /** * Must be explicitly `null` to disable xAxis * * Additionally a `truncate` option */ xAxis?: (XAXisComponentOption & Truncateable) | null; /** * Pass `true` to have 2 y-axes with default properties. Can pass an array of * objects to customize yAxis properties */ yAxes?: true | BaseChartProps['yAxis'][]; /** * Must be explicitly `null` to disable yAxis */ yAxis?: YAXisComponentOption | null; } function BaseChartUnwrapped({ colors, grid, tooltip, legend, dataZoom, toolBox, graphic, axisPointer, previousPeriod, echartsTheme, devicePixelRatio, minutesThresholdToDisplaySeconds, showTimeInTooltip, useShortDate, start, end, period, utc, yAxes, xAxes, style, forwardedRef, onClick, onLegendSelectChanged, onHighlight, onMouseOut, onMouseOver, onDataZoom, onRestore, onFinished, onRendered, options = {}, series = [], additionalSeries = [], yAxis = {}, xAxis = {}, autoHeightResize = false, height = 200, width, renderer = 'svg', notMerge = true, lazyUpdate = false, isGroupedByDate = false, transformSinglePointToBar = false, transformSinglePointToLine = false, onChartReady = () => {}, 'data-test-id': dataTestId, }: BaseChartProps) { const theme = useTheme(); const hasSinglePoints = (series as LineSeriesOption[] | undefined)?.every( s => Array.isArray(s.data) && s.data.length <= 1 ); 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 previousPeriodColors = previousPeriod && previousPeriod.length > 1 ? lightenHexToRgb(color) : undefined; const transformedSeries = (hasSinglePoints && transformSinglePointToBar ? (series as LineSeriesOption[] | undefined)?.map(s => ({ ...s, type: 'bar', barWidth: 40, barGap: 0, itemStyle: {...(s.areaStyle ?? {})}, })) : hasSinglePoints && transformSinglePointToLine ? (series as LineSeriesOption[] | undefined)?.map(s => ({ ...s, type: 'line', itemStyle: {...(s.lineStyle ?? {})}, markLine: s?.data?.[0]?.[1] !== undefined ? MarkLine({ silent: true, lineStyle: { type: 'solid', width: 1.5, }, data: [{yAxis: s?.data?.[0]?.[1]}], label: { show: false, }, }) : undefined, })) : series) ?? []; const transformedPreviousPeriod = previousPeriod?.map((previous, seriesIndex) => LineSeries({ name: previous.seriesName, data: previous.data.map(({name, value}) => [name, value]), lineStyle: { color: previousPeriodColors ? previousPeriodColors[seriesIndex] : theme.gray200, type: 'dotted', }, itemStyle: { color: previousPeriodColors ? previousPeriodColors[seriesIndex] : theme.gray200, }, stack: 'previous', animation: false, }) ) ?? []; const resolvedSeries = !previousPeriod ? [...transformedSeries, ...additionalSeries] : [...transformedSeries, ...transformedPreviousPeriod, ...additionalSeries]; 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)]; /** * If true seconds will be added to the time format in the tooltips and chart xAxis */ const addSecondsToTimeFormat = isGroupedByDate && defined(minutesThresholdToDisplaySeconds) ? getDiffInMinutes({start, end, period}) <= minutesThresholdToDisplaySeconds : false; const xAxisOrCustom = !xAxes ? xAxis !== null ? XAxis({ ...xAxis, theme, useShortDate, start, end, period, isGroupedByDate, addSecondsToTimeFormat, utc, }) : undefined : Array.isArray(xAxes) ? xAxes.map(axis => XAxis({ ...axis, theme, useShortDate, start, end, period, isGroupedByDate, addSecondsToTimeFormat, utc, }) ) : [XAxis(defaultAxesProps), XAxis(defaultAxesProps)]; const seriesData = Array.isArray(series?.[0]?.data) && series[0].data.length > 1 ? series[0].data : undefined; const bucketSize = seriesData ? seriesData[1][0] - seriesData[0][0] : undefined; const isTooltipPortalled = tooltip?.appendToBody; const tooltipOrNone = tooltip !== null ? ChartTooltip({ showTimeInTooltip, isGroupedByDate, addSecondsToTimeFormat, utc, bucketSize, ...tooltip, className: isTooltipPortalled ? `${tooltip?.className ?? ''} chart-tooltip-portal` : tooltip?.className, }) : undefined; const aria = useEchartsAriaLabels( { ...options, series: resolvedSeries, useUTC: utc, }, isGroupedByDate ); const chartOption = { ...options, 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, aria, }; const chartStyles = { height: autoHeightResize ? '100%' : 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 = useMemo( () => ({ click: (props, instance) => { handleClick(props, instance); onClick?.(props, instance); }, highlight: (props, instance) => onHighlight?.(props, instance), mouseout: (props, instance) => onMouseOut?.(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, onLegendSelectChanged, onMouseOut, onMouseOver, onDataZoom, onRestore, onFinished, onRendered, ] ); return ( {isTooltipPortalled && } ); } // Tooltip styles shared for regular and portalled tooltips const getTooltipStyles = (p: {theme: Theme}) => css` /* Tooltip styling */ .tooltip-series, .tooltip-footer { color: ${p.theme.subText}; font-family: ${p.theme.text.family}; font-variant-numeric: tabular-nums; padding: ${space(1)} ${space(2)}; border-radius: ${p.theme.borderRadius} ${p.theme.borderRadius} 0 0; } .tooltip-series { border-bottom: none; } .tooltip-series-solo { border-radius: ${p.theme.borderRadius}; } .tooltip-label { margin-right: ${space(1)}; } .tooltip-label strong { font-weight: normal; color: ${p.theme.textColor}; } .tooltip-label-value { color: ${p.theme.textColor}; } .tooltip-label-indent { margin-left: 18px; } .tooltip-series > div { display: flex; justify-content: space-between; align-items: baseline; } .tooltip-footer { border-top: solid 1px ${p.theme.innerBorder}; text-align: center; position: relative; width: auto; border-radius: ${p.theme.borderRadiusBottom}; display: flex; justify-content: space-between; gap: ${space(3)}; } .tooltip-footer-centered { justify-content: center; gap: 0; } .tooltip-arrow { top: 100%; left: 50%; position: absolute; pointer-events: none; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 8px solid ${p.theme.backgroundElevated}; margin-left: -8px; &:before { border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 8px solid ${p.theme.translucentBorder}; content: ''; display: block; position: absolute; top: -7px; left: -8px; z-index: -1; } } /* Tooltip description styling */ .tooltip-description { color: ${p.theme.white}; border-radius: ${p.theme.borderRadius}; background: #000; opacity: 0.9; padding: 5px 10px; position: relative; font-weight: bold; font-size: ${p.theme.fontSizeSmall}; line-height: 1.4; font-family: ${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%); } } `; // Contains styling for chart elements as we can't easily style those // elements directly const ChartContainer = styled('div')<{autoHeightResize: boolean}>` ${p => p.autoHeightResize && 'height: 100%;'} .echarts-for-react div:first-of-type { width: 100% !important; } .echarts-for-react text { font-variant-numeric: tabular-nums !important; } ${p => getTooltipStyles(p)} `; const getPortalledTooltipStyles = (p: {theme: Theme}) => css` .chart-tooltip-portal { ${getTooltipStyles(p)}; } `; const BaseChart = forwardRef((props, ref) => ( )); BaseChart.displayName = 'forwardRef(BaseChart)'; export default BaseChart;