From 219796f4e32c95a20d45fbc336295550c43d406b Mon Sep 17 00:00:00 2001 From: adityajai119 Date: Tue, 10 Nov 2026 00:05:42 +0530 Subject: [PATCH] fix(bar): stack bars on the end of bars raised by barMinHeight. close #20480 Generated-by: Claude Opus 5.5 via OpenCode --- src/layout/barGrid.ts | 121 ++++++++++--- src/processor/dataStack.ts | 65 +++++-- test/ut/spec/series/barStackMinHeight.test.ts | 169 ++++++++++++++++++ 3 files changed, 322 insertions(+), 33 deletions(-) create mode 100644 test/ut/spec/series/barStackMinHeight.test.ts diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index e252893262..f91a21cdec 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -48,6 +48,9 @@ import { import { BaseBarSeriesSubType, getStartValue, requireAxisStatisticsForBaseBar } from './barCommon'; import { COORD_SYS_TYPE_CARTESIAN_2D } from '../coord/cartesian/GridModel'; import { createBandWidthBasedAxisContainShapeHandler, makeAxisStatKey2 } from '../chart/helper/axisSnippets'; +import { findStackedOnItem } from '../processor/dataStack'; +import type SeriesData from '../data/SeriesData'; +import type SeriesModel from '../model/Series'; const callOnlyOnce = makeCallOnlyOnce(); @@ -405,6 +408,7 @@ export function createProgressiveLayout(seriesType: string): StageHandler { const isLarge = isInLargeMode(seriesModel); const barMinHeight = seriesModel.get('barMinHeight') || 0; + const stackedOnBarMinHeight = stacked && hasStackedOnBarMinHeight(seriesType, data); const stackedDimIdx = stackResultDim && data.getDimensionIndex(stackResultDim); @@ -431,11 +435,17 @@ export function createProgressiveLayout(seriesType: string): StageHandler { const baseValue = store.get(baseDimIdx, dataIndex) as number; let baseCoord = valueAxisStart; let stackStartValue; + let barMinHeightOffset = 0; // Because of the barMinHeight, we can not use the value in // stackResultDimension directly. if (stacked) { stackStartValue = +value - (store.get(valueDimIdx, dataIndex) as number); + if (stackedOnBarMinHeight) { + barMinHeightOffset = getStackedBarMinHeightOffset( + seriesType, data, data.getRawIndex(dataIndex) + ); + } } let x; @@ -446,38 +456,26 @@ export function createProgressiveLayout(seriesType: string): StageHandler { if (isValueAxisH) { const coord = cartesian.dataToPoint([value, baseValue]); if (stacked) { - const stackStartCoord = cartesian.dataToPoint([stackStartValue, baseValue])[0]; - // On a log axis, a `stackStartValue` that is out of the log domain - // (e.g., 0, resulting from stacking over a null value) maps to a - // non-finite coordinate. Draw the bar from the axis start in that - // case, as if it were the first bar in the stack. - baseCoord = isFinite(stackStartCoord) ? stackStartCoord : valueAxisStart; + baseCoord = getStackStartCoord( + cartesian, isValueAxisH, valueAxisStart, stackStartValue, baseValue + ); } - x = baseCoord; + x = baseCoord + barMinHeightOffset; y = coord[1] + columnOffset; - width = coord[0] - baseCoord; + width = applyBarMinHeight(coord[0] - baseCoord, barMinHeight, isValueAxisH); height = columnWidth; - - if (mathAbs(width) < barMinHeight) { - width = (width < 0 ? -1 : 1) * barMinHeight; - } } else { const coord = cartesian.dataToPoint([baseValue, value]); if (stacked) { - const stackStartCoord = cartesian.dataToPoint([baseValue, stackStartValue])[1]; - // See the comment in the horizontal case above. - baseCoord = isFinite(stackStartCoord) ? stackStartCoord : valueAxisStart; + baseCoord = getStackStartCoord( + cartesian, isValueAxisH, valueAxisStart, stackStartValue, baseValue + ); } x = coord[0] + columnOffset; - y = baseCoord; + y = baseCoord + barMinHeightOffset; width = columnWidth; - height = coord[1] - baseCoord; - - if (mathAbs(height) < barMinHeight) { - // Include zero to has a positive bar - height = (height <= 0 ? -1 : 1) * barMinHeight; - } + height = applyBarMinHeight(coord[1] - baseCoord, barMinHeight, isValueAxisH); } if (!isLarge) { @@ -518,6 +516,85 @@ function isInLargeMode(seriesModel: BaseBarSeriesModel) { return seriesModel.pipelineContext && seriesModel.pipelineContext.large; } +function getStackStartCoord( + cartesian: Cartesian2D, + isValueAxisH: boolean, + valueAxisStart: number, + stackStartValue: number, + baseValue: number +): number { + const stackStartCoord = isValueAxisH + ? cartesian.dataToPoint([stackStartValue, baseValue])[0] + : cartesian.dataToPoint([baseValue, stackStartValue])[1]; + // On a log axis, a `stackStartValue` that is out of the log domain + // (e.g., 0, resulting from stacking over a null value) maps to a + // non-finite coordinate. Draw the bar from the axis start in that + // case, as if it were the first bar in the stack. + return isFinite(stackStartCoord) ? stackStartCoord : valueAxisStart; +} + +function applyBarMinHeight(size: number, barMinHeight: number, isValueAxisH: boolean): number { + if (mathAbs(size) < barMinHeight) { + // Include zero to has a positive bar + return (isValueAxisH ? (size < 0 ? -1 : 1) : (size <= 0 ? -1 : 1)) * barMinHeight; + } + return size; +} + +function hasStackedOnBarMinHeight(seriesType: string, data: SeriesData): boolean { + let stackedOnSeries = data.getCalculationInfo('stackedOnSeries') as SeriesModel; + while (stackedOnSeries) { + if (stackedOnSeries.subType === seriesType && (stackedOnSeries as BaseBarSeriesModel).get('barMinHeight')) { + return true; + } + stackedOnSeries = stackedOnSeries.getData().getCalculationInfo('stackedOnSeries'); + } + return false; +} + +function getStackedBarMinHeightOffset(seriesType: string, data: SeriesData, rawIndex: number): number { + let offset = 0; + let stackedOnItem = findStackedOnItem(data, rawIndex); + while (stackedOnItem) { + const stackedOnSeries = stackedOnItem.data.hostModel as BaseBarSeriesModel; + if (stackedOnSeries.subType !== seriesType || !isCartesian2DInjectedAsDataCoordSys(stackedOnSeries)) { + break; + } + offset += getBarMinHeightExtension(stackedOnSeries, stackedOnItem.rawIndex); + stackedOnItem = findStackedOnItem(stackedOnItem.data, stackedOnItem.rawIndex); + } + return offset; +} + +function getBarMinHeightExtension(seriesModel: BaseBarSeriesModel, rawIndex: number): number { + const barMinHeight = seriesModel.get('barMinHeight') || 0; + if (!barMinHeight) { + return 0; + } + const data = seriesModel.getData(); + const cartesian = seriesModel.coordinateSystem as Cartesian2D; + const baseAxis = cartesian.getBaseAxis(); + const valueAxis = cartesian.getOtherAxis(baseAxis); + const isValueAxisH = valueAxis.isHorizontal(); + const valueDim = data.mapDimension(valueAxis.dim); + const valueAxisStart = valueAxis.toGlobalCoord(valueAxis.dataToCoord(getStartValue(valueAxis))); + const baseValue = data.getByRawIndex(data.mapDimension(baseAxis.dim), rawIndex) as number; + let value = data.getByRawIndex(valueDim, rawIndex) as number; + let baseCoord = valueAxisStart; + + if (isDimensionStacked(data, valueDim) && data.getCalculationInfo('stackedOnSeries')) { + const stackResult = data.getByRawIndex(data.getCalculationInfo('stackResultDimension'), rawIndex) as number; + baseCoord = getStackStartCoord(cartesian, isValueAxisH, valueAxisStart, stackResult - value, baseValue); + value = stackResult; + } + + const size = (isValueAxisH + ? cartesian.dataToPoint([value, baseValue])[0] + : cartesian.dataToPoint([baseValue, value])[1] + ) - baseCoord; + return applyBarMinHeight(size, barMinHeight, isValueAxisH) - size; +} + function barGridCreateAxisContainShapeHandler(seriesType: BaseBarSeriesSubType): AxisContainShapeHandler { // See also #6728, #4862, `test/bar-overflow-time-plot.html` `test/bar-overflow-plot2.html` // NOTE: diff --git a/src/processor/dataStack.ts b/src/processor/dataStack.ts index eb167cb965..b932d6b163 100644 --- a/src/processor/dataStack.ts +++ b/src/processor/dataStack.ts @@ -149,17 +149,7 @@ function calculateStack(stackInfoList: StackInfo[]) { ) as number; // Considering positive stack, negative stack and empty data - if ( - // A null/NaN value in a lower series must be excluded from the - // stack, otherwise it corrupts the sum. The other strategies - // already reject it via their `val > 0`/`val < 0` checks, but - // `all` needs an explicit guard. See #21685. - (stackStrategy === 'all' && !isNaN(val)) // single stack group - || (stackStrategy === 'positive' && val > 0) - || (stackStrategy === 'negative' && val < 0) - || (stackStrategy === 'samesign' && sum >= 0 && val > 0) // All positive stack - || (stackStrategy === 'samesign' && sum <= 0 && val < 0) // All negative stack - ) { + if (isStackedOn(stackStrategy, sum, val)) { // The sum has to be very small to be affected by the // floating arithmetic problem. An incorrect result will probably // cause axis min/max to be filtered incorrectly. @@ -177,3 +167,56 @@ function calculateStack(stackInfoList: StackInfo[]) { }); }); } + +function isStackedOn( + stackStrategy: SeriesStackOptionMixin['stackStrategy'], + sum: number, + val: number +): boolean { + return ( + // A null/NaN value in a lower series must be excluded from the + // stack, otherwise it corrupts the sum. The other strategies + // already reject it via their `val > 0`/`val < 0` checks, but + // `all` needs an explicit guard. See #21685. + (stackStrategy === 'all' && !isNaN(val)) // single stack group + || (stackStrategy === 'positive' && val > 0) + || (stackStrategy === 'negative' && val < 0) + || (stackStrategy === 'samesign' && sum >= 0 && val > 0) // All positive stack + || (stackStrategy === 'samesign' && sum <= 0 && val < 0) // All negative stack + ); +} + +export function findStackedOnItem( + data: SeriesData, + rawIndex: number +): { data: SeriesData, rawIndex: number } | null { + const sum = data.getByRawIndex(data.getCalculationInfo('stackedDimension'), rawIndex) as number; + if (isNaN(sum)) { + return null; + } + const isStackedByIndex = data.getCalculationInfo('isStackedByIndex'); + const byValue = isStackedByIndex + ? null + : data.getByRawIndex(data.getCalculationInfo('stackedByDimension'), rawIndex) as number; + const stackStrategy = (data.hostModel as SeriesModel) + .get('stackStrategy') || 'samesign'; + + let stackedOnSeries = data.getCalculationInfo('stackedOnSeries') as SeriesModel; + while (stackedOnSeries) { + const stackedOnData = stackedOnSeries.getData(); + const stackedOnRawIndex = isStackedByIndex + ? rawIndex + : stackedOnData.rawIndexOf(stackedOnData.getCalculationInfo('stackedByDimension'), byValue); + if (stackedOnRawIndex >= 0 && isStackedOn( + stackStrategy, + sum, + stackedOnData.getByRawIndex( + stackedOnData.getCalculationInfo('stackResultDimension'), stackedOnRawIndex + ) as number + )) { + return { data: stackedOnData, rawIndex: stackedOnRawIndex }; + } + stackedOnSeries = stackedOnData.getCalculationInfo('stackedOnSeries'); + } + return null; +} diff --git a/test/ut/spec/series/barStackMinHeight.test.ts b/test/ut/spec/series/barStackMinHeight.test.ts new file mode 100644 index 0000000000..c2585415bd --- /dev/null +++ b/test/ut/spec/series/barStackMinHeight.test.ts @@ -0,0 +1,169 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { EChartsType } from '@/src/echarts.all'; +import { createChart, getECModel } from '../../core/utHelper'; +import SeriesModel from '@/src/model/Series'; +import { RectLike } from 'zrender/src/core/BoundingRect'; + +describe('barStackMinHeight', function () { + + let chart: EChartsType; + + beforeEach(function () { + chart = createChart(); + }); + + afterEach(function () { + chart.dispose(); + }); + + function getItemLayout(seriesIndex: number, dataIndex: number): RectLike { + const seriesModel = getECModel(chart).getSeriesByIndex(seriesIndex) as SeriesModel; + return seriesModel.getData().getItemLayout(dataIndex); + } + + function getStart(layout: RectLike, horizontal: boolean): number { + return horizontal ? layout.x : layout.y; + } + + function getEnd(layout: RectLike, horizontal: boolean): number { + return horizontal ? layout.x + layout.width : layout.y + layout.height; + } + + function valueToPixel(value: number, horizontal: boolean): number { + return chart.convertToPixel(horizontal ? { xAxisIndex: 0 } : { yAxisIndex: 0 }, value) as number; + } + + function setStackOption(horizontal: boolean, series: object[]) { + const categoryAxis = { type: 'category', data: ['a', 'b'] }; + const valueAxis = { type: 'value' }; + chart.setOption({ + animation: false, + xAxis: horizontal ? valueAxis : categoryAxis, + yAxis: horizontal ? categoryAxis : valueAxis, + series: series.map(item => ({ type: 'bar', stack: 'total', ...item })) + }); + } + + const strategyCases = [ + { stackStrategy: 'samesign', lowerData: [0.1, -0.1], upperData: [50, -50] }, + { stackStrategy: 'all', lowerData: [0.1, -0.1], upperData: [-50, 50] }, + { stackStrategy: 'positive', lowerData: [0.1, 0.1], upperData: [50, -50] }, + { stackStrategy: 'negative', lowerData: [-0.1, -0.1], upperData: [-50, 50] } + ]; + + [false, true].forEach(function (horizontal) { + const direction = horizontal ? 'horizontal' : 'vertical'; + + strategyCases.forEach(function ({ stackStrategy, lowerData, upperData }) { + it(`stacks ${direction} bars on a raised bar with stackStrategy ${stackStrategy}`, function () { + setStackOption(horizontal, [ + { barMinHeight: 20, data: lowerData }, + { stackStrategy, data: upperData } + ]); + + for (let dataIndex = 0; dataIndex < lowerData.length; dataIndex++) { + const lower = getItemLayout(0, dataIndex); + const upper = getItemLayout(1, dataIndex); + const lowerValue = lowerData[dataIndex]; + const upperValue = upperData[dataIndex]; + + expect(Math.abs(getEnd(lower, horizontal) - getStart(lower, horizontal))).toBeCloseTo(20); + expect(getStart(upper, horizontal)).toBeCloseTo(getEnd(lower, horizontal)); + expect(getEnd(upper, horizontal) - getStart(upper, horizontal)).toBeCloseTo( + valueToPixel(lowerValue + upperValue, horizontal) - valueToPixel(lowerValue, horizontal) + ); + } + }); + }); + + it(`pushes every ${direction} bar above a bar raised by barMinHeight`, function () { + setStackOption(horizontal, [ + { barMinHeight: 20, data: [0.1, -0.1] }, + { data: [30, -30] }, + { data: [40, -40] } + ]); + + for (let dataIndex = 0; dataIndex < 2; dataIndex++) { + const bottom = getItemLayout(0, dataIndex); + const middle = getItemLayout(1, dataIndex); + const top = getItemLayout(2, dataIndex); + + expect(getStart(middle, horizontal)).toBeCloseTo(getEnd(bottom, horizontal)); + expect(getStart(top, horizontal)).toBeCloseTo(getEnd(middle, horizontal)); + } + }); + + it(`pushes ${direction} bars above a bar raised by barMinHeight with stackOrder seriesDesc`, function () { + setStackOption(horizontal, [ + { stackOrder: 'seriesDesc', data: [50, -50] }, + { barMinHeight: 20, data: [0.1, -0.1] } + ]); + + for (let dataIndex = 0; dataIndex < 2; dataIndex++) { + expect(getStart(getItemLayout(0, dataIndex), horizontal)).toBeCloseTo( + getEnd(getItemLayout(1, dataIndex), horizontal) + ); + } + }); + + it(`does not move ${direction} bars that are not stacked on a bar raised by barMinHeight`, function () { + setStackOption(horizontal, [ + { barMinHeight: 20, data: [0.1, -0.1] }, + { data: [-50, 50] } + ]); + + const zeroPixel = valueToPixel(0, horizontal); + expect(getStart(getItemLayout(1, 0), horizontal)).toBeCloseTo(zeroPixel); + expect(getStart(getItemLayout(1, 1), horizontal)).toBeCloseTo(zeroPixel); + }); + + it(`keeps the layout of stacked ${direction} bars when barMinHeight does not apply`, function () { + const lowerData = [10, -20]; + const middleData = [30, -40]; + const upperData = [50, -60]; + [undefined, 2].forEach(function (barMinHeight) { + setStackOption(horizontal, [ + { barMinHeight, data: lowerData }, + { barMinHeight, data: middleData }, + { barMinHeight, data: upperData } + ]); + + for (let dataIndex = 0; dataIndex < 2; dataIndex++) { + const stackedValues = [ + 0, + lowerData[dataIndex], + lowerData[dataIndex] + middleData[dataIndex], + lowerData[dataIndex] + middleData[dataIndex] + upperData[dataIndex] + ]; + for (let seriesIndex = 0; seriesIndex < 3; seriesIndex++) { + const layout = getItemLayout(seriesIndex, dataIndex); + expect(getStart(layout, horizontal)).toBeCloseTo( + valueToPixel(stackedValues[seriesIndex], horizontal) + ); + expect(getEnd(layout, horizontal)).toBeCloseTo( + valueToPixel(stackedValues[seriesIndex + 1], horizontal) + ); + } + } + }); + }); + }); +});