blob: 140afca2e62c5d0131fa2bac7799148fff3652a0 [file] [log] [blame]
/*
* 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);
}
}
});
}