You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@echarts.apache.org by su...@apache.org on 2020/09/27 17:46:53 UTC

[incubator-echarts] 02/05: feature: (1) Support custom series morphing with split/merge effect. (2) Add API: setOption(option, { transition: { ... } }) to indicate the transition relationship. (3) Fix underlying schedule framework buggy disopose when setOption at the second time and change data.

This is an automated email from the ASF dual-hosted git repository.

sushuang pushed a commit to branch custom-morph2
in repository https://gitbox.apache.org/repos/asf/incubator-echarts.git

commit 82331873c60d100b842a9d70b95c61db963527ae
Author: 100pah <su...@gmail.com>
AuthorDate: Wed Sep 23 17:12:59 2020 +0800

    feature:
    (1) Support custom series morphing with split/merge effect.
    (2) Add API: setOption(option, { transition: { ... } }) to indicate the transition relationship.
    (3) Fix underlying schedule framework buggy disopose when setOption at the second time and change data.
---
 src/chart/custom.ts              | 214 +++++++++++++++------
 src/coord/scaleRawExtentInfo.ts  |  10 +-
 src/data/DataDiffer.ts           | 238 ++++++++++++++++++------
 src/echarts.ts                   |  96 +++++++++-
 src/model/Global.ts              |   8 +-
 src/model/Series.ts              |  11 +-
 src/util/model.ts                |  26 ++-
 src/util/types.ts                |  34 +++-
 test/custom-shape-morphing2.html | 388 +++++++++++++++++++++++++++++++++++++++
 9 files changed, 892 insertions(+), 133 deletions(-)

diff --git a/src/chart/custom.ts b/src/chart/custom.ts
index 0c645f8..ca87fb4 100644
--- a/src/chart/custom.ts
+++ b/src/chart/custom.ts
@@ -19,7 +19,7 @@
 
 import {
     hasOwn, assert, isString, retrieve2, retrieve3, defaults, each,
-    keys, isArrayLike, bind, isFunction, eqNaN
+    keys, isArrayLike, bind, isFunction, eqNaN, isArray
 } from 'zrender/src/core/util';
 import * as graphicUtil from '../util/graphic';
 import { setDefaultStateProxy, enableHoverEmphasis } from '../util/states';
