This is an automated email from the ASF dual-hosted git repository.
villebro 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 983b633972f feat(bar-chart): add option to color bars by primary axis
when no dimensions are set (#37531)
983b633972f is described below
commit 983b633972fde332921f63f28d04f8975f14459f
Author: madhushreeag <[email protected]>
AuthorDate: Tue Mar 3 16:11:04 2026 -0800
feat(bar-chart): add option to color bars by primary axis when no
dimensions are set (#37531)
Co-authored-by: madhushree agarwal <[email protected]>
---
.../src/Timeseries/Regular/Bar/controlPanel.tsx | 2 +
.../src/Timeseries/transformProps.ts | 125 ++++++++-
.../src/Timeseries/transformers.ts | 49 +++-
.../plugin-chart-echarts/src/Timeseries/types.ts | 1 +
.../plugins/plugin-chart-echarts/src/controls.tsx | 28 ++
.../test/Timeseries/Bar/transformProps.test.ts | 283 +++++++++++++++++++++
6 files changed, 470 insertions(+), 18 deletions(-)
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
index 2986919303b..b8f62d42f87 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
@@ -41,6 +41,7 @@ import {
xAxisLabelRotation,
xAxisLabelInterval,
forceMaxInterval,
+ colorByPrimaryAxisSection,
} from '../../../controls';
import { OrientationType } from '../../types';
@@ -328,6 +329,7 @@ const config: ControlPanelConfig = {
['color_scheme'],
['time_shift_color'],
...showValueSectionWithoutStream,
+ ...colorByPrimaryAxisSection,
[
{
name: 'stackDimension',
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 237b3088cbe..ab9abfb792a 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -175,6 +175,7 @@ export default function transformProps(
seriesType,
showLegend,
showValue,
+ colorByPrimaryAxis,
sliceId,
sortSeriesType,
sortSeriesAscending,
@@ -421,6 +422,7 @@ export default function transformProps(
timeShiftColor,
theme,
hasDimensions: (groupBy?.length ?? 0) > 0,
+ colorByPrimaryAxis,
},
);
if (transformedSeries) {
@@ -438,6 +440,59 @@ export default function transformProps(
}
});
+ // Add x-axis color legend when colorByPrimaryAxis is enabled
+ if (colorByPrimaryAxis && groupBy.length === 0 && series.length > 0) {
+ // Hide original series from legend
+ series.forEach(s => {
+ s.legendHoverLink = false;
+ });
+
+ // Get x-axis values from the first series
+ const firstSeries = series[0];
+ if (firstSeries && Array.isArray(firstSeries.data)) {
+ const xAxisValues: (string | number)[] = [];
+
+ // Extract primary axis values (category axis)
+ // For horizontal charts the category is at index 1, for vertical at
index 0
+ const primaryAxisIndex = isHorizontal ? 1 : 0;
+ (firstSeries.data as any[]).forEach(point => {
+ let xValue;
+ if (point && typeof point === 'object' && 'value' in point) {
+ const val = point.value;
+ xValue = Array.isArray(val) ? val[primaryAxisIndex] : val;
+ } else if (Array.isArray(point)) {
+ xValue = point[primaryAxisIndex];
+ } else {
+ xValue = point;
+ }
+ xAxisValues.push(xValue);
+ });
+
+ // Create hidden series for legend (using 'line' type to not affect bar
width)
+ // Deduplicate x-axis values to avoid duplicate legend entries and
unnecessary series
+ const uniqueXAxisValues = Array.from(
+ new Set(xAxisValues.map(v => String(v))),
+ );
+ uniqueXAxisValues.forEach(xValue => {
+ const colorKey = xValue;
+ series.push({
+ name: xValue,
+ type: 'line', // Use line type to not affect bar positioning
+ data: [], // Empty - doesn't render
+ itemStyle: {
+ color: colorScale(colorKey, sliceId),
+ },
+ lineStyle: {
+ color: colorScale(colorKey, sliceId),
+ },
+ silent: true,
+ legendHoverLink: false,
+ showSymbol: false,
+ });
+ });
+ }
+ }
+
if (stack === StackControlsValue.Stream) {
const baselineSeries = getBaselineSeriesForStream(
series.map(entry => entry.data) as [string | number, number][][],
@@ -592,14 +647,43 @@ export default function transformProps(
isHorizontal,
);
- const legendData = rawSeries
- .filter(
- entry =>
- extractForecastSeriesContext(entry.name || '').type ===
- ForecastSeriesEnum.Observation,
- )
- .map(entry => entry.name || '')
- .concat(extractAnnotationLabels(annotationLayers));
+ const legendData =
+ colorByPrimaryAxis && groupBy.length === 0 && series.length > 0
+ ? // When colorByPrimaryAxis is enabled, show only primary axis values
(deduped + filtered)
+ (() => {
+ const firstSeries = series[0];
+ // For horizontal charts the category is at index 1, for vertical at
index 0
+ const primaryAxisIndex = isHorizontal ? 1 : 0;
+ if (firstSeries && Array.isArray(firstSeries.data)) {
+ const names = (firstSeries.data as any[])
+ .map(point => {
+ if (point && typeof point === 'object' && 'value' in point) {
+ const val = point.value;
+ return String(
+ Array.isArray(val) ? val[primaryAxisIndex] : val,
+ );
+ }
+ if (Array.isArray(point)) {
+ return String(point[primaryAxisIndex]);
+ }
+ return String(point);
+ })
+ .filter(
+ name => name !== '' && name !== 'undefined' && name !== 'null',
+ );
+ return Array.from(new Set(names));
+ }
+ return [];
+ })()
+ : // Otherwise show original series names
+ rawSeries
+ .filter(
+ entry =>
+ extractForecastSeriesContext(entry.name || '').type ===
+ ForecastSeriesEnum.Observation,
+ )
+ .map(entry => entry.name || '')
+ .concat(extractAnnotationLabels(annotationLayers));
let xAxis: any = {
type: xAxisType,
@@ -818,10 +902,27 @@ export default function transformProps(
padding,
),
scrollDataIndex: legendIndex || 0,
- data: legendData.sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
- }) as string[],
+ data:
+ colorByPrimaryAxis && groupBy.length === 0
+ ? // When colorByPrimaryAxis, configure legend items with roundRect
icons
+ legendData.map(name => ({
+ name,
+ icon: 'roundRect',
+ }))
+ : // Otherwise use normal legend data
+ legendData.sort((a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc'
+ ? a.localeCompare(b)
+ : b.localeCompare(a);
+ }),
+ // Disable legend selection and buttons when colorByPrimaryAxis is
enabled
+ ...(colorByPrimaryAxis && groupBy.length === 0
+ ? {
+ selectedMode: false, // Disable clicking legend items
+ selector: false, // Hide All/Invert buttons
+ }
+ : {}),
},
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
toolbox: {
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 f0cbdcd6d0a..64f6593ce1f 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
@@ -181,6 +181,31 @@ export function optimizeBarLabelPlacement(
return (series.data as TimeseriesDataRecord[]).map(transformValue);
}
+export function applyColorByPrimaryAxis(
+ series: SeriesOption,
+ colorScale: CategoricalColorScale,
+ sliceId: number | undefined,
+ opacity: number,
+ isHorizontal = false,
+): {
+ value: [string | number, number];
+ itemStyle: { color: string; opacity: number; borderWidth: number };
+}[] {
+ return (series.data as [string | number, number][]).map(value => {
+ // For horizontal charts the primary axis is index 1 (category), not index
0 (numeric)
+ const colorKey = String(isHorizontal ? value[1] : value[0]);
+
+ return {
+ value,
+ itemStyle: {
+ color: colorScale(colorKey, sliceId),
+ opacity,
+ borderWidth: 0,
+ },
+ };
+ });
+}
+
export function transformSeries(
series: SeriesOption,
colorScale: CategoricalColorScale,
@@ -214,6 +239,7 @@ export function transformSeries(
timeShiftColor?: boolean;
theme?: SupersetTheme;
hasDimensions?: boolean;
+ colorByPrimaryAxis?: boolean;
},
): SeriesOption | undefined {
const { name, data } = series;
@@ -244,6 +270,7 @@ export function transformSeries(
timeCompare = [],
timeShiftColor,
theme,
+ colorByPrimaryAxis = false,
} = opts;
const contexts = seriesContexts[name || ''] || [];
const hasForecast =
@@ -349,17 +376,27 @@ export function transformSeries(
return {
...series,
- ...(Array.isArray(data) && seriesType === 'bar'
- ? {
- data: optimizeBarLabelPlacement(series, isHorizontal),
- }
+ ...(Array.isArray(data)
+ ? colorByPrimaryAxis
+ ? {
+ data: applyColorByPrimaryAxis(
+ series,
+ colorScale,
+ sliceId,
+ opacity,
+ isHorizontal,
+ ),
+ }
+ : seriesType === 'bar' && !stack
+ ? { data: optimizeBarLabelPlacement(series, isHorizontal) }
+ : null
: null),
connectNulls,
queryIndex,
yAxisIndex,
name: forecastSeries.name,
- itemStyle,
- // @ts-expect-error
+ ...(colorByPrimaryAxis ? {} : { itemStyle }),
+ // @ts-ignore
type: plotType,
smooth: seriesType === 'smooth',
triggerLineEvent: true,
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 54775545fb6..a2051c0363d 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
@@ -97,6 +97,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
onlyTotal: boolean;
showExtraControls: boolean;
percentageThreshold: number;
+ colorByPrimaryAxis?: boolean;
orientation?: OrientationType;
} & LegendFormData &
TitleFormData;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
index 22db2ffed7c..3bf67ade2ae 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
@@ -140,6 +140,30 @@ export const showValueControl: ControlSetItem = {
},
};
+export const colorByPrimaryAxisControl: ControlSetItem = {
+ name: 'color_by_primary_axis',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Color By X-Axis'),
+ default: false,
+ renderTrigger: true,
+ description: t('Color bars by x-axis'),
+ visibility: ({ controls }: { controls: any }) =>
+ (!controls?.stack?.value || controls?.stack?.value === null) &&
+ (!controls?.groupby?.value || controls?.groupby?.value?.length === 0),
+ shouldMapStateToProps: () => true,
+ mapStateToProps: (state: any) => {
+ const isHorizontal = state?.controls?.orientation?.value ===
'horizontal';
+ return {
+ label: isHorizontal ? t('Color By Y-Axis') : t('Color By X-Axis'),
+ description: isHorizontal
+ ? t('Color bars by y-axis')
+ : t('Color bars by x-axis'),
+ };
+ },
+ },
+};
+
export const stackControl: ControlSetItem = {
name: 'stack',
config: {
@@ -200,6 +224,10 @@ export const showValueSection: ControlSetRow[] = [
[percentageThresholdControl],
];
+export const colorByPrimaryAxisSection: ControlSetRow[] = [
+ [colorByPrimaryAxisControl],
+];
+
export const showValueSectionWithoutStack: ControlSetRow[] = [
[showValueControl],
[onlyTotalControl],
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 5a3a63dfdde..e300b7b84de 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
@@ -351,4 +351,287 @@ describe('Bar Chart X-axis Time Formatting', () => {
expect(chartProps.formData.xAxisTimeFormat).toBe('smart_date');
});
});
+
+ describe('Color By X-Axis Feature', () => {
+ const categoricalData = [
+ {
+ data: [
+ { category: 'A', value: 100 },
+ { category: 'B', value: 150 },
+ { category: 'C', value: 200 },
+ ],
+ colnames: ['category', 'value'],
+ coltypes: ['STRING', 'BIGINT'],
+ },
+ ];
+
+ test('should apply color by x-axis when enabled with no dimensions', () =>
{
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ // Should have hidden legend series for each x-axis value
+ const series = transformedProps.echartOptions.series as any[];
+ expect(series.length).toBeGreaterThan(3); // Original series + hidden
legend series
+
+ // Check that legend data contains x-axis values
+ const legendData = transformedProps.legendData as string[];
+ expect(legendData).toContain('A');
+ expect(legendData).toContain('B');
+ expect(legendData).toContain('C');
+
+ // Check that legend items have roundRect icons
+ const legend = transformedProps.echartOptions.legend as any;
+ expect(legend.data).toBeDefined();
+ expect(Array.isArray(legend.data)).toBe(true);
+ if (legend.data.length > 0 && typeof legend.data[0] === 'object') {
+ expect(legend.data[0].icon).toBe('roundRect');
+ }
+ });
+
+ test('should NOT apply color by x-axis when dimensions are present', () =>
{
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: ['region'],
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ // Legend data should NOT contain x-axis values when dimensions exist
+ const legendData = transformedProps.legendData as string[];
+ // Should use series names, not x-axis values
+ expect(legendData.length).toBeLessThan(10);
+ });
+
+ test('should use x-axis values as color keys for consistent colors', () =>
{
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ const series = transformedProps.echartOptions.series as any[];
+
+ // Find the data series (not the hidden legend series)
+ const dataSeries = series.find(
+ s => s.data && s.data.length > 0 && s.type === 'bar',
+ );
+ expect(dataSeries).toBeDefined();
+
+ // Check that data points have individual itemStyle with colors
+ if (dataSeries && Array.isArray(dataSeries.data)) {
+ const dataPoint = dataSeries.data[0];
+ if (
+ dataPoint &&
+ typeof dataPoint === 'object' &&
+ 'itemStyle' in dataPoint
+ ) {
+ expect(dataPoint.itemStyle).toBeDefined();
+ expect(dataPoint.itemStyle.color).toBeDefined();
+ }
+ }
+ });
+
+ test('should disable legend selection when color by x-axis is enabled', ()
=> {
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ const legend = transformedProps.echartOptions.legend as any;
+ expect(legend.selectedMode).toBe(false);
+ expect(legend.selector).toBe(false);
+ });
+
+ test('should work without stacking enabled', () => {
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ stack: null,
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ // Should still create legend with x-axis values
+ const legendData = transformedProps.legendData as string[];
+ expect(legendData.length).toBeGreaterThan(0);
+ expect(legendData).toContain('A');
+ });
+
+ test('should handle when colorByPrimaryAxis is disabled', () => {
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: false,
+ groupby: [],
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ // Legend should not be disabled when feature is off
+ const legend = transformedProps.echartOptions.legend as any;
+ expect(legend.selectedMode).not.toBe(false);
+ });
+
+ test('should use category axis (Y) as color key for horizontal bar
charts', () => {
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ orientation: 'horizontal',
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ // Legend should contain category values (A, B, C), not numeric values
+ const legendData = transformedProps.legendData as string[];
+ expect(legendData).toContain('A');
+ expect(legendData).toContain('B');
+ expect(legendData).toContain('C');
+ });
+
+ test('should deduplicate legend entries when x-axis has repeated values',
() => {
+ const repeatedData = [
+ {
+ data: [
+ { category: 'A', value: 100 },
+ { category: 'A', value: 200 },
+ { category: 'B', value: 150 },
+ ],
+ colnames: ['category', 'value'],
+ coltypes: ['STRING', 'BIGINT'],
+ },
+ ];
+
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: repeatedData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ const legendData = transformedProps.legendData as string[];
+ // 'A' should appear only once despite being in the data twice
+ expect(legendData.filter(v => v === 'A').length).toBe(1);
+ expect(legendData).toContain('B');
+ });
+
+ test('should create exactly one hidden legend series per unique category',
() => {
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: categoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ const series = transformedProps.echartOptions.series as any[];
+ const hiddenSeries = series.filter(
+ s => s.type === 'line' && Array.isArray(s.data) && s.data.length === 0,
+ );
+ // One hidden series per unique category (A, B, C)
+ expect(hiddenSeries.length).toBe(3);
+ });
+ });
});