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