Compare commits

...

2 Commits

Author SHA1 Message Date
Evan
c22bae420c fix: clarify Y axis clamping comment covers derived bounds too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 02:10:39 -07:00
Evan
dd5f310a01 fix(echarts): truncate line/area/bar values at the Y axis bounds instead of dropping them
When explicit Y axis bounds are set, ECharts can drop an out-of-range
point (and the surrounding line segments) entirely rather than
truncating it at the boundary. Clamp series values to the configured
Y axis min/max so the chart shows a flat-topped/bottomed line instead
of a gap.

Fixes #27449
2026-07-21 23:32:29 -07:00
2 changed files with 107 additions and 0 deletions

View File

@@ -724,6 +724,41 @@ export default function transformProps(
}
}
// Whenever a Y axis bound is defined, whether explicitly configured or
// derived above from the data, clamp series values to those bounds
// instead of leaving raw out-of-range values in place. ECharts axis
// clipping can otherwise drop an out-of-bounds point (and the line
// segments around it) entirely rather than truncating it at the
// boundary (see https://github.com/apache/superset/issues/27449).
if (yAxisMin !== undefined || yAxisMax !== undefined) {
const valueIndex = isHorizontal ? 0 : 1;
const clampAxisValue = (
value: string | number | null | undefined,
): string | number | null | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) return value;
let clamped = value;
if (yAxisMin !== undefined) clamped = Math.max(clamped, yAxisMin);
if (yAxisMax !== undefined) clamped = Math.min(clamped, yAxisMax);
return clamped;
};
series.forEach(s => {
if (!Array.isArray(s.data)) return;
const clampedData = (
s.data as (string | number | null | undefined)[][]
).map(point => {
if (Array.isArray(point)) {
const newPoint = [...point];
newPoint[valueIndex] = clampAxisValue(newPoint[valueIndex]);
return newPoint;
}
return point;
});
// Matches the existing pattern used elsewhere in this file for
// narrowing the broad, union-typed ECharts `SeriesOption.data` field.
(s as any).data = clampedData;
});
}
// A dashboard-level time grain override (e.g. via a filter or the temporal
// range control) is delivered in extraFormData and should take precedence
// over the chart's own time grain when formatting temporal axes/tooltips.

View File

@@ -1383,6 +1383,78 @@ test('should not apply axis bounds calculation when seriesType is not Bar for ho
expect(xAxisRaw.max).toBeUndefined();
});
test('clamps series values to the yAxis max instead of dropping out-of-range points (#27449)', () => {
const queriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData(
[
{ 'Series A': 1 },
{ 'Series A': 2 },
{ 'Series A': 3 },
{ 'Series A': 4 },
{ 'Series A': 1000 },
{ 'Series A': 4 },
{ 'Series A': 2 },
],
{ intervalMs: 300000000 },
),
),
];
const chartProps = createTestChartProps({
formData: {
...formData,
groupby: [],
seriesType: EchartsTimeseriesSeriesType.Line,
truncateYAxis: true,
yAxisBounds: [0, 10],
},
queriesData,
});
const transformedProps = transformProps(chartProps);
const series = transformedProps.echartOptions.series as SeriesOption[];
const seriesA = series.find(s => s.name === 'Series A');
expect(seriesA).toBeDefined();
const data = seriesA!.data as [number, number][];
// The point that was 1000 should be present (not dropped) and clamped to
// the configured yAxis max of 10, rather than disappearing entirely.
expect(data).toHaveLength(7);
expect(data[4][1]).toBe(10);
});
test('clamps series values to the yAxis min when a value falls below it', () => {
const queriesData: ChartDataResponseResult[] = [
createTestQueryData(
createTestData(
[{ 'Series A': -1000 }, { 'Series A': 2 }, { 'Series A': 3 }],
{ intervalMs: 300000000 },
),
),
];
const chartProps = createTestChartProps({
formData: {
...formData,
groupby: [],
seriesType: EchartsTimeseriesSeriesType.Line,
truncateYAxis: true,
yAxisBounds: [0, 10],
},
queriesData,
});
const transformedProps = transformProps(chartProps);
const series = transformedProps.echartOptions.series as SeriesOption[];
const seriesA = series.find(s => s.name === 'Series A');
expect(seriesA).toBeDefined();
const data = seriesA!.data as [number, number][];
expect(data).toHaveLength(3);
expect(data[0][1]).toBe(0);
});
test('legend is visible on tall charts when enabled by the user', () => {
const chartProps = createTestChartProps({
height: 400,