/* * 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, extend} 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 SeriesData from '../data/SeriesData'; 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 './basicTransition'; import Model from '../model/Model'; import Displayable from 'zrender/src/graphic/Displayable'; const DATA_COUNT_THRESHOLD = 1e4; interface GlobalStore { oldSeries: SeriesModel[], oldDataGroupIds: string[], oldData: SeriesData[] }; const getUniversalTransitionGlobalStore = makeInner(); interface DiffItem { dataGroupId: string data: SeriesData dim: DimensionLoose divide: UniversalTransitionOption['divideShape'] dataIndex: number } interface TransitionSeries { dataGroupId: string data: SeriesData divide: UniversalTransitionOption['divideShape'] dim?: DimensionLoose } function getGroupIdDimension(data: SeriesData) { 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({ dataGroupId: seriesInfo.dataGroupId, 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); animationConfig && 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 && rawFrom !== from) // dividingMethod like clone may override the style(opacity) // So extend it to raw style. ? extend(extend({}, rawFrom.style), from.style) : 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 = diffItem.dataGroupId; // 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; 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 = {}; 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 && 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) .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: SeriesData) { if (data.hostModel) { return ((data.hostModel as SeriesModel) .getModel('universalTransition') as Model) .get('divideShape'); } } function findTransitionSeriesBatches( globalStore: GlobalStore, params: UpdateLifecycleParams ) { const updateBatches = createHashMap(); const oldDataMap = createHashMap<{ dataGroupId: string, data: SeriesData }>(); // 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, dataGroupId: string, data: SeriesData }>(); each(globalStore.oldSeries, (series, idx) => { const oldDataGroupId = globalStore.oldDataGroupIds[idx] as string; const oldData = globalStore.oldData[idx]; const transitionKey = getSeriesTransitionKey(series); const transitionKeyStr = convertArraySeriesKeyToString(transitionKey); oldDataMap.set(transitionKeyStr, { dataGroupId: oldDataGroupId, data: oldData }); if (isArray(transitionKey)) { // Same key can't in different array seriesKey. each(transitionKey, key => { oldDataMapForSplit.set(key, { key: transitionKeyStr, dataGroupId: oldDataGroupId, data: oldData }); }); } }); function checkTransitionSeriesKeyDuplicated(transitionKeyStr: string) { if (updateBatches.get(transitionKeyStr)) { warn(`Duplicated seriesKey in universalTransition ${transitionKeyStr}`); } } each(params.updatedSeries, series => { if (series.isUniversalTransitionEnabled() && series.isAnimationEnabled()) { const newDataGroupId = series.get('dataGroupId') as string; 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: [{ dataGroupId: oldData.dataGroupId, divide: getDivideShapeFromData(oldData.data), data: oldData.data }], newSeries: [{ dataGroupId: newDataGroupId, 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.data) { oldSeries.push({ dataGroupId: oldData.dataGroupId, divide: getDivideShapeFromData(oldData.data), data: oldData.data }); } }); if (oldSeries.length) { updateBatches.set(transitionKeyStr, { oldSeries, newSeries: [{ dataGroupId: newDataGroupId, 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: [{ dataGroupId: oldData.dataGroupId, data: oldData.data, divide: getDivideShapeFromData(oldData.data) }], newSeries: [] }; updateBatches.set(oldData.key, batch); } batch.newSeries.push({ dataGroupId: newDataGroupId, 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({ dataGroupId: globalStore.oldDataGroupIds[idx], 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({ dataGroupId: globalStore.oldDataGroupIds[idx], 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 savedDataGroupIds: string[] = globalStore.oldDataGroupIds = []; const savedData: SeriesData[] = 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]); savedDataGroupIds.push(allSeries[i].get('dataGroupId') as string); savedData.push(data); } } }); }