From e894c68a32f60c8ae9278b41dc8112143c1d1ad2 Mon Sep 17 00:00:00 2001 From: b84330808 Date: Tue, 20 Oct 2026 20:37:02 +0900 Subject: [PATCH] fix(toolbox): respect `series.stack` when switching magicType stack. close #15571 The stack button of magicType only regarded series as stacked when their `stack` was the inner keyword set by the button itself. So series stacked by users were stacked again on the first click (nothing changed, and separate stack groups were merged into one), and the original stack groups were lost after switching back from tiled. Now whether the series are stacked is decided by all of the target series, which also recognizes the stacks defined in `series.stack`, and the original stacks are restored when switching back to stack. Co-Authored-By: Claude Opus 5.5 --- src/component/toolbox/feature/MagicType.ts | 62 +++++++-- .../spec/component/toolbox/magicType.test.ts | 131 ++++++++++++++++++ 2 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 test/ut/spec/component/toolbox/magicType.test.ts diff --git a/src/component/toolbox/feature/MagicType.ts b/src/component/toolbox/feature/MagicType.ts index e13d6db6cd..f8053ea8cc 100644 --- a/src/component/toolbox/feature/MagicType.ts +++ b/src/component/toolbox/feature/MagicType.ts @@ -21,13 +21,19 @@ import * as echarts from '../../../core/echarts'; import * as zrUtil from 'zrender/src/core/util'; import {ToolboxFeature, ToolboxFeatureOption, ToolboxFeatureModel} from '../featureManager'; import { SeriesOption, ECUnitOption } from '../../../util/types'; -import GlobalModel from '../../../model/Global'; +import GlobalModel, { QueryConditionKindA } from '../../../model/Global'; import ExtensionAPI from '../../../core/ExtensionAPI'; import SeriesModel from '../../../model/Series'; -import { SINGLE_REFERRING } from '../../../util/model'; +import { SINGLE_REFERRING, makeInner } from '../../../util/model'; const INNER_STACK_KEYWORD = '__ec_magicType_stack__' as const; +const inner = makeInner<{ + // The `series.stack` defined by users, keyed by series id. + // It is restored when switching from tiled back to stack. + originalStacks: zrUtil.HashMap +}, GlobalModel>(); + const ICON_TYPES = ['line', 'bar', 'stack'] as const; // stack and tiled appears in pair for the title const TITLE_TYPES = ['line', 'bar', 'stack', 'tiled'] as const; @@ -105,11 +111,20 @@ class MagicType extends ToolboxFeature { const newOption: ECUnitOption = { series: [] }; + const seriesCondition: QueryConditionKindA = { + mainType: 'series', + query: seriesIndex == null ? null : { + seriesIndex: seriesIndex + } + }; + // Whether to switch to tiled is decided by all of the target series rather than + // each single series, so that the stacks defined in `series.stack` can be recognized. + const isStack = type === 'stack' && isLineBarStacked(ecModel, seriesCondition); const generateNewSeriesTypes = function (seriesModel: SeriesModel) { const seriesType = seriesModel.subType; const seriesId = seriesModel.id; const newSeriesOpt = seriesOptGenreator[type]( - seriesType, seriesId, seriesModel, model + seriesType, seriesId, seriesModel, model, isStack ); if (newSeriesOpt) { // PENDING If merge original option? @@ -145,14 +160,7 @@ class MagicType extends ToolboxFeature { model.setIconStatus(type, 'emphasis'); - ecModel.eachComponent( - { - mainType: 'series', - query: seriesIndex == null ? null : { - seriesIndex: seriesIndex - } - }, generateNewSeriesTypes - ); + ecModel.eachComponent(seriesCondition, generateNewSeriesTypes); let newTitle; let currentType = type as TitleType; @@ -193,9 +201,28 @@ type SeriesOptGenreator = ( seriesType: string, seriesId: string, seriesModel: SeriesModel, - model: ToolboxFeatureModel + model: ToolboxFeatureModel, + isStack: boolean ) => SeriesOption; +/** + * Whether the line or bar series are stacked, either by magicType or by + * the `series.stack` defined by users (shared by more than one series). + */ +function isLineBarStacked(ecModel: GlobalModel, condition: QueryConditionKindA): boolean { + const stackCounts = zrUtil.createHashMap(); + let stacked = false; + ecModel.eachComponent(condition, function (seriesModel: SeriesModel) { + const stack = seriesModel.get('stack') as string; + if (stack && (seriesModel.subType === 'line' || seriesModel.subType === 'bar')) { + const count = (stackCounts.get(stack) || 0) + 1; + stackCounts.set(stack, count); + stacked = stacked || stack === INNER_STACK_KEYWORD || count > 1; + } + }); + return stacked; +} + const seriesOptGenreator: Record = { 'line': function (seriesType, seriesId, seriesModel, model) { if (seriesType === 'bar') { @@ -223,13 +250,18 @@ const seriesOptGenreator: Record = { }, model.get(['option', 'bar']) || {}, true); } }, - 'stack': function (seriesType, seriesId, seriesModel, model) { - const isStack = seriesModel.get('stack') === INNER_STACK_KEYWORD; + 'stack': function (seriesType, seriesId, seriesModel, model, isStack) { if (seriesType === 'line' || seriesType === 'bar') { + const store = inner(seriesModel.ecModel); + const originalStacks = store.originalStacks || (store.originalStacks = zrUtil.createHashMap()); + const stack = seriesModel.get('stack'); + if (isStack && stack && stack !== INNER_STACK_KEYWORD) { + originalStacks.set(seriesId, stack); + } model.setIconStatus('stack', isStack ? 'normal' : 'emphasis'); return zrUtil.merge({ id: seriesId, - stack: isStack ? '' : INNER_STACK_KEYWORD + stack: isStack ? '' : (originalStacks.get(seriesId) || INNER_STACK_KEYWORD) }, model.get(['option', 'stack']) || {}, true); } } diff --git a/test/ut/spec/component/toolbox/magicType.test.ts b/test/ut/spec/component/toolbox/magicType.test.ts new file mode 100644 index 0000000000..90d21f08c8 --- /dev/null +++ b/test/ut/spec/component/toolbox/magicType.test.ts @@ -0,0 +1,131 @@ +/* +* 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 { createChart, getECModel } from '../../../core/utHelper'; +import { EChartsType } from '../../../../../src/echarts'; +import { EChartsOption } from '../../../../../src/export/option'; + + +describe('toolbox_magicType', function () { + let chart: EChartsType; + beforeEach(function () { + chart = createChart(); + }); + + afterEach(function () { + chart.dispose(); + }); + + function clickMagicTypeIcon(iconName: 'line' | 'bar' | 'stack') { + const toolboxModel = getECModel(chart).getComponent('toolbox'); + const toolboxView = (chart as any)._componentsMap[toolboxModel.__viewId]; + toolboxView._features.get('magicType').model.iconPaths[iconName].trigger('click'); + } + + function getSeriesStacks() { + return getECModel(chart).getSeries().map(seriesModel => seriesModel.get('stack' as any)); + } + + function makeOption(stacks: string[]): EChartsOption { + return { + toolbox: { + feature: { + magicType: { + type: ['line', 'bar', 'stack'] + } + } + }, + xAxis: { + type: 'category', + data: ['a', 'b', 'c'] + }, + yAxis: {}, + series: stacks.map(stack => ({ + type: 'bar', + stack: stack, + data: [1, 2, 3] + })) + }; + } + + it('should stack series without stack on the first click', function () { + chart.setOption(makeOption([undefined, undefined])); + + clickMagicTypeIcon('stack'); + const stacks = getSeriesStacks(); + expect(stacks[0]).toBeTruthy(); + expect(stacks[1]).toEqual(stacks[0]); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks().every(stack => !stack)).toEqual(true); + }); + + it('should toggle the stack of a single series', function () { + chart.setOption(makeOption([undefined])); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks()[0]).toBeTruthy(); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks()[0]).toBeFalsy(); + }); + + it('should stack series together when only one series has a stack', function () { + chart.setOption(makeOption([undefined, 'single'])); + + clickMagicTypeIcon('stack'); + const stacks = getSeriesStacks(); + expect(stacks[0]).toBeTruthy(); + expect(stacks[1]).toEqual(stacks[0]); + }); + + it('should tile series stacked by series.stack on the first click', function () { + chart.setOption(makeOption(['cars', 'cars'])); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks().every(stack => !stack)).toEqual(true); + }); + + it('should restore the stack groups defined by series.stack', function () { + chart.setOption(makeOption(['groupA', 'groupA', 'groupB', 'groupB'])); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks().every(stack => !stack)).toEqual(true); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks()).toEqual(['groupA', 'groupA', 'groupB', 'groupB']); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks().every(stack => !stack)).toEqual(true); + }); + + it('should keep the stack groups when switching between line and bar', function () { + chart.setOption(makeOption(['groupA', 'groupA', 'groupB', 'groupB'])); + + clickMagicTypeIcon('line'); + expect(getSeriesStacks()).toEqual(['groupA', 'groupA', 'groupB', 'groupB']); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks().every(stack => !stack)).toEqual(true); + + clickMagicTypeIcon('stack'); + expect(getSeriesStacks()).toEqual(['groupA', 'groupA', 'groupB', 'groupB']); + }); + +});