mirror of
https://github.com/apache/superset.git
synced 2026-08-28 11:01:17 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb9f82c90 | ||
|
|
7f0f2c1faa | ||
|
|
8ca0bbf54b | ||
|
|
8c60b0a59c | ||
|
|
f046ef030c | ||
|
|
7c06c3a23f | ||
|
|
dc0b64754a | ||
|
|
8b22d466e3 |
@@ -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 {
|
||||
@@ -515,6 +516,7 @@ export default function transformProps(
|
||||
areaOpacity: opacity,
|
||||
seriesType,
|
||||
showValue,
|
||||
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
|
||||
onlyTotal,
|
||||
stack: Boolean(stack),
|
||||
stackIdSuffix: '\na',
|
||||
@@ -604,6 +606,7 @@ export default function transformProps(
|
||||
areaOpacity: opacityB,
|
||||
seriesType: seriesTypeB,
|
||||
showValue: showValueB,
|
||||
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
|
||||
onlyTotal: onlyTotalB,
|
||||
stack: Boolean(stackB),
|
||||
stackIdSuffix: '\nb',
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ import {
|
||||
} from '../types';
|
||||
import EchartsTimeseries from './EchartsTimeseries';
|
||||
import {
|
||||
BarValueLabelPosition,
|
||||
EchartsTimeseriesSeriesType,
|
||||
OrientationType,
|
||||
type EchartsTimeseriesFormData,
|
||||
@@ -157,6 +158,7 @@ const defaultFormData: EchartsTimeseriesFormData & {
|
||||
xAxisLabelRotation: 0,
|
||||
xAxisLabelInterval: 0,
|
||||
showValue: false,
|
||||
valueLabelPosition: BarValueLabelPosition.Auto,
|
||||
onlyTotal: false,
|
||||
showExtraControls: true,
|
||||
percentageThreshold: 0,
|
||||
|
||||
@@ -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,10 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
xAxisLabelInterval: defaultXAxis.xAxisLabelInterval,
|
||||
groupby: [],
|
||||
showValue: false,
|
||||
// Legacy charts saved before this field existed have no valueLabelPosition
|
||||
// in form_data and must keep their pre-existing Outside End placement;
|
||||
// Auto is opt-in via the Value label position control, not the default.
|
||||
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
|
||||
labelPosition: 'auto',
|
||||
onlyTotal: false,
|
||||
percentageThreshold: 0,
|
||||
|
||||
@@ -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';
|
||||
@@ -291,6 +293,7 @@ export default function transformProps(
|
||||
seriesType,
|
||||
showLegend,
|
||||
showValue,
|
||||
valueLabelPosition,
|
||||
size,
|
||||
labelPosition,
|
||||
colorByPrimaryAxis,
|
||||
@@ -329,6 +332,8 @@ export default function transformProps(
|
||||
zoomable,
|
||||
stackDimension,
|
||||
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
const resolvedValueLabelPosition =
|
||||
valueLabelPosition ?? BarValueLabelPosition.OutsideEnd;
|
||||
|
||||
const refs: Refs = {};
|
||||
const groupBy = ensureIsArray(groupby);
|
||||
@@ -739,6 +744,7 @@ export default function transformProps(
|
||||
labelMap?.[seriesName]?.[0],
|
||||
) ?? defaultFormatter),
|
||||
showValue,
|
||||
valueLabelPosition: resolvedValueLabelPosition,
|
||||
onlyTotal,
|
||||
totalStackedValues: sortedTotalValues,
|
||||
showValueIndexes,
|
||||
@@ -1375,6 +1381,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,
|
||||
LabelPositionEnum,
|
||||
@@ -70,6 +74,107 @@ import {
|
||||
TIMESERIES_CONSTANTS,
|
||||
} from '../constants';
|
||||
|
||||
const AUTO_LABEL_FIT_RATIO = 0.8;
|
||||
const BAR_LABEL_DISTANCE = 5;
|
||||
// Neither an inside nor an outside placement gives a stacked segment's value
|
||||
// label legible, non-overlapping room once the segment's own extent drops
|
||||
// below roughly the label text's height, since the closest available
|
||||
// placement then collides with a neighboring segment's label regardless of
|
||||
// which side it's drawn on. Highcharts and D3 apply the same kind of floor.
|
||||
// The label font size is theme.fontSizeSM (~12px, see series.ts), so 16px
|
||||
// covers the glyph height plus a couple of pixels of breathing room.
|
||||
const MIN_LABEL_SEGMENT_SIZE_PX = 16;
|
||||
// The labelLayout callback only applies align/verticalAlign/width/height/
|
||||
// fontSize from its return value (LABEL_OPTION_TO_STYLE_KEYS in ECharts'
|
||||
// LabelManager) — there is no hide/ignore field, so a zero font size is the
|
||||
// supported way to suppress an individual label from this callback.
|
||||
const HIDDEN_LABEL_LAYOUT: LabelLayoutOption = { fontSize: 0 };
|
||||
|
||||
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, move oversized labels outside the bar, and
|
||||
* suppress labels for segments too small to legibly fit one either way. */
|
||||
export function getAutoBarLabelLayout(
|
||||
params: LabelLayoutOptionCallbackParams,
|
||||
isHorizontal: boolean,
|
||||
isNegative = false,
|
||||
): LabelLayoutOption {
|
||||
const segmentSize = isHorizontal
|
||||
? Math.abs(params.rect.width)
|
||||
: Math.abs(params.rect.height);
|
||||
if (segmentSize < MIN_LABEL_SEGMENT_SIZE_PX) {
|
||||
return HIDDEN_LABEL_LAYOUT;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -168,35 +273,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,
|
||||
labelPosition?: string,
|
||||
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:
|
||||
labelPosition && labelPosition !== 'auto'
|
||||
? labelPosition
|
||||
: '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(
|
||||
@@ -242,6 +381,7 @@ export function transformSeries(
|
||||
stackIdSuffix?: string;
|
||||
yAxisIndex?: number;
|
||||
showValue?: boolean;
|
||||
valueLabelPosition?: BarValueLabelPosition;
|
||||
onlyTotal?: boolean;
|
||||
legendState?: LegendState;
|
||||
formatter?: ValueFormatter;
|
||||
@@ -278,6 +418,7 @@ export function transformSeries(
|
||||
stackIdSuffix,
|
||||
yAxisIndex = 0,
|
||||
showValue,
|
||||
valueLabelPosition = BarValueLabelPosition.Auto,
|
||||
onlyTotal,
|
||||
formatter,
|
||||
legendState,
|
||||
@@ -399,29 +540,40 @@ 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') {
|
||||
// An explicit labelPosition (set before valueLabelPosition existed, or
|
||||
// still relevant to a saved chart) takes precedence for negative values;
|
||||
// otherwise fall back to the fit-aware valueLabelPosition-derived spot.
|
||||
const negativeLabelPosition: NegativeBarLabelPosition =
|
||||
labelPosition && labelPosition !== 'auto'
|
||||
? (labelPosition as NegativeBarLabelPosition)
|
||||
: getBarLabelPosition(valueLabelPosition, isHorizontal, true);
|
||||
transformedData = transformNegativeLabelsPosition(
|
||||
{ ...series, data: transformedData },
|
||||
isHorizontal,
|
||||
negativeLabelPosition,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
labelPosition,
|
||||
),
|
||||
}
|
||||
: null
|
||||
: null),
|
||||
...(Array.isArray(data) ? { data: transformedData } : null),
|
||||
connectNulls,
|
||||
queryIndex,
|
||||
yAxisIndex,
|
||||
@@ -454,15 +606,31 @@ export function transformSeries(
|
||||
showSymbol,
|
||||
symbol,
|
||||
symbolSize: symbolSizeFn ?? markerSize,
|
||||
...(isAutoBarLabel
|
||||
? {
|
||||
labelLayout: createAutoBarLabelLayout(transformedData, isHorizontal),
|
||||
}
|
||||
: {}),
|
||||
label: {
|
||||
show: !!showValue,
|
||||
position: (labelPosition === 'auto' || !labelPosition
|
||||
? isHorizontal
|
||||
? LabelPositionEnum.Right
|
||||
: LabelPositionEnum.Top
|
||||
: labelPosition) as LabelPositionEnum,
|
||||
// An explicit labelPosition (the generic control still used by
|
||||
// MixedTimeseries' bar series, and by standalone bar charts saved
|
||||
// before valueLabelPosition existed) wins outright. Otherwise bar
|
||||
// charts fall back to the fit-aware valueLabelPosition control, and
|
||||
// every other "Show value" chart type falls back to an
|
||||
// orientation-aware default.
|
||||
position:
|
||||
labelPosition && labelPosition !== 'auto'
|
||||
? (labelPosition as LabelPositionEnum)
|
||||
: plotType === 'bar'
|
||||
? getBarLabelPosition(valueLabelPosition, isHorizontal)
|
||||
: isHorizontal
|
||||
? LabelPositionEnum.Right
|
||||
: LabelPositionEnum.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 }),
|
||||
...(plotType === 'bar' ? { overflow: 'truncate' } : {}),
|
||||
color: theme?.colorText,
|
||||
textBorderWidth: 0,
|
||||
formatter: (params: any) => {
|
||||
// don't show confidence band value labels, as they're already visible on the tooltip
|
||||
|
||||
@@ -53,6 +53,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;
|
||||
@@ -100,6 +108,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
xAxisLabelRotation: number;
|
||||
xAxisLabelInterval: number | string;
|
||||
showValue: boolean;
|
||||
valueLabelPosition: BarValueLabelPosition;
|
||||
/**
|
||||
* Where the data label sits relative to its data point, applied when
|
||||
* `showValue` is on.
|
||||
|
||||
@@ -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,32 @@ export const showValueControl: ControlSetItem = {
|
||||
},
|
||||
};
|
||||
|
||||
// Bar-only: fit-aware placement (Auto avoids/suppresses colliding labels on
|
||||
// stacked segments) plus explicit end/center/base positions. Wired into
|
||||
// showValueSectionWithoutStream (Bar charts) instead of labelPositionControl
|
||||
// below, which stays the generic picker for every other "Show value" chart.
|
||||
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 labelPositionControl: ControlSetItem = {
|
||||
name: 'label_position',
|
||||
config: {
|
||||
@@ -257,9 +284,11 @@ export const showValueSectionWithoutStack: ControlSetRow[] = [
|
||||
[onlyTotalControl],
|
||||
];
|
||||
|
||||
// Bar charts (the only consumer of this section) use the fit-aware
|
||||
// valueLabelPositionControl instead of the generic labelPositionControl.
|
||||
export const showValueSectionWithoutStream: ControlSetRow[] = [
|
||||
[showValueControl],
|
||||
[labelPositionControl],
|
||||
[valueLabelPositionControl],
|
||||
[stackControlWithoutStream],
|
||||
[onlyTotalControl],
|
||||
[percentageThresholdControl],
|
||||
|
||||
+55
@@ -194,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,
|
||||
|
||||
+39
-47
@@ -23,14 +23,10 @@ import {
|
||||
StackControlOptionsWithoutStream,
|
||||
StackControlsValue,
|
||||
} from '../../../src/constants';
|
||||
import { OrientationType } from '../../../src/Timeseries/types';
|
||||
|
||||
// Narrow shape of the control under test: enough to exercise `visibility`
|
||||
// without reaching for `any`.
|
||||
type VisibilityControl = {
|
||||
name: string;
|
||||
config: { visibility: (props: ControlPanelsContainerProps) => boolean };
|
||||
};
|
||||
import {
|
||||
BarValueLabelPosition,
|
||||
OrientationType,
|
||||
} from '../../../src/Timeseries/types';
|
||||
|
||||
const config = controlPanel;
|
||||
|
||||
@@ -139,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.OutsideEnd,
|
||||
);
|
||||
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();
|
||||
@@ -299,42 +330,3 @@ test('x_axis_time_format should be hidden for numeric columns', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('should have visibility function for label_position', () => {
|
||||
const labelPositionCtrl = getControl(
|
||||
'label_position',
|
||||
) as unknown as VisibilityControl;
|
||||
expect(labelPositionCtrl).toBeDefined();
|
||||
expect(labelPositionCtrl.config.visibility).toBeDefined();
|
||||
expect(typeof labelPositionCtrl.config.visibility).toBe('function');
|
||||
|
||||
expect(
|
||||
labelPositionCtrl.config.visibility({
|
||||
controls: {
|
||||
show_value: { value: true },
|
||||
show_valueB: { value: false },
|
||||
},
|
||||
} as unknown as ControlPanelsContainerProps),
|
||||
).toBe(true);
|
||||
|
||||
// Visibility follows `show_value` alone. No Timeseries panel defines
|
||||
// `show_valueB` — Mixed declares its own suffixed controls — so it must not
|
||||
// reveal the control on its own.
|
||||
expect(
|
||||
labelPositionCtrl.config.visibility({
|
||||
controls: {
|
||||
show_value: { value: false },
|
||||
show_valueB: { value: true },
|
||||
},
|
||||
} as unknown as ControlPanelsContainerProps),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
labelPositionCtrl.config.visibility({
|
||||
controls: {
|
||||
show_value: { value: false },
|
||||
show_valueB: { value: false },
|
||||
},
|
||||
} as unknown as ControlPanelsContainerProps),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
+102
@@ -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,106 @@ 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,
|
||||
valueLabelPosition: BarValueLabelPosition.Auto,
|
||||
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 keep their pre-existing Outside End placement', () => {
|
||||
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(series.label).toMatchObject({ position: 'top' });
|
||||
expect(series.labelLayout).toBeUndefined();
|
||||
|
||||
Reflect.set(chartProps.formData, 'valueLabelPosition', undefined);
|
||||
const undefinedPositionOptions = transformProps(chartProps).echartOptions;
|
||||
const [undefinedPositionSeries] =
|
||||
undefinedPositionOptions.series as BarSeriesOption[];
|
||||
|
||||
expect(undefinedPositionSeries.label).toMatchObject({ position: 'top' });
|
||||
expect(undefinedPositionSeries.labelLayout).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('Bar Chart X-axis Time Formatting', () => {
|
||||
const baseFormData: SqlaFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
|
||||
+419
-4
@@ -19,14 +19,18 @@
|
||||
import {
|
||||
CategoricalColorScale,
|
||||
ChartProps,
|
||||
TimeGranularity,
|
||||
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 { 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,
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
import {
|
||||
transformSeries,
|
||||
transformNegativeLabelsPosition,
|
||||
getAutoBarLabelLayout,
|
||||
getPadding,
|
||||
} from '../../src/Timeseries/transformers';
|
||||
import transformProps from '../../src/Timeseries/transformProps';
|
||||
@@ -161,6 +166,416 @@ 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, 123456789012]] },
|
||||
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 },
|
||||
// A tall bar (well above the segment-legibility floor) whose 12-digit
|
||||
// label is too wide to fit inside, so ECharts still moves it outside.
|
||||
yAxis: { type: 'value', max: 250_000_000_000, show: false },
|
||||
series: [series],
|
||||
});
|
||||
|
||||
expect(chart.renderToSVGString()).toMatch(
|
||||
/fill="#333"[^>]*>123456789012<\/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: 30 },
|
||||
labelRect: { x: 1, y: 35, width: 30, height: 14 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 16,
|
||||
y: 55,
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
test('Auto suppresses the label for a vertical segment below the legibility floor', () => {
|
||||
// A 10px-tall stacked segment can't legibly fit its 14px-tall label inside
|
||||
// or outside without colliding with a neighboring segment's label.
|
||||
expect(
|
||||
getAutoBarLabelLayout(
|
||||
{
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 40, height: 10 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test('Auto keeps placing labels normally for a vertical segment at the legibility floor', () => {
|
||||
expect(
|
||||
getAutoBarLabelLayout(
|
||||
{
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 40, height: 16 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
},
|
||||
false,
|
||||
),
|
||||
).not.toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test('Auto suppresses the label for a horizontal segment below the legibility floor', () => {
|
||||
// Horizontal bars stack along the x axis, so the value-axis dimension that
|
||||
// matters is rect.width rather than rect.height.
|
||||
expect(
|
||||
getAutoBarLabelLayout(
|
||||
{
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'left',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 10, height: 40 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test('Auto suppresses labels for tiny adjacent stacked segments end to end', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
showValue: true,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
const { labelLayout } = result;
|
||||
|
||||
expect(typeof labelLayout).toBe('function');
|
||||
if (typeof labelLayout !== 'function') return;
|
||||
|
||||
expect(
|
||||
labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 40, height: 8 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
}),
|
||||
).toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
[BarValueLabelPosition.InsideEnd, 'insideTop'],
|
||||
[BarValueLabelPosition.OutsideEnd, 'top'],
|
||||
[BarValueLabelPosition.InsideCenter, 'inside'],
|
||||
[BarValueLabelPosition.InsideBase, 'insideBottom'],
|
||||
] as const)(
|
||||
'manual %s label placement is unaffected by tiny segments (no labelLayout applied)',
|
||||
(position, expected) => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
valueLabelPosition: position,
|
||||
showValue: true,
|
||||
theme: supersetTheme,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
|
||||
// Manual positions don't use the fit-aware labelLayout callback at all,
|
||||
// so a tiny segment can't trigger the Auto-only suppression behavior.
|
||||
expect(result.labelLayout).toBeUndefined();
|
||||
expect(result.label).toMatchObject({ position: expected });
|
||||
},
|
||||
);
|
||||
|
||||
describe('transformNegativeLabelsPosition', () => {
|
||||
test('label position bottom of negative value no Horizontal', () => {
|
||||
const isHorizontal = false;
|
||||
|
||||
Reference in New Issue
Block a user