blob: 5b5514da746347824b666f73a72f713f433b4088 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
AnnotationData,
AnnotationOpacity,
CategoricalColorScale,
EventAnnotationLayer,
FilterState,
FormulaAnnotationLayer,
getTimeFormatter,
IntervalAnnotationLayer,
isTimeseriesAnnotationResult,
NumberFormatter,
smartDateDetailedFormatter,
smartDateFormatter,
SupersetTheme,
TimeFormatter,
TimeseriesAnnotationLayer,
TimeseriesDataRecord,
} from '@superset-ui/core';
import { SeriesOption } from 'echarts';
import {
CallbackDataParams,
DefaultStatesMixin,
ItemStyleOption,
LineStyleOption,
OptionName,
SeriesLabelOption,
SeriesLineLabelOption,
ZRLineType,
} from 'echarts/types/src/util/types';
import {
MarkArea1DDataItemOption,
MarkArea2DDataItemOption,
} from 'echarts/types/src/component/marker/MarkAreaModel';
import { MarkLine1DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';
import { extractForecastSeriesContext } from '../utils/forecast';
import {
AxisType,
ForecastSeriesEnum,
LegendOrientation,
StackType,
} from '../types';
import { EchartsTimeseriesSeriesType } from './types';
import {
evalFormula,
extractRecordAnnotations,
formatAnnotationLabel,
parseAnnotationOpacity,
} from '../utils/annotation';
import { currentSeries, getChartPadding } from '../utils/series';
import {
AreaChartExtraControlsValue,
OpacityEnum,
TIMESERIES_CONSTANTS,
} from '../constants';
export function transformSeries(
series: SeriesOption,
colorScale: CategoricalColorScale,
opts: {
area?: boolean;
filterState?: FilterState;
seriesContexts?: { [key: string]: ForecastSeriesEnum[] };
markerEnabled?: boolean;
markerSize?: number;
areaOpacity?: number;
seriesType?: EchartsTimeseriesSeriesType;
stack?: StackType;
yAxisIndex?: number;
showValue?: boolean;
onlyTotal?: boolean;
formatter?: NumberFormatter;
totalStackedValues?: number[];
showValueIndexes?: number[];
thresholdValues?: number[];
richTooltip?: boolean;
seriesKey?: OptionName;
sliceId?: number;
isHorizontal?: boolean;
lineStyle?: LineStyleOption;
queryIndex?: number;
},
): SeriesOption | undefined {
const { name } = series;
const {
area,
filterState,
seriesContexts = {},
markerEnabled,
markerSize,
areaOpacity = 1,
seriesType,
stack,
yAxisIndex = 0,
showValue,
onlyTotal,
formatter,
totalStackedValues = [],
showValueIndexes = [],
thresholdValues = [],
richTooltip,
seriesKey,
sliceId,
isHorizontal = false,
queryIndex = 0,
} = opts;
const contexts = seriesContexts[name || ''] || [];
const hasForecast =
contexts.includes(ForecastSeriesEnum.ForecastTrend) ||
contexts.includes(ForecastSeriesEnum.ForecastLower) ||
contexts.includes(ForecastSeriesEnum.ForecastUpper);
const forecastSeries = extractForecastSeriesContext(name || '');
const isConfidenceBand =
forecastSeries.type === ForecastSeriesEnum.ForecastLower ||
forecastSeries.type === ForecastSeriesEnum.ForecastUpper;
const isFiltered =
filterState?.selectedValues && !filterState?.selectedValues.includes(name);
const opacity = isFiltered
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent;
// don't create a series if doing a stack or area chart and the result
// is a confidence band
if ((stack || area) && isConfidenceBand) return undefined;
const isObservation = forecastSeries.type === ForecastSeriesEnum.Observation;
const isTrend = forecastSeries.type === ForecastSeriesEnum.ForecastTrend;
let stackId;
if (isConfidenceBand) {
stackId = forecastSeries.name;
} else if (stack && isObservation) {
// the suffix of the observation series is '' (falsy), which disables
// stacking. Therefore we need to set something that is truthy.
stackId = 'obs';
} else if (stack && isTrend) {
stackId = forecastSeries.type;
}
let plotType;
if (
!isConfidenceBand &&
(seriesType === 'scatter' || (hasForecast && isObservation))
) {
plotType = 'scatter';
} else if (isConfidenceBand) {
plotType = 'line';
} else {
plotType = seriesType === 'bar' ? 'bar' : 'line';
}
// forcing the colorScale to return a different color for same metrics across different queries
const itemStyle = {
color: colorScale(seriesKey || forecastSeries.name, sliceId),
opacity,
};
let emphasis = {};
let showSymbol = false;
if (!isConfidenceBand) {
if (plotType === 'scatter') {
showSymbol = true;
} else if (hasForecast && isObservation) {
showSymbol = true;
} else if (plotType === 'line' && showValue) {
showSymbol = true;
} else if (plotType === 'line' && !richTooltip && !markerEnabled) {
// this is hack to make timeseries line chart clickable when tooltip trigger is 'item'
// so that the chart can emit cross-filtering
showSymbol = true;
itemStyle.opacity = 0;
emphasis = {
itemStyle: {
opacity: 1,
},
};
} else if (markerEnabled) {
showSymbol = true;
}
}
const lineStyle = isConfidenceBand
? { ...opts.lineStyle, opacity: OpacityEnum.Transparent }
: { ...opts.lineStyle, opacity };
return {
...series,
queryIndex,
yAxisIndex,
name: forecastSeries.name,
itemStyle,
// @ts-ignore
type: plotType,
smooth: seriesType === 'smooth',
triggerLineEvent: true,
// @ts-ignore
step: ['start', 'middle', 'end'].includes(seriesType as string)
? seriesType
: undefined,
stack: stackId,
lineStyle,
areaStyle:
area || forecastSeries.type === ForecastSeriesEnum.ForecastUpper
? {
opacity: opacity * areaOpacity,
}
: undefined,
emphasis: {
// bold on hover as required since 5.3.0 to retain backwards feature parity:
// https://apache.github.io/echarts-handbook/en/basics/release-note/5-3-0/#removing-the-default-bolding-emphasis-effect-in-the-line-chart
// TODO: should consider only adding emphasis to currently hovered series
lineStyle: {
width: 'bolder',
},
...emphasis,
},
showSymbol,
symbolSize: markerSize,
label: {
show: !!showValue,
position: isHorizontal ? 'right' : 'top',
formatter: (params: any) => {
const { value, dataIndex, seriesIndex, seriesName } = params;
const numericValue = isHorizontal ? value[0] : value[1];
const isSelectedLegend = currentSeries.legend === seriesName;
const isAreaExpand = stack === AreaChartExtraControlsValue.Expand;
if (!formatter) return numericValue;
if (!stack || isSelectedLegend) return formatter(numericValue);
if (!onlyTotal) {
if (
numericValue >=
(thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)
) {
return formatter(numericValue);
}
return '';
}
if (seriesIndex === showValueIndexes[dataIndex]) {
return formatter(isAreaExpand ? 1 : totalStackedValues[dataIndex]);
}
return '';
},
},
};
}
export function transformFormulaAnnotation(
layer: FormulaAnnotationLayer,
data: TimeseriesDataRecord[],
xAxisCol: string,
xAxisType: AxisType,
colorScale: CategoricalColorScale,
sliceId?: number,
): SeriesOption {
const { name, color, opacity, width, style } = layer;
return {
name,
id: name,
itemStyle: {
color: color || colorScale(name, sliceId),
},
lineStyle: {
opacity: parseAnnotationOpacity(opacity),
type: style as ZRLineType,
width,
},
type: 'line',
smooth: true,
data: evalFormula(layer, data, xAxisCol, xAxisType),
symbolSize: 0,
};
}
export function transformIntervalAnnotation(
layer: IntervalAnnotationLayer,
data: TimeseriesDataRecord[],
annotationData: AnnotationData,
colorScale: CategoricalColorScale,
theme: SupersetTheme,
sliceId?: number,
): SeriesOption[] {
const series: SeriesOption[] = [];
const annotations = extractRecordAnnotations(layer, annotationData);
annotations.forEach(annotation => {
const { name, color, opacity, showLabel } = layer;
const { descriptions, intervalEnd, time, title } = annotation;
const label = formatAnnotationLabel(name, title, descriptions);
const intervalData: (
| MarkArea1DDataItemOption
| MarkArea2DDataItemOption
)[] = [
[
{
name: label,
xAxis: time,
},
{
xAxis: intervalEnd,
},
],
];
const intervalLabel: SeriesLabelOption = showLabel
? {
show: true,
color: theme.colors.grayscale.dark2,
position: 'insideTop',
verticalAlign: 'top',
fontWeight: 'bold',
// @ts-ignore
emphasis: {
position: 'insideTop',
verticalAlign: 'top',
backgroundColor: theme.colors.grayscale.light5,
},
}
: {
show: false,
color: theme.colors.grayscale.dark2,
// @ts-ignore
emphasis: {
fontWeight: 'bold',
show: true,
position: 'insideTop',
verticalAlign: 'top',
backgroundColor: theme.colors.grayscale.light5,
},
};
series.push({
id: `Interval - ${label}`,
type: 'line',
animation: false,
markArea: {
silent: false,
itemStyle: {
color: color || colorScale(name, sliceId),
opacity: parseAnnotationOpacity(opacity || AnnotationOpacity.Medium),
emphasis: {
opacity: 0.8,
},
} as ItemStyleOption,
label: intervalLabel,
data: intervalData,
},
});
});
return series;
}
export function transformEventAnnotation(
layer: EventAnnotationLayer,
data: TimeseriesDataRecord[],
annotationData: AnnotationData,
colorScale: CategoricalColorScale,
theme: SupersetTheme,
sliceId?: number,
): SeriesOption[] {
const series: SeriesOption[] = [];
const annotations = extractRecordAnnotations(layer, annotationData);
annotations.forEach(annotation => {
const { name, color, opacity, style, width, showLabel } = layer;
const { descriptions, time, title } = annotation;
const label = formatAnnotationLabel(name, title, descriptions);
const eventData: MarkLine1DDataItemOption[] = [
{
name: label,
xAxis: time,
},
];
const lineStyle: LineStyleOption & DefaultStatesMixin['emphasis'] = {
width,
type: style as ZRLineType,
color: color || colorScale(name, sliceId),
opacity: parseAnnotationOpacity(opacity),
emphasis: {
width: width ? width + 1 : width,
opacity: 1,
},
};
const eventLabel: SeriesLineLabelOption = showLabel
? {
show: true,
color: theme.colors.grayscale.dark2,
position: 'insideEndTop',
fontWeight: 'bold',
formatter: (params: CallbackDataParams) => params.name,
// @ts-ignore
emphasis: {
backgroundColor: theme.colors.grayscale.light5,
},
}
: {
show: false,
color: theme.colors.grayscale.dark2,
position: 'insideEndTop',
// @ts-ignore
emphasis: {
formatter: (params: CallbackDataParams) => params.name,
fontWeight: 'bold',
show: true,
backgroundColor: theme.colors.grayscale.light5,
},
};
series.push({
id: `Event - ${label}`,
type: 'line',
animation: false,
markLine: {
silent: false,
symbol: 'none',
lineStyle,
label: eventLabel,
data: eventData,
},
});
});
return series;
}
export function transformTimeseriesAnnotation(
layer: TimeseriesAnnotationLayer,
markerSize: number,
data: TimeseriesDataRecord[],
annotationData: AnnotationData,
colorScale: CategoricalColorScale,
sliceId?: number,
): SeriesOption[] {
const series: SeriesOption[] = [];
const { hideLine, name, opacity, showMarkers, style, width, color } = layer;
const result = annotationData[name];
if (isTimeseriesAnnotationResult(result)) {
result.forEach(annotation => {
const { key, values } = annotation;
series.push({
type: 'line',
id: key,
name: key,
data: values.map(row => [row.x, row.y] as [OptionName, number]),
symbolSize: showMarkers ? markerSize : 0,
lineStyle: {
opacity: parseAnnotationOpacity(opacity),
type: style as ZRLineType,
width: hideLine ? 0 : width,
color: color || colorScale(name, sliceId),
},
});
});
}
return series;
}
export function getPadding(
showLegend: boolean,
legendOrientation: LegendOrientation,
addYAxisTitleOffset: boolean,
zoomable: boolean,
margin?: string | number | null,
addXAxisTitleOffset?: boolean,
yAxisTitlePosition?: string,
yAxisTitleMargin?: number,
xAxisTitleMargin?: number,
): {
bottom: number;
left: number;
right: number;
top: number;
} {
const yAxisOffset = addYAxisTitleOffset
? TIMESERIES_CONSTANTS.yAxisLabelTopOffset
: 0;
const xAxisOffset = addXAxisTitleOffset ? Number(xAxisTitleMargin) || 0 : 0;
return getChartPadding(showLegend, legendOrientation, margin, {
top:
yAxisTitlePosition && yAxisTitlePosition === 'Top'
? TIMESERIES_CONSTANTS.gridOffsetTop + (Number(yAxisTitleMargin) || 0)
: TIMESERIES_CONSTANTS.gridOffsetTop + yAxisOffset,
bottom: zoomable
? TIMESERIES_CONSTANTS.gridOffsetBottomZoomable + xAxisOffset
: TIMESERIES_CONSTANTS.gridOffsetBottom + xAxisOffset,
left:
yAxisTitlePosition === 'Left'
? TIMESERIES_CONSTANTS.gridOffsetLeft + (Number(yAxisTitleMargin) || 0)
: TIMESERIES_CONSTANTS.gridOffsetLeft,
right:
showLegend && legendOrientation === LegendOrientation.Right
? 0
: TIMESERIES_CONSTANTS.gridOffsetRight,
});
}
export function getTooltipTimeFormatter(
format?: string,
): TimeFormatter | StringConstructor {
if (format === smartDateFormatter.id) {
return smartDateDetailedFormatter;
}
if (format) {
return getTimeFormatter(format);
}
return String;
}
export function getXAxisFormatter(
format?: string,
): TimeFormatter | StringConstructor | undefined {
if (format === smartDateFormatter.id || !format) {
return undefined;
}
if (format) {
return getTimeFormatter(format);
}
return String;
}