You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/09/04 17:48:43 UTC
(superset) branch master updated: fix(echarts): render Plain Timeseries legends as HTML, not canvas (#43314)
This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 99a910b81c5 fix(echarts): render Plain Timeseries legends as HTML, not canvas (#43314)
99a910b81c5 is described below
commit 99a910b81c559522905f6f3a07712ea274eb2226
Author: Joe Li <jo...@preset.io>
AuthorDate: Fri Sep 4 10:48:36 2026 -0700
fix(echarts): render Plain Timeseries legends as HTML, not canvas (#43314)
Co-authored-by: Claude Sonnet 5 <no...@anthropic.com>
---
.../src/Timeseries/EchartsTimeseries.test.tsx | 60 ++++
.../src/Timeseries/EchartsTimeseries.tsx | 125 ++++++-
.../src/Timeseries/TimeseriesLegend.tsx | 196 +++++++++++
.../src/Timeseries/transformProps.ts | 178 ++++++++--
.../src/Timeseries/transformers.ts | 73 ++++
.../plugin-chart-echarts/src/Timeseries/types.ts | 19 ++
.../test/Timeseries/Bar/transformProps.test.ts | 138 +++-----
.../test/Timeseries/EchartsTimeseries.test.tsx | 223 ++++++++++++-
.../test/Timeseries/transformProps.test.ts | 370 ++++++++++++++++++++-
9 files changed, 1259 insertions(+), 123 deletions(-)
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
index b65e96c1b8c..e5b54e25ec6 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
@@ -290,6 +290,66 @@ test('observes extra control height changes when ResizeObserver is available', a
expect(disconnectSpy).toHaveBeenCalled();
});
+test('uses the post-control body height for compact custom-legend visibility', async () => {
+ mockOffsetHeight = 40;
+ const { queryByTestId } = render(
+ <EchartsTimeseries
+ {...defaultProps}
+ height={140}
+ echartOptions={{
+ grid: { bottom: 80, containLabel: true, top: 20 },
+ }}
+ formData={{ ...defaultFormData, zoomable: true }}
+ customLegend={
+ {
+ grid: { bottom: 80, top: 20 },
+ items: Array.from({ length: 20 }, (_, index) => ({
+ color: '#123456',
+ interactive: true,
+ name: `Series ${index}`,
+ selected: true,
+ })),
+ orientation: LegendOrientation.Top,
+ showSelectors: true,
+ } as never
+ }
+ />,
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('timeseries-custom-legend')).not.toBeInTheDocument();
+ expect(getLatestHeight()).toBe(100);
+ expect(getLatestEchartProps().echartOptions.grid).toEqual({
+ bottom: 80,
+ containLabel: false,
+ top: 12,
+ });
+ });
+});
+
+test('keeps a no-legend grid within a very small post-control body', async () => {
+ mockOffsetHeight = 40;
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ height={50}
+ echartOptions={{
+ grid: { bottom: 37, containLabel: false, top: 12 },
+ }}
+ formData={{ ...defaultFormData, zoomable: true }}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(getLatestHeight()).toBe(10);
+ expect(getLatestEchartProps().echartOptions.grid).toEqual({
+ bottom: 0,
+ containLabel: false,
+ top: 9,
+ });
+ });
+});
+
test('falls back to window resize listener when ResizeObserver is unavailable', async () => {
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
undefined;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
index fae555e33da..d8db68da7ba 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -26,6 +26,7 @@ import {
getColumnLabel,
getNumberFormatter,
LegendState,
+ WithLegend,
ensureIsArray,
createTimeRangeFromGranularity,
} from '@superset-ui/core';
@@ -40,17 +41,50 @@ import type GlobalModel from 'echarts/types/src/model/Global';
import type ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart';
+import {
+ getViableTimeseriesEchartOptions,
+ resolveTimeseriesGridOffset,
+} from './transformers';
import {
rebaseSeriesData,
snapToNearestX,
SeriesDataPoint,
} from './percentChange';
-import { OrientationType, TimeseriesChartTransformedProps } from './types';
+import {
+ OrientationType,
+ TimeseriesChartTransformedProps,
+ TimeseriesCustomLegend,
+} from './types';
import { formatSeriesName } from '../utils/series';
import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
import { ExtraControls } from '../components/ExtraControls';
+import TimeseriesLegend from './TimeseriesLegend';
+import { TIMESERIES_CONSTANTS } from '../constants';
const TIMER_DURATION = 300;
+const MAX_CUSTOM_LEGEND_HEIGHT = 160;
+const MAX_CUSTOM_LEGEND_HEIGHT_RATIO = 0.3;
+const MIN_TIMESERIES_PLOT_HEIGHT = 80;
+
+// Bound the legend after accounting for the fixed ECharts grid reservations,
+// leaving enough coordinate space for the plot itself to remain usable.
+export const getTimeseriesLegendMaxHeight = (
+ chartBodyHeight: number,
+ grid: TimeseriesCustomLegend['grid'],
+) =>
+ Math.min(
+ MAX_CUSTOM_LEGEND_HEIGHT,
+ Math.floor(Math.max(chartBodyHeight, 0) * MAX_CUSTOM_LEGEND_HEIGHT_RATIO),
+ Math.max(
+ Math.floor(
+ chartBodyHeight -
+ resolveTimeseriesGridOffset(grid.top, chartBodyHeight) -
+ resolveTimeseriesGridOffset(grid.bottom, chartBodyHeight) -
+ MIN_TIMESERIES_PLOT_HEIGHT,
+ ),
+ 0,
+ ),
+ );
const getTimestampFromTimeAxisValue = (value: string | number) => {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
@@ -84,6 +118,7 @@ const BASELINE_HANDLE_STRIPE_WIDTH = 2;
export default function EchartsTimeseries({
formData,
height,
+ customLegend,
width,
echartOptions,
groupby,
@@ -775,23 +810,87 @@ export default function EchartsTimeseries({
},
};
+ const dispatchLegendAction = useCallback(
+ (action: { name?: string; seriesName?: string; type: string }) => {
+ echartRef.current?.getEchartInstance()?.dispatchAction(action);
+ },
+ [],
+ );
+
+ const chartBodyHeight = Math.max(height - extraControlHeight, 0);
+ const customLegendMaxHeight = customLegend
+ ? getTimeseriesLegendMaxHeight(chartBodyHeight, customLegend.grid)
+ : 0;
+ const shouldRenderCustomLegend =
+ customLegend !== undefined &&
+ chartBodyHeight > TIMESERIES_CONSTANTS.compactChartHeight &&
+ customLegendMaxHeight > 0;
+ const chartEchartOptions = useMemo(
+ () =>
+ getViableTimeseriesEchartOptions(
+ echartOptions,
+ chartBodyHeight,
+ formData.zoomable,
+ ),
+ [chartBodyHeight, echartOptions, formData.zoomable],
+ );
+ const renderEchart = ({
+ chartHeight,
+ chartWidth,
+ }: {
+ chartHeight: number;
+ chartWidth: number;
+ }) => (
+ <Echart
+ ref={echartRef}
+ refs={refs}
+ height={chartHeight}
+ width={chartWidth}
+ echartOptions={chartEchartOptions}
+ eventHandlers={eventHandlers}
+ queryEventHandlers={queryEventHandlers}
+ zrEventHandlers={zrEventHandlers}
+ selectedValues={selectedValues}
+ vizType={formData.vizType}
+ />
+ );
+
return (
<>
<div ref={extraControlRef}>
<ExtraControls formData={formData} setControlValue={setControlValue} />
</div>
- <Echart
- ref={echartRef}
- refs={refs}
- height={height - extraControlHeight}
- width={width}
- echartOptions={echartOptions}
- eventHandlers={eventHandlers}
- queryEventHandlers={queryEventHandlers}
- zrEventHandlers={zrEventHandlers}
- selectedValues={selectedValues}
- vizType={formData.vizType}
- />
+ {customLegend && shouldRenderCustomLegend ? (
+ <WithLegend
+ height={chartBodyHeight}
+ position={customLegend.orientation}
+ width={width}
+ renderLegend={() => (
+ <TimeseriesLegend
+ {...customLegend}
+ maxHeight={customLegendMaxHeight}
+ onAll={() => dispatchLegendAction({ type: 'legendAllSelect' })}
+ onHover={name =>
+ dispatchLegendAction({
+ seriesName: name ?? undefined,
+ type: name === null ? 'downplay' : 'highlight',
+ })
+ }
+ onInverse={() =>
+ dispatchLegendAction({ type: 'legendInverseSelect' })
+ }
+ onToggle={name =>
+ dispatchLegendAction({ name, type: 'legendToggleSelect' })
+ }
+ />
+ )}
+ renderChart={({ height: chartHeight, width: chartWidth }) =>
+ renderEchart({ chartHeight, chartWidth })
+ }
+ />
+ ) : (
+ renderEchart({ chartHeight: chartBodyHeight, chartWidth: width })
+ )}
</>
);
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/TimeseriesLegend.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/TimeseriesLegend.tsx
new file mode 100644
index 00000000000..d272b357ec0
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/TimeseriesLegend.tsx
@@ -0,0 +1,196 @@
+/**
+ * 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 { memo } from 'react';
+import { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import type { TimeseriesCustomLegend } from './types';
+
+const LegendViewport = styled.div<{ maxHeight: number }>`
+ ${({ maxHeight, theme }) => `
+ box-sizing: border-box;
+ color: ${theme.colorText};
+ font-size: ${theme.fontSizeSM}px;
+ max-height: ${maxHeight}px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
+ width: 100%;
+ `}
+`;
+
+const SelectorRow = styled.div`
+ ${({ theme }) => `
+ align-items: center;
+ background: ${theme.colorBgContainer};
+ display: flex;
+ gap: ${theme.sizeUnit}px;
+ justify-content: flex-end;
+ padding-bottom: ${theme.sizeUnit}px;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ `}
+`;
+
+const SelectorButton = styled.button`
+ ${({ theme }) => `
+ background: transparent;
+ border: 1px solid ${theme.colorBorder};
+ border-radius: ${theme.borderRadius}px;
+ color: ${theme.colorText};
+ cursor: pointer;
+ font: inherit;
+ padding: 0 ${theme.sizeUnit}px;
+
+ &:hover {
+ color: ${theme.colorPrimary};
+ }
+ `}
+`;
+
+const ItemList = styled.ul`
+ ${({ theme }) => `
+ align-items: flex-start;
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.sizeUnit * 2}px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ `}
+`;
+
+const LegendItem = styled.li`
+ min-width: 0;
+`;
+
+const ItemButton = styled.button<{ selected: boolean }>`
+ ${({ selected, theme }) => `
+ align-items: flex-start;
+ appearance: none;
+ background: none;
+ border: none;
+ color: ${selected ? theme.colorText : theme.colorTextDisabled};
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ gap: ${theme.sizeUnit}px;
+ max-width: 100%;
+ padding: 0;
+ text-align: left;
+
+ &:disabled {
+ cursor: default;
+ }
+ `}
+`;
+
+const Swatch = styled.span<{ color: string; selected: boolean }>`
+ ${({ color, selected }) => `
+ background: ${selected ? color : 'transparent'};
+ border: 1px solid ${color};
+ box-sizing: border-box;
+ flex: 0 0 auto;
+ height: 12px;
+ margin-top: 2px;
+ width: 12px;
+ `}
+`;
+
+const Label = styled.span`
+ overflow-wrap: anywhere;
+ white-space: pre-line;
+`;
+
+const RowBreak = styled.li`
+ flex-basis: 100%;
+ height: 0;
+`;
+
+export type TimeseriesLegendProps = TimeseriesCustomLegend & {
+ maxHeight: number;
+ onAll: () => void;
+ onHover?: (name: string | null) => void;
+ onInverse: () => void;
+ onToggle: (name: string) => void;
+};
+
+function TimeseriesLegend({
+ items,
+ maxHeight,
+ onAll,
+ onHover,
+ onInverse,
+ onToggle,
+ showSelectors,
+}: TimeseriesLegendProps) {
+ if (items.length === 0 || maxHeight <= 0) {
+ return null;
+ }
+
+ return (
+ <LegendViewport data-test="timeseries-custom-legend" maxHeight={maxHeight}>
+ {showSelectors && (
+ <SelectorRow>
+ <SelectorButton type="button" onClick={onAll}>
+ {t('All')}
+ </SelectorButton>
+ <SelectorButton type="button" onClick={onInverse}>
+ {t('Inverse')}
+ </SelectorButton>
+ </SelectorRow>
+ )}
+ <ItemList>
+ {items.map((item, index) =>
+ item.name === '' || item.name === '\n' ? (
+ <RowBreak
+ // ECharts treats these exact values as row-break sentinels.
+ key={`row-break-${item.name}-${index}`}
+ />
+ ) : (
+ <LegendItem key={item.name}>
+ <ItemButton
+ aria-pressed={item.selected}
+ disabled={!item.interactive}
+ selected={item.selected}
+ type="button"
+ onClick={() => {
+ if (item.interactive) {
+ onToggle(item.name);
+ }
+ }}
+ onMouseEnter={() => onHover?.(item.name)}
+ onMouseLeave={() => onHover?.(null)}
+ >
+ <Swatch
+ aria-hidden
+ color={item.color}
+ selected={item.selected}
+ />
+ <Label>{item.name}</Label>
+ </ItemButton>
+ </LegendItem>
+ ),
+ )}
+ </ItemList>
+ </LegendViewport>
+ );
+}
+
+export default memo(TimeseriesLegend);
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
index 169a7563128..f655cece310 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -41,6 +41,7 @@ import {
isIntervalAnnotationLayer,
isPhysicalColumn,
isTimeseriesAnnotationLayer,
+ LegendState,
resolveAutoCurrency,
TimeseriesChartDataResponseResult,
TimeseriesDataRecord,
@@ -66,7 +67,9 @@ import {
EchartsTimeseriesSeriesType,
BarValueLabelPosition,
OrientationType,
+ TimeseriesCustomLegend,
TimeseriesChartTransformedProps,
+ TimeseriesLegendItem,
} from './types';
import { DEFAULT_FORM_DATA } from './constants';
import {
@@ -112,6 +115,7 @@ import { defaultGrid, defaultYAxis } from '../defaults';
import {
getBaselineSeriesForStream,
getPadding,
+ getViableTimeseriesEchartOptions,
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
@@ -137,6 +141,77 @@ import {
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
+type LegendSeriesVisual = {
+ itemStyle?: { color?: unknown };
+ lineStyle?: { color?: unknown };
+ name?: string | number;
+};
+
+function getLegendSeriesColor(
+ series: SeriesOption | undefined,
+ fallbackColor: string,
+): string {
+ const visual = series as LegendSeriesVisual | undefined;
+ const color = visual?.itemStyle?.color ?? visual?.lineStyle?.color;
+ return typeof color === 'string' ? color : fallbackColor;
+}
+
+function buildTimeseriesCustomLegend({
+ fallbackColor,
+ grid,
+ interactive,
+ legendNames,
+ legendState,
+ orientation,
+ series,
+}: {
+ fallbackColor: string;
+ grid: TimeseriesCustomLegend['grid'];
+ interactive: boolean;
+ legendNames: string[];
+ legendState?: LegendState;
+ orientation: LegendOrientation.Top | LegendOrientation.Bottom;
+ series: SeriesOption[];
+}): TimeseriesCustomLegend {
+ const firstSeriesByName = new Map<string, SeriesOption>();
+ series.forEach(seriesOption => {
+ const { name } = seriesOption as LegendSeriesVisual;
+ if (name !== undefined && !firstSeriesByName.has(String(name))) {
+ firstSeriesByName.set(String(name), seriesOption);
+ }
+ });
+
+ const seen = new Set<string>();
+ const items = legendNames.flatMap<TimeseriesLegendItem>(name => {
+ if (seen.has(name)) {
+ return [];
+ }
+ seen.add(name);
+
+ const rowBreak = name === '' || name === '\n';
+ const matchingSeries = firstSeriesByName.get(name);
+ if (!rowBreak && !matchingSeries) {
+ return [];
+ }
+
+ return [
+ {
+ color: getLegendSeriesColor(matchingSeries, fallbackColor),
+ interactive: interactive && !rowBreak,
+ name,
+ selected: legendState?.[name] !== false,
+ },
+ ];
+ });
+
+ return {
+ grid,
+ items,
+ orientation,
+ showSelectors: interactive,
+ };
+}
+
const visibleDashPatterns: ([number, number] | 'dashed' | 'dotted')[] = [
'dashed',
'dotted',
@@ -1138,9 +1213,30 @@ export default function transformProps(
name,
icon: 'roundRect',
}));
+ const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight;
+ const usesCompactLayout = height <= TIMESERIES_CONSTANTS.compactChartHeight;
+ const isLegendVisible = showLegend && !usesCompactLayout;
+ const usesPrimaryAxisLegend = colorByPrimaryAxis && groupBy.length === 0;
+ const resolvedLegendData = usesPrimaryAxisLegend
+ ? colorByPrimaryAxisLegendData
+ : sortedLegendData;
+ const resolvedLegendNames = (
+ usesPrimaryAxisLegend ? legendData : sortedLegendData
+ ).map(String);
+ const usesCustomLegend =
+ isLegendVisible &&
+ legendType === LegendType.Plain &&
+ (legendOrientation === LegendOrientation.Top ||
+ legendOrientation === LegendOrientation.Bottom);
+ const nativeLegendVisible = isLegendVisible && !usesCustomLegend;
+ // Use the exact final ordering ECharts receives. Forecast components share
+ // a legend name, and ECharts takes the first matching series as its visual.
+ const renderedSeries = dedupSeries(
+ reorderForecastSeries([...series]) as SeriesOption[],
+ );
const getLegendLayout = (candidateLegendMargin?: string | number | null) => {
const padding = getPadding(
- showLegend,
+ nativeLegendVisible,
legendOrientation,
addYAxisLabelOffset,
zoomable,
@@ -1165,20 +1261,18 @@ export default function transformProps(
: undefined,
chartHeight: height,
chartWidth: width,
- legendItems:
- colorByPrimaryAxis && groupBy.length === 0
- ? colorByPrimaryAxisLegendData
- : sortedLegendData,
+ legendItems: resolvedLegendData,
legendMargin: candidateLegendMargin,
orientation: legendOrientation,
- show: showLegend,
- showSelectors: !(colorByPrimaryAxis && groupBy.length === 0),
+ show: nativeLegendVisible,
+ showSelectors: !usesPrimaryAxisLegend,
theme,
type: legendType,
});
};
const initialLegendLayout = getLegendLayout(legendMargin);
const legendLayout =
+ nativeLegendVisible &&
isHorizontal &&
legendOrientation === LegendOrientation.Bottom &&
initialLegendLayout.effectiveLegendType === LegendType.Plain
@@ -1192,7 +1286,7 @@ export default function transformProps(
? legendMargin
: legendLayout.effectiveLegendMargin;
const padding = getPadding(
- showLegend,
+ nativeLegendVisible,
legendOrientation,
addYAxisLabelOffset,
zoomable,
@@ -1207,7 +1301,7 @@ export default function transformProps(
// 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) {
+ if (usesCompactLayout) {
padding.top = Math.min(padding.top, 12);
if (!zoomable) {
padding.bottom = Math.min(padding.bottom, 5);
@@ -1301,7 +1395,6 @@ export default function transformProps(
// >= 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
@@ -1378,6 +1471,9 @@ export default function transformProps(
grid: {
...defaultGrid,
...padding,
+ // Compact charts prioritize a viable coordinate system over keeping
+ // axis labels inside an already constrained grid rectangle.
+ containLabel: !usesCompactLayout,
},
xAxis,
yAxis,
@@ -1504,27 +1600,23 @@ export default function transformProps(
...getLegendProps(
effectiveLegendType,
legendOrientation,
- // Hide legend on compact charts — not enough vertical space
- isSmallChart ? false : showLegend,
+ nativeLegendVisible,
theme,
zoomable,
legendState,
padding,
),
scrollDataIndex: legendIndex || 0,
- data:
- colorByPrimaryAxis && groupBy.length === 0
- ? colorByPrimaryAxisLegendData
- : sortedLegendData,
+ data: resolvedLegendData,
// Disable legend selection and buttons when colorByPrimaryAxis is enabled
- ...(colorByPrimaryAxis && groupBy.length === 0
+ ...(usesPrimaryAxisLegend
? {
selectedMode: false, // Disable clicking legend items
selector: false, // Hide All/Invert buttons
}
: {}),
},
- series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
+ series: renderedSeries,
toolbox: {
show: zoomable,
top: TIMESERIES_CONSTANTS.toolboxTop,
@@ -1581,9 +1673,57 @@ export default function transformProps(
const mergedEchartOptions = customEchartOptions
? mergeCustomEChartOptions(echartOptions, customEchartOptions)
: echartOptions;
+ const viableEchartOptions = getViableTimeseriesEchartOptions(
+ mergedEchartOptions,
+ height,
+ zoomable,
+ );
+ const mergedSeries = viableEchartOptions.series;
+ const finalSeries = Array.isArray(mergedSeries)
+ ? (mergedSeries as SeriesOption[])
+ : mergedSeries && typeof mergedSeries === 'object'
+ ? [mergedSeries as SeriesOption]
+ : renderedSeries;
+ const mergedGrid = Array.isArray(viableEchartOptions.grid)
+ ? viableEchartOptions.grid[0]
+ : viableEchartOptions.grid;
+ const finalGrid =
+ mergedGrid && typeof mergedGrid === 'object' ? mergedGrid : padding;
+ const customLegend = usesCustomLegend
+ ? buildTimeseriesCustomLegend({
+ fallbackColor: theme.colorTextSecondary,
+ grid: {
+ bottom:
+ typeof finalGrid.bottom === 'number' ||
+ typeof finalGrid.bottom === 'string'
+ ? finalGrid.bottom
+ : padding.bottom,
+ top:
+ typeof finalGrid.top === 'number' ||
+ typeof finalGrid.top === 'string'
+ ? finalGrid.top
+ : padding.top,
+ },
+ interactive: !usesPrimaryAxisLegend,
+ legendNames: resolvedLegendNames,
+ legendState,
+ orientation: legendOrientation,
+ series: finalSeries,
+ })
+ : undefined;
+ const finalEchartOptions = usesCustomLegend
+ ? {
+ ...viableEchartOptions,
+ legend: {
+ ...(viableEchartOptions.legend as Record<string, unknown>),
+ show: false,
+ },
+ }
+ : viableEchartOptions;
return {
- echartOptions: mergedEchartOptions,
+ customLegend,
+ echartOptions: finalEchartOptions,
emitCrossFilters,
formData,
groupby: groupBy,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
index 8545ac64c4d..9c144b6d501 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
@@ -1002,3 +1002,76 @@ export function getPadding(
isHorizontal,
);
}
+
+const MIN_ECHARTS_GRID_HEIGHT = 1;
+
+export function resolveTimeseriesGridOffset(
+ offset: unknown,
+ chartHeight: number,
+) {
+ if (typeof offset === 'number') {
+ return Number.isFinite(offset) ? Math.max(offset, 0) : 0;
+ }
+ if (typeof offset !== 'string') {
+ return 0;
+ }
+
+ const percentage = offset.match(/^\s*(-?\d+(?:\.\d+)?)%\s*$/);
+ const pixels = percentage
+ ? (Number(percentage[1]) / 100) * chartHeight
+ : Number(offset);
+ return Number.isFinite(pixels) ? Math.max(pixels, 0) : 0;
+}
+
+export function getViableTimeseriesEchartOptions<Options extends object>(
+ options: Options,
+ chartHeight: number,
+ zoomable: boolean,
+): Options {
+ const optionWithGrid = options as Options & { grid?: unknown };
+ const gridOption = Array.isArray(optionWithGrid.grid)
+ ? optionWithGrid.grid[0]
+ : optionWithGrid.grid;
+ if (!gridOption || typeof gridOption !== 'object') {
+ return options;
+ }
+
+ const grid = gridOption as Record<string, unknown>;
+ const rawTop = resolveTimeseriesGridOffset(grid.top, chartHeight);
+ const rawBottom = resolveTimeseriesGridOffset(grid.bottom, chartHeight);
+ const isCompact = chartHeight <= TIMESERIES_CONSTANTS.compactChartHeight;
+ const requestedTop = isCompact ? Math.min(rawTop, 12) : rawTop;
+ const requestedBottom =
+ isCompact && !zoomable ? Math.min(rawBottom, 5) : rawBottom;
+ // Cap both reservations so even a tiny canvas retains a coordinate region.
+ const reservationBudget = Math.max(chartHeight - MIN_ECHARTS_GRID_HEIGHT, 0);
+ const top = Math.min(requestedTop, reservationBudget);
+ const bottom = Math.min(
+ requestedBottom,
+ Math.max(reservationBudget - top, 0),
+ );
+ const mustDisableContainLabel =
+ isCompact || requestedTop + requestedBottom > reservationBudget;
+
+ if (
+ top === rawTop &&
+ bottom === rawBottom &&
+ (!mustDisableContainLabel || grid.containLabel === false)
+ ) {
+ return options;
+ }
+
+ const viableGrid = {
+ ...grid,
+ bottom,
+ ...(mustDisableContainLabel ? { containLabel: false } : {}),
+ top,
+ };
+
+ return {
+ ...options,
+ grid: Array.isArray(optionWithGrid.grid)
+ ? [viableGrid, ...optionWithGrid.grid.slice(1)]
+ : viableGrid,
+ } as Options;
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
index 9f0dd771e74..7ab18920721 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
@@ -34,6 +34,7 @@ import {
CrossFilterTransformedProps,
LabelPositionEnum,
LegendFormData,
+ LegendOrientation,
StackType,
TitleFormData,
} from '../types';
@@ -133,10 +134,28 @@ export interface EchartsTimeseriesChartProps extends BaseChartProps<EchartsTimes
formData: EchartsTimeseriesFormData;
}
+export type TimeseriesLegendItem = {
+ color: string;
+ interactive: boolean;
+ name: string;
+ selected: boolean;
+};
+
+export type TimeseriesCustomLegend = {
+ grid: {
+ bottom: number | string;
+ top: number | string;
+ };
+ items: TimeseriesLegendItem[];
+ orientation: LegendOrientation.Top | LegendOrientation.Bottom;
+ showSelectors: boolean;
+};
+
export type TimeseriesChartTransformedProps =
BaseTransformedProps<EchartsTimeseriesFormData> &
ContextMenuTransformedProps &
CrossFilterTransformedProps & {
+ customLegend?: TimeseriesCustomLegend;
legendData?: OptionName[];
isRefreshing?: boolean;
xValueFormatter: TimeFormatter | StringConstructor;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts
index 6a95b70d3c5..7c64e97bea3 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts
@@ -43,11 +43,6 @@ import {
OrientationType,
EchartsTimeseriesSeriesType,
} from '../../../src/Timeseries/types';
-import { getPadding } from '../../../src/Timeseries/transformers';
-import {
- getHorizontalLegendAvailableWidth,
- getLegendLayoutResult,
-} from '../../../src/utils/series';
import { createEchartsTimeseriesTestChartProps } from '../../helpers';
function createTestQueryData(
@@ -646,6 +641,41 @@ describe('Bar Chart X-axis Time Formatting', () => {
expect(legend.selector).toBe(false);
});
+ test('marks custom Plain legend items non-interactive when color by x-axis is enabled', () => {
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ formData: {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ metric: 'value',
+ showLegend: true,
+ x_axis: 'category',
+ },
+ queriesData: categoricalData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+ const { customLegend } = transformedProps as unknown as {
+ customLegend?: {
+ items: { interactive: boolean; name: string }[];
+ showSelectors: boolean;
+ };
+ };
+
+ expect(customLegend?.showSelectors).toBe(false);
+ expect(customLegend?.items.map(item => item.name)).toEqual([
+ 'A',
+ 'B',
+ 'C',
+ ]);
+ expect(customLegend?.items.every(item => !item.interactive)).toBe(true);
+ });
+
test('should work without stacking enabled', () => {
const formData = {
...baseFormData,
@@ -922,39 +952,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
});
describe('Legend layout regressions', () => {
- const getBottomLegendLayout = (
- chartWidth: number,
- legendItems: string[],
- legendMargin?: string | number | null,
- ) =>
- getLegendLayoutResult({
- availableWidth: getHorizontalLegendAvailableWidth({
- chartWidth,
- orientation: LegendOrientation.Bottom,
- padding: getPadding(
- true,
- LegendOrientation.Bottom,
- false,
- false,
- legendMargin,
- false,
- undefined,
- undefined,
- undefined,
- true,
- ),
- }),
- chartHeight: baseChartPropsConfig.height,
- chartWidth,
- legendItems,
- legendMargin,
- orientation: LegendOrientation.Bottom,
- show: true,
- theme: supersetTheme,
- type: LegendType.Plain,
- });
-
- test('honors an explicit List selection for horizontal bottom legends and reserves margin', () => {
+ test('honors an explicit List selection with a custom horizontal bottom legend', () => {
const legendLabels = [
'This is a long sales legend',
'This is a long marketing legend',
@@ -1019,58 +1017,26 @@ describe('Bar Chart X-axis Time Formatting', () => {
const legend = transformedProps.echartOptions
.legend as LegendComponentOption;
const grid = transformedProps.echartOptions.grid as GridComponentOption;
- const legendItems = (legend.data as Array<string | { name: string }>).map(
- item => (typeof item === 'string' ? item : item.name),
- );
-
- const layout = getBottomLegendLayout(chartWidth, legendItems, null);
- const basePadding = getPadding(
- true,
- LegendOrientation.Bottom,
- false,
- false,
- null,
- false,
- undefined,
- undefined,
- undefined,
- true,
- );
- [basePadding.bottom, basePadding.left] = [
- basePadding.left,
- basePadding.bottom,
- ];
+ const { customLegend } = transformedProps as unknown as {
+ customLegend?: {
+ items: { name: string }[];
+ orientation: LegendOrientation;
+ };
+ };
+ const resolvedLegendItems = (
+ legend.data as Array<string | { name: string }>
+ ).map(item => (typeof item === 'string' ? item : item.name));
// The explicit List selection is honored end-to-end (never flips).
expect(legend.type).toBe(LegendType.Plain);
- expect(layout.effectiveType).toBe(LegendType.Plain);
-
- // #38675's margin reservation is retained: the wrapped rows reserve a
- // finite margin beyond the single-row baseline, so the grid shrinks to
- // reduce clipping instead of the legend flipping to scroll.
- expect(Number.isFinite(layout.effectiveMargin)).toBe(true);
-
- const reservedPadding = getPadding(
- true,
- LegendOrientation.Bottom,
- false,
- false,
- layout.effectiveMargin,
- false,
- undefined,
- undefined,
- undefined,
- true,
- );
- [reservedPadding.bottom, reservedPadding.left] = [
- reservedPadding.left,
- reservedPadding.bottom,
- ];
-
- expect(grid.bottom).toBe(reservedPadding.bottom);
- expect(grid.bottom as number).toBeGreaterThan(
- basePadding.bottom as number,
+ expect(legend.show).toBe(false);
+ expect(customLegend?.orientation).toBe(LegendOrientation.Bottom);
+ expect(customLegend?.items.map(item => item.name)).toEqual(
+ resolvedLegendItems,
);
+ // The plot canvas no longer reserves native legend rows; the independently
+ // scrolling HTML legend consumes space outside the ECharts grid.
+ expect(grid.bottom).toBe(20);
});
});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
index bb91ffea970..72104b286e5 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { logging } from '@apache-superset/core/utils';
@@ -27,6 +28,49 @@ import {
TimeseriesChartTransformedProps,
} from '../../src/Timeseries/types';
import type { EchartsProps } from '../../src/types';
+import { LegendOrientation } from '../../src/types';
+
+jest.mock('@visx/responsive', () => ({
+ ParentSize: ({
+ children,
+ }: {
+ children: (size: object) => React.ReactNode;
+ }) => {
+ const React = jest.requireActual<typeof import('react')>('react');
+ const hostRef = React.useRef<HTMLDivElement>(null);
+ const [size, setSize] = React.useState<{
+ height: number;
+ width: number;
+ }>();
+
+ React.useLayoutEffect(() => {
+ const frame = hostRef.current?.closest<HTMLElement>('.with-legend');
+ const legend = frame?.querySelector<HTMLElement>(
+ '[data-test="timeseries-custom-legend"]',
+ );
+ if (!frame || !legend) {
+ return;
+ }
+
+ const frameHeight = Number.parseFloat(frame.style.height);
+ const frameWidth = Number.parseFloat(frame.style.width);
+ const maxHeight = Number.parseFloat(
+ globalThis.getComputedStyle(legend).maxHeight,
+ );
+ const itemCount = legend.querySelectorAll('[aria-pressed]').length;
+ const selectorHeight = legend.querySelectorAll('[aria-pressed]').length
+ ? 20
+ : 0;
+ const naturalHeight = selectorHeight + Math.ceil(itemCount / 4) * 20;
+ setSize({
+ height: frameHeight - Math.min(maxHeight, naturalHeight),
+ width: frameWidth,
+ });
+ }, []);
+
+ return <div ref={hostRef}>{size ? children(size) : null}</div>;
+ },
+}));
// Percent-change draggable baseline: this is the one piece of the ECharts
// rebuilds with zero prior test coverage despite six separate production
@@ -40,6 +84,7 @@ import type { EchartsProps } from '../../src/types';
// mockImplementation afterward) because forwardRef() returns a React
// element descriptor, not a plain function a jest mock can invoke.
let mockChart: {
+ dispatchAction: jest.Mock;
setOption: jest.Mock;
getHeight: jest.Mock;
convertToPixel: jest.Mock;
@@ -57,7 +102,8 @@ jest.mock('../../src/components/Echart', () => {
useImperativeHandle(ref, () => ({
getEchartInstance: () => mockChart,
}));
- return null;
+ const { height } = props as { height: number };
+ return <div data-height={height} data-test="mock-echart" />;
}),
};
});
@@ -66,6 +112,7 @@ const PX_PER_UNIT = 100;
function setupChartMock() {
mockChart = {
+ dispatchAction: jest.fn(),
setOption: jest.fn(),
getHeight: jest.fn(() => 400),
// A trivial, invertible mapping so drag pixel deltas translate to
@@ -82,6 +129,24 @@ function setupChartMock() {
};
}
+function getCustomLegend(
+ itemCount: number,
+ overrides: Record<string, unknown> = {},
+) {
+ return {
+ items: Array.from({ length: itemCount }, (_, index) => ({
+ color: `rgb(${index % 255}, 0, 0)`,
+ interactive: true,
+ name: `Series ${index + 1}`,
+ selected: true,
+ })),
+ orientation: LegendOrientation.Top,
+ grid: { bottom: 20, top: 20 },
+ showSelectors: true,
+ ...overrides,
+ };
+}
+
const BASE_SERIES_DATA: [number, number][] = [
[0, 10],
[1, 20],
@@ -158,6 +223,160 @@ afterEach(() => {
jest.restoreAllMocks();
});
+test('renders fitting content at the allocated height without a scroll viewport', () => {
+ const { container } = renderTimeseries();
+
+ expect(screen.getByTestId('mock-echart')).toHaveAttribute(
+ 'data-height',
+ '400',
+ );
+ expect(container.querySelector('[style*="overflow-y"]')).toBeNull();
+});
+
+test('caps a dense custom Plain legend while keeping scrolling inside the legend region', () => {
+ renderTimeseries({
+ ...({ customLegend: getCustomLegend(200) } as any),
+ formData: { rebasePercentChange: false } as any,
+ });
+
+ const legend = screen.getByTestId('timeseries-custom-legend');
+ expect(legend).toHaveStyle({
+ maxHeight: '120px',
+ overflowY: 'auto',
+ });
+ expect(legend.style.height).toBe('');
+ expect(screen.getAllByRole('button')).toHaveLength(202);
+ expect(screen.getByTestId('mock-echart')).toHaveAttribute(
+ 'data-height',
+ '280',
+ );
+ expect(legend.closest('.with-legend')).toHaveStyle({
+ height: '400px',
+ width: '800px',
+ });
+});
+
+test('lets a short custom Plain legend use its natural height', () => {
+ renderTimeseries({
+ ...({ customLegend: getCustomLegend(2) } as any),
+ formData: { rebasePercentChange: false } as any,
+ });
+
+ const legend = screen.getByTestId('timeseries-custom-legend');
+ expect(legend).toHaveStyle({ maxHeight: '120px' });
+ expect(legend.style.height).toBe('');
+ expect(screen.getAllByRole('button')).toHaveLength(4);
+ expect(screen.getByTestId('mock-echart')).toHaveAttribute(
+ 'data-height',
+ '360',
+ );
+});
+
+test.each([
+ [99, 99],
+ [100, 100],
+ [120, 120],
+])(
+ 'does not allocate a custom legend when a %ipx zoomable chart has no usable grid space',
+ (height, expectedChartHeight) => {
+ renderTimeseries({
+ ...({
+ customLegend: getCustomLegend(200, {
+ grid: { bottom: 80, top: 20 },
+ }),
+ } as any),
+ formData: { rebasePercentChange: false, zoomable: true } as any,
+ height,
+ });
+
+ expect(
+ screen.queryByTestId('timeseries-custom-legend'),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId('mock-echart')).toHaveAttribute(
+ 'data-height',
+ String(expectedChartHeight),
+ );
+ },
+);
+
+test('accounts for axis-title grid reservations when bounding the custom legend', () => {
+ renderTimeseries({
+ ...({
+ customLegend: getCustomLegend(200, {
+ grid: { bottom: 80, top: 60 },
+ }),
+ } as any),
+ formData: { rebasePercentChange: false } as any,
+ height: 240,
+ });
+
+ expect(screen.getByTestId('timeseries-custom-legend')).toHaveStyle({
+ maxHeight: '20px',
+ });
+ expect(screen.getByTestId('mock-echart')).toHaveAttribute(
+ 'data-height',
+ '220',
+ );
+});
+
+test.each([
+ [LegendOrientation.Top, 'column'],
+ [LegendOrientation.Bottom, 'column-reverse'],
+])('places a custom Plain legend at %s', (orientation, flexDirection) => {
+ renderTimeseries({
+ ...({
+ customLegend: getCustomLegend(2, { orientation }),
+ } as any),
+ formData: { rebasePercentChange: false } as any,
+ });
+
+ expect(
+ screen.getByTestId('timeseries-custom-legend').closest('.with-legend'),
+ ).toHaveStyle({ flexDirection });
+});
+
+test('dispatches the native ECharts toggle, All, and Inverse legend actions', () => {
+ renderTimeseries({
+ ...({ customLegend: getCustomLegend(2) } as any),
+ formData: { rebasePercentChange: false } as any,
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Series 1' }));
+ fireEvent.click(screen.getByRole('button', { name: 'All' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Inverse' }));
+
+ expect(mockChart.dispatchAction.mock.calls).toEqual([
+ [{ name: 'Series 1', type: 'legendToggleSelect' }],
+ [{ type: 'legendAllSelect' }],
+ [{ type: 'legendInverseSelect' }],
+ ]);
+});
+
+test('does not dispatch legend actions for non-interactive color-by-primary-axis items', () => {
+ renderTimeseries({
+ ...({
+ customLegend: getCustomLegend(2, {
+ items: getCustomLegend(2).items.map(item => ({
+ ...item,
+ interactive: false,
+ })),
+ showSelectors: false,
+ }),
+ } as any),
+ formData: { rebasePercentChange: false } as any,
+ });
+
+ const item = screen.getByRole('button', { name: 'Series 1' });
+ expect(item).toBeDisabled();
+ fireEvent.click(item);
+
+ expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Inverse' }),
+ ).not.toBeInTheDocument();
+ expect(mockChart.dispatchAction).not.toHaveBeenCalled();
+});
+
test('draws the baseline handle at the first x value on mount', () => {
renderTimeseries();
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
index 45d782ff458..d0fc6ca482a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
@@ -37,7 +37,8 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
-import type { SeriesOption } from 'echarts';
+import { init, type SeriesOption } from 'echarts';
+import type { GridComponentOption } from 'echarts/components';
import transformProps from '../../src/Timeseries/transformProps';
import {
EchartsTimeseriesSeriesType,
@@ -46,6 +47,7 @@ import {
} from '../../src/Timeseries/types';
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
import {
+ ForecastSeriesEnum,
LegendOrientation,
LegendType,
EchartsTimeseriesChartProps,
@@ -160,6 +162,27 @@ const formData: SqlaFormData = {
viz_type: 'my_viz',
};
+type CustomLegendResult = {
+ customLegend?: {
+ grid: {
+ bottom: number | string;
+ top: number | string;
+ };
+ items: {
+ color: string;
+ interactive: boolean;
+ name: string;
+ selected: boolean;
+ }[];
+ orientation: LegendOrientation.Top | LegendOrientation.Bottom;
+ showSelectors: boolean;
+ };
+};
+
+function getCustomLegend(transformed: ReturnType<typeof transformProps>) {
+ return (transformed as unknown as CustomLegendResult).customLegend;
+}
+
describe('EchartsTimeseries transformProps', () => {
test('should transform chart props for viz', () => {
const chartProps = createTestChartProps({});
@@ -1177,6 +1200,345 @@ test('honors an explicit List selection for zoomable top legends even when toolb
expect((transformed.echartOptions.legend as any).type).toBe(LegendType.Plain);
});
+test('moves a visible horizontal Plain legend into a custom HTML legend and restores normal plot padding', () => {
+ const chartProps = createTestChartProps({
+ width: 800,
+ height: 400,
+ formData: {
+ ...formData,
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ yAxisTitleMargin: 0,
+ yAxisTitlePosition: 'Left',
+ },
+ });
+
+ const transformed = transformProps(chartProps);
+ const legend = transformed.echartOptions.legend as {
+ show?: boolean;
+ type?: LegendType;
+ };
+ const grid = transformed.echartOptions.grid as GridComponentOption;
+ const customLegend = getCustomLegend(transformed);
+
+ expect(legend).toMatchObject({ show: false, type: LegendType.Plain });
+ expect(grid).toMatchObject({ top: 20, bottom: 20 });
+ expect(customLegend).toMatchObject({
+ orientation: LegendOrientation.Top,
+ showSelectors: true,
+ });
+ expect(customLegend?.items.map(item => item.name)).toEqual([
+ 'San Francisco',
+ 'New York',
+ ]);
+ expect(customLegend?.items.every(item => item.interactive)).toBe(true);
+ expect(customLegend?.items.every(item => item.selected)).toBe(true);
+ expect(customLegend?.items.every(item => Boolean(item.color))).toBe(true);
+ expect('contentHeight' in transformed).toBe(false);
+});
+
+test.each([LegendOrientation.Top, LegendOrientation.Bottom])(
+ 'uses the custom HTML legend for a %s-oriented Plain legend',
+ legendOrientation => {
+ const transformed = transformProps(
+ createTestChartProps({
+ formData: {
+ ...formData,
+ legendOrientation,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ },
+ }),
+ );
+
+ expect(getCustomLegend(transformed)?.orientation).toBe(legendOrientation);
+ expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
+ false,
+ );
+ },
+);
+
+test.each([
+ [LegendType.Scroll, LegendOrientation.Top],
+ [LegendType.Plain, LegendOrientation.Left],
+ [LegendType.Plain, LegendOrientation.Right],
+] as const)(
+ 'keeps %s/%s legends on the native ECharts path',
+ (legendType, legendOrientation) => {
+ const transformed = transformProps(
+ createTestChartProps({
+ formData: {
+ ...formData,
+ legendOrientation,
+ legendType,
+ showLegend: true,
+ },
+ }),
+ );
+
+ expect(getCustomLegend(transformed)).toBeUndefined();
+ expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
+ true,
+ );
+ },
+);
+
+test('keeps the custom legend absent when a compact chart hides the Plain legend', () => {
+ const transformed = transformProps(
+ createTestChartProps({
+ height: 80,
+ formData: {
+ ...formData,
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ },
+ }),
+ );
+ const grid = transformed.echartOptions.grid as GridComponentOption;
+
+ expect(getCustomLegend(transformed)).toBeUndefined();
+ expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
+ false,
+ );
+ expect(grid).toMatchObject({ top: 12, bottom: 5 });
+ expect(80 - Number(grid.top) - Number(grid.bottom)).toBeGreaterThan(0);
+});
+
+test.each([
+ [10, 9, 0.25, 9, 0],
+ [13, 12, 0.25, 12, 0],
+ [20, 12, 0.25, 12, 7],
+ [30, 12, 1, 12, 17],
+ [99, 12, 7, 12, 80],
+ [100, 12, 8, 12, 80],
+])(
+ 'keeps the hidden-legend zoomable ECharts grid within a %ipx canvas',
+ (height, expectedGridY, expectedGridHeight, expectedTop, expectedBottom) => {
+ const getContext = jest
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
+ .mockReturnValue({
+ measureText: (text: string) => ({ width: text.length * 7 }),
+ } as never);
+ const transformed = transformProps(
+ createTestChartProps({
+ height,
+ formData: {
+ ...formData,
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ zoomable: true,
+ },
+ }),
+ );
+ const chart = init(null, null, {
+ height,
+ renderer: 'svg',
+ ssr: true,
+ width: transformed.width,
+ });
+
+ try {
+ chart.setOption(transformed.echartOptions);
+ const gridModel = (
+ chart as unknown as {
+ getModel: () => {
+ getComponent: (component: string) => {
+ coordinateSystem: {
+ getRect: () => { height: number; y: number };
+ };
+ };
+ };
+ }
+ )
+ .getModel()
+ .getComponent('grid');
+
+ expect(getCustomLegend(transformed)).toBeUndefined();
+ expect(transformed.echartOptions.grid).toMatchObject({
+ bottom: expectedBottom,
+ top: expectedTop,
+ });
+ const gridRect = gridModel.coordinateSystem.getRect();
+ expect(gridRect).toMatchObject({
+ height: expectedGridHeight,
+ y: expectedGridY,
+ });
+ expect(gridRect.y).toBeGreaterThanOrEqual(0);
+ expect(gridRect.y + gridRect.height).toBeLessThanOrEqual(height);
+ } finally {
+ chart.dispose();
+ getContext.mockRestore();
+ }
+ },
+);
+
+test('passes final axis-title grid reservations to the custom legend', () => {
+ const transformed = transformProps(
+ createTestChartProps({
+ height: 300,
+ formData: {
+ ...formData,
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ xAxisTitle: 'Time',
+ xAxisTitleMargin: 60,
+ yAxisTitle: 'Value',
+ yAxisTitleMargin: 40,
+ yAxisTitlePosition: 'Top',
+ zoomable: true,
+ },
+ }),
+ );
+ const grid = transformed.echartOptions.grid as GridComponentOption;
+
+ expect(getCustomLegend(transformed)?.grid).toEqual({
+ bottom: grid.bottom,
+ top: grid.top,
+ });
+});
+
+test('derives custom legend items from a single-object custom series override', () => {
+ const transformed = transformProps(
+ createTestChartProps({
+ formData: {
+ ...formData,
+ echartOptions: `{
+ series: {
+ name: 'San Francisco',
+ type: 'line',
+ data: [[0, 9]],
+ itemStyle: { color: '#123456' }
+ }
+ }`,
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ },
+ }),
+ );
+
+ expect(getCustomLegend(transformed)?.items).toEqual([
+ expect.objectContaining({
+ color: '#123456',
+ name: 'San Francisco',
+ }),
+ ]);
+});
+
+test('keeps a hidden native legend model active for custom legend dispatch actions', () => {
+ const transformed = transformProps(
+ createTestChartProps({
+ formData: {
+ ...formData,
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ },
+ }),
+ );
+ const chart = init(null, null, {
+ height: transformed.height,
+ renderer: 'svg',
+ ssr: true,
+ width: transformed.width,
+ });
+
+ try {
+ chart.setOption(transformed.echartOptions);
+ const toggled = jest.fn();
+ const inverted = jest.fn();
+ const selectedAll = jest.fn();
+ chart.on('legendselectchanged', toggled);
+ chart.on('legendinverseselect', inverted);
+ chart.on('legendselectall', selectedAll);
+ chart.dispatchAction({
+ name: 'San Francisco',
+ type: 'legendToggleSelect',
+ });
+
+ expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
+ false,
+ );
+ expect(toggled).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selected: expect.objectContaining({
+ 'New York': true,
+ 'San Francisco': false,
+ }),
+ }),
+ );
+
+ chart.dispatchAction({ type: 'legendInverseSelect' });
+ expect(inverted).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selected: expect.objectContaining({
+ 'New York': false,
+ 'San Francisco': true,
+ }),
+ }),
+ );
+
+ chart.dispatchAction({ type: 'legendAllSelect' });
+ expect(selectedAll).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selected: expect.objectContaining({
+ 'New York': true,
+ 'San Francisco': true,
+ }),
+ }),
+ );
+ } finally {
+ chart.dispose();
+ }
+});
+
+test('derives custom legend visuals from the final reordered Forecast series', () => {
+ const legendNames = ['Forecast Alpha', 'Forecast Beta'];
+ const forecastValues = Object.fromEntries(
+ legendNames.flatMap((name, index) => [
+ [name, index + 1],
+ [`${name}${ForecastSeriesEnum.ForecastLower}`, index],
+ [`${name}${ForecastSeriesEnum.ForecastUpper}`, index + 2],
+ [`${name}${ForecastSeriesEnum.ForecastTrend}`, index + 1.5],
+ ]),
+ );
+ const transformed = transformProps(
+ createTestChartProps({
+ formData: {
+ ...formData,
+ forecastEnabled: true,
+ legendOrientation: LegendOrientation.Top,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ },
+ queriesData: [
+ createTestQueryData(
+ createTestData([forecastValues], { intervalMs: 300000000 }),
+ ),
+ ],
+ }),
+ );
+ const renderedSeries = transformed.echartOptions.series as SeriesOption[];
+ const customLegend = getCustomLegend(transformed);
+
+ legendNames.forEach(name => {
+ const representative = renderedSeries.find(series => series.name === name);
+ const item = customLegend?.items.find(candidate => candidate.name === name);
+
+ expect(representative?.id).toBe(
+ `${name}${ForecastSeriesEnum.ForecastLower}`,
+ );
+ expect(item?.color).toBe(
+ (representative as { itemStyle?: { color?: string } } | undefined)
+ ?.itemStyle?.color,
+ );
+ });
+});
+
test('honors user-selected plain legend type for top orientation when space allows (#39540)', () => {
// Regression test for issue #39540: switching the legend type control from
// scroll to plain must reach the rendered ECharts config. Horizontal legends
@@ -1195,8 +1557,9 @@ test('honors user-selected plain legend type for top orientation when space allo
legend: { show?: boolean; type?: LegendType };
};
- expect(legend.show).toBe(true);
+ expect(legend.show).toBe(false);
expect(legend.type).toBe(LegendType.Plain);
+ expect(getCustomLegend(transformProps(chartProps))).toBeDefined();
});
test('honors user-selected plain legend type for bottom orientation when space allows (#39540)', () => {
@@ -1213,8 +1576,9 @@ test('honors user-selected plain legend type for bottom orientation when space a
legend: { show?: boolean; type?: LegendType };
};
- expect(legend.show).toBe(true);
+ expect(legend.show).toBe(false);
expect(legend.type).toBe(LegendType.Plain);
+ expect(getCustomLegend(transformProps(chartProps))).toBeDefined();
});
const timeCompareFormData: SqlaFormData = {