This is an automated email from the ASF dual-hosted git repository.
jli 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 77148277b96 feat(charts): improve negative stacked bar label
positioning and accessibility (#37405)
77148277b96 is described below
commit 77148277b96b1a3b1a60b11d364d2f92659cbd04
Author: Vanessa Giannoni <[email protected]>
AuthorDate: Wed Feb 11 22:46:10 2026 -0300
feat(charts): improve negative stacked bar label positioning and
accessibility (#37405)
---
.../src/Timeseries/transformers.ts | 47 +++++++----
.../test/Timeseries/transformers.test.ts | 97 +++++++++++++---------
2 files changed, 90 insertions(+), 54 deletions(-)
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 dd054d42b50..01674bf29d5 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
@@ -31,6 +31,7 @@ import {
ValueFormatter,
} from '@superset-ui/core';
import { SupersetTheme, isThemeDark } from '@apache-superset/core/ui';
+import { getContrastingColor } from '@superset-ui/core';
import type {
CallbackDataParams,
DefaultStatesMixin,
@@ -142,28 +143,39 @@ export const getBaselineSeriesForStream = (
};
};
-export function transformNegativeLabelsPosition(
+export function optimizeBarLabelPlacement(
series: SeriesOption,
isHorizontal: boolean,
): TimeseriesDataRecord[] {
/*
- * Adjusts label position for negative values in bar series
+ * Adjusts label position for all values in bar series
+ * Positions labels inside bars at appropriate edges to avoid axis overlap
* @param series - Array of series options
* @param isHorizontal - Whether chart is horizontal
- * @returns data with adjusted label positions for negative values
+ * @returns data with adjusted label positions for all values
*/
const transformValue = (value: any) => {
const [xValue, yValue] = Array.isArray(value) ? value : [null, null];
const axisValue = isHorizontal ? xValue : yValue;
- return axisValue < 0
- ? {
- value,
- label: {
- position: 'outside',
- },
- }
- : value;
+ if (axisValue === null || axisValue === undefined) {
+ return value;
+ }
+
+ // Use inside positioning for all bar charts to avoid axis overlap
+ const labelPosition =
+ axisValue < 0
+ ? isHorizontal
+ ? 'insideLeft'
+ : 'insideBottom'
+ : isHorizontal
+ ? 'insideRight'
+ : 'insideTop';
+
+ return {
+ value,
+ label: { position: labelPosition },
+ };
};
return (series.data as TimeseriesDataRecord[]).map(transformValue);
@@ -337,8 +349,10 @@ export function transformSeries(
return {
...series,
- ...(Array.isArray(data) && seriesType === 'bar' && !stack
- ? { data: transformNegativeLabelsPosition(series, isHorizontal) }
+ ...(Array.isArray(data) && seriesType === 'bar'
+ ? {
+ data: optimizeBarLabelPlacement(series, isHorizontal),
+ }
: null),
connectNulls,
queryIndex,
@@ -371,8 +385,11 @@ export function transformSeries(
symbolSize: markerSize,
label: {
show: !!showValue,
- position: isHorizontal ? 'right' : 'top',
- color: theme?.colorText,
+ position: stack ? 'inside' : isHorizontal ? 'right' : 'top',
+ color:
+ stack || seriesType === 'bar'
+ ? getContrastingColor(String(itemStyle.color))
+ : theme?.colorText,
textBorderWidth: 0,
formatter: (params: any) => {
// don't show confidence band value labels, as they're already visible
on the tooltip
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
index ae7a7423f72..68f48196df2 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
@@ -22,20 +22,24 @@ import { supersetTheme } from '@apache-superset/core/ui';
import type { SeriesOption } from 'echarts';
import { EchartsTimeseriesSeriesType } from '../../src';
import { TIMESERIES_CONSTANTS } from '../../src/constants';
-import { LegendOrientation } from '../../src/types';
+import {
+ LegendOrientation,
+ EchartsTimeseriesChartProps,
+} from '../../src/types';
import {
transformSeries,
- transformNegativeLabelsPosition,
+ optimizeBarLabelPlacement,
getPadding,
} from '../../src/Timeseries/transformers';
import transformProps from '../../src/Timeseries/transformProps';
-import { EchartsTimeseriesChartProps } from '../../src/types';
import * as seriesUtils from '../../src/utils/series';
-// Mock the colorScale function
-const mockColorScale = jest.fn(
- (key: string, sliceId?: number) => `color-for-${key}-${sliceId}`,
-) as unknown as CategoricalColorScale;
+// Mock the colorScale function to return different colors based on key
+const mockColorScale = jest.fn((key: string) => {
+ if (key === 'test-key') return '#1f77b4'; // blue
+ if (key === 'series-key') return '#ff7f0e'; // orange
+ return '#2ca02c'; // green for any other key
+}) as unknown as CategoricalColorScale;
describe('transformSeries', () => {
const series = { name: 'test-series' };
@@ -49,7 +53,8 @@ describe('transformSeries', () => {
const result = transformSeries(series, mockColorScale, 'test-key', opts);
- expect((result as any)?.itemStyle.color).toBe('color-for-test-key-1');
+ expect(mockColorScale).toHaveBeenCalledWith('test-key', 1);
+ expect((result as any)?.itemStyle.color).toBe('#1f77b4');
});
test('should use seriesKey if timeShiftColor is not enabled', () => {
@@ -61,7 +66,8 @@ describe('transformSeries', () => {
const result = transformSeries(series, mockColorScale, 'test-key', opts);
- expect((result as any)?.itemStyle.color).toBe('color-for-series-key-2');
+ expect(mockColorScale).toHaveBeenCalledWith('series-key', 2);
+ expect((result as any)?.itemStyle.color).toBe('#ff7f0e');
});
test('should apply border styles for bar series with connectNulls', () => {
@@ -123,8 +129,8 @@ describe('transformSeries', () => {
});
});
-describe('transformNegativeLabelsPosition', () => {
- test('label position bottom of negative value no Horizontal', () => {
+describe('optimizeBarLabelPlacement', () => {
+ test('label position for non-stacked vertical charts', () => {
const isHorizontal = false;
const series: SeriesOption = {
data: [
@@ -137,15 +143,12 @@ describe('transformNegativeLabelsPosition', () => {
type: EchartsTimeseriesSeriesType.Bar,
stack: undefined,
};
- const result =
- Array.isArray(series.data) && series.type === 'bar' && !series.stack
- ? transformNegativeLabelsPosition(series, isHorizontal)
- : series.data;
- expect((result as any)[0].label).toBe(undefined);
- expect((result as any)[1].label).toBe(undefined);
- expect((result as any)[2].label.position).toBe('outside');
- expect((result as any)[3].label.position).toBe('outside');
- expect((result as any)[4].label).toBe(undefined);
+ const result = optimizeBarLabelPlacement(series, isHorizontal);
+ expect((result as any)[0].label.position).toBe('insideTop');
+ expect((result as any)[1].label.position).toBe('insideTop');
+ expect((result as any)[2].label.position).toBe('insideBottom');
+ expect((result as any)[3].label.position).toBe('insideBottom');
+ expect((result as any)[4].label.position).toBe('insideTop');
});
test('label position left of negative value is Horizontal', () => {
@@ -162,15 +165,12 @@ describe('transformNegativeLabelsPosition', () => {
stack: undefined,
};
- const result =
- Array.isArray(series.data) && series.type === 'bar' && !series.stack
- ? transformNegativeLabelsPosition(series, isHorizontal)
- : series.data;
- expect((result as any)[0].label).toBe(undefined);
- expect((result as any)[1].label.position).toBe('outside');
- expect((result as any)[2].label).toBe(undefined);
- expect((result as any)[3].label.position).toBe('outside');
- expect((result as any)[4].label.position).toBe('outside');
+ const result = optimizeBarLabelPlacement(series, isHorizontal);
+ expect((result as any)[0].label.position).toBe('insideRight');
+ expect((result as any)[1].label.position).toBe('insideLeft');
+ expect((result as any)[2].label.position).toBe('insideRight');
+ expect((result as any)[3].label.position).toBe('insideLeft');
+ expect((result as any)[4].label.position).toBe('insideLeft');
});
test('label position to line type', () => {
@@ -192,7 +192,7 @@ describe('transformNegativeLabelsPosition', () => {
!series.stack &&
series.type !== 'line' &&
series.type === 'bar'
- ? transformNegativeLabelsPosition(series, isHorizontal)
+ ? optimizeBarLabelPlacement(series, isHorizontal)
: series.data;
expect((result as any)[0].label).toBe(undefined);
expect((result as any)[1].label).toBe(undefined);
@@ -215,15 +215,34 @@ describe('transformNegativeLabelsPosition', () => {
stack: 'obs',
};
- const result =
- Array.isArray(series.data) && series.type === 'bar' && !series.stack
- ? transformNegativeLabelsPosition(series, isHorizontal)
- : series.data;
- expect((result as any)[0].label).toBe(undefined);
- expect((result as any)[1].label).toBe(undefined);
- expect((result as any)[2].label).toBe(undefined);
- expect((result as any)[3].label).toBe(undefined);
- expect((result as any)[4].label).toBe(undefined);
+ const result = optimizeBarLabelPlacement(series, isHorizontal);
+ expect((result as any)[0].label.position).toBe('insideTop');
+ expect((result as any)[1].label.position).toBe('insideTop');
+ expect((result as any)[2].label.position).toBe('insideBottom');
+ expect((result as any)[3].label.position).toBe('insideBottom');
+ expect((result as any)[4].label.position).toBe('insideTop');
+ });
+
+ test('label position for horizontal stacked charts', () => {
+ const isHorizontal = true;
+ const series: SeriesOption = {
+ data: [
+ [1, 2020],
+ [-3, 2021],
+ [2, 2022],
+ [-4, 2023],
+ [-6, 2024],
+ ],
+ type: EchartsTimeseriesSeriesType.Bar,
+ stack: 'obs',
+ };
+
+ const result = optimizeBarLabelPlacement(series, isHorizontal);
+ expect((result as any)[0].label.position).toBe('insideRight');
+ expect((result as any)[1].label.position).toBe('insideLeft');
+ expect((result as any)[2].label.position).toBe('insideRight');
+ expect((result as any)[3].label.position).toBe('insideLeft');
+ expect((result as any)[4].label.position).toBe('insideLeft');
});
});