This is an automated email from the ASF dual-hosted git repository. susiwen8 pushed a commit to branch codex/normalized-stacked-area in repository https://gitbox.apache.org/repos/asf/echarts.git
commit 8314ce5fd2fe14ffcd4d7458c63db87602008f21 Author: susiwen8 <[email protected]> AuthorDate: Mon May 4 21:18:01 2026 +0800 Enable normalized stacked area charts Line stack values can now opt into stackNormalize to render 100% stacked area charts while preserving the default stacked line behavior. The stack processor normalizes only all-line stack groups and keeps existing stack strategy and stack order semantics. Unit coverage and an HTML visual case lock the behavior, and AGENTS records that dist output is generated during npm publish. Constraint: dist files are generated during npm publish and must not be manually edited Rejected: Modify dist or generated declaration output directly | generated artifacts should come from the release flow Confidence: high Scope-risk: moderate Reversibility: clean Tested: npx jest --config test/ut/jest.config.cjs --coverage=false test/ut/spec/series/lineStack.test.ts Tested: npm run checktype Tested: npx eslint src/processor/dataStack.ts src/chart/line/LineSeries.ts test/ut/spec/series/lineStack.test.ts Tested: git diff --check on changed source, HTML, test, and AGENTS files Not-tested: npm run test:dts:fast; existing NodeNext declaration errors block the broader d.ts suite before this option is exercised --- AGENTS.md | 20 +++++++ src/chart/line/LineSeries.ts | 3 + src/processor/dataStack.ts | 103 ++++++++++++++++++++++++++++----- test/area-stack.html | 77 ++++++++++++++++++++++++ test/ut/spec/series/lineStack.test.ts | 106 ++++++++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..bf7f3242c --- /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 0cd0c9ef8..908b2805f 100644 --- a/src/chart/line/LineSeries.ts +++ b/src/chart/line/LineSeries.ts @@ -113,6 +113,9 @@ export interface LineSeriesOption extends SeriesOption<LineStateOption<CallbackD connectNulls?: boolean + // Normalize stacked line values by the stack total. + stackNormalize?: boolean + showSymbol?: boolean // false | 'auto': follow the label interval strategy. // true: show all symbols. diff --git a/src/processor/dataStack.ts b/src/processor/dataStack.ts index f83c7f90f..75029faea 100644 --- a/src/processor/dataStack.ts +++ b/src/processor/dataStack.ts @@ -24,8 +24,14 @@ import { SeriesOption, SeriesStackOptionMixin } from '../util/types'; import SeriesData, { DataCalculationInfo } from '../data/SeriesData'; import { addSafe } from '../util/number'; +interface StackNormalizeOption { + stackNormalize?: boolean +} + +type StackSeriesOption = SeriesOption & SeriesStackOptionMixin & StackNormalizeOption; + type StackInfo = Pick< - DataCalculationInfo<SeriesOption & SeriesStackOptionMixin>, + DataCalculationInfo<StackSeriesOption>, 'stackedDimension' | 'isStackedByIndex' | 'stackedByDimension' @@ -33,7 +39,7 @@ type StackInfo = Pick< | 'stackedOverDimension' > & { data: SeriesData - seriesModel: SeriesModel<SeriesOption & SeriesStackOptionMixin> + seriesModel: SeriesModel<StackSeriesOption> }; // (1) [Caution]: the logic is correct based on the premises: @@ -43,7 +49,7 @@ type StackInfo = Pick< // Should be executed after series is filtered and before stack calculation. export default function dataStack(ecModel: GlobalModel) { const stackInfoMap = createHashMap<StackInfo[]>(); - ecModel.eachSeries(function (seriesModel: SeriesModel<SeriesOption & SeriesStackOptionMixin>) { + ecModel.eachSeries(function (seriesModel: SeriesModel<StackSeriesOption>) { const stack = seriesModel.get('stack'); // Compatible: when `stack` is set as '', do not stack. if (stack) { @@ -95,11 +101,25 @@ export default function dataStack(ecModel: GlobalModel) { }); // Calculate stack values - calculateStack(stackInfoList); + calculateStack(stackInfoList, shouldNormalizeStack(stackInfoList)); }); } -function calculateStack(stackInfoList: StackInfo[]) { +function shouldNormalizeStack(stackInfoList: StackInfo[]) { + let hasStackNormalize = false; + + for (let i = 0; i < stackInfoList.length; i++) { + const seriesModel = stackInfoList[i].seriesModel; + if (seriesModel.type !== 'series.line') { + return false; + } + hasStackNormalize = hasStackNormalize || !!seriesModel.get('stackNormalize'); + } + + return hasStackNormalize; +} + +function calculateStack(stackInfoList: StackInfo[], normalizeStack: boolean) { each(stackInfoList, function (targetStackInfo, idxInStack) { const resultVal: number[] = []; const resultNaN = [NaN, NaN]; @@ -111,7 +131,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, 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 +168,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 +186,64 @@ function calculateStack(stackInfoList: StackInfo[]) { }); }); } + +function normalizeStackValue( + stackInfoList: StackInfo[], + targetStackInfo: StackInfo, + dataIndex: number, + stackStrategy: StackSeriesOption['stackStrategy'] +) { + const rawValue = targetStackInfo.data.get(targetStackInfo.stackedDimension, dataIndex) as number; + + if (isNaN(rawValue)) { + return NaN; + } + + const total = getStackTotal(stackInfoList, targetStackInfo, dataIndex, rawValue, stackStrategy); + return total ? rawValue / Math.abs(total) : 0; +} + +function getStackTotal( + stackInfoList: StackInfo[], + targetStackInfo: StackInfo, + dataIndex: number, + targetValue: number, + stackStrategy: StackSeriesOption['stackStrategy'] +) { + const targetData = targetStackInfo.data; + const isStackedByIndex = targetStackInfo.isStackedByIndex; + let byValue: number; + let total = 0; + + if (!isStackedByIndex) { + byValue = targetData.get(targetStackInfo.stackedByDimension, dataIndex) as number; + } + + for (let i = 0; i < stackInfoList.length; i++) { + const stackInfo = stackInfoList[i]; + const rawIndex = isStackedByIndex + ? targetData.getRawIndex(dataIndex) + : stackInfo.data.rawIndexOf(stackInfo.stackedByDimension, byValue); + + if (rawIndex >= 0) { + const value = stackInfo.data.getByRawIndex(stackInfo.stackedDimension, rawIndex) as number; + if (!isNaN(value) && isStackedValueInStrategy(value, targetValue, stackStrategy)) { + total = addSafe(total, value); + } + } + } + + return total; +} + +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 c60eaa1b6..1058945b1 100644 --- a/test/area-stack.html +++ b/test/area-stack.html @@ -44,6 +44,7 @@ under the License. <div id="stack-all"></div> <div id="stack-positive"></div> <div id="stack-negative"></div> + <div id="stack-normalize"></div> <script> @@ -753,5 +754,81 @@ under the License. }); </script> + + <script> + + require(['echarts'], function (echarts) { + + var option = { + legend: { + top: 25 + }, + tooltip: { + trigger: 'axis' + }, + grid: { + top: 80, + left: 45, + right: 30, + bottom: 40 + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] + }, + yAxis: { + type: 'value', + min: 0, + max: 1 + }, + series: [ + { + name: 'Direct', + type: 'line', + stack: 'normalized', + stackNormalize: true, + areaStyle: {}, + symbol: 'none', + data: [10, 30, 20, 40, 25] + }, + { + name: 'Email', + type: 'line', + stack: 'normalized', + stackNormalize: true, + areaStyle: {}, + symbol: 'none', + data: [20, 10, 30, 20, 25] + }, + { + name: 'Ads', + type: 'line', + stack: 'normalized', + stackNormalize: true, + areaStyle: {}, + symbol: 'none', + data: [30, 40, 10, 20, 25] + }, + { + name: 'Search', + type: 'line', + stack: 'normalized', + stackNormalize: true, + areaStyle: {}, + symbol: 'none', + data: [40, 20, 40, 20, 25] + } + ] + }; + + testHelper.create(echarts, 'stack-normalize', { + title: 'Normalized stacked area (top boundary should stay at 1)', + option: option, + height: 420 + }); + }); + + </script> </body> </html> diff --git a/test/ut/spec/series/lineStack.test.ts b/test/ut/spec/series/lineStack.test.ts new file mode 100644 index 000000000..67c38e1fe --- /dev/null +++ b/test/ut/spec/series/lineStack.test.ts @@ -0,0 +1,106 @@ +/* +* 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('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); + }); +}); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
