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;