fix(plugin-chart-echarts): support truncated numeric x-axis (#26215)

Co-authored-by: Michael S. Molina <michael.s.molina@gmail.com>
(cherry picked from commit 07e5fe8a66)
This commit is contained in:
Ville Brofeldt
2023-12-08 05:40:09 -08:00
committed by Michael S. Molina
parent b699df7030
commit d0961d0ed8
19 changed files with 150 additions and 31 deletions

View File

@@ -26,6 +26,7 @@ export const DEFAULT_FORM_DATA: Partial<EchartsBubbleFormData> = {
logYAxis: false, logYAxis: false,
xAxisTitleMargin: 30, xAxisTitleMargin: 30,
yAxisTitleMargin: 30, yAxisTitleMargin: 30,
truncateXAxis: false,
truncateYAxis: false, truncateYAxis: false,
yAxisBounds: [null, null], yAxisBounds: [null, null],
xAxisLabelRotation: 0, xAxisLabelRotation: 0,

View File

@@ -26,7 +26,7 @@ import {
} from '@superset-ui/chart-controls'; } from '@superset-ui/chart-controls';
import { DEFAULT_FORM_DATA } from './constants'; import { DEFAULT_FORM_DATA } from './constants';
import { legendSection } from '../controls'; import { legendSection, truncateXAxis, xAxisBounds } from '../controls';
const { logAxis, truncateYAxis, yAxisBounds, xAxisLabelRotation, opacity } = const { logAxis, truncateYAxis, yAxisBounds, xAxisLabelRotation, opacity } =
DEFAULT_FORM_DATA; DEFAULT_FORM_DATA;
@@ -247,6 +247,8 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[truncateXAxis],
[xAxisBounds],
[ [
{ {
name: 'truncateYAxis', name: 'truncateYAxis',

View File

@@ -28,9 +28,9 @@ import {
import { EchartsBubbleChartProps, EchartsBubbleFormData } from './types'; import { EchartsBubbleChartProps, EchartsBubbleFormData } from './types';
import { DEFAULT_FORM_DATA, MINIMUM_BUBBLE_SIZE } from './constants'; import { DEFAULT_FORM_DATA, MINIMUM_BUBBLE_SIZE } from './constants';
import { defaultGrid } from '../defaults'; import { defaultGrid } from '../defaults';
import { getLegendProps } from '../utils/series'; import { getLegendProps, getMinAndMaxFromBounds } from '../utils/series';
import { Refs } from '../types'; import { Refs } from '../types';
import { parseYAxisBound } from '../utils/controls'; import { parseAxisBound } from '../utils/controls';
import { getDefaultTooltip } from '../utils/tooltip'; import { getDefaultTooltip } from '../utils/tooltip';
import { getPadding } from '../Timeseries/transformers'; import { getPadding } from '../Timeseries/transformers';
import { convertInteger } from '../utils/convertInteger'; import { convertInteger } from '../utils/convertInteger';
@@ -84,6 +84,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
series: bubbleSeries, series: bubbleSeries,
xAxisLabel: bubbleXAxisTitle, xAxisLabel: bubbleXAxisTitle,
yAxisLabel: bubbleYAxisTitle, yAxisLabel: bubbleYAxisTitle,
xAxisBounds,
xAxisFormat, xAxisFormat,
yAxisFormat, yAxisFormat,
yAxisBounds, yAxisBounds,
@@ -91,6 +92,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
logYAxis, logYAxis,
xAxisTitleMargin, xAxisTitleMargin,
yAxisTitleMargin, yAxisTitleMargin,
truncateXAxis,
truncateYAxis, truncateYAxis,
xAxisLabelRotation, xAxisLabelRotation,
yAxisLabelRotation, yAxisLabelRotation,
@@ -141,7 +143,8 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
const yAxisFormatter = getNumberFormatter(yAxisFormat); const yAxisFormatter = getNumberFormatter(yAxisFormat);
const tooltipSizeFormatter = getNumberFormatter(tooltipSizeFormat); const tooltipSizeFormatter = getNumberFormatter(tooltipSizeFormat);
const [min, max] = yAxisBounds.map(parseYAxisBound); const [xAxisMin, xAxisMax] = xAxisBounds.map(parseAxisBound);
const [yAxisMin, yAxisMax] = yAxisBounds.map(parseAxisBound);
const padding = getPadding( const padding = getPadding(
showLegend, showLegend,
@@ -155,6 +158,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
convertInteger(xAxisTitleMargin), convertInteger(xAxisTitleMargin),
); );
const xAxisType = logXAxis ? AxisType.log : AxisType.value;
const echartOptions: EChartsCoreOption = { const echartOptions: EChartsCoreOption = {
series, series,
xAxis: { xAxis: {
@@ -172,7 +176,8 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
fontWight: 'bolder', fontWight: 'bolder',
}, },
nameGap: convertInteger(xAxisTitleMargin), nameGap: convertInteger(xAxisTitleMargin),
type: logXAxis ? AxisType.log : AxisType.value, type: xAxisType,
...getMinAndMaxFromBounds(xAxisType, truncateXAxis, xAxisMin, xAxisMax),
}, },
yAxis: { yAxis: {
axisLabel: { formatter: yAxisFormatter }, axisLabel: { formatter: yAxisFormatter },
@@ -189,8 +194,8 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
fontWight: 'bolder', fontWight: 'bolder',
}, },
nameGap: convertInteger(yAxisTitleMargin), nameGap: convertInteger(yAxisTitleMargin),
min, min: yAxisMin,
max, max: yAxisMax,
type: logYAxis ? AxisType.log : AxisType.value, type: logYAxis ? AxisType.log : AxisType.value,
}, },
legend: { legend: {

View File

@@ -53,7 +53,7 @@ import {
ForecastSeriesEnum, ForecastSeriesEnum,
Refs, Refs,
} from '../types'; } from '../types';
import { parseYAxisBound } from '../utils/controls'; import { parseAxisBound } from '../utils/controls';
import { import {
getOverMaxHiddenFormatter, getOverMaxHiddenFormatter,
dedupSeries, dedupSeries,
@@ -345,9 +345,9 @@ export default function transformProps(
}); });
// yAxisBounds need to be parsed to replace incompatible values with undefined // yAxisBounds need to be parsed to replace incompatible values with undefined
let [min, max] = (yAxisBounds || []).map(parseYAxisBound); let [min, max] = (yAxisBounds || []).map(parseAxisBound);
let [minSecondary, maxSecondary] = (yAxisBoundsSecondary || []).map( let [minSecondary, maxSecondary] = (yAxisBoundsSecondary || []).map(
parseYAxisBound, parseAxisBound,
); );
const array = ensureIsArray(chartProps.rawFormData?.time_compare); const array = ensureIsArray(chartProps.rawFormData?.time_compare);

View File

@@ -37,6 +37,8 @@ import {
richTooltipSection, richTooltipSection,
seriesOrderSection, seriesOrderSection,
percentageThresholdControl, percentageThresholdControl,
truncateXAxis,
xAxisBounds,
} from '../../controls'; } from '../../controls';
import { AreaChartStackControlOptions } from '../../constants'; import { AreaChartStackControlOptions } from '../../constants';
@@ -241,6 +243,8 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[truncateXAxis],
[xAxisBounds],
[ [
{ {
name: 'truncateYAxis', name: 'truncateYAxis',

View File

@@ -35,6 +35,8 @@ import {
richTooltipSection, richTooltipSection,
seriesOrderSection, seriesOrderSection,
showValueSection, showValueSection,
truncateXAxis,
xAxisBounds,
} from '../../../controls'; } from '../../../controls';
import { OrientationType } from '../../types'; import { OrientationType } from '../../types';
@@ -224,6 +226,8 @@ function createAxisControl(axis: 'x' | 'y'): ControlSetRow[] {
}, },
}, },
], ],
[truncateXAxis],
[xAxisBounds],
[ [
{ {
name: 'truncateYAxis', name: 'truncateYAxis',

View File

@@ -38,6 +38,8 @@ import {
richTooltipSection, richTooltipSection,
seriesOrderSection, seriesOrderSection,
showValueSection, showValueSection,
truncateXAxis,
xAxisBounds,
} from '../../../controls'; } from '../../../controls';
const { const {
@@ -229,6 +231,8 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[truncateXAxis],
[xAxisBounds],
[ [
{ {
name: 'truncateYAxis', name: 'truncateYAxis',

View File

@@ -37,6 +37,8 @@ import {
richTooltipSection, richTooltipSection,
seriesOrderSection, seriesOrderSection,
showValueSection, showValueSection,
truncateXAxis,
xAxisBounds,
} from '../../../controls'; } from '../../../controls';
const { const {
@@ -173,6 +175,8 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[truncateXAxis],
[xAxisBounds],
[ [
{ {
name: 'truncateYAxis', name: 'truncateYAxis',

View File

@@ -37,6 +37,8 @@ import {
richTooltipSection, richTooltipSection,
seriesOrderSection, seriesOrderSection,
showValueSectionWithoutStack, showValueSectionWithoutStack,
truncateXAxis,
xAxisBounds,
} from '../../../controls'; } from '../../../controls';
const { const {
@@ -173,6 +175,8 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[truncateXAxis],
[xAxisBounds],
[ [
{ {
name: 'truncateYAxis', name: 'truncateYAxis',

View File

@@ -35,6 +35,8 @@ import {
richTooltipSection, richTooltipSection,
seriesOrderSection, seriesOrderSection,
showValueSection, showValueSection,
truncateXAxis,
xAxisBounds,
} from '../../controls'; } from '../../controls';
const { const {
@@ -223,6 +225,8 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[truncateXAxis],
[xAxisBounds],
[ [
{ {
name: 'truncateYAxis', name: 'truncateYAxis',

View File

@@ -57,6 +57,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
seriesType: EchartsTimeseriesSeriesType.Line, seriesType: EchartsTimeseriesSeriesType.Line,
stack: false, stack: false,
tooltipTimeFormat: 'smart_date', tooltipTimeFormat: 'smart_date',
truncateXAxis: true,
truncateYAxis: false, truncateYAxis: false,
yAxisBounds: [null, null], yAxisBounds: [null, null],
zoomable: false, zoomable: false,

View File

@@ -54,7 +54,7 @@ import {
} from './types'; } from './types';
import { DEFAULT_FORM_DATA } from './constants'; import { DEFAULT_FORM_DATA } from './constants';
import { ForecastSeriesEnum, ForecastValue, Refs } from '../types'; import { ForecastSeriesEnum, ForecastValue, Refs } from '../types';
import { parseYAxisBound } from '../utils/controls'; import { parseAxisBound } from '../utils/controls';
import { import {
calculateLowerLogTick, calculateLowerLogTick,
dedupSeries, dedupSeries,
@@ -64,6 +64,7 @@ import {
getAxisType, getAxisType,
getColtypesMapping, getColtypesMapping,
getLegendProps, getLegendProps,
getMinAndMaxFromBounds,
} from '../utils/series'; } from '../utils/series';
import { import {
extractAnnotationLabels, extractAnnotationLabels,
@@ -161,8 +162,10 @@ export default function transformProps(
stack, stack,
tooltipTimeFormat, tooltipTimeFormat,
tooltipSortByMetric, tooltipSortByMetric,
truncateXAxis,
truncateYAxis, truncateYAxis,
xAxis: xAxisOrig, xAxis: xAxisOrig,
xAxisBounds,
xAxisLabelRotation, xAxisLabelRotation,
xAxisSortSeries, xAxisSortSeries,
xAxisSortSeriesAscending, xAxisSortSeriesAscending,
@@ -388,15 +391,20 @@ export default function transformProps(
} }
}); });
// yAxisBounds need to be parsed to replace incompatible values with undefined // axis bounds need to be parsed to replace incompatible values with undefined
let [min, max] = (yAxisBounds || []).map(parseYAxisBound); const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);
// default to 0-100% range when doing row-level contribution chart // default to 0-100% range when doing row-level contribution chart
if ((contributionMode === 'row' || isAreaExpand) && stack) { if ((contributionMode === 'row' || isAreaExpand) && stack) {
if (min === undefined) min = 0; if (yAxisMin === undefined) yAxisMin = 0;
if (max === undefined) max = 1; if (yAxisMax === undefined) yAxisMax = 1;
} else if (logAxis && min === undefined && minPositiveValue !== undefined) { } else if (
min = calculateLowerLogTick(minPositiveValue); logAxis &&
yAxisMin === undefined &&
minPositiveValue !== undefined
) {
yAxisMin = calculateLowerLogTick(minPositiveValue);
} }
const tooltipFormatter = const tooltipFormatter =
@@ -452,12 +460,14 @@ export default function transformProps(
xAxisType === AxisType.time && timeGrainSqla xAxisType === AxisType.time && timeGrainSqla
? TIMEGRAIN_TO_TIMESTAMP[timeGrainSqla] ? TIMEGRAIN_TO_TIMESTAMP[timeGrainSqla]
: 0, : 0,
...getMinAndMaxFromBounds(xAxisType, truncateXAxis, xAxisMin, xAxisMax),
}; };
let yAxis: any = { let yAxis: any = {
...defaultYAxis, ...defaultYAxis,
type: logAxis ? AxisType.log : AxisType.value, type: logAxis ? AxisType.log : AxisType.value,
min, min: yAxisMin,
max, max: yAxisMax,
minorTick: { show: true }, minorTick: { show: true },
minorSplitLine: { show: minorSplitLine }, minorSplitLine: { show: minorSplitLine },
axisLabel: { axisLabel: {

View File

@@ -75,10 +75,12 @@ export type EchartsTimeseriesFormData = QueryFormData & {
stack: StackType; stack: StackType;
timeCompare?: string[]; timeCompare?: string[];
tooltipTimeFormat?: string; tooltipTimeFormat?: string;
truncateXAxis: boolean;
truncateYAxis: boolean; truncateYAxis: boolean;
yAxisFormat?: string; yAxisFormat?: string;
xAxisTimeFormat?: string; xAxisTimeFormat?: string;
timeGrainSqla?: TimeGranularity; timeGrainSqla?: TimeGranularity;
xAxisBounds: [number | undefined | null, number | undefined | null];
yAxisBounds: [number | undefined | null, number | undefined | null]; yAxisBounds: [number | undefined | null, number | undefined | null];
zoomable: boolean; zoomable: boolean;
richTooltip: boolean; richTooltip: boolean;

View File

@@ -248,3 +248,34 @@ export const seriesOrderSection: ControlSetRow[] = [
[sortSeriesType], [sortSeriesType],
[sortSeriesAscending], [sortSeriesAscending],
]; ];
export const truncateXAxis: ControlSetItem = {
name: 'truncateXAxis',
config: {
type: 'CheckboxControl',
label: t('Truncate X Axis'),
default: DEFAULT_FORM_DATA.truncateXAxis,
renderTrigger: true,
description: t(
'Truncate X Axis. Can be overridden by specifying a min or max bound. Only applicable for numercal X axis.',
),
},
};
export const xAxisBounds: ControlSetItem = {
name: 'xAxisBounds',
config: {
type: 'BoundsControl',
label: t('X Axis Bounds'),
renderTrigger: true,
default: DEFAULT_FORM_DATA.xAxisBounds,
description: t(
'Bounds for numerical X axis. Not applicable for temporal or categorical axes. ' +
'When left empty, the bounds are dynamically defined based on the min/max of the data. ' +
"Note that this feature will only expand the axis range. It won't " +
"narrow the data's extent.",
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.truncateXAxis?.value),
},
};

View File

@@ -20,7 +20,7 @@
import { validateNumber } from '@superset-ui/core'; import { validateNumber } from '@superset-ui/core';
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export function parseYAxisBound( export function parseAxisBound(
bound?: string | number | null, bound?: string | number | null,
): number | undefined { ): number | undefined {
if (bound === undefined || bound === null || Number.isNaN(Number(bound))) { if (bound === undefined || bound === null || Number.isNaN(Number(bound))) {

View File

@@ -543,3 +543,17 @@ export function calculateLowerLogTick(minPositiveValue: number) {
const logBase10 = Math.floor(Math.log10(minPositiveValue)); const logBase10 = Math.floor(Math.log10(minPositiveValue));
return Math.pow(10, logBase10); return Math.pow(10, logBase10);
} }
export function getMinAndMaxFromBounds(
axisType: AxisType,
truncateAxis: boolean,
min?: number,
max?: number,
): { min: number | 'dataMin'; max: number | 'dataMax' } | {} {
return truncateAxis && axisType === AxisType.value
? {
min: min === undefined ? 'dataMin' : min,
max: max === undefined ? 'dataMax' : max,
}
: {};
}

View File

@@ -48,6 +48,7 @@ describe('Bubble transformProps', () => {
expressionType: 'simple', expressionType: 'simple',
label: 'SUM(sales)', label: 'SUM(sales)',
}, },
xAxisBounds: [null, null],
yAxisBounds: [null, null], yAxisBounds: [null, null],
}; };
const chartProps = new ChartProps({ const chartProps = new ChartProps({

View File

@@ -16,22 +16,22 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { parseYAxisBound } from '../../src/utils/controls'; import { parseAxisBound } from '../../src/utils/controls';
describe('parseYAxisBound', () => { describe('parseYAxisBound', () => {
it('should return undefined for invalid values', () => { it('should return undefined for invalid values', () => {
expect(parseYAxisBound(null)).toBeUndefined(); expect(parseAxisBound(null)).toBeUndefined();
expect(parseYAxisBound(undefined)).toBeUndefined(); expect(parseAxisBound(undefined)).toBeUndefined();
expect(parseYAxisBound(NaN)).toBeUndefined(); expect(parseAxisBound(NaN)).toBeUndefined();
expect(parseYAxisBound('abc')).toBeUndefined(); expect(parseAxisBound('abc')).toBeUndefined();
}); });
it('should return numeric value for valid values', () => { it('should return numeric value for valid values', () => {
expect(parseYAxisBound(0)).toEqual(0); expect(parseAxisBound(0)).toEqual(0);
expect(parseYAxisBound('0')).toEqual(0); expect(parseAxisBound('0')).toEqual(0);
expect(parseYAxisBound(1)).toEqual(1); expect(parseAxisBound(1)).toEqual(1);
expect(parseYAxisBound('1')).toEqual(1); expect(parseAxisBound('1')).toEqual(1);
expect(parseYAxisBound(10.1)).toEqual(10.1); expect(parseAxisBound(10.1)).toEqual(10.1);
expect(parseYAxisBound('10.1')).toEqual(10.1); expect(parseAxisBound('10.1')).toEqual(10.1);
}); });
}); });

View File

@@ -36,6 +36,7 @@ import {
getChartPadding, getChartPadding,
getLegendProps, getLegendProps,
getOverMaxHiddenFormatter, getOverMaxHiddenFormatter,
getMinAndMaxFromBounds,
sanitizeHtml, sanitizeHtml,
sortAndFilterSeries, sortAndFilterSeries,
sortRows, sortRows,
@@ -879,3 +880,30 @@ test('getAxisType', () => {
expect(getAxisType(GenericDataType.BOOLEAN)).toEqual(AxisType.category); expect(getAxisType(GenericDataType.BOOLEAN)).toEqual(AxisType.category);
expect(getAxisType(GenericDataType.STRING)).toEqual(AxisType.category); expect(getAxisType(GenericDataType.STRING)).toEqual(AxisType.category);
}); });
test('getMinAndMaxFromBounds returns empty object when not truncating', () => {
expect(getMinAndMaxFromBounds(AxisType.value, false, 10, 100)).toEqual({});
});
test('getMinAndMaxFromBounds returns automatic bounds when truncating', () => {
expect(
getMinAndMaxFromBounds(AxisType.value, true, undefined, undefined),
).toEqual({
min: 'dataMin',
max: 'dataMax',
});
});
test('getMinAndMaxFromBounds returns automatic upper bound when truncating', () => {
expect(getMinAndMaxFromBounds(AxisType.value, true, 10, undefined)).toEqual({
min: 10,
max: 'dataMax',
});
});
test('getMinAndMaxFromBounds returns automatic lower bound when truncating', () => {
expect(getMinAndMaxFromBounds(AxisType.value, true, undefined, 100)).toEqual({
min: 'dataMin',
max: 100,
});
});