@@ -81,8 +81,8 @@ import {
 import Transformable from 'zrender/src/core/Transformable';
 import { ItemStyleProps } from '../model/mixin/itemStyle';
 import { cloneValue } from 'zrender/src/animation/Animator';
-import { warn, error } from '../util/log';
-import { morphPath } from 'zrender/src/tool/morphPath';
+import { warn, error, throwError } from '../util/log';
+import { morphPath, splitShapeForMorphingFrom, isPathMorphing } from 'zrender/src/tool/morphPath';
 
 
 const inner = makeInner<{
@@ -460,22 +460,58 @@ class CustomSeriesView extends ChartView {
         // is complicated, where merge mode is probably necessary for optimization.
         // For example, reuse graphic elements and only update the transform when
         // roam or data zoom according to `actionType`.
-        data.diff(oldData)
-            .add(function (newIdx) {
-                createOrUpdateItem(
-                    null, newIdx, renderItem(newIdx, payload), customSeries, group, data
-                );
-            })
-            .update(function (newIdx, oldIdx) {
+
+        const transOpt = customSeries.__transientTransitionOpt;
+
+        (new DataDiffer(
+            oldData ? oldData.getIndices() : [],
+            data.getIndices(),
+            createGetKey(oldData, transOpt && transOpt.from),
+            createGetKey(data, transOpt && transOpt.to),
+            null,
+            transOpt ? 'multiple' : 'single'
+        ))
+        .add(function (newIdx) {
+            createOrUpdateItem(
+                null, newIdx, renderItem(newIdx, payload), customSeries, group, data, null
+            );
+        })
+        .update(function (newIdx, oldIdx) {
+            createOrUpdateItem(
+                oldData.getItemGraphicEl(oldIdx),
+                newIdx, renderItem(newIdx, payload), customSeries, group, data, null
+            );
+        })
+        .remove(function (oldIdx) {
+            doRemoveEl(oldData.getItemGraphicEl(oldIdx), customSeries, group);
+        })
+        .updateManyToOne(function (newIdx, oldIndices) {
+            const oldElsToMerge: graphicUtil.Path[] = [];
+            for (let i = 0; i < oldIndices.length; i++) {
+                const oldEl = oldData.getItemGraphicEl(oldIndices[i]);
+                if (elCanMorph(oldEl)) {
+                    oldElsToMerge.push(oldEl);
+                }
+                removeElementDirectly(oldEl, group);
+            }
+            createOrUpdateItem(
+                null, newIdx, renderItem(newIdx, payload), customSeries,
+                group, data, oldElsToMerge
+            );
+        })
+        .updateOneToMany(function (newIndices, oldIdx) {
+            const newLen = newIndices.length;
+            const oldEl = oldData.getItemGraphicEl(oldIdx);
+            const oldElSplitted = elCanMorph(oldEl) ? splitShapeForMorphingFrom(oldEl, newLen) : [];
+            removeElementDirectly(oldEl, group);
+            for (let i = 0; i < newLen; i++) {
                 createOrUpdateItem(
-                    oldData.getItemGraphicEl(oldIdx),
-                    newIdx, renderItem(newIdx, payload), customSeries, group, data
+                    null, newIndices[i], renderItem(newIndices[i], payload), customSeries,
+                    group, data, oldElSplitted[i]
                 );
-            })
-            .remove(function (oldIdx) {
-                doRemoveEl(oldData.getItemGraphicEl(oldIdx), customSeries, group);
-            })
-            .execute();
+            }
+        })
+        .execute();
 
         // Do clipping
         const clipPath = customSeries.get('clip', true)
@@ -516,7 +552,9 @@ class CustomSeriesView extends ChartView {
             }
         }
         for (let idx = params.start; idx < params.end; idx++) {
-            const el = createOrUpdateItem(null, idx, renderItem(idx, payload), customSeries, this.group, data);
+            const el = createOrUpdateItem(
+                null, idx, renderItem(idx, payload), customSeries, this.group, data, null
+            );
             el.traverse(setIncrementalAndHoverLayer);
         }
     }
@@ -544,6 +582,40 @@ class CustomSeriesView extends ChartView {
 ChartView.registerClass(CustomSeriesView);
 
 
+function createGetKey(data: List, dimension: DimensionLoose) {
+    if (!data) {
+        return;
+    }
+
+    const diffBy = data.getDimension(dimension);
+
+    if (diffBy == null) {
+        return function (rawIdx: number, dataIndex: number) {
+            return data.getId(dataIndex);
+        };
+    }
+
+    const dimInfo = data.getDimensionInfo(diffBy);
+    if (!dimInfo) {
+        let errMsg = '';
+        if (__DEV__) {
+            errMsg = `${dimension} is not a valid dimension.`;
+        }
+        throwError(errMsg);
+    }
+    const ordinalMeta = dimInfo.ordinalMeta;
+    return function (rawIdx: number, dataIndex: number) {
+        let key = data.get(diffBy, dataIndex);
+        if (ordinalMeta) {
+            key = ordinalMeta.categories[key as number];
+        }
+        return (key == null || eqNaN(key))
+            ? rawIdx + ''
+            : '_ec_' + key;
+    };
+}
+
+
 function createEl(elOption: CustomElementOption): Element {
     const graphicType = elOption.type;
     let el;
@@ -582,11 +654,13 @@ function createEl(elOption: CustomElementOption): Element {
     }
     else {
         const Clz = graphicUtil.getShapeClass(graphicType);
-
-        if (__DEV__) {
-            assert(Clz, 'graphic type "' + graphicType + '" can not be found.');
+        if (!Clz) {
+            let errMsg = '';
+            if (__DEV__) {
+                errMsg = 'graphic type "' + graphicType + '" can not be found.';
+            }
+            throwError(errMsg);
         }
-
         el = new Clz();
     }
 
@@ -654,7 +728,7 @@ function createEl(elOption: CustomElementOption): Element {
  */
 function updateElNormal(
     el: Element,
-    morphingFromEl: Element,
+    morphingFromEl: graphicUtil.Path | graphicUtil.Path[],
     dataIndex: number,
     elOption: CustomElementOption,
     styleOpt: CustomElementOption['style'],
@@ -757,7 +831,7 @@ function updateElNormal(
     styleOpt ? el.dirty() : el.markRedraw();
 
     if (morphingFromEl) {
-        applyShapeMorphingAnimation(morphingFromEl, el, seriesModel, dataIndex);
+        applyShapeMorphingAnimation(morphingFromEl, el as graphicUtil.Path, seriesModel, dataIndex);
     }
 }
 
@@ -766,7 +840,7 @@ function updateElNormal(
 function prepareShapeOrExtraUpdate(
     mainAttr: 'shape' | 'extra',
     el: Element,
-    morphingFromEl: Element,
+    morphingFromEl: graphicUtil.Path | graphicUtil.Path[],
     elOption: CustomElementOption,
     allProps: LooseElementProps,
     transFromProps: LooseElementProps,
@@ -834,14 +908,14 @@ function prepareShapeOrExtraUpdate(
 // See [STRATEGY_TRANSITION].
 function prepareTransformUpdate(
     el: Element,
-    morphingFromEl: Element,
+    morphingFromEl: graphicUtil.Path | graphicUtil.Path[],
     elOption: CustomElementOption,
     allProps: ElementProps,
     transFromProps: ElementProps,
     isInit: boolean
 ): void {
     const enterFrom = elOption.enterFrom;
-    const fromEl = morphingFromEl || el;
+    const fromEl = (morphingFromEl instanceof graphicUtil.Path) && morphingFromEl || el;
     if (isInit && enterFrom) {
         const enterFromKeys = keys(enterFrom);
         for (let i = 0; i < enterFromKeys.length; i++) {
@@ -904,7 +978,7 @@ function prepareTransformUpdate(
 // See [STRATEGY_TRANSITION].
 function prepareStyleUpdate(
     el: Element,
-    morphingFromEl: Element,
+    morphingFromEl: graphicUtil.Path | graphicUtil.Path[],
     styleOpt: CustomElementOption['style'],
     transFromProps: LooseElementProps,
     isInit: boolean
@@ -913,7 +987,7 @@ function prepareStyleUpdate(
         return;
     }
 
-    const fromEl = morphingFromEl || el;
+    const fromEl = (morphingFromEl instanceof graphicUtil.Path) && morphingFromEl || el;
 
     const fromElStyle = (fromEl as LooseElementProps).style as LooseElementProps['style'];
     let transFromStyleProps: LooseElementProps['style'];
@@ -1546,7 +1620,8 @@ function createOrUpdateItem(
     elOption: CustomElementOption,
     seriesModel: CustomSeriesModel,
     group: ViewRootGroup,
-    data: List<CustomSeriesModel>
+    data: List<CustomSeriesModel>,
+    morphingFroms: graphicUtil.Path | graphicUtil.Path[]
 ): Element {
     // [Rule]
     // If `renderItem` returns `null`/`undefined`/`false`, remove the previous el if existing.
@@ -1557,10 +1632,10 @@ function createOrUpdateItem(
 
     // If `elOption` is `null`/`undefined`/`false` (when `renderItem` returns nothing).
     if (!elOption) {
-        el && group.remove(el);
+        removeElementDirectly(el, group);
         return;
     }
-    el = doCreateOrUpdateEl(el, dataIndex, elOption, seriesModel, group, true);
+    el = doCreateOrUpdateEl(el, dataIndex, elOption, seriesModel, group, true, morphingFroms);
     el && data.setItemGraphicEl(dataIndex, el);
 
     enableHoverEmphasis(el, elOption.focus, elOption.blurScope);
@@ -1568,13 +1643,12 @@ function createOrUpdateItem(
     return el;
 }
 
-function applyShapeMorphingAnimation(oldEl: Element, el: Element, seriesModel: SeriesModel, dataIndex: number) {
-    if (!((oldEl instanceof graphicUtil.Path) && (el instanceof graphicUtil.Path))) {
-        if (__DEV__) {
-            error('`morph` can only be applied on two paths.');
-        }
-        return;
-    }
+function applyShapeMorphingAnimation(
+    oldEl: graphicUtil.Path | graphicUtil.Path[],
+    el: graphicUtil.Path,
+    seriesModel: SeriesModel,
+    dataIndex: number
+) {
     if (seriesModel.isAnimationEnabled()) {
         const duration = seriesModel.get('animationDurationUpdate');
         const delay = seriesModel.get('animationDelayUpdate');
@@ -1590,13 +1664,20 @@ function applyShapeMorphingAnimation(oldEl: Element, el: Element, seriesModel: S
     }
 }
 
+function hintInvalidMorph(): void {
+    if (__DEV__) {
+        error('`morph` can only be applied on two paths.');
+    }
+}
+
 function doCreateOrUpdateEl(
     el: Element,
     dataIndex: number,
     elOption: CustomElementOption,
     seriesModel: CustomSeriesModel,
     group: ViewRootGroup,
-    isRoot: boolean
+    isRoot: boolean,
+    morphingFroms: graphicUtil.Path | graphicUtil.Path[]
 ): Element {
 
     if (__DEV__) {
@@ -1604,17 +1685,29 @@ function doCreateOrUpdateEl(
     }
 
     let toBeReplacedIdx = -1;
-    let oldEl: Element;
+    let morphingFromEl: graphicUtil.Path | graphicUtil.Path[];
+    const optionMorph = (elOption as CustomZRPathOption).morph;
 
-    if (el && doesElNeedRecreate(el, elOption)) {
-        // Should keep at the original index, otherwise "merge by index" will be incorrect.
-        toBeReplacedIdx = group.childrenRef().indexOf(el);
-        oldEl = el;
-        el = null;
+    if (el) {
+        const elNeedRecreate = doesElNeedRecreate(el, elOption);
+        if (optionMorph) {
+            if (!elCanMorph(el)) {
+                hintInvalidMorph();
+            }
+            // When an el has been morphing, we also need to make it go through
+            // morphing logic, other it will blink.
+            else if (elNeedRecreate || isPathMorphing(el)) {
+                morphingFromEl = el;
+            }
+        }
+        if (elNeedRecreate) {
+            // Should keep at the original index, otherwise "merge by index" will be incorrect.
+            toBeReplacedIdx = group.childrenRef().indexOf(el);
+            el = null;
+        }
     }
 
-    let isInit = !el;
-    let needsMorphing = false;
+    const elIsNewCreated = !el;
 
     if (!el) {
         el = createEl(elOption);
@@ -1626,13 +1719,13 @@ function doCreateOrUpdateEl(
         el.clearStates();
     }
 
-    // Do shape morphing
-    if ((elOption as CustomZRPathOption).morph && el && oldEl && (el !== oldEl)) {
-        needsMorphing = true;
-        // Use update animation when morph is enabled.
-        isInit = false;
+    if (morphingFroms && optionMorph) {
+        morphingFromEl = morphingFroms;
     }
 
+    // Use update animation when morph is enabled.
+    const isInit = elIsNewCreated && !morphingFromEl;
+
     attachedTxInfoTmp.normal.cfg = attachedTxInfoTmp.normal.conOpt =
         attachedTxInfoTmp.emphasis.cfg = attachedTxInfoTmp.emphasis.conOpt =
         attachedTxInfoTmp.blur.cfg = attachedTxInfoTmp.blur.conOpt =
@@ -1650,7 +1743,7 @@ function doCreateOrUpdateEl(
 
     updateElNormal(
         el,
-        needsMorphing ? oldEl : null,
+        morphingFromEl,
         dataIndex,
         elOption,
         elOption.style,
@@ -1945,7 +2038,8 @@ function mergeChildren(
             newChildren[index],
             seriesModel,
             el,
-            false
+            false,
+            null
         );
     }
     for (let i = el.childCount() - 1; i >= index; i--) {
@@ -1997,7 +2091,8 @@ function processAddUpdate(
         childOption,
         context.seriesModel,
         context.group,
-        false
+        false,
+        null
     );
 }
 
@@ -2036,3 +2131,10 @@ function hasOwnPathData(shape: CustomSVGPathOption['shape']): boolean {
     return shape && (hasOwn(shape, 'pathData') || hasOwn(shape, 'd'));
 }
 
+function elCanMorph(el: Element): el is graphicUtil.Path {
+    return el && el instanceof graphicUtil.Path;
+}
+
+function removeElementDirectly(el: Element, group: ViewRootGroup): void {
+    el && group.remove(el);
+}
diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts
index 9f90b1a..7b3c92d 100644
--- a/src/coord/scaleRawExtentInfo.ts
+++ b/src/coord/scaleRawExtentInfo.ts
@@ -286,12 +286,12 @@ const DATA_MIN_MAX_ATTR = { min: '_dataMin', max: '_dataMax' } as const;
  * Scale extent info probably be required multiple times during a workflow.
  * For example:
  * (1) `dataZoom` depends it to get the axis extent in "100%" state.
- * (2) `processor/extentCalculator` depends it to make sure whethe axis extent is specified.
+ * (2) `processor/extentCalculator` depends it to make sure whether axis extent is specified.
  * (3) `coordSys.update` use it to finally decide the scale extent.
- * But the callback of `min`/`max` should not be called multiple time.
- * The code should not be duplicated either.
- * So we cache the result in the scale instance, which will be recreated in the begining
- * of the workflow.
+ * But the callback of `min`/`max` should not be called multiple times.
+ * The code below should not be implemented repeatedly either.
+ * So we cache the result in the scale instance, which will be recreated at the begining
+ * of the workflow (because `scale` instance will be recreated each round of the workflow).
  */
 export function ensureScaleRawExtentInfo(
     scale: Scale,
diff --git a/src/data/DataDiffer.ts b/src/data/DataDiffer.ts
index 993ead1..43baad1 100644
--- a/src/data/DataDiffer.ts
+++ b/src/data/DataDiffer.ts
@@ -20,28 +20,51 @@
 import {ArrayLike} from 'zrender/src/core/types';
 
 // return key.
-export type DiffKeyGetter = (this: DataDiffer, value: unknown, index: number) => string;
-export type DiffCallbackAdd = (newIndex: number) => void;
-export type DiffCallbackUpdate = (newIndex: number, oldIndex: number) => void;
-export type DiffCallbackRemove = (oldIndex: number) => void;
+type DiffKeyGetter<CTX = unknown> =
+    (this: DataDiffer<CTX>, value: unknown, index: number) => string;
 
+type DiffCallbackAdd = (newIndex: number) => void;
+type DiffCallbackUpdate = (newIndex: number, oldIndex: number) => void;
+type DiffCallbackRemove = (oldIndex: number) => void;
+type DiffCallbackUpdateManyToOne = (newIndex: number, oldIndex: number[]) => void;
+type DiffCallbackUpdateOneToMany = (newIndex: number[], oldIndex: number) => void;
+
+/**
+ * The value of `DataIndexMap` can only be:
+ * + a number
+ * + a number[] that length >= 2.
+ * + null/undefined
+ */
 type DataIndexMap = {[key: string]: number | number[]};
 
+function dataIndexMapValueLength(
+    valNumOrArrLengthMoreThan2: number | number[]
+): number {
+    return valNumOrArrLengthMoreThan2 == null
+        ? 0
+        : ((valNumOrArrLengthMoreThan2 as number[]).length || 1);
+}
+
 function defaultKeyGetter(item: string): string {
     return item;
 }
 
-class DataDiffer<Ctx = unknown> {
+export type DataDiffCallbackMode = 'single' | 'multiple';
+
+class DataDiffer<CTX = unknown> {
 
     private _old: ArrayLike<unknown>;
     private _new: ArrayLike<unknown>;
-    private _oldKeyGetter: DiffKeyGetter;
-    private _newKeyGetter: DiffKeyGetter;
+    private _oldKeyGetter: DiffKeyGetter<CTX>;
+    private _newKeyGetter: DiffKeyGetter<CTX>;
     private _add: DiffCallbackAdd;
     private _update: DiffCallbackUpdate;
+    private _updateManyToOne: DiffCallbackUpdateManyToOne;
+    private _updateOneToMany: DiffCallbackUpdateOneToMany;
     private _remove: DiffCallbackRemove;
+    private _cbModeMultiple: boolean;
 
-    readonly context: Ctx;
+    readonly context: CTX;
 
     /**
      * @param context Can be visited by this.context in callback.
@@ -49,9 +72,10 @@ class DataDiffer<Ctx = unknown> {
     constructor(
         oldArr: ArrayLike<unknown>,
         newArr: ArrayLike<unknown>,
-        oldKeyGetter?: DiffKeyGetter,
-        newKeyGetter?: DiffKeyGetter,
-        context?: Ctx
+        oldKeyGetter?: DiffKeyGetter<CTX>,
+        newKeyGetter?: DiffKeyGetter<CTX>,
+        context?: CTX,
+        cbMode?: DataDiffCallbackMode
     ) {
         this._old = oldArr;
         this._new = newArr;
@@ -61,12 +85,14 @@ class DataDiffer<Ctx = unknown> {
 
         // Visible in callback via `this.context`;
         this.context = context;
+
+        this._cbModeMultiple = cbMode === 'multiple';
     }
 
     /**
      * Callback function when add a data
      */
-    add(func: DiffCallbackAdd): DataDiffer {
+    add(func: DiffCallbackAdd): this {
         this._add = func;
         return this;
     }
@@ -74,94 +100,200 @@ class DataDiffer<Ctx = unknown> {
     /**
      * Callback function when update a data
      */
-    update(func: DiffCallbackUpdate): DataDiffer {
+    update(func: DiffCallbackUpdate): this {
         this._update = func;
         return this;
     }
 
     /**
+     * Callback function when update a data and only work in `cbMode: 'byKey'`.
+     */
+    updateManyToOne(func: DiffCallbackUpdateManyToOne): this {
+        this._updateManyToOne = func;
+        return this;
+    }
+
+    /**
+     * Callback function when update a data and only work in `cbMode: 'byKey'`.
+     */
+    updateOneToMany(func: DiffCallbackUpdateOneToMany): this {
+        this._updateOneToMany = func;
+        return this;
+    }
+
+    /**
      * Callback function when remove a data
      */
-    remove(func: DiffCallbackRemove): DataDiffer {
+    remove(func: DiffCallbackRemove): this {
         this._remove = func;
         return this;
     }
 
     execute(): void {
+        this[this._cbModeMultiple ? '_executeByKey' : '_executeByIndex']();
+    }
+
+    private _executeByIndex(): void {
         const oldArr = this._old;
         const newArr = this._new;
-
-        const oldDataIndexMap: DataIndexMap = {};
         const newDataIndexMap: DataIndexMap = {};
-        const oldDataKeyArr: string[] = [];
-        const newDataKeyArr: string[] = [];
-        let i;
+        const oldDataKeyArr: string[] = new Array(oldArr.length);
+        const newDataKeyArr: string[] = new Array(newArr.length);
 
-        this._initIndexMap(oldArr, oldDataIndexMap, oldDataKeyArr, '_oldKeyGetter');
+        this._initIndexMap(oldArr, null, oldDataKeyArr, '_oldKeyGetter');
         this._initIndexMap(newArr, newDataIndexMap, newDataKeyArr, '_newKeyGetter');
 
-        for (i = 0; i < oldArr.length; i++) {
-            const key = oldDataKeyArr[i];
-            let idx = newDataIndexMap[key];
+        for (let i = 0; i < oldArr.length; i++) {
+            const oldKey = oldDataKeyArr[i];
+            const newIdxMapVal = newDataIndexMap[oldKey];
+            const newIdxMapValLen = dataIndexMapValueLength(newIdxMapVal);
 
             // idx can never be empty array here. see 'set null' logic below.
-            if (idx != null) {
+            if (newIdxMapValLen > 1) {
                 // Consider there is duplicate key (for example, use dataItem.name as key).
                 // We should make sure every item in newArr and oldArr can be visited.
-                const len = (idx as number[]).length;
-                if (len) {
-                    len === 1 && (newDataIndexMap[key] = null);
-                    idx = (idx as number[]).shift();
-                }
-                else {
-                    newDataIndexMap[key] = null;
+                const newIdx = (newIdxMapVal as number[]).shift();
+                if ((newIdxMapVal as number[]).length === 1) {
+                    newDataIndexMap[oldKey] = (newIdxMapVal as number[])[0];
                 }
-                this._update && this._update(idx as number, i);
+                this._update && this._update(newIdx as number, i);
+            }
+            else if (newIdxMapValLen === 1) {
+                newDataIndexMap[oldKey] = null;
+                this._update && this._update(newIdxMapVal as number, i);
             }
             else {
                 this._remove && this._remove(i);
             }
         }
 
-        for (i = 0; i < newDataKeyArr.length; i++) {
-            const key = newDataKeyArr[i];
-            if (newDataIndexMap.hasOwnProperty(key)) {
-                const idx = newDataIndexMap[key];
-                if (idx == null) {
-                    continue;
-                }
-                // idx can never be empty array here. see 'set null' logic above.
-                if (!(idx as number[]).length) {
-                    this._add && this._add(idx as number);
+        this._performRestAdd(newDataKeyArr, newDataIndexMap);
+    }
+
+    /**
+     * For example, consider the case:
+     * oldData: [o0, o1, o2, o3, o4, o5, o6, o7],
+     * newData: [n0, n1, n2, n3, n4, n5, n6, n7, n8],
+     * Where:
+     *     o0, o1, n0 has key 'a' (many to one)
+     *     o5, n4, n5, n6 has key 'b' (one to many)
+     *     o2, n1 has key 'c' (one to one)
+     *     n2, n3 has key 'd' (add)
+     *     o3, o4 has key 'e' (remove)
+     *     o6, o7, n7, n8 has key 'f' (many to many, treated as add and remove)
+     * Then:
+     *     (The order of the following directives are not ensured.)
+     *     this._updateManyToOne(n0, [o0, o1]);
+     *     this._updateOneToMany([n4, n5, n6], o5);
+     *     this._update(n1, o2);
+     *     this._remove(o3);
+     *     this._remove(o4);
+     *     this._remove(o6);
+     *     this._remove(o7);
+     *     this._add(n2);
+     *     this._add(n3);
+     *     this._add(n7);
+     *     this._add(n8);
+     */
+    private _executeByKey(): void {
+        const oldArr = this._old;
+        const newArr = this._new;
+        const oldDataIndexMap: DataIndexMap = {};
+        const newDataIndexMap: DataIndexMap = {};
+        const oldDataKeyArr: string[] = [];
+        const newDataKeyArr: string[] = [];
+
+        this._initIndexMap(oldArr, oldDataIndexMap, oldDataKeyArr, '_oldKeyGetter');
+        this._initIndexMap(newArr, newDataIndexMap, newDataKeyArr, '_newKeyGetter');
+
+        for (let i = 0; i < oldDataKeyArr.length; i++) {
+            const oldKey = oldDataKeyArr[i];
+            const oldIdxMapVal = oldDataIndexMap[oldKey];
+            const newIdxMapVal = newDataIndexMap[oldKey];
+            const oldIdxMapValLen = dataIndexMapValueLength(oldIdxMapVal);
+            const newIdxMapValLen = dataIndexMapValueLength(newIdxMapVal);
+
+            if (oldIdxMapValLen > 1 && newIdxMapValLen === 1) {
+                this._updateManyToOne && this._updateManyToOne(newIdxMapVal as number, oldIdxMapVal as number[]);
+                newDataIndexMap[oldKey] = null;
+            }
+            else if (oldIdxMapValLen === 1 && newIdxMapValLen > 1) {
+                this._updateOneToMany && this._updateOneToMany(newIdxMapVal as number[], oldIdxMapVal as number);
+                newDataIndexMap[oldKey] = null;
+            }
+            else if (oldIdxMapValLen === 1 && newIdxMapValLen === 1) {
+                this._update && this._update(newIdxMapVal as number, oldIdxMapVal as number);
+                newDataIndexMap[oldKey] = null;
+            }
+            else if (oldIdxMapValLen > 1) {
+                for (let i = 0; i < oldIdxMapValLen; i++) {
+                    this._remove && this._remove((oldIdxMapVal as number[])[i]);
                 }
-                else {
-                    for (let j = 0, len = (idx as number[]).length; j < len; j++) {
-                        this._add && this._add((idx as number[])[j]);
-                    }
+            }
+            else {
+                this._remove && this._remove(oldIdxMapVal as number);
+            }
+        }
+
+        this._performRestAdd(newDataKeyArr, newDataIndexMap);
+    }
+
+    private _performRestAdd(newDataKeyArr: string[], newDataIndexMap: DataIndexMap) {
+        for (let i = 0; i < newDataKeyArr.length; i++) {
+            const newKey = newDataKeyArr[i];
+            const newIdxMapVal = newDataIndexMap[newKey];
+            const idxMapValLen = dataIndexMapValueLength(newIdxMapVal);
+            if (idxMapValLen > 1) {
+                for (let j = 0; j < idxMapValLen; j++) {
+                    this._add && this._add((newIdxMapVal as number[])[j]);
                 }
             }
+            else if (idxMapValLen === 1) {
+                this._add && this._add(newIdxMapVal as number);
+            }
+            // Support both `newDataKeyArr` are duplication removed or not removed.
+            newDataIndexMap[newKey] = null;
         }
     }
 
     private _initIndexMap(
         arr: ArrayLike<unknown>,
+        // Can be null.
         map: DataIndexMap,
+        // In 'byKey', the output `keyArr` is duplication removed.
+        // In 'byIndex', the output `keyArr` is not duplication removed and
+        //     its indices are accurately corresponding to `arr`.
         keyArr: string[],
         keyGetterName: '_oldKeyGetter' | '_newKeyGetter'
     ): void {
+        const cbModeByKey = this._cbModeMultiple;
+
         for (let i = 0; i < arr.length; i++) {
             // Add prefix to avoid conflict with Object.prototype.
             const key = '_ec_' + this[keyGetterName](arr[i], i);
-            let existence = map[key];
-            if (existence == null) {
-                keyArr.push(key);
+            if (!cbModeByKey) {
+                keyArr[i] = key;
+            }
+            if (!map) {
+                continue;
+            }
+
+            const idxMapVal = map[key];
+            const idxMapValLen = dataIndexMapValueLength(idxMapVal);
+
+            if (idxMapValLen === 0) {
+                // Simple optimize: in most cases, one index has one key,
+                // do not need array.
                 map[key] = i;
+                if (cbModeByKey) {
+                    keyArr.push(key);
+                }
+            }
+            else if (idxMapValLen === 1) {
+                map[key] = [idxMapVal as number, i];
             }
             else {
-                if (!(existence as number[]).length) {
-                    map[key] = existence = [existence as number];
-                }
-                (existence as number[]).push(i);
+                (idxMapVal as number[]).push(i);
             }
         }
     }
diff --git a/src/echarts.ts b/src/echarts.ts
index dbe4378..c27a65b 100644
--- a/src/echarts.ts
+++ b/src/echarts.ts
@@ -88,14 +88,15 @@ import {
     ComponentMainType,
     ComponentSubType,
     ColorString,
-    SelectChangedPayload
+    SelectChangedPayload,
+    DimensionLoose
 } from './util/types';
 import Displayable from 'zrender/src/graphic/Displayable';
 import IncrementalDisplayable from 'zrender/src/graphic/IncrementalDisplayable';
 import { seriesSymbolTask, dataSymbolTask } from './visual/symbol';
 import { getVisualFromData, getItemVisualFromData } from './visual/helper';
 import LabelManager from './label/LabelManager';
-import { deprecateLog } from './util/log';
+import { deprecateLog, throwError } from './util/log';
 import { handleLegacySelectEvents } from './legacy/dataSelectAction';
 
 // At least canvas renderer.
@@ -185,9 +186,19 @@ interface SetOptionOpts {
     silent?: boolean;
     // Rule: only `id` mapped will be merged,
     // other components of the certain `mainType` will be removed.
-    replaceMerge?: GlobalModelSetOptionOpts['replaceMerge']
+    replaceMerge?: GlobalModelSetOptionOpts['replaceMerge'];
+    transition?: SetOptionTransitionOptItem | SetOptionTransitionOptItem[];
 };
 
+interface SetOptionTransitionOptItem {
+    from: SetOptionTransitionOptFinder | DimensionLoose;
+    to: SetOptionTransitionOptFinder | DimensionLoose;
+}
+interface SetOptionTransitionOptFinder extends modelUtil.ModelFinderObject {
+    dimension: DimensionLoose;
+}
+
+
 type EventMethodName = 'on' | 'off';
 function createRegisterEventWithLowercaseECharts(method: EventMethodName) {
     return function (this: ECharts, ...args: any): ECharts {
@@ -254,6 +265,10 @@ let renderSeries: (
 let performPostUpdateFuncs: (ecModel: GlobalModel, api: ExtensionAPI) => void;
 let createExtensionAPI: (ecIns: ECharts) => ExtensionAPI;
 let enableConnect: (ecIns: ECharts) => void;
+let setTransitionOpt: (
+    chart: ECharts,
+    transitionOpt: SetOptionTransitionOptItem | SetOptionTransitionOptItem[]
+) => void;
 
 let markStatusToUpdate: (ecIns: ECharts) => void;
 let applyChangedStates: (ecIns: ECharts) => void;
@@ -309,6 +324,7 @@ class ECharts extends Eventful {
 
     private _labelManager: LabelManager;
 
+
     private [OPTION_UPDATED_KEY]: boolean | {silent: boolean};
     private [IN_MAIN_PROCESS_KEY]: boolean;
     private [CONNECT_STATUS_KEY]: ConnectStatus;
@@ -502,10 +518,12 @@ class ECharts extends Eventful {
 
         let silent;
         let replaceMerge;
+        let transitionOpt;
         if (isObject(notMerge)) {
             lazyUpdate = notMerge.lazyUpdate;
             silent = notMerge.silent;
             replaceMerge = notMerge.replaceMerge;
+            transitionOpt = notMerge.transition;
             notMerge = notMerge.notMerge;
         }
 
@@ -519,7 +537,9 @@ class ECharts extends Eventful {
             ecModel.init(null, null, null, theme, this._locale, optionManager);
         }
 
-        this._model.setOption(option as ECOption, {replaceMerge: replaceMerge}, optionPreprocessorFuncs);
+        this._model.setOption(option as ECOption, { replaceMerge }, optionPreprocessorFuncs);
+
+        setTransitionOpt(this, transitionOpt);
 
         if (lazyUpdate) {
             this[OPTION_UPDATED_KEY] = {silent: silent};
@@ -852,7 +872,7 @@ class ECharts extends Eventful {
         const ecModel = this._model;
 
         const parsedFinder = modelUtil.parseFinder(ecModel, finder, {
-            defaultMainType: 'series'
+            useDefaultMainType: ['series']
         });
 
         const seriesModel = parsedFinder.seriesModel;
@@ -1926,6 +1946,8 @@ class ECharts extends Eventful {
                     unfinished = true;
                 }
 
+                seriesModel.__transientTransitionOpt = null;
+
                 chartView.group.silent = !!seriesModel.get('silent');
                 // Should not call markRedraw on group, because it will disable zrender
                 // increamental render (alway render from the __startIndex each frame)
@@ -2260,6 +2282,70 @@ class ECharts extends Eventful {
             });
         };
 
+        setTransitionOpt = function (
+            chart: ECharts,
+            transitionOpt: SetOptionTransitionOptItem | SetOptionTransitionOptItem[]
+        ): void {
+            const ecModel = chart._model;
+            zrUtil.each(modelUtil.normalizeToArray(transitionOpt), transOpt => {
+
+                function normalizeFromTo(fromTo: DimensionLoose | SetOptionTransitionOptFinder) {
+                    return (zrUtil.isString(fromTo) || zrUtil.isNumber(fromTo))
+                        ? { dimension: fromTo }
+                        : fromTo;
+                }
+
+                let errMsg;
+                const fromOpt = normalizeFromTo(transOpt.from);
+                const toOpt = normalizeFromTo(transOpt.to);
+
+                if (fromOpt == null || toOpt == null) {
+                    if (__DEV__) {
+                        errMsg = '`transition.from` and `transition.to` must be specified.';
+                    }
+                    throwError(errMsg);
+                }
+
+                const finderOpt = {
+                    useDefaultMainType: ['series'],
+                    includeMainTypes: ['series'],
+                    enableAll: false,
+                    enableNone: false
+                };
+                const fromResult = modelUtil.parseFinder(ecModel, fromOpt, finderOpt);
+                const toResult = modelUtil.parseFinder(ecModel, toOpt, finderOpt);
+                const toSeries = toResult.seriesModel;
+
+                if (toSeries == null) {
+                    errMsg = '';
+                    if (__DEV__) {
+                        errMsg = '`transition` is only supported on series.';
+                    }
+                }
+                if (fromResult.seriesModel !== toSeries) {
+                    errMsg = '';
+                    if (__DEV__) {
+                        errMsg = '`transition.from` and `transition.to` must be specified to the same series.';
+                    }
+                }
+                if (fromOpt.dimension == null || toOpt.dimension == null) {
+                    errMsg = '';
+                    if (__DEV__) {
+                        errMsg = '`dimension` must be specified in `transition`.';
+                    }
+                }
+                if (errMsg != null) {
+                    throwError(errMsg);
+                }
+
+                // Just a temp solution: mount them on series.
+                toSeries.__transientTransitionOpt = {
+                    from: fromOpt.dimension,
+                    to: toOpt.dimension
+                };
+            });
+        };
+
     })();
 }
 
diff --git a/src/model/Global.ts b/src/model/Global.ts
index b1f5dfb..ec7927c 100644
--- a/src/model/Global.ts
+++ b/src/model/Global.ts
@@ -141,7 +141,7 @@ class GlobalModel extends Model<ECUnitOption> {
             'please use chart.getOption()'
         );
 
-        const innerOpt = normalizeReplaceMergeInput(opts);
+        const innerOpt = normalizeSetOptionInput(opts);
 
         this._optionManager.setOption(option, optionPreprocessorFuncs, innerOpt);
 
@@ -157,9 +157,9 @@ class GlobalModel extends Model<ECUnitOption> {
      */
     resetOption(
         type: 'recreate' | 'timeline' | 'media',
-        opt?: GlobalModelSetOptionOpts
+        opt?: Pick<GlobalModelSetOptionOpts, 'replaceMerge'>
     ): boolean {
-        return this._resetOption(type, normalizeReplaceMergeInput(opt));
+        return this._resetOption(type, normalizeSetOptionInput(opt));
     }
 
     private _resetOption(
@@ -933,7 +933,7 @@ function filterBySubType(
         : components;
 }
 
-function normalizeReplaceMergeInput(opts: GlobalModelSetOptionOpts): InnerSetOptionOpts {
+function normalizeSetOptionInput(opts: GlobalModelSetOptionOpts): InnerSetOptionOpts {
     const replaceMergeMainTypeMap = createHashMap<boolean, string>();
     opts && each(modelUtil.normalizeToArray(opts.replaceMerge), function (mainType) {
         if (__DEV__) {
diff --git a/src/model/Series.ts b/src/model/Series.ts
index a082994..3a0f774 100644
--- a/src/model/Series.ts
+++ b/src/model/Series.ts
@@ -23,7 +23,7 @@ import * as modelUtil from '../util/model';
 import {
     DataHost, DimensionName, StageHandlerProgressParams,
     SeriesOption, ZRColor, BoxLayoutOptionMixin,
-    ScaleDataValue, Dictionary, OptionDataItemObject, SeriesDataType
+    ScaleDataValue, Dictionary, OptionDataItemObject, SeriesDataType, DimensionLoose
 } from '../util/types';
 import ComponentModel, { ComponentModelConstructor } from './Component';
 import {ColorPaletteMixin} from './mixin/colorPalette';
@@ -130,6 +130,15 @@ class SeriesModel<Opt extends SeriesOption = SeriesOption> extends ComponentMode
     // Injected outside
     pipelineContext: PipelineContext;
 
+    // only avalible in `render()` caused by `setOption`.
+    __transientTransitionOpt: {
+        // [MEMO] Currently only support single "from". If intending to
+        // support multiple "from", if not hard to implement "merge morph",
+        // but correspondingly not easy to implement "split morph".
+        from: DimensionLoose;
+        to: DimensionLoose;
+    };
+
     // ---------------------------------------
     // Props to tell visual/style.ts about how to do visual encoding.
     // ---------------------------------------
diff --git a/src/util/model.ts b/src/util/model.ts
index c09a154..d7e46e8 100644
--- a/src/util/model.ts
+++ b/src/util/model.ts
@@ -27,7 +27,9 @@ import {
     assert,
     isString,
     indexOf,
-    isStringSafe
+    isStringSafe,
+    hasOwn,
+    defaults
 } from 'zrender/src/core/util';
 import env from 'zrender/src/core/env';
 import GlobalModel from '../model/Global';
@@ -750,6 +752,7 @@ let innerUniqueIndex = getRandomIdBase();
 export type ModelFinderIndexQuery = number | number[] | 'all' | 'none' | false;
 export type ModelFinderIdQuery = OptionId | OptionId[];
 export type ModelFinderNameQuery = OptionId | OptionId[];
+// If string, like 'series', means { seriesIndex: 0 }.
 export type ModelFinder = string | ModelFinderObject;
 export type ModelFinderObject = {
     seriesIndex?: ModelFinderIndexQuery, seriesId?: ModelFinderIdQuery, seriesName?: ModelFinderNameQuery
@@ -793,7 +796,13 @@ export type ParsedModelFinder = ParsedModelFinderKnown & {
 export function parseFinder(
     ecModel: GlobalModel,
     finderInput: ModelFinder,
-    opt?: {defaultMainType?: ComponentMainType, includeMainTypes?: ComponentMainType[]}
+    opt?: {
+        includeMainTypes?: ComponentMainType[];
+        // The `mainType` listed will set `useDefault: true`.
+        useDefaultMainType?: ComponentMainType[];
+        enableAll?: boolean;
+        enableNone?: boolean;
+    }
 ): ParsedModelFinder {
     let finder: ModelFinderObject;
     if (isString(finderInput)) {
@@ -805,7 +814,6 @@ export function parseFinder(
         finder = finderInput;
     }
 
-    const defaultMainType = opt ? opt.defaultMainType : null;
     const queryOptionMap = createHashMap<QueryReferringUserOption, ComponentMainType>();
     const result = {} as ParsedModelFinder;
 
@@ -823,7 +831,6 @@ export function parseFinder(
         if (
             !mainType
             || !queryType
-            || (mainType !== defaultMainType && value == null)
             || (opt && opt.includeMainTypes && indexOf(opt.includeMainTypes, mainType) < 0)
         ) {
             return;
@@ -833,15 +840,20 @@ export function parseFinder(
         queryOption[queryType] = value as any;
     });
 
+    const useDefaultMainType = opt && opt.useDefaultMainType || [];
+    each(useDefaultMainType, function (mainType) {
+        !queryOptionMap.get(mainType) && queryOptionMap.set(mainType, {});
+    });
+
     queryOptionMap.each(function (queryOption, mainType) {
         const queryResult = queryReferringComponents(
             ecModel,
             mainType,
             queryOption,
             {
-                useDefault: mainType === defaultMainType,
-                enableAll: true,
-                enableNone: true
+                useDefault: indexOf(useDefaultMainType, mainType) >= 0,
+                enableAll: (opt && opt.enableAll != null) ? opt.enableAll : true,
+                enableNone: (opt && opt.enableNone != null) ? opt.enableNone : true
             }
         );
         result[mainType + 'Models'] = queryResult.models;
diff --git a/src/util/types.ts b/src/util/types.ts
index d57e6b3..dbe4d21 100644
--- a/src/util/types.ts
+++ b/src/util/types.ts
@@ -46,6 +46,7 @@ import { PathStyleProps } from 'zrender/src/graphic/Path';
 import { ImageStyleProps } from 'zrender/src/graphic/Image';
 import ZRText, { TextStyleProps } from 'zrender/src/graphic/Text';
 import { Source } from '../data/Source';
+import { ModelFinderObject } from './model';
 
 
 
@@ -237,13 +238,42 @@ export interface StageHandlerOverallReset {
     (ecModel: GlobalModel, api: ExtensionAPI, payload?: Payload): void
 }
 export interface StageHandler {
-    seriesType?: string;
+    /**
+     * Indicate that the task will be only piped all series
+     * (`performRawSeries` indicate whether includes filtered series).
+     */
     createOnAllSeries?: boolean;
+    /**
+     * Indicate that the task will be only piped in the pipeline of this type of series.
+     * (`performRawSeries` indicate whether includes filtered series).
+     */
+    seriesType?: string;
+    /**
+     * Indicate that the task will be only piped in the pipeline of the returned series.
+     */
+    getTargetSeries?: (ecModel: GlobalModel, api: ExtensionAPI) => HashMap<SeriesModel>;
+
+    /**
+     * If `true`, filtered series will also be "performed".
+     */
     performRawSeries?: boolean;
+
+    /**
+     * Called only when this task in a pipeline.
+     */
     plan?: StageHandlerPlan;
+    /**
+     * If `overallReset` specified, an "overall task" will be created.
+     * "overall task" does not belong to a certain pipeline.
+     * They always be "performed" in certain phase (depends on when they declared).
+     * They has "stub"s to connect with pipelines (one stub for one pipeline),
+     * delivering info like "dirty" and "output end".
+     */
     overallReset?: StageHandlerOverallReset;
+    /**
+     * Called only when this task in a pipeline, and "dirty".
+     */
     reset?: StageHandlerReset;
-    getTargetSeries?: (ecModel: GlobalModel, api: ExtensionAPI) => HashMap<SeriesModel>;
 }
 
 export interface StageHandlerInternal extends StageHandler {
diff --git a/test/custom-shape-morphing2.html b/test/custom-shape-morphing2.html
new file mode 100644
index 0000000..01e8bc2
--- /dev/null
+++ b/test/custom-shape-morphing2.html
@@ -0,0 +1,388 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<html>
+    <head>
+        <meta charset='utf-8'>
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        <script src='lib/esl.js'></script>
+        <script src='lib/config.js'></script>
+        <script src='lib/jquery.min.js'></script>
+        <script src="lib/testHelper.js"></script>
+        <link rel="stylesheet" href="lib/reset.css" />
+    </head>
+    <body>
+        <style>
+        </style>
+
+        <div id="main0"></div>
+
+
+
+        <script>
+
+            require([
+                'echarts'
+            ], function (echarts) {
+
+                const COLORS = [
+                    '#37A2DA', '#e06343', '#37a354', '#b55dba', '#b5bd48', '#8378EA', '#96BFFF'
+                ];
+                var COUNT = 50;
+                var CONTENT_COLOR = '#37A2DA';
+
+                function makeRandomValue(range, precision) {
+                    return +(
+                        Math.random() * (range[1] - range[0]) + range[0]
+                    ).toFixed(precision);
+                }
+
+                var M_TAG_LIST = ['MA', 'MB', 'MC', 'MD'];
+                var Z_TAG_LIST = ['ZA', 'ZB', 'ZC', 'ZD', 'ZE'];
+                var ANIMATION_DURATION_UPDATE = 1500;
+
+                function initRawData() {
+                    var DIMENSION = {
+                        DATE: 0,
+                        ATA: 1,
+                        STE: 2,
+                        CTZ: 3,
+                        M_TAG: 4,
+                        Z_TAG: 5,
+                        ID: 6
+                    };
+                    var data = [];
+                    var currDate = +new Date(2015, 2, 1);
+                    var ONE_DAY = 3600 * 24 * 1000;
+                    for (var i = 0; i < COUNT; i++, currDate += ONE_DAY) {
+                        var line = [];
+                        data.push(line);
+                        line[DIMENSION.DATE] = currDate;
+                        line[DIMENSION.ATA] = makeRandomValue([10, 40], 0);
+                        line[DIMENSION.STE] = makeRandomValue([0.01, 0.99], 2);
+                        line[DIMENSION.CTZ] = makeRandomValue([1, 10], 1);
+                        line[DIMENSION.M_TAG] = M_TAG_LIST[makeRandomValue([0, M_TAG_LIST.length - 1], 0)];
+                        line[DIMENSION.Z_TAG] = Z_TAG_LIST[makeRandomValue([0, Z_TAG_LIST.length - 1], 0)];
+                        line[DIMENSION.ID] = 'P' + i;
+                    }
+                    return {
+                        DIMENSION: DIMENSION,
+                        data: data
+                    };
+                }
+                function aggregateSum(rawDataWrap, byDimProp, RESULT_DIMENSION) {
+                    var map = {};
+                    var result = [];
+                    var data = rawDataWrap.data;
+
+                    for (var i = 0; i < data.length; i++) {
+                        var line = data[i];
+                        var byVal = line[rawDataWrap.DIMENSION[byDimProp]];
+                        if (!map.hasOwnProperty(byVal)) {
+                            var newLine = [];
+                            map[byVal] = newLine;
+                            result.push(newLine);
+                            newLine[RESULT_DIMENSION.ATA] = 0;
+                            newLine[RESULT_DIMENSION.STE] = 0;
+                            newLine[RESULT_DIMENSION.CTZ] = 0;
+                            newLine[RESULT_DIMENSION[byDimProp]] = byVal;
+                        }
+                        else {
+                            var newLine = map[byVal];
+                            newLine[RESULT_DIMENSION.ATA] += line[rawDataWrap.DIMENSION.ATA];
+                            newLine[RESULT_DIMENSION.STE] += line[rawDataWrap.DIMENSION.STE];
+                            newLine[RESULT_DIMENSION.CTZ] += line[rawDataWrap.DIMENSION.CTZ];
+                        }
+                    }
+
+                    return {
+                        DIMENSION: RESULT_DIMENSION,
+                        data: result,
+                        uniqueKey: 'M_TAG'
+                    };
+                }
+
+                var rawDataWrap = initRawData();
+                // console.log(JSON.stringify(rawDataWrap.data));
+                rawDataWrap.data = [[1425139200000,34,0.13,2,"MD","ZD","P0"],[1425225600000,28,0.71,1.5,"MB","ZD","P1"],[1425312000000,23,0.9,2.8,"MA","ZC","P2"],[1425398400000,21,0.58,6,"MB","ZC","P3"],[1425484800000,14,0.1,1.6,"MC","ZA","P4"],[1425571200000,21,0.6,7.7,"MC","ZA","P5"],[1425657600000,23,0.31,2.6,"MC","ZC","P6"],[1425744000000,34,0.74,2.4,"MD","ZE","P7"],[1425830400000,14,0.59,2.3,"MB","ZD","P8"],[1425916800000,18,0.85,5.1,"MB","ZB","P9"],[1426003200000,36,0.96,1.2,"MC"," [...]
+                var mTagSumDataWrap = aggregateSum(rawDataWrap, 'M_TAG', {
+                    ATA: 0,
+                    STE: 1,
+                    CTZ: 2,
+                    M_TAG: 3
+                });
+                var zTagSumDataWrap = aggregateSum(rawDataWrap, 'Z_TAG', {
+                    ATA: 0,
+                    STE: 1,
+                    CTZ: 2,
+                    Z_TAG: 3
+                });
+
+
+                function create_Scatter_ATA_STE() {
+                    var option = {
+                        tooltip: {},
+                        grid: {
+                            containLabel: true
+                        },
+                        xAxis: {
+                            name: 'STE'
+                        },
+                        yAxis: {
+                            name: 'ATA'
+                        },
+                        dataZoom: [{
+                            type: 'slider',
+                        }, {
+                            type: 'inside'
+                        }],
+                        series: {
+                            type: 'custom',
+                            coordinateSystem: 'cartesian2d',
+                            animationDurationUpdate: ANIMATION_DURATION_UPDATE,
+                            data: rawDataWrap.data,
+                            encode: {
+                                itemName: rawDataWrap.DIMENSION.ID,
+                                x: rawDataWrap.DIMENSION.STE,
+                                y: rawDataWrap.DIMENSION.ATA,
+                                tooltip: [rawDataWrap.DIMENSION.STE, rawDataWrap.DIMENSION.ATA]
+                            },
+                            renderItem: function (params, api) {
+                                var pos = api.coord([
+                                    api.value(rawDataWrap.DIMENSION.STE),
+                                    api.value(rawDataWrap.DIMENSION.ATA)
+                                ]);
+                                return {
+                                    type: 'circle',
+                                    // x: pos[0],
+                                    // y: pos[1],
+                                    morph: true,
+                                    shape: {
+                                        // cx: 0,
+                                        // cy: 0,
+                                        cx: pos[0],
+                                        cy: pos[1],
+                                        r: 10,
+                                        transition: ['cx', 'cy', 'r']
+                                    },
+                                    style: {
+                                        transition: 'lineWidth',
+                                        fill: CONTENT_COLOR,
+                                        stroke: '#555',
+                                        lineWidth: 1
+                                    }
+                                };
+                            }
+                        }
+                    };
+
+                    return {
+                        option: option,
+                        dataWrap: rawDataWrap
+                    };
+                }
+
+                function create_Bar_mSum_ATA(mTagSumDataWrap) {
+                    var option = {
+                        tooltip: {},
+                        grid: {
+                            containLabel: true
+                        },
+                        xAxis: {
+                            type: 'category'
+                        },
+                        yAxis: {
+                        },
+                        dataZoom: [{
+                            type: 'slider',
+                        }, {
+                            type: 'inside'
+                        }],
+                        series: {
+                            type: 'custom',
+                            coordinateSystem: 'cartesian2d',
+                            animationDurationUpdate: ANIMATION_DURATION_UPDATE,
+                            data: mTagSumDataWrap.data,
+                            encode: {
+                                x: mTagSumDataWrap.DIMENSION.M_TAG,
+                                y: mTagSumDataWrap.DIMENSION.ATA,
+                                tooltip: [mTagSumDataWrap.DIMENSION.M_TAG, mTagSumDataWrap.DIMENSION.ATA]
+                            },
+                            renderItem: function (params, api) {
+                                var mTagVal = api.value(mTagSumDataWrap.DIMENSION.M_TAG);
+                                var ataVal = api.value(mTagSumDataWrap.DIMENSION.ATA);
+                                var tarPos = api.coord([mTagVal, ataVal]);
+                                var zeroPos = api.coord([mTagVal, 0]);
+                                var size = api.size([mTagVal, ataVal]);
+                                var width = size[0] * 0.4;
+                                return {
+                                    type: 'rect',
+                                    morph: true,
+                                    shape: {
+                                        x: tarPos[0] - width / 2,
+                                        y: tarPos[1],
+                                        height: zeroPos[1] - tarPos[1],
+                                        width: width,
+                                        transition: ['x', 'y', 'width', 'height']
+                                    },
+                                    style: {
+                                        transition: 'lineWidth',
+                                        fill: CONTENT_COLOR,
+                                        stroke: '#555',
+                                        lineWidth: 0
+                                    }
+                                };
+                            }
+                        }
+                    };
+
+                    return {
+                        option: option,
+                        dataWrap: mTagSumDataWrap
+                    };
+                }
+
+                function create_Pie_mSum_ATA(mTagSumDataWrap) {
+                    var totalValue = mTagSumDataWrap.data.reduce(function (val, item) {
+                        return val + item[mTagSumDataWrap.DIMENSION.ATA];
+                    }, 0);
+                    let angles = [];
+                    let currentAngle = -Math.PI / 2;
+                    for (let i = 0; i < mTagSumDataWrap.data.length; i++) {
+                        const angle = mTagSumDataWrap.data[i][mTagSumDataWrap.DIMENSION.ATA] / totalValue * Math.PI * 2;
+                        angles.push([currentAngle, angle + currentAngle]);
+                        currentAngle += angle;
+                    }
+
+                    var option = {
+                        tooltip: {},
+                        series: {
+                            type: 'custom',
+                            coordinateSystem: 'none',
+                            animationDurationUpdate: ANIMATION_DURATION_UPDATE,
+                            data: mTagSumDataWrap.data,
+                            encode: {
+                                itemName: mTagSumDataWrap.DIMENSION.M_TAG,
+                                value: mTagSumDataWrap.DIMENSION.ATA,
+                                tooltip: [mTagSumDataWrap.DIMENSION.ATA]
+                            },
+                            renderItem: function (params, api) {
+                                const width = chart.getWidth();
+                                const height = chart.getHeight();
+                                return {
+                                    type: 'sector',
+                                    morph: true,
+                                    shape: {
+                                        cx: width / 2,
+                                        cy: height / 2,
+                                        r: Math.min(width, height) / 3,
+                                        r0: Math.min(width, height) / 5,
+                                        startAngle: angles[params.dataIndex][0],
+                                        endAngle: angles[params.dataIndex][1],
+                                        clockwise: true
+                                    },
+                                    style: {
+                                        fill: CONTENT_COLOR,
+                                        stroke: '#555',
+                                        strokeNoScale: true
+                                    },
+                                };
+                            }
+                        }
+                    };
+
+                    return {
+                        option: option,
+                        dataWrap: mTagSumDataWrap
+                    };
+                }
+
+                function createScatter_zSum_ATA(zTagSumDataWrap) {
+                }
+
+
+                var currOptionName = 'Scatter_ATA_STE';
+                var optionInfoList = {
+                    'Scatter_ATA_STE': create_Scatter_ATA_STE(rawDataWrap),
+                    'Bar_mTagSum_ATA': create_Bar_mSum_ATA(mTagSumDataWrap),
+                    'Pie_mTagSum_ATA': create_Pie_mSum_ATA(mTagSumDataWrap),
+                };
+
+                function next(nextOptionName) {
+                    const lastOptionInfo = optionInfoList[currOptionName];
+                    const nextOptionInfo = optionInfoList[nextOptionName];
+
+                    const commonDimension = findCommonDimension(lastOptionInfo, nextOptionInfo)
+                        || findCommonDimension(nextOptionInfo, lastOptionInfo);
+                    const fromDimension = lastOptionInfo.dataWrap.DIMENSION[commonDimension];
+                    const toDimension = nextOptionInfo.dataWrap.DIMENSION[commonDimension];
+                    const transitionOpt = (fromDimension != null && toDimension != null)
+                        ? { from: fromDimension, to: toDimension } : null;
+
+                    currOptionName = nextOptionName;
+
+                    chart.setOption(nextOptionInfo.option, {
+                        replaceMerge: ['xAxis', 'yAxis'],
+                        transition: transitionOpt
+                    });
+                }
+
+                function findCommonDimension(optionInfoA, optionInfoB) {
+                    var uniqueKey = optionInfoB.dataWrap.uniqueKey;
+                    if (uniqueKey != null && optionInfoA.dataWrap.DIMENSION[uniqueKey] != null) {
+                        return uniqueKey;
+                    }
+                }
+
+                var chart = testHelper.create(echarts, 'main0', {
+                    title: [
+                        'Test: buttons, should morph animation merge/split.',
+                        'Test: click buttons **before animation finished**, should no blink.',
+                        'Test: click buttons **twice**, should no blink.',
+                        'Test: use dataZoom, update animation should exist'
+                    ],
+                    option: optionInfoList[currOptionName].option,
+                    height: 600,
+                    buttons: [{
+                        text: 'ToBar',
+                        onclick: function () {
+                            next('Bar_mTagSum_ATA');
+                        }
+                    }, {
+                        text: 'ToScatter',
+                        onclick: function () {
+                            next('Scatter_ATA_STE');
+                        }
+                    }, {
+                        text: 'ToPie',
+                        onclick: function () {
+                            next('Pie_mTagSum_ATA');
+                        }
+                    }]
+                });
+
+            });
+
+        </script>
+
+
+
+    </body>
+</html>
\ No newline at end of file


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@echarts.apache.org
For additional commands, e-mail: commits-help@echarts.apache.org