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);
+    });
+  });
 });

Reply via email to