| /* |
| * 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. |
| */ |
| |
| // Universal transitions that can animate between any shapes(series) and any properties in any amounts. |
| |
| import SeriesModel, { SERIES_UNIVERSAL_TRANSITION_PROP } from '../model/Series'; |
| import {createHashMap, each, map, filter, isArray} from 'zrender/src/core/util'; |
| import Element, { ElementAnimateConfig } from 'zrender/src/Element'; |
| import { applyMorphAnimation, getPathList } from './morphTransitionHelper'; |
| import Path from 'zrender/src/graphic/Path'; |
| import { EChartsExtensionInstallRegisters } from '../extension'; |
| import { initProps } from '../util/graphic'; |
| import DataDiffer from '../data/DataDiffer'; |
| import List from '../data/List'; |
| import { Dictionary, DimensionLoose, OptionDataItemObject, UniversalTransitionOption } from '../util/types'; |
| import { |
| UpdateLifecycleParams, |
| UpdateLifecycleTransitionItem, |
| UpdateLifecycleTransitionSeriesFinder |
| } from '../core/lifecycle'; |
| import { makeInner, normalizeToArray } from '../util/model'; |
| import { warn } from '../util/log'; |
| import ExtensionAPI from '../core/ExtensionAPI'; |
| import { getAnimationConfig, getOldStyle } from './basicTrasition'; |
| import Model from '../model/Model'; |
| import Displayable from 'zrender/src/graphic/Displayable'; |
| |
| const DATA_COUNT_THRESHOLD = 1e4; |
| |
| interface GlobalStore { oldSeries: SeriesModel[], oldData: List[] }; |
| const getUniversalTransitionGlobalStore = makeInner<GlobalStore, ExtensionAPI>(); |
| |
| interface DiffItem { |
| data: List |
| dim: DimensionLoose |
| divide: UniversalTransitionOption['divideShape'] |
| dataIndex: number |
| } |
| interface TransitionSeries { |
| data: List |
| divide: UniversalTransitionOption['divideShape'] |
| dim?: DimensionLoose |
| } |
| |
| function getGroupIdDimension(data: List) { |
| const dimensions = data.dimensions; |
| for (let i = 0; i < dimensions.length; i++) { |
| const dimInfo = data.getDimensionInfo(dimensions[i]); |
| if (dimInfo && dimInfo.otherDims.itemGroupId === 0) { |
| return dimensions[i]; |
| } |
| } |
| } |
| |
| function flattenDataDiffItems(list: TransitionSeries[]) { |
| const items: DiffItem[] = []; |
| |
| each(list, seriesInfo => { |
| const data = seriesInfo.data; |
| if (data.count() > DATA_COUNT_THRESHOLD) { |
| if (__DEV__) { |
| warn('Universal transition is disabled on large data > 10k.'); |
| } |
| return; |
| } |
| const indices = data.getIndices(); |
| const groupDim = getGroupIdDimension(data); |
| for (let dataIndex = 0; dataIndex < indices.length; dataIndex++) { |
| items.push({ |
| data, |
| dim: seriesInfo.dim || groupDim, |
| divide: seriesInfo.divide, |
| dataIndex |
| }); |
| } |
| }); |
| |
| return items; |
| } |
| |
| |
| function fadeInElement(newEl: Element, newSeries: SeriesModel, newIndex: number) { |
| newEl.traverse(el => { |
| if (el instanceof Path) { |
| // TODO use fade in animation for target element. |
| initProps(el, { |
| style: { |
| opacity: 0 |
| } |
| }, newSeries, { |
| dataIndex: newIndex, |
| isFrom: true |
| }); |
| } |
| }); |
| } |
| function removeEl(el: Element) { |
| if (el.parent) { |
| // Bake parent transform to element. |
| // So it can still have proper transform to transition after it's removed. |
| const computedTransform = el.getComputedTransform(); |
| el.setLocalTransform(computedTransform); |
| el.parent.remove(el); |
| } |
| } |
| function stopAnimation(el: Element) { |
| el.stopAnimation(); |
| if (el.isGroup) { |
| el.traverse(child => { |
| child.stopAnimation(); |
| }); |
| } |
| } |
| function animateElementStyles(el: Element, dataIndex: number, seriesModel: SeriesModel) { |
| const animationConfig = getAnimationConfig('update', seriesModel, dataIndex); |
| el.traverse(child => { |
| if (child instanceof Displayable) { |
| const oldStyle = getOldStyle(child); |
| if (oldStyle) { |
| child.animateFrom({ |
| style: oldStyle |
| }, animationConfig); |
| } |
| } |
| }); |
| } |
| |
| |
| function isAllIdSame(oldDiffItems: DiffItem[], newDiffItems: DiffItem[]) { |
| const len = oldDiffItems.length; |
| if (len !== newDiffItems.length) { |
| return false; |
| } |
| for (let i = 0; i < len; i++) { |
| const oldItem = oldDiffItems[i]; |
| const newItem = newDiffItems[i]; |
| if (oldItem.data.getId(oldItem.dataIndex) !== newItem.data.getId(newItem.dataIndex)) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| function transitionBetween( |
| oldList: TransitionSeries[], |
| newList: TransitionSeries[], |
| api: ExtensionAPI |
| ) { |
| |
| const oldDiffItems = flattenDataDiffItems(oldList); |
| const newDiffItems = flattenDataDiffItems(newList); |
| |
| function updateMorphingPathProps( |
| from: Path, to: Path, |
| rawFrom: Path, rawTo: Path, |
| animationCfg: ElementAnimateConfig |
| ) { |
| if (rawFrom || from) { |
| to.animateFrom({ |
| style: (rawFrom || from).style |
| }, animationCfg); |
| } |
| } |
| |
| |
| function findKeyDim(items: DiffItem[]) { |
| for (let i = 0; i < items.length; i++) { |
| if (items[i].dim) { |
| return items[i].dim; |
| } |
| } |
| } |
| const oldKeyDim = findKeyDim(oldDiffItems); |
| const newKeyDim = findKeyDim(newDiffItems); |
| |
| let hasMorphAnimation = false; |
| |
| function createKeyGetter(isOld: boolean, onlyGetId: boolean) { |
| return function (diffItem: DiffItem): string { |
| const data = diffItem.data; |
| const dataIndex = diffItem.dataIndex; |
| // TODO if specified dim |
| if (onlyGetId) { |
| return data.getId(dataIndex); |
| } |
| |
| // Use group id as transition key by default. |
| // So we can achieve multiple to multiple animation like drilldown / up naturally. |
| // If group id not exits. Use id instead. If so, only one to one transition will be applied. |
| const dataGroupId = data.hostModel && (data.hostModel as SeriesModel).get('dataGroupId') as string; |
| |
| // If specified key dimension(itemGroupId by default). Use this same dimension from other data. |
| // PENDING: If only use key dimension of newData. |
| const keyDim = isOld |
| ? (oldKeyDim || newKeyDim) |
| : (newKeyDim || oldKeyDim); |
| |
| const dimInfo = keyDim && data.getDimensionInfo(keyDim); |
| const dimOrdinalMeta = dimInfo && dimInfo.ordinalMeta; |
| |
| if (dimInfo) { |
| // Get from encode.itemGroupId. |
| const key = data.get(dimInfo.name, dataIndex); |
| if (dimOrdinalMeta) { |
| return dimOrdinalMeta.categories[key as number] as string || (key + ''); |
| } |
| return key + ''; |
| } |
| |
| // Get groupId from raw item. { groupId: '' } |
| const itemVal = data.getRawDataItem(dataIndex) as OptionDataItemObject<unknown>; |
| if (itemVal && itemVal.groupId) { |
| return itemVal.groupId + ''; |
| } |
| return (dataGroupId || data.getId(dataIndex)); |
| }; |
| } |
| |
| // Use id if it's very likely to be an one to one animation |
| // It's more robust than groupId |
| // TODO Check if key dimension is specified. |
| const useId = isAllIdSame(oldDiffItems, newDiffItems); |
| const isElementStillInChart: Dictionary<boolean> = {}; |
| |
| if (!useId) { |
| // We may have different diff strategy with basicTransition if we use other dimension as key. |
| // If so, we can't simply check if oldEl is same with newEl. We need a map to check if oldEl is still being used in the new chart. |
| // We can't use the elements that already being morphed. Let it keep it's original basic transition. |
| for (let i = 0; i < newDiffItems.length; i++) { |
| const newItem = newDiffItems[i]; |
| const el = newItem.data.getItemGraphicEl(newItem.dataIndex); |
| if (el) { |
| isElementStillInChart[el.id] = true; |
| } |
| } |
| } |
| |
| function updateOneToOne(newIndex: number, oldIndex: number) { |
| |
| const oldItem = oldDiffItems[oldIndex]; |
| const newItem = newDiffItems[newIndex]; |
| |
| const newSeries = newItem.data.hostModel as SeriesModel; |
| |
| // TODO Mark this elements is morphed and don't morph them anymore |
| const oldEl = oldItem.data.getItemGraphicEl(oldItem.dataIndex); |
| const newEl = newItem.data.getItemGraphicEl(newItem.dataIndex); |
| |
| // Can't handle same elements. |
| if (oldEl === newEl) { |
| newEl && animateElementStyles(newEl, newItem.dataIndex, newSeries); |
| return; |
| } |
| |
| if ( |
| // We can't use the elements that already being morphed |
| (oldEl && isElementStillInChart[oldEl.id]) |
| ) { |
| return; |
| } |
| |
| if (newEl) { |
| // TODO: If keep animating the group in case |
| // some of the elements don't want to be morphed. |
| // TODO Label? |
| stopAnimation(newEl); |
| |
| if (oldEl) { |
| stopAnimation(oldEl); |
| |
| // If old element is doing leaving animation. stop it and remove it immediately. |
| removeEl(oldEl); |
| |
| hasMorphAnimation = true; |
| applyMorphAnimation( |
| getPathList(oldEl), |
| getPathList(newEl), |
| newItem.divide, |
| newSeries, |
| newIndex, |
| updateMorphingPathProps |
| ); |
| } |
| else { |
| fadeInElement(newEl, newSeries, newIndex); |
| } |
| } |
| // else keep oldEl leaving animation. |
| } |
| |
| (new DataDiffer( |
| oldDiffItems, |
| newDiffItems, |
| createKeyGetter(true, useId), |
| createKeyGetter(false, useId), |
| null, |
| 'multiple' |
| )) |
| .update(updateOneToOne) |
| .updateManyToOne(function (newIndex, oldIndices) { |
| const newItem = newDiffItems[newIndex]; |
| const newData = newItem.data; |
| const newSeries = newData.hostModel as SeriesModel; |
| const newEl = newData.getItemGraphicEl(newItem.dataIndex); |
| const oldElsList = filter( |
| map(oldIndices, idx => |
| oldDiffItems[idx].data.getItemGraphicEl(oldDiffItems[idx].dataIndex) |
| ), |
| oldEl => oldEl && oldEl !== newEl && !isElementStillInChart[oldEl.id] |
| ); |
| |
| if (newEl) { |
| stopAnimation(newEl); |
| if (oldElsList.length) { |
| // If old element is doing leaving animation. stop it and remove it immediately. |
| each(oldElsList, oldEl => { |
| stopAnimation(oldEl); |
| removeEl(oldEl); |
| }); |
| |
| hasMorphAnimation = true; |
| applyMorphAnimation( |
| getPathList(oldElsList), |
| getPathList(newEl), |
| newItem.divide, |
| newSeries, |
| newIndex, |
| updateMorphingPathProps |
| ); |
| |
| } |
| else { |
| fadeInElement(newEl, newSeries, newItem.dataIndex); |
| } |
| } |
| // else keep oldEl leaving animation. |
| }) |
| .updateOneToMany(function (newIndices, oldIndex) { |
| const oldItem = oldDiffItems[oldIndex]; |
| const oldEl = oldItem.data.getItemGraphicEl(oldItem.dataIndex); |
| |
| // We can't use the elements that already being morphed |
| if (oldEl && isElementStillInChart[oldEl.id]) { |
| return; |
| } |
| |
| const newElsList = filter( |
| map(newIndices, idx => |
| newDiffItems[idx].data.getItemGraphicEl(newDiffItems[idx].dataIndex) |
| ), |
| el => el && el !== oldEl |
| ); |
| const newSeris = newDiffItems[newIndices[0]].data.hostModel as SeriesModel; |
| |
| if (newElsList.length) { |
| each(newElsList, newEl => stopAnimation(newEl)); |
| if (oldEl) { |
| stopAnimation(oldEl); |
| // If old element is doing leaving animation. stop it and remove it immediately. |
| removeEl(oldEl); |
| |
| hasMorphAnimation = true; |
| applyMorphAnimation( |
| getPathList(oldEl), |
| getPathList(newElsList), |
| oldItem.divide, // Use divide on old. |
| newSeris, |
| newIndices[0], |
| updateMorphingPathProps |
| ); |
| } |
| else { |
| each(newElsList, newEl => fadeInElement(newEl, newSeris, newIndices[0])); |
| } |
| } |
| |
| // else keep oldEl leaving animation. |
| }) |
| .updateManyToMany(function (newIndices, oldIndices) { |
| // If two data are same and both have groupId. |
| // Normally they should be diff by id. |
| new DataDiffer( |
| oldIndices, |
| newIndices, |
| (rawIdx: number) => oldDiffItems[rawIdx].data.getId(oldDiffItems[rawIdx].dataIndex), |
| (rawIdx: number) => newDiffItems[rawIdx].data.getId(newDiffItems[rawIdx].dataIndex) |
| ).update((newIndex, oldIndex) => { |
| // Use the original index |
| updateOneToOne(newIndices[newIndex], oldIndices[oldIndex]); |
| }).execute(); |
| }) |
| .execute(); |
| |
| if (hasMorphAnimation) { |
| each(newList, ({ data }) => { |
| const seriesModel = data.hostModel as SeriesModel; |
| const view = seriesModel && api.getViewOfSeriesModel(seriesModel as SeriesModel); |
| const animationCfg = getAnimationConfig('update', seriesModel, 0); // use 0 index. |
| if (view && seriesModel.isAnimationEnabled() && animationCfg.duration > 0) { |
| view.group.traverse(el => { |
| if (el instanceof Path && !el.animators.length) { |
| // We can't accept there still exists element that has no animation |
| // if universalTransition is enabled |
| el.animateFrom({ |
| style: { |
| opacity: 0 |
| } |
| }, animationCfg); |
| } |
| }); |
| } |
| }); |
| } |
| } |
| |
| function getSeriesTransitionKey(series: SeriesModel) { |
| const seriesKey = (series.getModel('universalTransition') as Model<UniversalTransitionOption>) |
| .get('seriesKey'); |
| if (!seriesKey) { |
| // Use series id by default. |
| return series.id; |
| } |
| return seriesKey; |
| } |
| |
| function convertArraySeriesKeyToString(seriesKey: string[] | string) { |
| if (isArray(seriesKey)) { |
| // Order independent. |
| return seriesKey.sort().join(','); |
| } |
| return seriesKey; |
| } |
| |
| interface SeriesTransitionBatch { |
| oldSeries: TransitionSeries[] |
| newSeries: TransitionSeries[] |
| } |
| |
| function getDivideShapeFromData(data: List) { |
| if (data.hostModel) { |
| return ((data.hostModel as SeriesModel) |
| .getModel('universalTransition') as Model<UniversalTransitionOption>) |
| .get('divideShape'); |
| } |
| } |
| |
| function findTransitionSeriesBatches( |
| globalStore: GlobalStore, |
| params: UpdateLifecycleParams |
| ) { |
| const updateBatches = createHashMap<SeriesTransitionBatch>(); |
| |
| const oldDataMap = createHashMap<List>(); |
| // Map that only store key in array seriesKey. |
| // Which is used to query the old data when transition from one to multiple series. |
| const oldDataMapForSplit = createHashMap<{ |
| key: string, |
| data: List |
| }>(); |
| |
| each(globalStore.oldSeries, (series, idx) => { |
| const oldData = globalStore.oldData[idx]; |
| const transitionKey = getSeriesTransitionKey(series); |
| const transitionKeyStr = convertArraySeriesKeyToString(transitionKey); |
| oldDataMap.set(transitionKeyStr, oldData); |
| |
| if (isArray(transitionKey)) { |
| // Same key can't in different array seriesKey. |
| each(transitionKey, key => { |
| oldDataMapForSplit.set(key, { |
| data: oldData, |
| key: transitionKeyStr |
| }); |
| }); |
| } |
| }); |
| |
| function checkTransitionSeriesKeyDuplicated(transitionKeyStr: string) { |
| if (updateBatches.get(transitionKeyStr)) { |
| warn(`Duplicated seriesKey in universalTransition ${transitionKeyStr}`); |
| } |
| } |
| each(params.updatedSeries, series => { |
| if (series.isUniversalTransitionEnabled() && series.isAnimationEnabled()) { |
| const newData = series.getData(); |
| const transitionKey = getSeriesTransitionKey(series); |
| const transitionKeyStr = convertArraySeriesKeyToString(transitionKey); |
| // Only transition between series with same id. |
| const oldData = oldDataMap.get(transitionKeyStr); |
| // string transition key is the best match. |
| if (oldData) { |
| if (__DEV__) { |
| checkTransitionSeriesKeyDuplicated(transitionKeyStr); |
| } |
| // TODO check if data is same? |
| updateBatches.set(transitionKeyStr, { |
| oldSeries: [{ |
| divide: getDivideShapeFromData(oldData), |
| data: oldData |
| }], |
| newSeries: [{ |
| divide: getDivideShapeFromData(newData), |
| data: newData |
| }] |
| }); |
| } |
| else { |
| // Transition from multiple series. |
| if (isArray(transitionKey)) { |
| if (__DEV__) { |
| checkTransitionSeriesKeyDuplicated(transitionKeyStr); |
| } |
| const oldSeries: TransitionSeries[] = []; |
| each(transitionKey, key => { |
| const oldData = oldDataMap.get(key); |
| if (oldData) { |
| oldSeries.push({ |
| divide: getDivideShapeFromData(oldData), |
| data: oldData |
| }); |
| } |
| }); |
| if (oldSeries.length) { |
| updateBatches.set(transitionKeyStr, { |
| oldSeries, |
| newSeries: [{ |
| data: newData, |
| divide: getDivideShapeFromData(newData) |
| }] |
| }); |
| } |
| } |
| else { |
| // Try transition to multiple series. |
| const oldData = oldDataMapForSplit.get(transitionKey); |
| if (oldData) { |
| let batch = updateBatches.get(oldData.key); |
| if (!batch) { |
| batch = { |
| oldSeries: [{ |
| data: oldData.data, |
| divide: getDivideShapeFromData(oldData.data) |
| }], |
| newSeries: [] |
| }; |
| updateBatches.set(oldData.key, batch); |
| } |
| batch.newSeries.push({ |
| data: newData, |
| divide: getDivideShapeFromData(newData) |
| }); |
| } |
| } |
| } |
| } |
| }); |
| |
| return updateBatches; |
| } |
| |
| function querySeries(series: SeriesModel[], finder: UpdateLifecycleTransitionSeriesFinder) { |
| for (let i = 0; i < series.length; i++) { |
| const found = finder.seriesIndex != null && finder.seriesIndex === series[i].seriesIndex |
| || finder.seriesId != null && finder.seriesId === series[i].id; |
| if (found) { |
| return i; |
| } |
| } |
| } |
| |
| function transitionSeriesFromOpt( |
| transitionOpt: UpdateLifecycleTransitionItem, |
| globalStore: GlobalStore, |
| params: UpdateLifecycleParams, |
| api: ExtensionAPI |
| ) { |
| const from: TransitionSeries[] = []; |
| const to: TransitionSeries[] = []; |
| each(normalizeToArray(transitionOpt.from), finder => { |
| const idx = querySeries(globalStore.oldSeries, finder); |
| if (idx >= 0) { |
| from.push({ |
| data: globalStore.oldData[idx], |
| // TODO can specify divideShape in transition. |
| divide: getDivideShapeFromData(globalStore.oldData[idx]), |
| dim: finder.dimension |
| }); |
| } |
| }); |
| each(normalizeToArray(transitionOpt.to), finder => { |
| const idx = querySeries(params.updatedSeries, finder); |
| if (idx >= 0) { |
| const data = params.updatedSeries[idx].getData(); |
| to.push({ |
| data, |
| divide: getDivideShapeFromData(data), |
| dim: finder.dimension |
| }); |
| } |
| }); |
| if (from.length > 0 && to.length > 0) { |
| transitionBetween(from, to, api); |
| } |
| } |
| |
| export function installUniversalTransition(registers: EChartsExtensionInstallRegisters) { |
| |
| registers.registerUpdateLifecycle('series:beforeupdate', (ecMOdel, api, params) => { |
| each(normalizeToArray(params.seriesTransition), transOpt => { |
| each(normalizeToArray(transOpt.to), (finder) => { |
| const series = params.updatedSeries; |
| for (let i = 0; i < series.length; i++) { |
| if (finder.seriesIndex != null && finder.seriesIndex === series[i].seriesIndex |
| || finder.seriesId != null && finder.seriesId === series[i].id) { |
| series[i][SERIES_UNIVERSAL_TRANSITION_PROP] = true; |
| } |
| } |
| }); |
| }); |
| }); |
| registers.registerUpdateLifecycle('series:transition', (ecModel, api, params) => { |
| // TODO api provide an namespace that can save stuff per instance |
| const globalStore = getUniversalTransitionGlobalStore(api); |
| |
| // TODO multiple to multiple series. |
| if (globalStore.oldSeries && params.updatedSeries && params.optionChanged) { |
| // Use give transition config if its' give; |
| const transitionOpt = params.seriesTransition; |
| if (transitionOpt) { |
| each(normalizeToArray(transitionOpt), opt => { |
| transitionSeriesFromOpt(opt, globalStore, params, api); |
| }); |
| } |
| else { // Else guess from series based on transition series key. |
| const updateBatches = findTransitionSeriesBatches(globalStore, params); |
| each(updateBatches.keys(), key => { |
| const batch = updateBatches.get(key); |
| transitionBetween(batch.oldSeries, batch.newSeries, api); |
| }); |
| } |
| |
| // Reset |
| each(params.updatedSeries, series => { |
| // Reset; |
| if (series[SERIES_UNIVERSAL_TRANSITION_PROP]) { |
| series[SERIES_UNIVERSAL_TRANSITION_PROP] = false; |
| } |
| }); |
| } |
| |
| // Save all series of current update. Not only the updated one. |
| const allSeries = ecModel.getSeries(); |
| const savedSeries: SeriesModel[] = globalStore.oldSeries = []; |
| const savedData: List[] = globalStore.oldData = []; |
| for (let i = 0; i < allSeries.length; i++) { |
| const data = allSeries[i].getData(); |
| // Only save the data that can have transition. |
| // Avoid large data costing too much extra memory |
| if (data.count() < DATA_COUNT_THRESHOLD) { |
| savedSeries.push(allSeries[i]); |
| savedData.push(data); |
| } |
| } |
| }); |
| } |