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 = {