Files
superset2/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts

1203 lines
36 KiB
TypeScript

/**
* 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.
*/
/* eslint-disable camelcase */
import { invert } from 'lodash';
import { t } from '@apache-superset/core/translation';
import {
AnnotationLayer,
AxisType,
buildCustomFormatters,
CategoricalColorNamespace,
CurrencyFormatter,
ensureIsArray,
tooltipHtml,
getCustomFormatter,
getMetricLabel,
getNumberFormatter,
getXAxisLabel,
isDefined,
isEventAnnotationLayer,
isFormulaAnnotationLayer,
isIntervalAnnotationLayer,
isPhysicalColumn,
isTimeseriesAnnotationLayer,
resolveAutoCurrency,
TimeseriesChartDataResponseResult,
TimeseriesDataRecord,
NumberFormats,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
extractExtraMetrics,
getOriginalSeries,
getTimeOffset,
isDerivedSeries,
} from '@superset-ui/chart-controls';
import type { EChartsCoreOption } from 'echarts/core';
import type {
LineStyleOption,
CallbackDataParams,
} from 'echarts/types/src/util/types';
import type { SeriesOption } from 'echarts';
import {
EchartsTimeseriesChartProps,
EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType,
OrientationType,
TimeseriesChartTransformedProps,
} from './types';
import { DEFAULT_FORM_DATA } from './constants';
import {
ForecastSeriesEnum,
ForecastValue,
LegendOrientation,
LegendType,
Refs,
} from '../types';
import { parseAxisBound } from '../utils/controls';
import {
calculateLowerLogTick,
dedupSeries,
extractDataTotalValues,
extractSeries,
extractShowValueIndexes,
extractTooltipKeys,
getAxisType,
getColtypesMapping,
getHorizontalLegendAvailableWidth,
getLegendProps,
getMinAndMaxFromBounds,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
extractAnnotationLabels,
getAnnotationData,
} from '../utils/annotation';
import {
extractForecastSeriesContext,
extractForecastSeriesContexts,
extractForecastValuesFromTooltipParams,
formatForecastTooltipSeries,
rebaseForecastDatum,
reorderForecastSeries,
} from '../utils/forecast';
import { convertInteger } from '../utils/convertInteger';
import { defaultGrid, defaultYAxis } from '../defaults';
import {
getBaselineSeriesForStream,
getPadding,
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
transformSeries,
transformTimeseriesAnnotation,
} from './transformers';
import {
OpacityEnum,
StackControlsValue,
TIMEGRAIN_TO_TIMESTAMP,
TIMESERIES_CONSTANTS,
} from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
getPercentFormatter,
getTooltipTimeFormatter,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
const visibleDashPatterns: ([number, number] | 'dashed' | 'dotted')[] = [
'dashed',
'dotted',
[6, 15], // narrow dashed
[2, 10], // wide dotted
[20, 3], // wide dashed
];
const visibleSymbols = [
'rect',
'triangle',
'diamond',
'roundRect',
'pin',
] as const;
function getSymbolMarker(symbol: string, color: string) {
const size = 10;
switch (symbol) {
case 'circle':
return `<span style="
display:inline-block;width:${size}px;height:${size}px;
border-radius:50%;background:${color};margin-right:5px"></span>`;
case 'rect':
return `<span style="
display:inline-block;width:${size}px;height:${size}px;
background:${color};margin-right:5px"></span>`;
case 'roundRect':
return `<span style="
display:inline-block;width:${size}px;height:${size}px;border-radius:2px;
background:${color};margin-right:5px"></span>`;
case 'triangle':
return `<span style="
display:inline-block;width:0;height:0;
border-left:${size / 2}px solid transparent;
border-right:${size / 2}px solid transparent;
border-bottom:${size}px solid ${color};
margin-right:5px"></span>`;
case 'diamond':
return `<span style="
display:inline-block;width:${size - 2}px;height:${size - 2}px;
background:${color};transform: rotate(45deg) translateX(1px) translateY(-1px);
margin-right:5px"></span>`;
case 'pin':
return `<span style="
display:inline-block;width:${size - 2}px;height:${size - 2}px;
background:${color};transform: rotate(45deg) translateX(1px) translateY(-1px);
border-radius:50%;border-bottom-right-radius:0;margin-right:5px"></span>`;
default:
return `<span style="
display:inline-block;width:${size}px;height:${size}px;
border-radius:50%;background:${color};margin-right:5px"></span>`;
}
}
export default function transformProps(
chartProps: EchartsTimeseriesChartProps,
): TimeseriesChartTransformedProps {
const {
width,
height,
filterState,
legendState,
formData: { echartOptions: _echartOptions, ...formData },
hooks,
queriesData,
datasource,
theme,
inContextMenu,
emitCrossFilters,
legendIndex,
} = chartProps;
let focusedSeries: string | null = null;
const {
verboseMap = {},
columnFormats = {},
currencyFormats = {},
currencyCodeColumn,
} = datasource;
const [queryData] = queriesData;
const {
data = [],
label_map = {},
detected_currency: backendDetectedCurrency,
} = queryData as TimeseriesChartDataResponseResult;
const dataTypes = getColtypesMapping(queryData);
const annotationData = getAnnotationData(chartProps);
const {
area,
annotationLayers,
colorScheme,
contributionMode,
forecastEnabled,
groupby,
legendOrientation,
legendType,
legendMargin,
legendSort,
logAxis,
markerEnabled,
markerSize,
metrics,
minorSplitLine,
minorTicks,
onlyTotal,
opacity,
orientation,
percentageThreshold,
richTooltip,
seriesType,
showLegend,
showValue,
colorByPrimaryAxis,
sliceId,
sortSeriesType,
sortSeriesAscending,
timeGrainSqla,
forceMaxInterval,
timeCompare,
timeShiftColor,
stack,
tooltipTimeFormat,
tooltipSortByMetric,
showTooltipTotal,
showTooltipPercentage,
truncateXAxis,
truncateYAxis,
xAxis: xAxisOrig,
xAxisBounds,
xAxisForceCategorical,
xAxisLabelRotation,
xAxisLabelInterval,
xAxisSort,
xAxisSortAsc,
xAxisTimeFormat,
xAxisNumberFormat,
xAxisTitle,
xAxisTitleMargin,
yAxisBounds,
yAxisFormat,
currencyFormat,
yAxisTitle,
yAxisTitleMargin,
yAxisTitlePosition,
zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const refs: Refs = {};
const groupBy = ensureIsArray(groupby);
const labelMap: { [key: string]: string[] } = Object.entries(
label_map,
).reduce((acc, entry) => {
if (
entry[1].length > groupBy.length &&
Array.isArray(timeCompare) &&
timeCompare.includes(entry[1][0])
) {
entry[1].shift();
}
return { ...acc, [entry[0]]: entry[1] };
}, {});
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
const rebasedData = rebaseForecastDatum(data, verboseMap);
let xAxisLabel = getXAxisLabel(chartProps.rawFormData) as string;
if (
isPhysicalColumn(chartProps.rawFormData?.x_axis) &&
isDefined(verboseMap[xAxisLabel])
) {
xAxisLabel = verboseMap[xAxisLabel];
}
const isHorizontal = orientation === OrientationType.Horizontal;
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
rebasedData,
{
stack,
percentageThreshold,
xAxisCol: xAxisLabel,
legendState,
},
);
const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData).map(
getMetricLabel,
);
const isMultiSeries = groupBy.length || metrics?.length > 1;
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
const xAxisType = getAxisType(stack, xAxisForceCategorical, xAxisDataType);
const [rawSeries, sortedTotalValues, minPositiveValue] = extractSeries(
rebasedData,
{
fillNeighborValue: stack && !forecastEnabled ? 0 : undefined,
xAxis: xAxisLabel,
extraMetricLabels,
stack,
totalStackedValues,
isHorizontal,
sortSeriesType,
sortSeriesAscending,
xAxisSortSeries: isMultiSeries ? xAxisSort : undefined,
xAxisSortSeriesAscending: isMultiSeries ? xAxisSortAsc : undefined,
xAxisType,
},
);
const showValueIndexes = extractShowValueIndexes(rawSeries, {
stack,
onlyTotal,
isHorizontal,
legendState,
});
const seriesContexts = extractForecastSeriesContexts(
rawSeries.map(series => series.name as string),
);
const isAreaExpand = stack === StackControlsValue.Expand;
const series: SeriesOption[] = [];
const forcePercentFormatter = Boolean(contributionMode || isAreaExpand);
const percentFormatter = forcePercentFormatter
? getPercentFormatter(yAxisFormat)
: getPercentFormatter(NumberFormats.PERCENT_2_POINT);
// Resolve currency for AUTO mode (backend detection takes precedence)
const resolvedCurrency = resolveAutoCurrency(
currencyFormat,
backendDetectedCurrency,
data,
currencyCodeColumn,
);
const defaultFormatter = resolvedCurrency?.symbol
? new CurrencyFormatter({
d3Format: yAxisFormat,
currency: resolvedCurrency,
})
: getNumberFormatter(yAxisFormat);
const customFormatters = buildCustomFormatters(
metrics,
currencyFormats,
columnFormats,
yAxisFormat,
resolvedCurrency,
data,
currencyCodeColumn,
);
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
const offsetLineWidths: { [key: string]: number } = {};
// For horizontal bar charts, calculate min/max from data to avoid cutting off labels
const shouldCalculateDataBounds =
isHorizontal &&
seriesType === EchartsTimeseriesSeriesType.Bar &&
truncateYAxis;
let dataMax: number | undefined;
let dataMin: number | undefined;
rawSeries.forEach(entry => {
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
// isDerivedSeries checks for time comparison series patterns:
// - "metric__1 day ago" pattern (via hasTimeOffset)
// - "1 day ago, groupby" pattern (via hasTimeOffset)
// - exact match "1 day ago" (via seriesName parameter)
const derivedSeries = isDerivedSeries(
entry,
chartProps.rawFormData,
seriesName,
);
const lineStyle: LineStyleOption = {};
let lineSymbol;
if (derivedSeries && timeShiftColor) {
// Get the time offset for this series to assign different dash patterns
const offset = getTimeOffset(entry, array) || seriesName;
if (!offsetLineWidths[offset]) {
offsetLineWidths[offset] = Object.keys(offsetLineWidths).length + 1;
}
// Use visible dash patterns that vary by offset index
// Pattern: [dash length, gap length] - scaled to be clearly visible
const patternIndex = offsetLineWidths[offset];
lineStyle.type =
visibleDashPatterns[patternIndex % visibleDashPatterns.length];
lineStyle.opacity = OpacityEnum.DerivedSeries;
lineSymbol = visibleSymbols[patternIndex % visibleSymbols.length];
}
// Calculate min/max from data for horizontal bar charts
if (shouldCalculateDataBounds && entry.data && Array.isArray(entry.data)) {
(entry.data as [number, any][]).forEach((datum: [number, any]) => {
const value = datum[0];
if (typeof value === 'number' && !Number.isNaN(value)) {
if (dataMax === undefined || value > dataMax) {
dataMax = value;
}
if (dataMin === undefined || value < dataMin) {
dataMin = value;
}
}
});
}
let colorScaleKey = getOriginalSeries(seriesName, array);
// When there's a single metric with dimensions, the backend replaces the metric
// with the time offset in derived series (e.g., "28 days ago, Medium" instead of
// "SUM(sales), 28 days ago, Medium"). To match colors, strip the metric label
// from original series so both produce the same key (e.g., "Medium").
if (
groupby &&
groupby.length > 0 &&
array.length > 0 &&
metrics?.length === 1
) {
const metricLabel = getMetricLabel(metrics[0]);
colorScaleKey = colorScaleKey.replace(`${metricLabel}, `, '');
}
// If series name exactly matches a time offset (single metric case, no dimensions),
// find the original series for color matching
if (derivedSeries && array.includes(seriesName)) {
const originalSeries = rawSeries.find(
s =>
!isDerivedSeries(
s,
chartProps.rawFormData,
inverted[String(s.name || '')] || String(s.name || ''),
),
);
if (originalSeries) {
const originalSeriesName =
inverted[String(originalSeries.name || '')] ||
String(originalSeries.name || '');
colorScaleKey = getOriginalSeries(originalSeriesName, array);
}
}
const transformedSeries = transformSeries(
entry,
colorScale,
colorScaleKey,
{
area,
connectNulls: derivedSeries,
filterState,
seriesContexts,
markerEnabled,
markerSize,
areaOpacity: opacity,
seriesType,
legendState,
stack,
formatter: forcePercentFormatter
? percentFormatter
: (getCustomFormatter(
customFormatters,
metrics,
labelMap?.[seriesName]?.[0],
) ?? defaultFormatter),
showValue,
onlyTotal,
totalStackedValues: sortedTotalValues,
showValueIndexes,
thresholdValues,
richTooltip,
sliceId,
isHorizontal,
lineStyle,
lineSymbol,
timeCompare: array,
timeShiftColor,
theme,
hasDimensions: (groupBy?.length ?? 0) > 0,
colorByPrimaryAxis,
},
);
if (transformedSeries) {
if (stack === StackControlsValue.Stream) {
// bug in Echarts - `stackStrategy: 'all'` doesn't work with nulls, so we cast them to 0
series.push({
...transformedSeries,
data: (transformedSeries.data as any).map(
(row: [string | number, number]) => [row[0], row[1] ?? 0],
),
});
} else {
series.push(transformedSeries);
}
}
});
// Add x-axis color legend when colorByPrimaryAxis is enabled
if (colorByPrimaryAxis && groupBy.length === 0 && series.length > 0) {
// Hide original series from legend
series.forEach(s => {
s.legendHoverLink = false;
});
// Get x-axis values from the first series
const firstSeries = series[0];
if (firstSeries && Array.isArray(firstSeries.data)) {
const xAxisValues: (string | number)[] = [];
// Extract primary axis values (category axis)
// For horizontal charts the category is at index 1, for vertical at index 0
const primaryAxisIndex = isHorizontal ? 1 : 0;
(firstSeries.data as any[]).forEach(point => {
let xValue;
if (point && typeof point === 'object' && 'value' in point) {
const val = point.value;
xValue = Array.isArray(val) ? val[primaryAxisIndex] : val;
} else if (Array.isArray(point)) {
xValue = point[primaryAxisIndex];
} else {
xValue = point;
}
xAxisValues.push(xValue);
});
// Create hidden series for legend (using 'line' type to not affect bar width)
// Deduplicate x-axis values to avoid duplicate legend entries and unnecessary series
const uniqueXAxisValues = Array.from(
new Set(xAxisValues.map(v => String(v))),
);
uniqueXAxisValues.forEach(xValue => {
const colorKey = xValue;
series.push({
name: xValue,
type: 'line', // Use line type to not affect bar positioning
data: [], // Empty - doesn't render
itemStyle: {
color: colorScale(colorKey, sliceId),
},
lineStyle: {
color: colorScale(colorKey, sliceId),
},
silent: true,
legendHoverLink: false,
showSymbol: false,
});
});
}
}
if (stack === StackControlsValue.Stream) {
const baselineSeries = getBaselineSeriesForStream(
series.map(entry => entry.data) as [string | number, number][][],
seriesType,
);
series.unshift(baselineSeries);
}
const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<string, number>, selectedValue: string) => {
const index = series.findIndex(({ name }) => name === selectedValue);
return {
...acc,
[index]: selectedValue,
};
},
{},
);
annotationLayers
.filter((layer: AnnotationLayer) => layer.show)
.forEach((layer: AnnotationLayer) => {
if (isFormulaAnnotationLayer(layer))
series.push(
transformFormulaAnnotation(
layer,
rebasedData as TimeseriesDataRecord[],
xAxisLabel,
xAxisType,
colorScale,
sliceId,
orientation,
),
);
else if (isIntervalAnnotationLayer(layer)) {
series.push(
...transformIntervalAnnotation(
layer,
data,
annotationData,
colorScale,
theme,
sliceId,
orientation,
),
);
} else if (isEventAnnotationLayer(layer)) {
series.push(
...transformEventAnnotation(
layer,
data,
annotationData,
colorScale,
theme,
sliceId,
orientation,
),
);
} else if (isTimeseriesAnnotationLayer(layer)) {
series.push(
...transformTimeseriesAnnotation(
layer,
markerSize,
data,
annotationData,
colorScale,
sliceId,
orientation,
),
);
}
});
if (
stack === StackControlsValue.Stack &&
stackDimension &&
chartProps.rawFormData.groupby
) {
const idxSelectedDimension =
formData.metrics.length > 1
? 1
: 0 + chartProps.rawFormData.groupby.indexOf(stackDimension);
for (const s of series) {
if (s.id) {
const columnsArr = labelMap[s.id];
(s as any).stack = columnsArr[idxSelectedDimension];
}
}
}
// axis bounds need to be parsed to replace incompatible values with undefined
const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);
// default to 0-100% range when doing row-level contribution chart
if ((contributionMode === 'row' || isAreaExpand) && stack) {
if (yAxisMin === undefined) yAxisMin = 0;
if (yAxisMax === undefined) yAxisMax = 1;
} else if (
logAxis &&
yAxisMin === undefined &&
minPositiveValue !== undefined
) {
yAxisMin = calculateLowerLogTick(minPositiveValue);
}
// For horizontal bar charts, set max/min from calculated data bounds
if (shouldCalculateDataBounds) {
// Set max to actual data max to avoid gaps and ensure labels are visible
if (dataMax !== undefined && yAxisMax === undefined) {
yAxisMax = dataMax;
}
// Set min to actual data min for diverging bars
if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) {
yAxisMin = dataMin;
}
}
const tooltipFormatter =
xAxisDataType === GenericDataType.Temporal
? getTooltipTimeFormatter(tooltipTimeFormat)
: String;
const xAxisFormatter =
xAxisDataType === GenericDataType.Temporal
? getXAxisFormatter(xAxisTimeFormat, timeGrainSqla)
: xAxisDataType === GenericDataType.Numeric
? getNumberFormatter(xAxisNumberFormat)
: String;
const {
setDataMask = () => {},
setControlValue = () => {},
onContextMenu,
onLegendStateChanged,
onLegendScroll,
} = hooks;
const addYAxisLabelOffset =
!!yAxisTitle && convertInteger(yAxisTitleMargin) !== 0;
const addXAxisLabelOffset =
!!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0;
const legendData =
colorByPrimaryAxis && groupBy.length === 0 && series.length > 0
? (() => {
const firstSeries = series[0];
const primaryAxisIndex = isHorizontal ? 1 : 0;
if (firstSeries && Array.isArray(firstSeries.data)) {
const names = (firstSeries.data as any[])
.map(point => {
if (point && typeof point === 'object' && 'value' in point) {
const val = point.value;
return String(
Array.isArray(val) ? val[primaryAxisIndex] : val,
);
}
if (Array.isArray(point)) {
return String(point[primaryAxisIndex]);
}
return String(point);
})
.filter(
name => name !== '' && name !== 'undefined' && name !== 'null',
);
return Array.from(new Set(names));
}
return [];
})()
: rawSeries
.filter(
entry =>
extractForecastSeriesContext(entry.name || '').type ===
ForecastSeriesEnum.Observation,
)
.map(entry => entry.name || '')
.concat(extractAnnotationLabels(annotationLayers));
const sortedLegendData = [...legendData].sort((a: string, b: string) => {
if (!legendSort) return 0;
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
});
const colorByPrimaryAxisLegendData = legendData.map(name => ({
name,
icon: 'roundRect',
}));
const getLegendLayout = (candidateLegendMargin?: string | number | null) => {
const padding = getPadding(
showLegend,
legendOrientation,
addYAxisLabelOffset,
zoomable,
candidateLegendMargin,
addXAxisLabelOffset,
yAxisTitlePosition,
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
isHorizontal,
);
return resolveLegendLayout({
availableWidth:
legendOrientation === LegendOrientation.Top ||
legendOrientation === LegendOrientation.Bottom
? getHorizontalLegendAvailableWidth({
chartWidth: width,
orientation: legendOrientation,
padding,
zoomable,
})
: undefined,
chartHeight: height,
chartWidth: width,
legendItems:
colorByPrimaryAxis && groupBy.length === 0
? colorByPrimaryAxisLegendData
: sortedLegendData,
legendMargin: candidateLegendMargin,
orientation: legendOrientation,
show: showLegend,
showSelectors: !(colorByPrimaryAxis && groupBy.length === 0),
theme,
type: legendType,
});
};
const initialLegendLayout = getLegendLayout(legendMargin);
const legendLayout =
isHorizontal &&
legendOrientation === LegendOrientation.Bottom &&
initialLegendLayout.effectiveLegendType === LegendType.Plain
? getLegendLayout(initialLegendLayout.effectiveLegendMargin)
: initialLegendLayout;
const { effectiveLegendType } = legendLayout;
const effectiveLegendMargin =
isHorizontal &&
legendOrientation === LegendOrientation.Bottom &&
legendLayout.effectiveLegendType === LegendType.Scroll
? legendMargin
: legendLayout.effectiveLegendMargin;
const padding = getPadding(
showLegend,
legendOrientation,
addYAxisLabelOffset,
zoomable,
effectiveLegendMargin,
addXAxisLabelOffset,
yAxisTitlePosition,
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
isHorizontal,
);
// Reduce grid padding for small charts to maximize the drawing area.
// Keep enough top padding so the max label doesn't clip against the cell border.
// Preserve bottom padding when zoomable, since getPadding() reserves space for the dataZoom slider.
if (height < TIMESERIES_CONSTANTS.compactChartHeight) {
padding.top = Math.min(padding.top, 12);
if (!zoomable) {
padding.bottom = Math.min(padding.bottom, 5);
}
}
// When showMaxLabel is true, ECharts may render a label at the axis
// boundary that formats identically to the last data-point tick (e.g.
// "2005" appears twice with Year grain). Wrap the formatter to suppress
// consecutive duplicate labels.
const showMaxLabel = xAxisType === AxisType.Time && xAxisLabelRotation === 0;
const deduplicatedFormatter = showMaxLabel
? (() => {
let lastLabel: string | undefined;
const wrapper = (value: number | string) => {
const label =
typeof xAxisFormatter === 'function'
? (xAxisFormatter as Function)(value)
: String(value);
if (label === lastLabel) {
return '';
}
lastLabel = label;
return label;
};
if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
(wrapper as any).id = (xAxisFormatter as any).id;
}
return wrapper;
})()
: xAxisFormatter;
let xAxis: any = {
type: xAxisType,
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
axisLabel: {
// When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already
// have less overlap, so disabling hideOverlap is safe.
// At 0° rotation, keep hideOverlap to prevent long labels
// from overlapping each other, with showMaxLabel to ensure
// the last data point label stays visible (#37181).
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
// Force last label on non-rotated time axes to prevent
// hideOverlap from hiding it. Skipped when rotated to
// avoid phantom labels at the axis boundary.
...(showMaxLabel && {
showMaxLabel: true,
alignMaxLabel: 'right',
}),
},
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && timeGrainSqla && !forceMaxInterval
? TIMEGRAIN_TO_TIMESTAMP[
timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP
]
: 0,
maxInterval:
xAxisType === AxisType.Time && timeGrainSqla && forceMaxInterval
? TIMEGRAIN_TO_TIMESTAMP[
timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP
]
: undefined,
...getMinAndMaxFromBounds(
xAxisType,
truncateXAxis,
xAxisMin,
xAxisMax,
seriesType,
),
};
// Adapt y-axis to chart height: three tiers based on available space.
// >= 100px: full axis with proportional tick count
// 60-99px: show only min/max boundary labels (splitNumber=1), hide lines/ticks
// < 60px: hide all axis decorations, show line only
const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight;
const isMicroChart = height < TIMESERIES_CONSTANTS.microChartHeight;
const yAxisSplitNumber = isMicroChart
? undefined
: isSmallChart
? 1
: Math.max(
3,
Math.floor(height / TIMESERIES_CONSTANTS.yAxisPixelsPerTick),
);
let yAxis: any = {
...defaultYAxis,
type: logAxis ? AxisType.Log : AxisType.Value,
...(yAxisSplitNumber !== undefined && { splitNumber: yAxisSplitNumber }),
min: yAxisMin,
max: yAxisMax,
minorTick: { show: isSmallChart ? false : minorTicks },
minorSplitLine: { show: isSmallChart ? false : minorSplitLine },
splitLine: { show: !isSmallChart },
axisLabel: {
show: !isMicroChart,
showMinLabel: !isMicroChart,
showMaxLabel: !isMicroChart,
hideOverlap: true,
formatter: getYAxisFormatter(
metrics,
forcePercentFormatter,
customFormatters,
defaultFormatter,
yAxisFormat,
),
},
axisTick: { show: !isSmallChart },
scale: truncateYAxis,
name: isSmallChart ? undefined : yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
};
// Increase right padding for rotated time axis labels to prevent
// the last label from being clipped at the chart boundary.
if (
xAxisType === AxisType.Time &&
xAxisLabelRotation !== 0 &&
!isHorizontal
) {
padding.right = Math.max(
padding.right || 0,
TIMESERIES_CONSTANTS.gridOffsetRight +
Math.ceil(
Math.abs(Math.sin((xAxisLabelRotation * Math.PI) / 180)) * 80,
),
);
}
if (isHorizontal) {
[xAxis, yAxis] = [yAxis, xAxis];
[padding.bottom, padding.left] = [padding.left, padding.bottom];
// Increase right padding for horizontal bar charts to ensure value labels are visible
if (seriesType === EchartsTimeseriesSeriesType.Bar && showValue) {
padding.right = Math.max(
padding.right || 0,
TIMESERIES_CONSTANTS.horizontalBarLabelRightPadding,
);
}
}
const echartOptions: EChartsCoreOption = {
useUTC: true,
grid: {
...defaultGrid,
...padding,
},
xAxis,
yAxis,
tooltip: {
...getDefaultTooltip(refs),
show: !inContextMenu,
trigger: richTooltip ? 'axis' : 'item',
formatter: (params: any) => {
const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1];
const xValue: number = richTooltip
? params[0].value[xIndex]
: params.value[xIndex];
const forecastValue: CallbackDataParams[] = richTooltip
? params
: [params];
const sortedKeys = extractTooltipKeys(
forecastValue,
yIndex,
richTooltip,
tooltipSortByMetric,
);
const filteredForecastValue = forecastValue.filter(
(item: CallbackDataParams) =>
!annotationLayers.some(
(annotation: AnnotationLayer) =>
item.seriesName === annotation.name,
),
);
const forecastValues: Record<string, ForecastValue> =
extractForecastValuesFromTooltipParams(forecastValue, isHorizontal);
const filteredForecastValues: Record<string, ForecastValue> =
extractForecastValuesFromTooltipParams(
filteredForecastValue,
isHorizontal,
);
const isForecast = Object.values(forecastValues).some(
value =>
value.forecastTrend || value.forecastLower || value.forecastUpper,
);
const formatter = forcePercentFormatter
? percentFormatter
: (getCustomFormatter(customFormatters, metrics) ?? defaultFormatter);
const rows: string[][] = [];
const total = Object.values(filteredForecastValues).reduce(
(acc, value) =>
value.observation !== undefined ? acc + value.observation : acc,
0,
);
const allowTotal = Boolean(isMultiSeries) && richTooltip && !isForecast;
const showPercentage =
allowTotal && !forcePercentFormatter && showTooltipPercentage;
const keys = Object.keys(forecastValues);
let focusedRow;
sortedKeys
.filter(key => keys.includes(key))
.forEach(key => {
const value = forecastValues[key];
if (value.observation === 0 && stack) {
return;
}
const seriesForKey = series.find(s => s.name === key);
const symbolForSeries = (seriesForKey as any)?.symbol || 'circle';
const marker = value.color
? getSymbolMarker(symbolForSeries, value.color)
: value.marker;
const row = formatForecastTooltipSeries({
...value,
seriesName: key,
formatter,
marker,
});
const annotationRow = annotationLayers.some(
item => item.name === key,
);
if (
showPercentage &&
value.observation !== undefined &&
!annotationRow
) {
row.push(
percentFormatter.format(value.observation / (total || 1)),
);
}
rows.push(row);
if (key === focusedSeries) {
focusedRow = rows.length - 1;
}
});
if (stack) {
rows.reverse();
if (focusedRow !== undefined) {
focusedRow = rows.length - focusedRow - 1;
}
}
if (allowTotal && showTooltipTotal) {
const totalRow = ['Total', formatter.format(total)];
if (showPercentage) {
totalRow.push(percentFormatter.format(1));
}
rows.push(totalRow);
}
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
},
},
legend: {
...getLegendProps(
effectiveLegendType,
legendOrientation,
// Hide legend on compact charts — not enough vertical space
isSmallChart ? false : showLegend,
theme,
zoomable,
legendState,
padding,
),
scrollDataIndex: legendIndex || 0,
data:
colorByPrimaryAxis && groupBy.length === 0
? colorByPrimaryAxisLegendData
: sortedLegendData,
// Disable legend selection and buttons when colorByPrimaryAxis is enabled
...(colorByPrimaryAxis && groupBy.length === 0
? {
selectedMode: false, // Disable clicking legend items
selector: false, // Hide All/Invert buttons
}
: {}),
},
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
toolbox: {
show: zoomable,
top: TIMESERIES_CONSTANTS.toolboxTop,
right: TIMESERIES_CONSTANTS.toolboxRight,
feature: {
dataZoom: {
...(stack ? { yAxisIndex: false } : {}), // disable y-axis zoom for stacked charts
title: {
zoom: t('zoom area'),
back: t('restore zoom'),
},
},
},
},
dataZoom: zoomable
? [
{
type: 'slider',
start: TIMESERIES_CONSTANTS.dataZoomStart,
end: TIMESERIES_CONSTANTS.dataZoomEnd,
bottom: TIMESERIES_CONSTANTS.zoomBottom,
yAxisIndex: isHorizontal ? 0 : undefined,
},
{
type: 'inside',
yAxisIndex: 0,
zoomOnMouseWheel: false,
moveOnMouseWheel: true,
},
{
type: 'inside',
xAxisIndex: 0,
zoomOnMouseWheel: false,
moveOnMouseWheel: true,
},
]
: [],
};
const onFocusedSeries = (seriesName: string | null) => {
focusedSeries = seriesName;
};
let customEchartOptions;
try {
// Parse custom EChart options safely using AST analysis
// This replaces the unsafe `new Function()` approach with a secure parser
// that only allows static data structures (no function callbacks)
customEchartOptions = safeParseEChartOptions(_echartOptions);
} catch (_) {
customEchartOptions = undefined;
}
const mergedEchartOptions = customEchartOptions
? mergeCustomEChartOptions(echartOptions, customEchartOptions)
: echartOptions;
return {
echartOptions: mergedEchartOptions,
emitCrossFilters,
formData,
groupby: groupBy,
height,
labelMap,
selectedValues,
setDataMask,
setControlValue,
width,
legendData,
onContextMenu,
onLegendStateChanged,
onFocusedSeries,
xValueFormatter: tooltipFormatter,
xAxis: {
label: xAxisLabel,
type: xAxisType,
},
refs,
coltypeMapping: dataTypes,
onLegendScroll,
};
}