mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
feat(bar-chart): add option to color bars by primary axis when no dimensions are set (#37531)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,6 +97,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
onlyTotal: boolean;
|
||||
showExtraControls: boolean;
|
||||
percentageThreshold: number;
|
||||
colorByPrimaryAxis?: boolean;
|
||||
orientation?: OrientationType;
|
||||
} & LegendFormData &
|
||||
TitleFormData;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user