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 d0edb666293..6966be78a3e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -711,6 +711,10 @@ export default function transformProps( onLegendScroll, } = hooks; + const addYAxisLabelOffset = + !!yAxisTitle && convertInteger(yAxisTitleMargin) !== 0; + const addXAxisLabelOffset = + !!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0; const legendData = colorByPrimaryAxis && groupBy.length === 0 && series.length > 0 ? (() => { @@ -745,10 +749,6 @@ export default function transformProps( ) .map(entry => entry.name || '') .concat(extractAnnotationLabels(annotationLayers)); - const addYAxisLabelOffset = - !!yAxisTitle && convertInteger(yAxisTitleMargin) !== 0; - const addXAxisLabelOffset = - !!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0; const sortedLegendData = [...legendData].sort((a: string, b: string) => { if (!legendSort) return 0; @@ -824,6 +824,16 @@ export default function transformProps( isHorizontal, ); + // Reduce grid padding for small charts to maximize the drawing area. + // Keep enough top padding so the max label doesn't clip against the cell border. + // Preserve bottom padding when zoomable, since getPadding() reserves space for the dataZoom slider. + if (height < TIMESERIES_CONSTANTS.compactChartHeight) { + padding.top = Math.min(padding.top, 12); + if (!zoomable) { + padding.bottom = Math.min(padding.bottom, 5); + } + } + // When showMaxLabel is true, ECharts may render a label at the axis // boundary that formats identically to the last data-point tick (e.g. // "2005" appears twice with Year grain). Wrap the formatter to suppress @@ -896,14 +906,35 @@ export default function transformProps( ), }; + // Adapt y-axis to chart height: three tiers based on available space. + // >= 100px: full axis with proportional tick count + // 60-99px: show only min/max boundary labels (splitNumber=1), hide lines/ticks + // < 60px: hide all axis decorations, show line only + const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight; + const isMicroChart = height < TIMESERIES_CONSTANTS.microChartHeight; + const yAxisSplitNumber = isMicroChart + ? undefined + : isSmallChart + ? 1 + : Math.max( + 3, + Math.floor(height / TIMESERIES_CONSTANTS.yAxisPixelsPerTick), + ); + let yAxis: any = { ...defaultYAxis, type: logAxis ? AxisType.Log : AxisType.Value, + ...(yAxisSplitNumber !== undefined && { splitNumber: yAxisSplitNumber }), min: yAxisMin, max: yAxisMax, - minorTick: { show: minorTicks }, - minorSplitLine: { show: minorSplitLine }, + minorTick: { show: isSmallChart ? false : minorTicks }, + minorSplitLine: { show: isSmallChart ? false : minorSplitLine }, + splitLine: { show: !isSmallChart }, axisLabel: { + show: !isMicroChart, + showMinLabel: !isMicroChart, + showMaxLabel: !isMicroChart, + hideOverlap: true, formatter: getYAxisFormatter( metrics, forcePercentFormatter, @@ -912,8 +943,9 @@ export default function transformProps( yAxisFormat, ), }, + axisTick: { show: !isSmallChart }, scale: truncateYAxis, - name: yAxisTitle, + name: isSmallChart ? undefined : yAxisTitle, nameGap: convertInteger(yAxisTitleMargin), nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end', }; @@ -1065,7 +1097,8 @@ export default function transformProps( ...getLegendProps( effectiveLegendType, legendOrientation, - showLegend, + // Hide legend on compact charts — not enough vertical space + isSmallChart ? false : showLegend, theme, zoomable, legendState, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts index 76de9217868..d1169f8a27d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts @@ -47,6 +47,11 @@ export const TIMESERIES_CONSTANTS = { extraControlsOffset: 22, // Min right padding (px) for horizontal bar charts to ensure value labels are fully visible horizontalBarLabelRightPadding: 70, + // Height thresholds (px) for responsive y-axis behavior + compactChartHeight: 100, + microChartHeight: 60, + // One y-axis tick per this many pixels of chart height + yAxisPixelsPerTick: 80, }; export enum OpacityEnum { 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 c30491601c9..373cf6a0d54 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 @@ -1338,6 +1338,101 @@ test('should not apply axis bounds calculation when seriesType is not Bar for ho expect(xAxisRaw.max).toBeUndefined(); }); +test('legend is visible on tall charts when enabled by the user', () => { + const chartProps = createTestChartProps({ + height: 400, + formData: { showLegend: true }, + }); + const { legend } = transformProps(chartProps).echartOptions as any; + + expect(legend.show).toBe(true); +}); + +test('legend is hidden on small charts even when enabled by the user', () => { + const chartProps = createTestChartProps({ + height: 80, + formData: { showLegend: true }, + }); + const { legend } = transformProps(chartProps).echartOptions as any; + + expect(legend.show).toBe(false); +}); + +test('y-axis labels remain visible on small charts for scale reference', () => { + const chartProps = createTestChartProps({ height: 80 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(true); +}); + +test('y-axis labels are hidden on micro charts for a sparkline view', () => { + const chartProps = createTestChartProps({ height: 40 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(false); +}); + +test('y-axis tick count scales with chart height', () => { + const short = transformProps(createTestChartProps({ height: 200 })); + const tall = transformProps(createTestChartProps({ height: 500 })); + const shortYAxis = short.echartOptions.yAxis as any; + const tallYAxis = tall.echartOptions.yAxis as any; + + expect(tallYAxis.splitNumber).toBeGreaterThan(shortYAxis.splitNumber); +}); + +test('small chart y-axis uses splitNumber=1 to show only boundary labels', () => { + const chartProps = createTestChartProps({ height: 80 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.splitNumber).toBe(1); +}); + +test('zoomable small chart preserves bottom padding for the dataZoom slider', () => { + const chartProps = createTestChartProps({ + height: 80, + formData: { zoomable: true }, + }); + const result = transformProps(chartProps); + const grid = result.echartOptions.grid as any; + + expect(grid.bottom).toBeGreaterThan(5); +}); + +test('boundary: height at exactly 100px uses full axis behavior', () => { + const chartProps = createTestChartProps({ height: 100 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(true); + expect(yAxis.splitNumber).toBeGreaterThanOrEqual(3); +}); + +test('boundary: height at 99px triggers small chart behavior', () => { + const chartProps = createTestChartProps({ + height: 99, + formData: { showLegend: true }, + }); + const { yAxis, legend } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.splitNumber).toBe(1); + expect(legend.show).toBe(false); +}); + +test('boundary: height at exactly 60px shows labels but uses compact axis', () => { + const chartProps = createTestChartProps({ height: 60 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(true); + expect(yAxis.splitNumber).toBe(1); +}); + +test('boundary: height at 59px triggers micro chart behavior', () => { + const chartProps = createTestChartProps({ height: 59 }); + const { yAxis } = transformProps(chartProps).echartOptions as any; + + expect(yAxis.axisLabel.show).toBe(false); +}); + test('x-axis formatter deduplicates consecutive identical labels for coarse time grains', () => { const yearData = [ { __timestamp: Date.UTC(2003, 0, 1), sales: 100 },