123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706 |
- 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, 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 {IS_ACCEPTANCE_TEST} from 'sentry/constants';
- 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 type {Theme} from 'sentry/utils/theme';
- import Grid from './components/grid';
- import Legend from './components/legend';
- import Tooltip, {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<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;
- };
- interface TooltipOption
- extends Omit<TooltipComponentOption, 'valueFormatter'>,
- 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<any>
- ) => string;
- }
- type Props = {
- /**
- * 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<ReactEchartsCore>;
- /**
- * 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<string, boolean>;
- 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 | Props['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 | Props['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,
- }: Props) {
- 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
- ? Tooltip({
- 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,
- 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,
- 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 (
- <ChartContainer autoHeightResize={autoHeightResize} data-test-id={dataTestId}>
- {isTooltipPortalled && <Global styles={getPortalledTooltipStyles({theme})} />}
- <ReactEchartsCore
- ref={forwardedRef}
- echarts={echarts}
- notMerge={notMerge}
- lazyUpdate={lazyUpdate}
- theme={echartsTheme}
- onChartReady={onChartReady}
- onEvents={eventsMap}
- style={chartStyles}
- opts={{
- height: autoHeightResize ? undefined : height,
- width,
- renderer,
- devicePixelRatio,
- }}
- option={chartOption}
- />
- </ChartContainer>
- );
- }
- // Tooltip styles shared for regular and portalled tooltips
- const getTooltipStyles = (p: {theme: Theme}) => css`
- /* Tooltip styling */
- .tooltip-series,
- .tooltip-date {
- 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-indent {
- margin-left: 18px;
- }
- .tooltip-series > div {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- }
- .tooltip-date {
- border-top: solid 1px ${p.theme.innerBorder};
- text-align: center;
- position: relative;
- width: auto;
- border-radius: ${p.theme.borderRadiusBottom};
- }
- .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<ReactEchartsRef, Props>((props, ref) => (
- <BaseChartUnwrapped forwardedRef={ref} {...props} />
- ));
- BaseChart.displayName = 'forwardRef(BaseChart)';
- export default BaseChart;
|