You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/08/13 20:57:23 UTC
(superset) 01/03: fix(echarts): make bar value labels fit-aware
This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch fix-bar-chart-label-placement
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 8a1e793b372a75dc348453533353554f19b980d7
Author: sadpandajoe <jc...@gmail.com>
AuthorDate: Thu Aug 13 19:45:02 2026 +0000
fix(echarts): make bar value labels fit-aware
---
.../src/Timeseries/constants.ts | 2 +
.../src/Timeseries/transformProps.ts | 8 +
.../src/Timeseries/transformers.ts | 216 +++++++++++++++++----
.../plugin-chart-echarts/src/Timeseries/types.ts | 9 +
.../plugins/plugin-chart-echarts/src/controls.tsx | 24 +++
.../test/Timeseries/Bar/controlPanel.test.ts | 40 +++-
.../test/Timeseries/Bar/transformProps.test.ts | 60 ++++++
.../test/Timeseries/transformers.test.ts | 186 +++++++++++++++++-
8 files changed, 503 insertions(+), 42 deletions(-)
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts
index 71430e870c9..df262e24275 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts
@@ -23,6 +23,7 @@ import {
import { t } from '@apache-superset/core/translation';
import { LegendOrientation, LegendType } from '../types';
import {
+ BarValueLabelPosition,
OrientationType,
EchartsTimeseriesSeriesType,
EchartsTimeseriesFormData,
@@ -85,6 +86,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
xAxisLabelInterval: defaultXAxis.xAxisLabelInterval,
groupby: [],
showValue: false,
+ valueLabelPosition: BarValueLabelPosition.Auto,
onlyTotal: false,
percentageThreshold: 0,
orientation: OrientationType.Vertical,
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 ca7d2f4fe91..de269e65f1b 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -46,6 +46,7 @@ import {
NumberFormats,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
+import { isThemeDark } from '@apache-superset/core/theme';
import {
extractExtraMetrics,
getOriginalSeries,
@@ -62,6 +63,7 @@ import {
EchartsTimeseriesChartProps,
EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType,
+ BarValueLabelPosition,
OrientationType,
TimeseriesChartTransformedProps,
} from './types';
@@ -289,6 +291,7 @@ export default function transformProps(
seriesType,
showLegend,
showValue,
+ valueLabelPosition,
size,
colorByPrimaryAxis,
sliceId,
@@ -735,6 +738,7 @@ export default function transformProps(
labelMap?.[seriesName]?.[0],
) ?? defaultFormatter),
showValue,
+ valueLabelPosition,
onlyTotal,
totalStackedValues: sortedTotalValues,
showValueIndexes,
@@ -1368,6 +1372,10 @@ export default function transformProps(
const echartOptions: EChartsCoreOption = {
useUTC: true,
+ ...(seriesType === EchartsTimeseriesSeriesType.Bar &&
+ valueLabelPosition === BarValueLabelPosition.Auto
+ ? { darkMode: isThemeDark(theme) }
+ : {}),
grid: {
...defaultGrid,
...padding,
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 6d167787d3d..8eee349b73b 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
@@ -35,6 +35,9 @@ import type {
CallbackDataParams,
DefaultStatesMixin,
ItemStyleOption,
+ LabelLayoutOption,
+ LabelLayoutOptionCallback,
+ LabelLayoutOptionCallbackParams,
LineStyleOption,
OptionName,
SeriesLabelOption,
@@ -49,6 +52,7 @@ import type {
import type { MarkLine1DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';
import { extractForecastSeriesContext } from '../utils/forecast';
import {
+ BarValueLabelPosition,
EchartsTimeseriesSeriesType,
ForecastSeriesEnum,
LegendOrientation,
@@ -69,6 +73,84 @@ import {
TIMESERIES_CONSTANTS,
} from '../constants';
+const AUTO_LABEL_FIT_RATIO = 0.8;
+const BAR_LABEL_DISTANCE = 5;
+
+type BarLabelPosition =
+ | 'bottom'
+ | 'inside'
+ | 'insideBottom'
+ | 'insideLeft'
+ | 'insideRight'
+ | 'insideTop'
+ | 'left'
+ | 'right'
+ | 'top';
+
+type NegativeBarLabelPosition = BarLabelPosition | 'outside';
+
+/** Resolve the fixed ECharts label position for a bar value. */
+function getBarLabelPosition(
+ position: BarValueLabelPosition,
+ isHorizontal: boolean,
+ isNegative = false,
+): BarLabelPosition {
+ if (position === BarValueLabelPosition.OutsideEnd) {
+ if (isHorizontal) return isNegative ? 'left' : 'right';
+ return isNegative ? 'bottom' : 'top';
+ }
+ if (position === BarValueLabelPosition.InsideCenter) return 'inside';
+ const isEnd = position !== BarValueLabelPosition.InsideBase;
+ const usePositiveEnd = isEnd !== isNegative;
+ if (isHorizontal) return usePositiveEnd ? 'insideRight' : 'insideLeft';
+ return usePositiveEnd ? 'insideTop' : 'insideBottom';
+}
+
+/** Place a horizontal bar label just beyond its value end. */
+function getHorizontalOutsideLayout(
+ params: LabelLayoutOptionCallbackParams,
+ isNegative: boolean,
+): LabelLayoutOption {
+ return {
+ x: isNegative
+ ? params.rect.x - BAR_LABEL_DISTANCE
+ : params.rect.x + params.rect.width + BAR_LABEL_DISTANCE,
+ y: params.rect.y + params.rect.height / 2,
+ align: isNegative ? 'right' : 'left',
+ verticalAlign: 'middle',
+ };
+}
+
+/** Place a vertical bar label just beyond its value end. */
+function getVerticalOutsideLayout(
+ params: LabelLayoutOptionCallbackParams,
+ isNegative: boolean,
+): LabelLayoutOption {
+ return {
+ x: params.rect.x + params.rect.width / 2,
+ y: isNegative
+ ? params.rect.y + params.rect.height + BAR_LABEL_DISTANCE
+ : params.rect.y - BAR_LABEL_DISTANCE,
+ align: 'center',
+ verticalAlign: isNegative ? 'top' : 'bottom',
+ };
+}
+
+/** Keep fitting labels inside and move oversized labels outside the bar. */
+export function getAutoBarLabelLayout(
+ params: LabelLayoutOptionCallbackParams,
+ isHorizontal: boolean,
+ isNegative = false,
+): LabelLayoutOption {
+ const barLength = Math.abs(
+ isHorizontal ? params.rect.width : params.rect.height,
+ );
+ if (params.labelRect.width <= barLength * AUTO_LABEL_FIT_RATIO) return {};
+ return isHorizontal
+ ? getHorizontalOutsideLayout(params, isNegative)
+ : getVerticalOutsideLayout(params, isNegative);
+}
+
function parseTimeShiftToMs(timeShift?: string | null): number {
if (!timeShift) return 0;
@@ -167,31 +249,69 @@ export const getBaselineSeriesForStream = (
};
};
+/** Identify object-form ECharts data items. */
+function isDataItemObject(
+ dataItem: unknown,
+): dataItem is Record<string, unknown> {
+ return (
+ typeof dataItem === 'object' &&
+ dataItem !== null &&
+ !Array.isArray(dataItem)
+ );
+}
+
+/** Return whether an ECharts bar datum is negative on its value axis. */
+function isNegativeBarDataItem(
+ dataItem: unknown,
+ isHorizontal: boolean,
+): boolean {
+ const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
+ const axisValue = Array.isArray(value)
+ ? value[isHorizontal ? 0 : 1]
+ : undefined;
+ return typeof axisValue === 'number' && axisValue < 0;
+}
+
+/** Create a fit-aware layout callback bound to one bar series. */
+function createAutoBarLabelLayout(
+ data: unknown,
+ isHorizontal: boolean,
+): LabelLayoutOptionCallback {
+ return params => {
+ const dataItem =
+ Array.isArray(data) && params.dataIndex !== undefined
+ ? data[params.dataIndex]
+ : undefined;
+ return getAutoBarLabelLayout(
+ params,
+ isHorizontal,
+ isNegativeBarDataItem(dataItem, isHorizontal),
+ );
+ };
+}
+
+/** Apply the value-end label position to a negative bar datum. */
+function transformNegativeLabel(
+ dataItem: unknown,
+ isHorizontal: boolean,
+ negativePosition: NegativeBarLabelPosition,
+): unknown {
+ if (!isNegativeBarDataItem(dataItem, isHorizontal)) return dataItem;
+ const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
+ const item = isDataItemObject(dataItem) ? dataItem : { value };
+ const label = isDataItemObject(item.label) ? item.label : {};
+ return { ...item, label: { ...label, position: negativePosition } };
+}
+
+/** Adjust label positions for negative values in a bar series. */
export function transformNegativeLabelsPosition(
series: SeriesOption,
isHorizontal: boolean,
+ negativePosition: NegativeBarLabelPosition = 'outside',
): TimeseriesDataRecord[] {
- /*
- * Adjusts label position for negative values in bar series
- * @param series - Array of series options
- * @param isHorizontal - Whether chart is horizontal
- * @returns data with adjusted label positions for negative 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;
- };
-
- return (series.data as TimeseriesDataRecord[]).map(transformValue);
+ return (series.data as unknown[]).map(dataItem =>
+ transformNegativeLabel(dataItem, isHorizontal, negativePosition),
+ ) as TimeseriesDataRecord[];
}
export function applyColorByPrimaryAxis(
@@ -237,6 +357,7 @@ export function transformSeries(
stackIdSuffix?: string;
yAxisIndex?: number;
showValue?: boolean;
+ valueLabelPosition?: BarValueLabelPosition;
onlyTotal?: boolean;
legendState?: LegendState;
formatter?: ValueFormatter;
@@ -272,6 +393,7 @@ export function transformSeries(
stackIdSuffix,
yAxisIndex = 0,
showValue,
+ valueLabelPosition = BarValueLabelPosition.Auto,
onlyTotal,
formatter,
legendState,
@@ -392,23 +514,30 @@ export function transformSeries(
symbol = opts.lineSymbol || (isDarkMode ? 'circle' : 'emptyCircle');
}
+ let transformedData = data;
+ if (Array.isArray(data) && colorByPrimaryAxis) {
+ transformedData = applyColorByPrimaryAxis(
+ series,
+ colorScale,
+ sliceId,
+ opacity,
+ isHorizontal,
+ );
+ }
+ if (Array.isArray(transformedData) && plotType === 'bar') {
+ transformedData = transformNegativeLabelsPosition(
+ { ...series, data: transformedData },
+ isHorizontal,
+ getBarLabelPosition(valueLabelPosition, isHorizontal, true),
+ );
+ }
+
+ const isAutoBarLabel =
+ plotType === 'bar' && valueLabelPosition === BarValueLabelPosition.Auto;
+
return {
...series,
- ...(Array.isArray(data)
- ? colorByPrimaryAxis
- ? {
- data: applyColorByPrimaryAxis(
- series,
- colorScale,
- sliceId,
- opacity,
- isHorizontal,
- ),
- }
- : seriesType === 'bar' && !stack
- ? { data: transformNegativeLabelsPosition(series, isHorizontal) }
- : null
- : null),
+ ...(Array.isArray(data) ? { data: transformedData } : null),
connectNulls,
queryIndex,
yAxisIndex,
@@ -441,10 +570,21 @@ export function transformSeries(
showSymbol,
symbol,
symbolSize: symbolSizeFn ?? markerSize,
+ ...(isAutoBarLabel
+ ? {
+ labelLayout: createAutoBarLabelLayout(transformedData, isHorizontal),
+ }
+ : {}),
label: {
show: !!showValue,
- position: isHorizontal ? 'right' : 'top',
- color: theme?.colorText,
+ position:
+ plotType === 'bar'
+ ? getBarLabelPosition(valueLabelPosition, isHorizontal)
+ : isHorizontal
+ ? 'right'
+ : 'top',
+ // ECharts derives contrast-aware colors from the bar fill when unset.
+ ...(isAutoBarLabel ? {} : { 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/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
index befc2383968..b1e08d5b0c9 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
@@ -51,6 +51,14 @@ export enum EchartsTimeseriesSeriesType {
End = 'end',
}
+export enum BarValueLabelPosition {
+ Auto = 'auto',
+ InsideEnd = 'insideEnd',
+ OutsideEnd = 'outsideEnd',
+ InsideCenter = 'insideCenter',
+ InsideBase = 'insideBase',
+}
+
export type EchartsTimeseriesFormData = QueryFormData & {
annotationLayers: AnnotationLayer[];
area: boolean;
@@ -97,6 +105,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
xAxisLabelRotation: number;
xAxisLabelInterval: number | string;
showValue: boolean;
+ valueLabelPosition: BarValueLabelPosition;
onlyTotal: boolean;
showExtraControls: boolean;
percentageThreshold: number;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
index c0aa4f4f308..eab2d59b354 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
@@ -34,6 +34,7 @@ import {
StackControlOptionsWithoutStream,
} from './constants';
import { DEFAULT_FORM_DATA } from './Timeseries/constants';
+import { BarValueLabelPosition } from './Timeseries/types';
import { defaultXAxis } from './defaults';
const { legendMargin, legendOrientation, legendType, showLegend } =
@@ -140,6 +141,28 @@ export const showValueControl: ControlSetItem = {
},
};
+export const valueLabelPositionControl: ControlSetItem = {
+ name: 'value_label_position',
+ config: {
+ type: 'SelectControl',
+ freeForm: false,
+ clearable: false,
+ label: t('Value label position'),
+ choices: [
+ [BarValueLabelPosition.Auto, t('Auto')],
+ [BarValueLabelPosition.InsideEnd, t('Inside End')],
+ [BarValueLabelPosition.OutsideEnd, t('Outside End')],
+ [BarValueLabelPosition.InsideCenter, t('Inside Center')],
+ [BarValueLabelPosition.InsideBase, t('Inside Base')],
+ ],
+ default: DEFAULT_FORM_DATA.valueLabelPosition,
+ renderTrigger: true,
+ description: t('Choose where to display values relative to the bars'),
+ visibility: ({ controls }: ControlPanelsContainerProps) =>
+ Boolean(controls?.show_value?.value),
+ },
+};
+
export const colorByPrimaryAxisControl: ControlSetItem = {
name: 'color_by_primary_axis',
config: {
@@ -235,6 +258,7 @@ export const showValueSectionWithoutStack: ControlSetRow[] = [
export const showValueSectionWithoutStream: ControlSetRow[] = [
[showValueControl],
+ [valueLabelPositionControl],
[stackControlWithoutStream],
[onlyTotalControl],
[percentageThresholdControl],
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts
index 01bb1db740c..461e5607ee7 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/controlPanel.test.ts
@@ -23,7 +23,10 @@ import {
StackControlOptionsWithoutStream,
StackControlsValue,
} from '../../../src/constants';
-import { OrientationType } from '../../../src/Timeseries/types';
+import {
+ BarValueLabelPosition,
+ OrientationType,
+} from '../../../src/Timeseries/types';
const config = controlPanel;
@@ -132,6 +135,41 @@ test('should include stack control in the panel', () => {
expect(stackControl).toBeDefined();
});
+test('should expose Auto and manual value label positions for Bar charts', () => {
+ const valueLabelPositionControl = getControl(
+ 'value_label_position',
+ ) as unknown as {
+ config: {
+ choices: [BarValueLabelPosition, string][];
+ default: BarValueLabelPosition;
+ visibility: (props: ControlPanelsContainerProps) => boolean;
+ };
+ };
+
+ expect(valueLabelPositionControl.config.default).toBe(
+ BarValueLabelPosition.Auto,
+ );
+ expect(
+ valueLabelPositionControl.config.choices.map(([value]) => value),
+ ).toEqual([
+ BarValueLabelPosition.Auto,
+ BarValueLabelPosition.InsideEnd,
+ BarValueLabelPosition.OutsideEnd,
+ BarValueLabelPosition.InsideCenter,
+ BarValueLabelPosition.InsideBase,
+ ]);
+ expect(
+ valueLabelPositionControl.config.visibility({
+ controls: { show_value: { value: true } },
+ } as unknown as ControlPanelsContainerProps),
+ ).toBe(true);
+ expect(
+ valueLabelPositionControl.config.visibility({
+ controls: { show_value: { value: false } },
+ } as unknown as ControlPanelsContainerProps),
+ ).toBe(false);
+});
+
test('should use StackControlOptionsWithoutStream for stack control', () => {
const stackControl: any = getControl('stack');
expect(stackControl).toBeDefined();
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 cd69678b226..b56457b9ec0 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
@@ -29,6 +29,7 @@ import type {
GridComponentOption,
LegendComponentOption,
} from 'echarts/components';
+import type { BarSeriesOption } from 'echarts/charts';
import {
EchartsTimeseriesChartProps,
LegendOrientation,
@@ -37,6 +38,7 @@ import {
import transformProps from '../../../src/Timeseries/transformProps';
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
import {
+ BarValueLabelPosition,
EchartsTimeseriesFormData,
OrientationType,
EchartsTimeseriesSeriesType,
@@ -74,6 +76,64 @@ function createTestQueryData(
};
}
+test('manual Bar value label position flows through transformProps', () => {
+ const chartProps = createEchartsTimeseriesTestChartProps<
+ EchartsTimeseriesFormData,
+ EchartsTimeseriesChartProps
+ >({
+ defaultFormData: DEFAULT_FORM_DATA,
+ defaultVizType: 'echarts_timeseries_bar',
+ formData: {
+ seriesType: EchartsTimeseriesSeriesType.Bar,
+ valueLabelPosition: BarValueLabelPosition.OutsideEnd,
+ metrics: ['Sales'],
+ xAxis: '__timestamp',
+ showValue: true,
+ },
+ queriesData: [
+ createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
+ colnames: ['Sales', '__timestamp'],
+ coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
+ }),
+ ],
+ });
+
+ const { echartOptions } = transformProps(chartProps);
+ const [series] = echartOptions.series as BarSeriesOption[];
+
+ expect(series.label).toMatchObject({ position: 'top' });
+ expect(series.labelLayout).toBeUndefined();
+ expect(echartOptions.darkMode).toBeUndefined();
+});
+
+test('Auto Bar labels enable theme-aware ECharts contrast', () => {
+ const chartProps = createEchartsTimeseriesTestChartProps<
+ EchartsTimeseriesFormData,
+ EchartsTimeseriesChartProps
+ >({
+ defaultFormData: DEFAULT_FORM_DATA,
+ defaultVizType: 'echarts_timeseries_bar',
+ formData: {
+ seriesType: EchartsTimeseriesSeriesType.Bar,
+ metrics: ['Sales'],
+ xAxis: '__timestamp',
+ showValue: true,
+ },
+ queriesData: [
+ createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
+ colnames: ['Sales', '__timestamp'],
+ coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
+ }),
+ ],
+ });
+
+ const { echartOptions } = transformProps(chartProps);
+ const [series] = echartOptions.series as BarSeriesOption[];
+
+ expect(typeof series.labelLayout).toBe('function');
+ expect(echartOptions.darkMode).toBe(false);
+});
+
describe('Bar Chart X-axis Time Formatting', () => {
const baseFormData: SqlaFormData = {
...DEFAULT_FORM_DATA,
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 84022759a50..902ca5f02f4 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
@@ -24,9 +24,13 @@ import {
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import type { SeriesOption } from 'echarts';
-import type { ScatterSeriesOption } from 'echarts/charts';
-import { EchartsTimeseriesSeriesType } from '../../src';
-import { TIMESERIES_CONSTANTS } from '../../src/constants';
+import type {
+ BarSeriesOption,
+ LineSeriesOption,
+ ScatterSeriesOption,
+} from 'echarts/charts';
+import { BarValueLabelPosition, EchartsTimeseriesSeriesType } from '../../src';
+import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
import {
LegendOrientation,
EchartsTimeseriesChartProps,
@@ -160,6 +164,182 @@ describe('transformSeries', () => {
});
});
+test('Auto bar labels move outside narrow stacked segments', () => {
+ const result = transformSeries(
+ { name: 'test-series', type: 'bar', data: [[2026, 1]] },
+ mockColorScale,
+ 'test-key',
+ {
+ seriesType: EchartsTimeseriesSeriesType.Bar,
+ stack: StackControlsValue.Stack,
+ showValue: true,
+ },
+ ) as BarSeriesOption;
+ const { labelLayout } = result;
+
+ expect(result.label).toMatchObject({
+ show: true,
+ position: 'insideTop',
+ });
+ expect((result.label as { color?: string }).color).toBeUndefined();
+ expect(typeof labelLayout).toBe('function');
+ if (typeof labelLayout !== 'function') return;
+
+ expect(
+ labelLayout({
+ dataIndex: 0,
+ seriesIndex: 0,
+ text: '1,000',
+ align: 'center',
+ verticalAlign: 'middle',
+ rect: { x: 10, y: 20, width: 12, height: 20 },
+ labelRect: { x: 1, y: 22, width: 30, height: 14 },
+ }),
+ ).toEqual({
+ x: 16,
+ y: 15,
+ align: 'center',
+ verticalAlign: 'bottom',
+ });
+});
+
+test('Auto bar labels stay inside when they use at most 80% of bar length', () => {
+ const result = transformSeries(
+ { name: 'test-series', type: 'bar', data: [[2026, 1]] },
+ mockColorScale,
+ 'test-key',
+ { seriesType: EchartsTimeseriesSeriesType.Bar },
+ ) as BarSeriesOption;
+ const { labelLayout } = result;
+
+ expect(typeof labelLayout).toBe('function');
+ if (typeof labelLayout !== 'function') return;
+
+ expect(
+ labelLayout({
+ dataIndex: 0,
+ seriesIndex: 0,
+ text: '1,000',
+ align: 'center',
+ verticalAlign: 'top',
+ rect: { x: 10, y: 20, width: 12, height: 40 },
+ labelRect: { x: 0, y: 25, width: 32, height: 14 },
+ }),
+ ).toEqual({});
+});
+
+test('Auto bar labels use horizontal bar length and move to the value end', () => {
+ const result = transformSeries(
+ { name: 'test-series', type: 'bar', data: [[1, 2026]] },
+ mockColorScale,
+ 'test-key',
+ { seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
+ ) as BarSeriesOption;
+ const { labelLayout } = result;
+
+ expect(typeof labelLayout).toBe('function');
+ if (typeof labelLayout !== 'function') return;
+
+ expect(
+ labelLayout({
+ dataIndex: 0,
+ seriesIndex: 0,
+ text: '1,000',
+ align: 'right',
+ verticalAlign: 'middle',
+ rect: { x: 10, y: 20, width: 20, height: 12 },
+ labelRect: { x: 0, y: 19, width: 30, height: 14 },
+ }),
+ ).toEqual({
+ x: 35,
+ y: 26,
+ align: 'left',
+ verticalAlign: 'middle',
+ });
+});
+
+test.each([
+ [BarValueLabelPosition.InsideEnd, 'insideTop'],
+ [BarValueLabelPosition.OutsideEnd, 'top'],
+ [BarValueLabelPosition.InsideCenter, 'inside'],
+ [BarValueLabelPosition.InsideBase, 'insideBottom'],
+] as const)(
+ 'manual %s bar labels use fixed position %s',
+ (position, expected) => {
+ const result = transformSeries(
+ { name: 'test-series', type: 'bar', data: [[2026, 1]] },
+ mockColorScale,
+ 'test-key',
+ {
+ seriesType: EchartsTimeseriesSeriesType.Bar,
+ valueLabelPosition: position,
+ theme: supersetTheme,
+ },
+ ) as BarSeriesOption;
+
+ expect(result.labelLayout).toBeUndefined();
+ expect(result.label).toMatchObject({
+ position: expected,
+ color: supersetTheme.colorText,
+ });
+ },
+);
+
+test('Auto positions negative stacked segments at their inside end', () => {
+ const result = transformSeries(
+ { name: 'test-series', type: 'bar', data: [[2026, -1]] },
+ mockColorScale,
+ 'test-key',
+ {
+ seriesType: EchartsTimeseriesSeriesType.Bar,
+ stack: StackControlsValue.Stack,
+ },
+ ) as BarSeriesOption;
+
+ expect(result.data).toEqual([
+ {
+ value: [2026, -1],
+ label: { position: 'insideBottom' },
+ },
+ ]);
+ expect(typeof result.labelLayout).toBe('function');
+ if (typeof result.labelLayout !== 'function') return;
+ expect(
+ result.labelLayout({
+ dataIndex: 0,
+ seriesIndex: 0,
+ text: '-1,000',
+ align: 'center',
+ verticalAlign: 'bottom',
+ rect: { x: 10, y: 20, width: 12, height: 10 },
+ labelRect: { x: 1, y: 15, width: 30, height: 14 },
+ }),
+ ).toEqual({
+ x: 16,
+ y: 35,
+ align: 'center',
+ verticalAlign: 'top',
+ });
+});
+
+test('Auto label layout does not change non-Bar series', () => {
+ const result = transformSeries(
+ { name: 'test-series', type: 'line', data: [[2026, 1]] },
+ mockColorScale,
+ 'test-key',
+ {
+ seriesType: EchartsTimeseriesSeriesType.Line,
+ theme: supersetTheme,
+ },
+ ) as LineSeriesOption;
+
+ expect(result).not.toHaveProperty('labelLayout');
+ expect(result.label).toMatchObject({
+ position: 'top',
+ color: supersetTheme.colorText,
+ });
+});
+
describe('transformNegativeLabelsPosition', () => {
test('label position bottom of negative value no Horizontal', () => {
const isHorizontal = false;