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']); + }); + +});