Compare commits

...
10 changed files with 734 additions and 43 deletions
@@ -59,6 +59,7 @@ import {
LegendOrientation,
Refs,
} from '../types';
import { BarValueLabelPosition } from '../Timeseries/types';
import { parseAxisBound } from '../utils/controls';
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
import {
@@ -512,6 +513,7 @@ export default function transformProps(
areaOpacity: opacity,
seriesType,
showValue,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
onlyTotal,
stack: Boolean(stack),
stackIdSuffix: '\na',
@@ -600,6 +602,7 @@ export default function transformProps(
areaOpacity: opacityB,
seriesType: seriesTypeB,
showValue: showValueB,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
onlyTotal: onlyTotalB,
stack: Boolean(stackB),
stackIdSuffix: '\nb',
@@ -23,6 +23,7 @@ import {
import { t } from '@apache-superset/core/translation';
import { LegendOrientation, LegendType } from '../types';
import {
BarValueLabelPosition,
OrientationType,
EchartsTimeseriesSeriesType,
EchartsTimeseriesFormData,
@@ -86,6 +87,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
xAxisLabelInterval: defaultXAxis.xAxisLabelInterval,
groupby: [],
showValue: false,
valueLabelPosition: BarValueLabelPosition.Auto,
onlyTotal: false,
percentageThreshold: 0,
orientation: OrientationType.Vertical,
@@ -47,6 +47,7 @@ import {
NumberFormats,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { isThemeDark } from '@apache-superset/core/theme';
import {
extractExtraMetrics,
getOriginalSeries,
@@ -63,6 +64,7 @@ import {
EchartsTimeseriesChartProps,
EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType,
BarValueLabelPosition,
OrientationType,
TimeseriesChartTransformedProps,
} from './types';
@@ -290,6 +292,7 @@ export default function transformProps(
seriesType,
showLegend,
showValue,
valueLabelPosition,
size,
colorByPrimaryAxis,
sliceId,
@@ -327,6 +330,8 @@ export default function transformProps(
zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const resolvedValueLabelPosition =
valueLabelPosition ?? BarValueLabelPosition.Auto;
const refs: Refs = {};
const groupBy = ensureIsArray(groupby);
@@ -737,6 +742,7 @@ export default function transformProps(
labelMap?.[seriesName]?.[0],
) ?? defaultFormatter),
showValue,
valueLabelPosition: resolvedValueLabelPosition,
onlyTotal,
totalStackedValues: sortedTotalValues,
showValueIndexes,
@@ -1370,6 +1376,10 @@ export default function transformProps(
const echartOptions: EChartsCoreOption = {
useUTC: true,
...(seriesType === EchartsTimeseriesSeriesType.Bar &&
resolvedValueLabelPosition === BarValueLabelPosition.Auto
? { darkMode: isThemeDark(theme) }
: {}),
grid: {
...defaultGrid,
...padding,
@@ -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,87 @@ 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 fitsWidth =
params.labelRect.width <=
Math.abs(params.rect.width) * AUTO_LABEL_FIT_RATIO;
const fitsHeight =
params.labelRect.height <=
Math.abs(params.rect.height) * AUTO_LABEL_FIT_RATIO;
if (fitsWidth && fitsHeight) return {};
return isHorizontal
? getHorizontalOutsideLayout(params, isNegative)
: getVerticalOutsideLayout(params, isNegative);
}
function parseTimeShiftToMs(timeShift?: string | null): number {
if (!timeShift) return 0;
@@ -167,31 +252,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 +360,7 @@ export function transformSeries(
stackIdSuffix?: string;
yAxisIndex?: number;
showValue?: boolean;
valueLabelPosition?: BarValueLabelPosition;
onlyTotal?: boolean;
legendState?: LegendState;
formatter?: ValueFormatter;
@@ -272,6 +396,7 @@ export function transformSeries(
stackIdSuffix,
yAxisIndex = 0,
showValue,
valueLabelPosition = BarValueLabelPosition.Auto,
onlyTotal,
formatter,
legendState,
@@ -392,23 +517,33 @@ 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;
const isInsideBarLabel =
plotType === 'bar' &&
valueLabelPosition !== BarValueLabelPosition.OutsideEnd;
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 +576,22 @@ 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 from the bar fill for inside positions.
// Auto x/y overflow clears the position, selecting its outside fill.
...(isInsideBarLabel ? {} : { color: theme?.colorText }),
textBorderWidth: 0,
formatter: (params: any) => {
// don't show confidence band value labels, as they're already visible on the tooltip
@@ -52,6 +52,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;
@@ -99,6 +107,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
xAxisLabelRotation: number;
xAxisLabelInterval: number | string;
showValue: boolean;
valueLabelPosition: BarValueLabelPosition;
onlyTotal: boolean;
showExtraControls: boolean;
percentageThreshold: number;
@@ -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],
@@ -43,6 +43,7 @@ import {
} from '../../src/MixedTimeseries/types';
import { createEchartsTimeseriesTestChartProps } from '../helpers';
import type { SeriesOption } from 'echarts';
import type { BarSeriesOption } from 'echarts/charts';
type LabelFormatterParams = {
value: [number, number];
@@ -193,6 +194,61 @@ function formatSeriesLabel(
});
}
test('bar value labels retain their legacy outside position', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, showValueB: true },
queriesData,
});
const transformed = transformProps(chartProps);
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
(series): series is BarSeriesOption => series.type === 'bar',
);
expect(barSeries).not.toHaveLength(0);
barSeries.forEach(series => {
expect(series.label).toMatchObject({ show: true, position: 'top' });
expect(series.labelLayout).toBeUndefined();
});
});
test('negative bar values retain their legacy outside position', () => {
const negativeRows = [
{ boy: -1, girl: -2, ds: 599616000000 },
{ boy: -3, girl: -4, ds: 599916000000 },
];
const negativeQueriesData = [
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
];
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: negativeQueriesData,
formData: { ...formData, showValueB: true },
queriesData: negativeQueriesData,
});
const transformed = transformProps(chartProps);
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
(series): series is BarSeriesOption => series.type === 'bar',
);
expect(barSeries).not.toHaveLength(0);
barSeries.forEach(series => {
expect(series.data?.[0]).toMatchObject({
label: { position: 'bottom' },
});
});
});
test('should transform chart props for viz with showQueryIdentifiers=false', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
@@ -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();
@@ -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,105 @@ 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);
});
test('legacy Bar labels without a saved position enable Auto contrast', () => {
const legacyFormData: Partial<EchartsTimeseriesFormData> = {
...DEFAULT_FORM_DATA,
};
delete legacyFormData.valueLabelPosition;
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: legacyFormData as EchartsTimeseriesFormData,
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],
}),
],
});
expect(chartProps.formData).not.toHaveProperty('valueLabelPosition');
const { echartOptions } = transformProps(chartProps);
const [series] = echartOptions.series as BarSeriesOption[];
expect(typeof series.labelLayout).toBe('function');
expect(echartOptions.darkMode).toBe(false);
Reflect.set(chartProps.formData, 'valueLabelPosition', undefined);
const undefinedPositionOptions = transformProps(chartProps).echartOptions;
const [undefinedPositionSeries] =
undefinedPositionOptions.series as BarSeriesOption[];
expect(typeof undefinedPositionSeries.labelLayout).toBe('function');
expect(undefinedPositionOptions.darkMode).toBe(false);
});
describe('Bar Chart X-axis Time Formatting', () => {
const baseFormData: SqlaFormData = {
...DEFAULT_FORM_DATA,
@@ -19,14 +19,19 @@
import {
CategoricalColorScale,
ChartProps,
getNumberFormatter,
TimeGranularity,
} from '@superset-ui/core';
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 { init, type SeriesOption } from 'echarts';
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 +165,302 @@ 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 labels stay inside when both dimensions fit within 80% of the bar', () => {
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: 50, height: 40 },
labelRect: { x: 19, y: 25, width: 32, height: 14 },
}),
).toEqual({});
});
test('Auto moves wide labels outside tall narrow vertical bars', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 100]] },
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: 200 },
labelRect: { x: 1, y: 25, width: 30, height: 14 },
}),
).toEqual({
x: 16,
y: 15,
align: 'center',
verticalAlign: 'bottom',
});
});
test('Auto overflow uses ECharts outside-label text color', () => {
const darkBarColorScale = jest.fn(() => '#111111');
const series = transformSeries(
{ name: 'test-series', type: 'bar', data: [[0, 123456789]] },
darkBarColorScale as unknown as CategoricalColorScale,
'test-key',
{
formatter: getNumberFormatter('d'),
seriesType: EchartsTimeseriesSeriesType.Bar,
showValue: true,
},
) as BarSeriesOption;
const chart = init(null, null, {
renderer: 'svg',
ssr: true,
width: 300,
height: 220,
});
chart.setOption({
animation: false,
darkMode: false,
xAxis: { type: 'category', data: ['A'], show: false },
yAxis: { type: 'value', max: 1_000_000_000, show: false },
series: [series],
});
expect(chart.renderToSVGString()).toMatch(
/fill="#333"[^>]*>123456789<\/text>/,
);
chart.dispose();
});
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 });
if (position === BarValueLabelPosition.OutsideEnd) {
expect(result.label).toMatchObject({ color: supersetTheme.colorText });
} else {
expect(result.label).not.toHaveProperty('color');
}
},
);
test('manual Outside End positions negative stacked segments below the bar', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, -1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
},
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [2026, -1],
label: { position: 'bottom' },
},
]);
expect(result.labelLayout).toBeUndefined();
});
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 moves horizontal negative labels beyond their value end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[-1, 2026]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [-1, 2026],
label: { position: 'insideLeft' },
},
]);
expect(typeof result.labelLayout).toBe('function');
if (typeof result.labelLayout !== 'function') return;
expect(
result.labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '-1,000',
align: 'left',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 20, height: 12 },
labelRect: { x: 10, y: 19, width: 30, height: 14 },
}),
).toEqual({
x: 5,
y: 26,
align: 'right',
verticalAlign: 'middle',
});
});
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;