diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..bf7f3242c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# Agent Instructions + +This file applies to the entire repository. + +## Fix Completion Requirements + +- Every bug fix must include an HTML test case, usually under `test/`, that reproduces or verifies the fixed behavior. +- Prefer updating an existing relevant HTML test when it clearly covers the scenario; otherwise add a focused new HTML test. +- Every completed fix must include screenshot evidence from the HTML test case in the final report. +- The screenshot should show the fixed state clearly enough for visual review. When the fix is interaction-dependent, capture the relevant interaction state after reproducing it. +- If a screenshot or HTML test case cannot be produced, the final report must explain the blocker and the closest verification that was performed. + +## Verification + +- Run the smallest relevant automated checks for the changed area. +- For visual or rendering fixes, open the HTML test case in a browser and capture a screenshot before claiming completion. + +## Generated Files + +- Do not manually modify files under `dist/`; they are generated automatically when publishing to npm. diff --git a/src/chart/line/LineSeries.ts b/src/chart/line/LineSeries.ts index 0cd0c9ef83..6ecb975e85 100644 --- a/src/chart/line/LineSeries.ts +++ b/src/chart/line/LineSeries.ts @@ -113,6 +113,13 @@ export interface LineSeriesOption extends SeriesOption, + DataCalculationInfo, 'stackedDimension' | 'isStackedByIndex' | 'stackedByDimension' @@ -33,9 +39,23 @@ type StackInfo = Pick< | 'stackedOverDimension' > & { data: SeriesData - seriesModel: SeriesModel + seriesModel: SeriesModel }; +interface StackTotal { + all: number + positive: number + negative: number +} + +type StackTotalMap = HashMap; +type StackTotalKey = string | number; + +interface StackTotalMaps { + byIndex?: StackTotalMap + byDimension?: StackTotalMap +} + // (1) [Caution]: the logic is correct based on the premises: // data processing stage is blocked in stream. // See @@ -43,7 +63,7 @@ type StackInfo = Pick< // Should be executed after series is filtered and before stack calculation. export default function dataStack(ecModel: GlobalModel) { const stackInfoMap = createHashMap(); - ecModel.eachSeries(function (seriesModel: SeriesModel) { + ecModel.eachSeries(function (seriesModel: SeriesModel) { const stack = seriesModel.get('stack'); // Compatible: when `stack` is set as '', do not stack. if (stack) { @@ -95,11 +115,24 @@ export default function dataStack(ecModel: GlobalModel) { }); // Calculate stack values - calculateStack(stackInfoList); + calculateStack(stackInfoList, shouldNormalizeStack(stackInfoList)); }); } -function calculateStack(stackInfoList: StackInfo[]) { +function shouldNormalizeStack(stackInfoList: StackInfo[]) { + for (let i = 0; i < stackInfoList.length; i++) { + const seriesModel = stackInfoList[i].seriesModel; + if (seriesModel.type !== 'series.line' || !seriesModel.get('stackNormalize')) { + return false; + } + } + + return stackInfoList.length > 0; +} + +function calculateStack(stackInfoList: StackInfo[], normalizeStack: boolean) { + const stackTotalMaps: StackTotalMaps = {}; + each(stackInfoList, function (targetStackInfo, idxInStack) { const resultVal: number[] = []; const resultNaN = [NaN, NaN]; @@ -111,7 +144,9 @@ function calculateStack(stackInfoList: StackInfo[]) { // Should not write on raw data, because stack series model list changes // depending on legend selection. targetData.modify(dims, function (v0, v1, dataIndex) { - let sum = targetData.get(targetStackInfo.stackedDimension, dataIndex) as number; + let sum = normalizeStack + ? normalizeStackValue(stackInfoList, stackTotalMaps, targetStackInfo, dataIndex, stackStrategy) + : targetData.get(targetStackInfo.stackedDimension, dataIndex) as number; // Consider `connectNulls` of line area, if value is NaN, stackedOver // should also be NaN, to draw a appropriate belt area. @@ -146,13 +181,7 @@ function calculateStack(stackInfoList: StackInfo[]) { ) as number; // Considering positive stack, negative stack and empty data - if ( - stackStrategy === 'all' // 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 (isStackedValueInStrategy(val, sum, stackStrategy)) { // 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. @@ -170,3 +199,106 @@ function calculateStack(stackInfoList: StackInfo[]) { }); }); } + +function getStackTotalMap( + stackInfoList: StackInfo[], + stackTotalMaps: StackTotalMaps, + isStackedByIndex: boolean +) { + const totalMapKey = isStackedByIndex ? 'byIndex' : 'byDimension'; + return stackTotalMaps[totalMapKey] + || (stackTotalMaps[totalMapKey] = calculateStackTotalMap(stackInfoList, isStackedByIndex)); +} + +function calculateStackTotalMap(stackInfoList: StackInfo[], isStackedByIndex: boolean) { + const stackTotalMap = createHashMap(); + + for (let i = 0; i < stackInfoList.length; i++) { + const stackInfo = stackInfoList[i]; + const data = stackInfo.data; + + for (let dataIndex = 0, len = data.count(); dataIndex < len; dataIndex++) { + const value = data.get(stackInfo.stackedDimension, dataIndex) as number; + + if (isNaN(value)) { + continue; + } + + const key: StackTotalKey = isStackedByIndex + ? data.getRawIndex(dataIndex) + : data.get(stackInfo.stackedByDimension, dataIndex) as StackTotalKey; + + addStackTotal(stackTotalMap, key, value); + } + } + + return stackTotalMap; +} + +function addStackTotal(stackTotalMap: StackTotalMap, key: StackTotalKey, value: number) { + const total = stackTotalMap.get(key) || stackTotalMap.set(key, { + all: 0, + positive: 0, + negative: 0 + }); + + total.all = addSafe(total.all, value); + if (value > 0) { + total.positive = addSafe(total.positive, value); + } + else if (value < 0) { + total.negative = addSafe(total.negative, value); + } +} + +function normalizeStackValue( + stackInfoList: StackInfo[], + stackTotalMaps: StackTotalMaps, + targetStackInfo: StackInfo, + dataIndex: number, + stackStrategy: StackSeriesOption['stackStrategy'] +) { + const rawValue = targetStackInfo.data.get(targetStackInfo.stackedDimension, dataIndex) as number; + + if (isNaN(rawValue)) { + return NaN; + } + + const stackTotalMap = getStackTotalMap(stackInfoList, stackTotalMaps, targetStackInfo.isStackedByIndex); + const key: StackTotalKey = targetStackInfo.isStackedByIndex + ? targetStackInfo.data.getRawIndex(dataIndex) + : targetStackInfo.data.get(targetStackInfo.stackedByDimension, dataIndex) as StackTotalKey; + const totalInfo = stackTotalMap.get(key); + const total = totalInfo && getStackTotal(totalInfo, rawValue, stackStrategy); + return total ? rawValue / Math.abs(total) : 0; +} + +function getStackTotal( + totalInfo: StackTotal, + targetValue: number, + stackStrategy: StackSeriesOption['stackStrategy'] +) { + if (stackStrategy === 'all') { + return totalInfo.all; + } + else if (stackStrategy === 'positive') { + return totalInfo.positive; + } + else if (stackStrategy === 'negative') { + return totalInfo.negative; + } + + return targetValue >= 0 ? totalInfo.positive : totalInfo.negative; +} + +function isStackedValueInStrategy( + value: number, + targetValue: number, + stackStrategy: StackSeriesOption['stackStrategy'] +) { + return stackStrategy === 'all' + || (stackStrategy === 'positive' && value > 0) + || (stackStrategy === 'negative' && value < 0) + || (stackStrategy === 'samesign' && targetValue >= 0 && value > 0) + || (stackStrategy === 'samesign' && targetValue <= 0 && value < 0); +} diff --git a/test/area-stack.html b/test/area-stack.html index c60eaa1b64..bdcbee7e63 100644 --- a/test/area-stack.html +++ b/test/area-stack.html @@ -44,6 +44,7 @@
+
+ + diff --git a/test/ut/spec/series/lineStack.test.ts b/test/ut/spec/series/lineStack.test.ts new file mode 100644 index 0000000000..186bdead8e --- /dev/null +++ b/test/ut/spec/series/lineStack.test.ts @@ -0,0 +1,290 @@ +/* +* 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'; +import SeriesModel from '@/src/model/Series'; +import { createChart, getECModel } from '../../core/utHelper'; + +function getStackResultValue(chart: EChartsType, seriesIndex: number, dataIndex: number): number { + const seriesModel = getECModel(chart).getSeriesByIndex(seriesIndex) as SeriesModel; + const data = seriesModel.getData(); + return data.get(data.getCalculationInfo('stackResultDimension'), dataIndex) as number; +} + +describe('series.line stack', function () { + let chart: EChartsType; + + beforeEach(function () { + chart = createChart(); + }); + + afterEach(function () { + chart.dispose(); + }); + + it('normalizes stacked area values to the stack total', function () { + chart.setOption({ + xAxis: { + data: ['A', 'B'] + }, + yAxis: {}, + series: [ + { + type: 'line', + stack: 'total', + stackNormalize: true, + areaStyle: {}, + data: [1, 2] + }, + { + type: 'line', + stack: 'total', + stackNormalize: true, + areaStyle: {}, + data: [3, 6] + }, + { + type: 'line', + stack: 'total', + stackNormalize: true, + areaStyle: {}, + data: [6, 2] + } + ] + }); + + expect(getStackResultValue(chart, 0, 0)).toBeCloseTo(0.1); + expect(getStackResultValue(chart, 1, 0)).toBeCloseTo(0.4); + expect(getStackResultValue(chart, 2, 0)).toBeCloseTo(1); + + expect(getStackResultValue(chart, 0, 1)).toBeCloseTo(0.2); + expect(getStackResultValue(chart, 1, 1)).toBeCloseTo(0.8); + expect(getStackResultValue(chart, 2, 1)).toBeCloseTo(1); + }); + + it('normalizes negative stacked values with samesign strategy', function () { + chart.setOption({ + xAxis: { + data: ['A'] + }, + yAxis: {}, + series: [ + { + type: 'line', + stack: 'total', + stackNormalize: true, + areaStyle: {}, + data: [-2] + }, + { + type: 'line', + stack: 'total', + stackNormalize: true, + areaStyle: {}, + data: [-3] + }, + { + type: 'line', + stack: 'total', + stackNormalize: true, + areaStyle: {}, + data: [5] + } + ] + }); + + expect(getStackResultValue(chart, 0, 0)).toBeCloseTo(-0.4); + expect(getStackResultValue(chart, 1, 0)).toBeCloseTo(-1); + expect(getStackResultValue(chart, 2, 0)).toBeCloseTo(1); + }); + + it('normalizes stacked values with all strategy', function () { + chart.setOption({ + xAxis: { + data: ['A', 'B'] + }, + yAxis: {}, + series: [ + { + type: 'line', + stack: 'total', + stackStrategy: 'all', + stackNormalize: true, + areaStyle: {}, + data: [2, -2] + }, + { + type: 'line', + stack: 'total', + stackStrategy: 'all', + stackNormalize: true, + areaStyle: {}, + data: [-1, -3] + }, + { + type: 'line', + stack: 'total', + stackStrategy: 'all', + stackNormalize: true, + areaStyle: {}, + data: [3, 1] + } + ] + }); + + expect(getStackResultValue(chart, 0, 0)).toBeCloseTo(0.5); + expect(getStackResultValue(chart, 1, 0)).toBeCloseTo(0.25); + expect(getStackResultValue(chart, 2, 0)).toBeCloseTo(1); + + expect(getStackResultValue(chart, 0, 1)).toBeCloseTo(-0.5); + expect(getStackResultValue(chart, 1, 1)).toBeCloseTo(-1.25); + expect(getStackResultValue(chart, 2, 1)).toBeCloseTo(-1); + }); + + it('normalizes stacked values with positive strategy', function () { + chart.setOption({ + xAxis: { + data: ['A'] + }, + yAxis: {}, + series: [ + { + type: 'line', + stack: 'total', + stackStrategy: 'positive', + stackNormalize: true, + areaStyle: {}, + data: [-1] + }, + { + type: 'line', + stack: 'total', + stackStrategy: 'positive', + stackNormalize: true, + areaStyle: {}, + data: [2] + }, + { + type: 'line', + stack: 'total', + stackStrategy: 'positive', + stackNormalize: true, + areaStyle: {}, + data: [3] + } + ] + }); + + expect(getStackResultValue(chart, 0, 0)).toBeCloseTo(-0.2); + expect(getStackResultValue(chart, 1, 0)).toBeCloseTo(0.4); + expect(getStackResultValue(chart, 2, 0)).toBeCloseTo(1); + }); + + it('normalizes stacked values with negative strategy', function () { + chart.setOption({ + xAxis: { + data: ['A'] + }, + yAxis: {}, + series: [ + { + type: 'line', + stack: 'total', + stackStrategy: 'negative', + stackNormalize: true, + areaStyle: {}, + data: [1] + }, + { + type: 'line', + stack: 'total', + stackStrategy: 'negative', + stackNormalize: true, + areaStyle: {}, + data: [-2] + }, + { + type: 'line', + stack: 'total', + stackStrategy: 'negative', + stackNormalize: true, + areaStyle: {}, + data: [-3] + } + ] + }); + + expect(getStackResultValue(chart, 0, 0)).toBeCloseTo(0.2); + expect(getStackResultValue(chart, 1, 0)).toBeCloseTo(-0.4); + expect(getStackResultValue(chart, 2, 0)).toBeCloseTo(-1); + }); + + it('keeps regular stacked values when stackNormalize is not enabled', function () { + chart.setOption({ + xAxis: { + data: ['A'] + }, + yAxis: {}, + series: [ + { + type: 'line', + stack: 'total', + areaStyle: {}, + data: [1] + }, + { + type: 'line', + stack: 'total', + areaStyle: {}, + data: [3] + } + ] + }); + + expect(getStackResultValue(chart, 0, 0)).toBe(1); + expect(getStackResultValue(chart, 1, 0)).toBe(4); + }); + + it('keeps regular stacked values when only part of the stack enables stackNormalize', function () { + chart.setOption({ + xAxis: { + data: ['A'] + }, + yAxis: {}, + series: [ + { + type: 'line', + stack: 'total', + stackNormalize: true, + areaStyle: {}, + data: [1] + }, + { + type: 'line', + stack: 'total', + areaStyle: {}, + data: [3] + } + ] + }); + + expect(getStackResultValue(chart, 0, 0)).toBe(1); + expect(getStackResultValue(chart, 1, 0)).toBe(4); + }); +});