/** * 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, AxisType, CategoricalColorScale, EventAnnotationLayer, FilterState, FormulaAnnotationLayer, IntervalAnnotationLayer, LegendState, TimeseriesAnnotationLayer, TimeseriesDataRecord, ValueFormatter, } from '@superset-ui/core'; import { SupersetTheme, isThemeDark } from '@apache-superset/core/theme'; import type { CallbackDataParams, DefaultStatesMixin, ItemStyleOption, LineStyleOption, OptionName, SeriesLabelOption, SeriesLineLabelOption, ZRLineType, } from 'echarts/types/src/util/types'; import type { SeriesOption } from 'echarts'; import type { MarkArea1DDataItemOption, MarkArea2DDataItemOption, } from 'echarts/types/src/component/marker/MarkAreaModel'; import type { MarkLine1DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel'; import { extractForecastSeriesContext } from '../utils/forecast'; import { EchartsTimeseriesSeriesType, ForecastSeriesEnum, LabelPositionEnum, LegendOrientation, OrientationType, StackType, } from '../types'; import { evalFormula, extractRecordAnnotations, formatAnnotationLabel, parseAnnotationOpacity, } from '../utils/annotation'; import { getChartPadding, getTimeCompareStackId } from '../utils/series'; import { OpacityEnum, StackControlsValue, TIMESERIES_CONSTANTS, } from '../constants'; function parseTimeShiftToMs(timeShift?: string | null): number { if (!timeShift) return 0; const match = timeShift .trim() .match(/^(-?\d+(?:\.\d+)?)\s*(second|minute|hour|day|week|month|year)s?$/i); if (!match) return 0; const value = Number(match[1]); const unit = match[2].toLowerCase(); const MS: Record = { second: 1000, minute: 60 * 1000, hour: 60 * 60 * 1000, day: 24 * 60 * 60 * 1000, week: 7 * 24 * 60 * 60 * 1000, month: 30 * 24 * 60 * 60 * 1000, year: 365 * 24 * 60 * 60 * 1000, }; return value * (MS[unit] ?? 0); } // based on weighted wiggle algorithm // source: https://ieeexplore.ieee.org/document/4658136 export const getBaselineSeriesForStream = ( series: [string | number, number][][], seriesType: EchartsTimeseriesSeriesType, ) => { const seriesLength = series[0].length; const baselineSeriesDelta: [string | number, number][] = Array.from( { length: seriesLength }, () => [0, 0], ); const getVal = (value: number | null) => value ?? 0; for (let i = 0; i < seriesLength; i += 1) { let seriesSum = 0; let weightedSeriesSum = 0; for (let j = 0; j < series.length; j += 1) { const delta = i > 0 ? getVal(series[j][i][1]) - getVal(series[j][i - 1][1]) : getVal(series[j][i][1]); let deltaPrev = 0; for (let k = 1; k < j - 1; k += 1) { deltaPrev += i > 0 ? getVal(series[k][i][1]) - getVal(series[k][i - 1][1]) : getVal(series[k][i][1]); } weightedSeriesSum += (0.5 * delta + deltaPrev) * getVal(series[j][i][1]); seriesSum += getVal(series[j][i][1]); } baselineSeriesDelta[i] = [series[0][i][0], -weightedSeriesSum / seriesSum]; } const baselineSeries = baselineSeriesDelta.reduce< [string | number, number][] >((acc, curr, i) => { if (i === 0) { acc.push(curr); } else { acc.push([curr[0], acc[i - 1][1] + curr[1]]); } return acc; }, []); return { data: baselineSeries, name: 'baseline', stack: 'obs', stackStrategy: 'all' as const, type: 'line' as const, lineStyle: { opacity: 0, }, tooltip: { show: false, }, silent: true, showSymbol: false, areaStyle: { opacity: 0, }, step: [ EchartsTimeseriesSeriesType.Start, EchartsTimeseriesSeriesType.Middle, EchartsTimeseriesSeriesType.End, ].includes(seriesType) ? (seriesType as | EchartsTimeseriesSeriesType.Start | EchartsTimeseriesSeriesType.Middle | EchartsTimeseriesSeriesType.End) : undefined, smooth: seriesType === EchartsTimeseriesSeriesType.Smooth, }; }; export function transformNegativeLabelsPosition( series: SeriesOption, isHorizontal: boolean, labelPosition?: string, ): TimeseriesDataRecord[] { /* * Adjusts label position for negative values in bar series * @param series - Array of series options * @param isHorizontal - Whether chart is horizontal * @returns data with adjusted label positions for negative values */ const transformValue = (value: any) => { const [xValue, yValue] = Array.isArray(value) ? value : [null, null]; const axisValue = isHorizontal ? xValue : yValue; return axisValue < 0 ? { value, label: { position: labelPosition && labelPosition !== 'auto' ? labelPosition : 'outside', }, } : value; }; return (series.data as TimeseriesDataRecord[]).map(transformValue); } export function applyColorByPrimaryAxis( series: SeriesOption, colorScale: CategoricalColorScale, sliceId: number | undefined, opacity: number, isHorizontal = false, ): { value: [string | number, number]; itemStyle: { color: string; opacity: number; borderWidth: number }; }[] { return (series.data as [string | number, number][]).map(value => { // For horizontal charts the primary axis is index 1 (category), not index 0 (numeric) const colorKey = String(isHorizontal ? value[1] : value[0]); return { value, itemStyle: { color: colorScale(colorKey, sliceId), opacity, borderWidth: 0, }, }; }); } export function transformSeries( series: SeriesOption, colorScale: CategoricalColorScale, colorScaleKey: string, opts: { area?: boolean; connectNulls?: boolean; filterState?: FilterState; seriesContexts?: { [key: string]: ForecastSeriesEnum[] }; markerEnabled?: boolean; markerSize?: number; symbolSizeFn?: (value: (number | string | null)[]) => number; areaOpacity?: number; seriesType?: EchartsTimeseriesSeriesType; stack?: StackType; stackIdSuffix?: string; yAxisIndex?: number; showValue?: boolean; onlyTotal?: boolean; legendState?: LegendState; formatter?: ValueFormatter; totalStackedValues?: number[]; showValueIndexes?: number[]; thresholdValues?: number[]; richTooltip?: boolean; seriesKey?: OptionName; sliceId?: number; isHorizontal?: boolean; lineSymbol?: string; lineStyle?: LineStyleOption; queryIndex?: number; timeCompare?: string[]; timeShiftColor?: boolean; theme?: SupersetTheme; hasDimensions?: boolean; colorByPrimaryAxis?: boolean; labelPosition?: string; }, ): SeriesOption | undefined { const { name, data } = series; const { area, connectNulls, filterState, seriesContexts = {}, markerEnabled, markerSize, symbolSizeFn, areaOpacity = 1, seriesType, stack, stackIdSuffix, yAxisIndex = 0, showValue, onlyTotal, formatter, legendState, totalStackedValues = [], showValueIndexes = [], thresholdValues = [], richTooltip, seriesKey, sliceId, isHorizontal = false, queryIndex = 0, timeCompare = [], timeShiftColor, theme, colorByPrimaryAxis = false, labelPosition, } = 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; // When cross-filtering by X-axis (no dimensions), selectedValues contains // X-axis values rather than series names, so skip series-level dimming. const isFiltered = opts.hasDimensions !== false && filterState?.selectedValues && !filterState?.selectedValues.includes(name); const opacity = isFiltered ? OpacityEnum.SemiTransparent : opts.lineStyle?.opacity || 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 = getTimeCompareStackId('obs', timeCompare, name); } else if (stack && isTrend) { stackId = getTimeCompareStackId(forecastSeries.type, timeCompare, name); } if (stackId && stackIdSuffix) { stackId += stackIdSuffix; } let plotType; if ( !isConfidenceBand && (seriesType === 'scatter' || (hasForecast && isObservation)) ) { plotType = 'scatter'; } else if (isConfidenceBand) { plotType = 'line'; } else { plotType = seriesType === 'bar' ? 'bar' : 'line'; } const isDarkMode = theme ? isThemeDark(theme) : false; /** * if timeShiftColor is enabled the colorScaleKey forces the color to be the * same as the original series, otherwise uses separate colors * */ const itemStyle: ItemStyleOption = { color: timeShiftColor ? colorScale(colorScaleKey, sliceId) : colorScale(seriesKey || forecastSeries.name, sliceId), opacity, borderWidth: 0, }; if (seriesType === 'bar' && connectNulls) { itemStyle.borderWidth = 1.5; itemStyle.borderType = 'dotted'; itemStyle.borderColor = itemStyle.color; } 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 || (stack === StackControlsValue.Stream && area) ? { ...opts.lineStyle, opacity: OpacityEnum.Transparent } : { ...opts.lineStyle, opacity }; // Use filled circles in dark mode to avoid the white fill issue with hollow circles // Use emptyCircle explicitly in light mode let symbol; if (plotType === 'line') { symbol = opts.lineSymbol || (isDarkMode ? 'circle' : 'emptyCircle'); } return { ...series, ...(Array.isArray(data) ? colorByPrimaryAxis ? { data: applyColorByPrimaryAxis( series, colorScale, sliceId, opacity, isHorizontal, ), } : seriesType === 'bar' && !stack ? { data: transformNegativeLabelsPosition( series, isHorizontal, labelPosition, ), } : null : null), connectNulls, queryIndex, yAxisIndex, name: forecastSeries.name, ...(colorByPrimaryAxis ? {} : { itemStyle }), // @ts-ignore type: plotType, // Cap bar width so a single data point doesn't stretch across the // entire chart area. Bars with many categories auto-size below this cap. ...(plotType === 'bar' ? { barMaxWidth: 100 } : {}), smooth: seriesType === 'smooth', triggerLineEvent: true, // @ts-expect-error step: ['start', 'middle', 'end'].includes(seriesType as string) ? seriesType : undefined, stack: stackId, stackStrategy: isConfidenceBand || stack === StackControlsValue.Stream ? 'all' : 'samesign', lineStyle, areaStyle: area || forecastSeries.type === ForecastSeriesEnum.ForecastUpper ? { opacity: opacity * areaOpacity, } : undefined, emphasis, showSymbol, symbol, symbolSize: symbolSizeFn ?? markerSize, label: { show: !!showValue, position: (labelPosition === 'auto' || !labelPosition ? isHorizontal ? LabelPositionEnum.Right : LabelPositionEnum.Top : labelPosition) as LabelPositionEnum, ...(plotType === 'bar' ? { overflow: 'truncate' } : {}), color: theme?.colorText, textBorderWidth: 0, formatter: (params: any) => { // don't show confidence band value labels, as they're already visible on the tooltip if ( [ ForecastSeriesEnum.ForecastUpper, ForecastSeriesEnum.ForecastLower, ].includes(forecastSeries.type) ) { return ''; } const { value, dataIndex, seriesIndex, seriesName } = params; const numericValue = isHorizontal ? value[0] : value[1]; const isSelectedLegend = !legendState || legendState[seriesName]; const isAreaExpand = stack === StackControlsValue.Expand; if (!formatter) { return numericValue; } if (!stack && isSelectedLegend) { return formatter(numericValue); } if (!onlyTotal) { // A stacked segment with no height begins and ends at the same // coordinate as the top of the segment beneath it, so its label is // drawn over that segment's label. Zero and null have no height, so // they carry no label. The rich tooltip omits zero observations from // a stacked series for the same reason. if (stack && !numericValue) { return ''; } 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, orientation?: OrientationType, ): SeriesOption { const { name, color, opacity, width, style } = layer; const isHorizontal = orientation === OrientationType.Horizontal; return { name, id: name, z: 10, 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).map(([x, y]) => isHorizontal ? [y, x] : [x, y], ), symbolSize: 0, }; } export function transformIntervalAnnotation( layer: IntervalAnnotationLayer, data: TimeseriesDataRecord[], annotationData: AnnotationData, colorScale: CategoricalColorScale, theme: SupersetTheme, sliceId?: number, orientation?: OrientationType, ): SeriesOption[] { const series: SeriesOption[] = []; const annotations = extractRecordAnnotations(layer, annotationData); if (annotations.length === 0) { return series; } const { name, color, opacity, showLabel } = layer; const isHorizontal = orientation === OrientationType.Horizontal; const intervalsByStartTime = new Map(); annotations.forEach(annotation => { const { descriptions, time = '', title } = annotation; const label = formatAnnotationLabel(name, title, descriptions); const existing = intervalsByStartTime.get(time); if (existing) { existing.push(label); } else { intervalsByStartTime.set(time, [label]); } }); const allIntervalData: ( | MarkArea1DDataItemOption | MarkArea2DDataItemOption )[] = annotations.map(annotation => { const { intervalEnd, time = '' } = annotation; const combinedLabel = (intervalsByStartTime.get(time) || []).join('\n'); return [ { name: combinedLabel, ...(isHorizontal ? { yAxis: time } : { xAxis: time }), }, isHorizontal ? { yAxis: intervalEnd } : { xAxis: intervalEnd }, ]; }); const intervalLabel: SeriesLabelOption = showLabel ? { show: true, color: theme.colorTextLabel, position: 'insideTop', verticalAlign: 'top', fontWeight: 'bold', // @ts-expect-error emphasis: { position: 'insideTop', verticalAlign: 'top', backgroundColor: theme.colorPrimaryBgHover, }, } : { show: false, color: theme.colorTextLabel, emphasis: { fontWeight: 'bold', show: true, position: 'insideTop', verticalAlign: 'top', backgroundColor: theme.colorPrimaryBgHover, }, }; // Push a single series with all intervals in the markArea data series.push({ id: `Interval - ${name}`, type: 'line', animation: false, z: 10, markArea: { silent: false, itemStyle: { color: color || colorScale(name, sliceId), opacity: parseAnnotationOpacity(opacity || AnnotationOpacity.Medium), emphasis: { opacity: 0.8, }, } as ItemStyleOption, label: intervalLabel, data: allIntervalData, }, }); return series; } export function transformEventAnnotation( layer: EventAnnotationLayer, data: TimeseriesDataRecord[], annotationData: AnnotationData, colorScale: CategoricalColorScale, theme: SupersetTheme, sliceId?: number, orientation?: OrientationType, ): SeriesOption[] { const series: SeriesOption[] = []; const annotations = extractRecordAnnotations(layer, annotationData); if (annotations.length === 0) { return series; } const { name, color, opacity, style, width, showLabel } = layer; const isHorizontal = orientation === OrientationType.Horizontal; const eventsByTime = new Map(); annotations.forEach(annotation => { const { descriptions, time = '', title } = annotation; const label = formatAnnotationLabel(name, title, descriptions); const existing = eventsByTime.get(time); if (existing) { existing.labels.push(label); } else { eventsByTime.set(time, { time, labels: [label] }); } }); const allEventData: MarkLine1DDataItemOption[] = Array.from( eventsByTime.values(), ).map(({ time, labels }) => ({ name: labels.join('\n'), ...(isHorizontal ? { yAxis: time } : { 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.colorTextLabel, position: 'insideEndTop', fontWeight: 'bold', formatter: (params: CallbackDataParams) => params.name, // @ts-expect-error emphasis: { backgroundColor: theme.colorPrimaryBgHover, }, } : { show: false, color: theme.colorTextLabel, position: 'insideEndTop', emphasis: { formatter: (params: CallbackDataParams) => params.name, fontWeight: 'bold', show: true, backgroundColor: theme.colorPrimaryBgHover, }, }; // Push a single series with all events in the markLine data series.push({ id: `Event - ${name}`, type: 'line', animation: false, z: 10, markLine: { silent: false, symbol: 'none', lineStyle, label: eventLabel, data: allEventData, }, }); return series; } export function transformTimeseriesAnnotation( layer: TimeseriesAnnotationLayer, markerSize: number, data: TimeseriesDataRecord[], annotationData: AnnotationData, colorScale: CategoricalColorScale, sliceId?: number, orientation?: OrientationType, ): SeriesOption[] { const series: SeriesOption[] = []; const { hideLine, name, opacity, showMarkers, style, width, color } = layer; const shiftMs = parseTimeShiftToMs((layer as any)?.overrides?.time_shift); const result = annotationData[name]; const isHorizontal = orientation === OrientationType.Horizontal; const { records } = result; if (records) { const data = records.map(record => { const keys = Object.keys(record); let x = keys.length > 0 ? record[keys[0]] : 0; const y = keys.length > 1 ? record[keys[1]] : 0; if (shiftMs !== 0 && x != null) { const xMs = typeof x === 'string' ? new Date(x).getTime() : Number(x); if (!Number.isNaN(xMs)) { x = xMs + shiftMs; } } return isHorizontal ? ([y, x] as [number, OptionName]) : ([x, y] as [OptionName, number]); }); const computedStyle = { opacity: parseAnnotationOpacity(opacity), type: style as ZRLineType, width: hideLine ? 0 : width, color: color || colorScale(name, sliceId), }; series.push({ type: 'line', id: name, name, z: 10, data, symbolSize: showMarkers ? markerSize : 0, itemStyle: computedStyle, lineStyle: computedStyle, }); } 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, isHorizontal?: boolean, ): { 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) : yAxisTitlePosition === 'Left' ? TIMESERIES_CONSTANTS.gridOffsetTop : TIMESERIES_CONSTANTS.gridOffsetTop + yAxisOffset, bottom: zoomable && !isHorizontal ? 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, }, isHorizontal, ); }