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]

Reply via email to