Skip to main content

ECharts Morph Transition

· 15 min read

TLDR

This article explores how ECharts implements morphing transitions between different shapes. We'll examine the core implementation in the source code, breaking down the process into key steps including path conversion, structure alignment, and optimal rotation calculation. Through this analysis, we'll understand how ECharts achieves smooth shape transformations while maintaining visual continuity.

Introduction

The concept of morphing transition is a common animation effect used to smoothly transform between different shapes, providing a gentle visual guide for users without appearing abrupt. ECharts also has this effect built-in, and while it might have some shortcomings compared to the transition effects in GSAP, it can meet the animation needs for most scenarios.

The following content requires a basic understanding of Bézier curves. If you are not familiar with Bézier curves, I strongly recommend watching the excellent Bézier curve tutorial videos below.

Shape morph transition

In ECharts, use the applyMorphAnimation function as the entry point for morphing transitions. https://github.com/apache/echarts/blob/master/src/animation/morphTransitionHelper.ts#L110

// src/animation/morphTransitionHelper.ts
export function applyMorphAnimation(
from: DescendentPaths | DescendentPaths[],
to: DescendentPaths | DescendentPaths[],
divideShape: UniversalTransitionOption['divideShape'],
seriesModel: SeriesModel,
dataIndex: number,
animateOtherProps: (
fromIndividual: Path,
toIndividual: Path,
rawFrom: Path,
rawTo: Path,
animationCfg: ElementAnimateConfig
) => void
) {
// ...
}

You can see that the types for "from" and "to" can be arrays, which means ECharts internally handles one-to-one, many-to-one, one-to-many transformations, many-to-many is implemented by another function. Here, we'll start with the simplest one-to-one example to delve into the details.

In the Zrender/src/tool/morphPath.ts file, you can see the implementation of the morphing transition effect.

The process of handling transformations mainly involves three steps:

  1. Convert the path into an array of Cubic Bézier curves.
  2. Align the subpaths to ensure that both arrays of Bézier curves have the same number of subpaths and that each subpath contains the same Bézier curves.
  3. Find the optimal rotation angle to avoid kinks during the transition.

Convert the path into an array of Cubic Bézier curves

Note the author's comments in the code on "how to fit an arc using Bézier curves," as this is an important issue.

export function pathToBezierCurves(path: PathProxy) {
const data = path.data;
const len = path.len();

const bezierArrayGroups: number[][] = [];
let currentSubpath: number[];

let xi = 0;
let yi = 0;
let x0 = 0;
let y0 = 0;

function createNewSubpath(x: number, y: number) {
// More than one M command
if (currentSubpath && currentSubpath.length > 2) {
bezierArrayGroups.push(currentSubpath);
}
currentSubpath = [x, y];
}

function addLine(x0: number, y0: number, x1: number, y1: number) {
if (!(aroundEqual(x0, x1) && aroundEqual(y0, y1))) {
currentSubpath.push(x0, y0, x1, y1, x1, y1);
}
}

function addArc(
startAngle: number,
endAngle: number,
cx: number,
cy: number,
rx: number,
ry: number
) {
// https://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves
const delta = Math.abs(endAngle - startAngle);
const len = (Math.tan(delta / 4) * 4) / 3;
const dir = endAngle < startAngle ? -1 : 1;

const c1 = Math.cos(startAngle);
const s1 = Math.sin(startAngle);
const c2 = Math.cos(endAngle);
const s2 = Math.sin(endAngle);

const x1 = c1 * rx + cx;
const y1 = s1 * ry + cy;

const x4 = c2 * rx + cx;
const y4 = s2 * ry + cy;

const hx = rx * len * dir;
const hy = ry * len * dir;
currentSubpath.push(
// Move control points on tangent.
x1 - hx * s1,
y1 + hy * c1,
x4 + hx * s2,
y4 - hy * c2,
x4,
y4
);
}

let x1;
let y1;
let x2;
let y2;

for (let i = 0; i < len; ) {
const cmd = data[i++];
const isFirst = i === 1;

if (isFirst) {
// If the first command is L, C, Q
// Then the previous point is the same as the first point of the drawing command
// The first command is Arc, which will be handled separately later
xi = data[i];
yi = data[i + 1];

x0 = xi;
y0 = yi;

if (cmd === CMD.L || cmd === CMD.C || cmd === CMD.Q) {
// Start point
currentSubpath = [x0, y0];
}
}

switch (cmd) {
case CMD.M:
// moveTo command creates a new subpath and updates the new starting point
// Used when calling closePath
xi = x0 = data[i++];
yi = y0 = data[i++];

createNewSubpath(x0, y0);
break;
case CMD.L:
x1 = data[i++];
y1 = data[i++];
addLine(xi, yi, x1, y1);
xi = x1;
yi = y1;
break;
case CMD.C:
currentSubpath.push(
data[i++],
data[i++],
data[i++],
data[i++],
(xi = data[i++]),
(yi = data[i++])
);
break;
case CMD.Q:
x1 = data[i++];
y1 = data[i++];
x2 = data[i++];
y2 = data[i++];
currentSubpath.push(
// Convert quadratic to cubic
xi + (2 / 3) * (x1 - xi),
yi + (2 / 3) * (y1 - yi),
x2 + (2 / 3) * (x1 - x2),
y2 + (2 / 3) * (y1 - y2),
x2,
y2
);
xi = x2;
yi = y2;
break;
case CMD.A:
const cx = data[i++];
const cy = data[i++];
const rx = data[i++];
const ry = data[i++];
const startAngle = data[i++];
const endAngle = data[i++] + startAngle;

// TODO Arc rotation
i += 1;
const anticlockwise = !data[i++];

x1 = Math.cos(startAngle) * rx + cx;
y1 = Math.sin(startAngle) * ry + cy;
if (isFirst) {
// Directly use arc command
// First command starting point not yet defined
x0 = x1;
y0 = y1;
createNewSubpath(x0, y0);
} else {
// Connect a line between current point to arc start point.
addLine(xi, yi, x1, y1);
}

xi = Math.cos(endAngle) * rx + cx;
yi = Math.sin(endAngle) * ry + cy;

const step = ((anticlockwise ? -1 : 1) * Math.PI) / 2;

for (
let angle = startAngle;
anticlockwise ? angle > endAngle : angle < endAngle;
angle += step
) {
const nextAngle = anticlockwise
? Math.max(angle + step, endAngle)
: Math.min(angle + step, endAngle);
addArc(angle, nextAngle, cx, cy, rx, ry);
}

break;
case CMD.R:
x0 = xi = data[i++];
y0 = yi = data[i++];
x1 = x0 + data[i++];
y1 = y0 + data[i++];

// rect is an individual path.
createNewSubpath(x1, y0);
addLine(x1, y0, x1, y1);
addLine(x1, y1, x0, y1);
addLine(x0, y1, x0, y0);
addLine(x0, y0, x1, y0);
break;
case CMD.Z:
currentSubpath && addLine(xi, yi, x0, y0);
xi = x0;
yi = y0;
break;
}
}

if (currentSubpath && currentSubpath.length > 2) {
bezierArrayGroups.push(currentSubpath);
}

return bezierArrayGroups;
}

Align the subpaths

Handle three situations:

  • When subpath1 does not exist:
    • Create a new subpath based on subpath2 using createSubpath
    • Keep subpath2 unchanged
  • When subpath2 does not exist:
    • Create a new subpath based on subpath1 using createSubpath
    • Keep subpath1 unchanged
  • When both subpaths exist:
    • Align the two subpaths using the alignSubpath function
    • Update lastSubpath1 and lastSubpath2 for subsequent use
/**
* Make two bezier arrays aligns on structure. To have better animation.
*
* It will:
* Make two bezier arrays have same number of subpaths.
* Make each subpath has equal number of bezier curves.
*
* array is the convert result of pathToBezierCurves.
*/
export function alignBezierCurves(array1: number[][], array2: number[][]) {
let lastSubpath1;
let lastSubpath2;

let newArray1 = [];
let newArray2 = [];

for (let i = 0; i < Math.max(array1.length, array2.length); i++) {
const subpath1 = array1[i];
const subpath2 = array2[i];

let newSubpath1;
let newSubpath2;

if (!subpath1) {
newSubpath1 = createSubpath(lastSubpath1 || subpath2, subpath2);
newSubpath2 = subpath2;
} else if (!subpath2) {
newSubpath2 = createSubpath(lastSubpath2 || subpath1, subpath1);
newSubpath1 = subpath1;
} else {
[newSubpath1, newSubpath2] = alignSubpath(subpath1, subpath2);
lastSubpath1 = newSubpath1;
lastSubpath2 = newSubpath2;
}

newArray1.push(newSubpath1);
newArray2.push(newSubpath2);
}

return [newArray1, newArray2];
}

Find the optimal rotation angle

  • Align the two subpaths by calculating their centroids.
  • Find the best starting point match using findBestRingOffset.
  • Use rotational search to determine the angle that minimizes the transformation distance.
  • Use the sum of squared distances as the evaluation criterion.

This is almost identical to GSAP's animation approach. You can get more details from the video.

/**
* If we interpolating between two bezier curve arrays.
* It will have many broken effects during the transition.
* So we try to apply an extra rotation which can make each bezier curve morph as small as possible.
*/
function findBestMorphingRotation(
fromArr: number[][],
toArr: number[][],
searchAngleIteration: number,
searchAngleRange: number
): MorphingData {
const result = [];

let fromNeedsReverse: boolean;

for (let i = 0; i < fromArr.length; i++) {
let fromSubpathBezier = fromArr[i];
const toSubpathBezier = toArr[i];

const fromCp = centroid(fromSubpathBezier);
const toCp = centroid(toSubpathBezier);

if (fromNeedsReverse == null) {
// Reverse from array if two have different directions.
// Determine the clockwise based on the first subpath.
// Reverse all subpaths or not. Avoid winding rule changed.
fromNeedsReverse = fromCp[2] < 0 !== toCp[2] < 0;
}

const newFromSubpathBezier: number[] = [];
const newToSubpathBezier: number[] = [];
let bestAngle = 0;
let bestScore = Infinity;
let tmpArr: number[] = [];

const len = fromSubpathBezier.length;
if (fromNeedsReverse) {
// Make sure clockwise
fromSubpathBezier = reverse(fromSubpathBezier);
}
const offset =
findBestRingOffset(fromSubpathBezier, toSubpathBezier, fromCp, toCp) * 6;

const len2 = len - 2;
for (let k = 0; k < len2; k += 2) {
// Not include the start point.
const idx = ((offset + k) % len2) + 2;
newFromSubpathBezier[k + 2] = fromSubpathBezier[idx] - fromCp[0];
newFromSubpathBezier[k + 3] = fromSubpathBezier[idx + 1] - fromCp[1];
}
newFromSubpathBezier[0] = fromSubpathBezier[offset] - fromCp[0];
newFromSubpathBezier[1] = fromSubpathBezier[offset + 1] - fromCp[1];

if (searchAngleIteration > 0) {
const step = searchAngleRange / searchAngleIteration;
for (
let angle = -searchAngleRange / 2;
angle <= searchAngleRange / 2;
angle += step
) {
const sa = Math.sin(angle);
const ca = Math.cos(angle);
let score = 0;

for (let k = 0; k < fromSubpathBezier.length; k += 2) {
const x0 = newFromSubpathBezier[k];
const y0 = newFromSubpathBezier[k + 1];
const x1 = toSubpathBezier[k] - toCp[0];
const y1 = toSubpathBezier[k + 1] - toCp[1];

// Apply rotation on the target point.
const newX1 = x1 * ca - y1 * sa;
const newY1 = x1 * sa + y1 * ca;

tmpArr[k] = newX1;
tmpArr[k + 1] = newY1;

const dx = newX1 - x0;
const dy = newY1 - y0;

// Use dot product to have min direction change.
// const d = Math.sqrt(x0 * x0 + y0 * y0);
// score += x0 * dx / d + y0 * dy / d;
score += dx * dx + dy * dy;
}

if (score < bestScore) {
bestScore = score;
bestAngle = angle;
// Copy.
for (let m = 0; m < tmpArr.length; m++) {
newToSubpathBezier[m] = tmpArr[m];
}
}
}
} else {
for (let i = 0; i < len; i += 2) {
newToSubpathBezier[i] = toSubpathBezier[i] - toCp[0];
newToSubpathBezier[i + 1] = toSubpathBezier[i + 1] - toCp[1];
}
}

result.push({
from: newFromSubpathBezier,
to: newToSubpathBezier,
fromCp,
toCp,
rotation: -bestAngle,
});
}
return result;
}

Universal morph transition

One-to-many and many-to-one transitions

After exploring the above content, you might have gained some understanding of basic morph transition. Next, let's return to the applyMorphAnimation function to see how ECharts handles one-to-many and many-to-one transitions.

export function applyMorphAnimation(
from: DescendentPaths | DescendentPaths[],
to: DescendentPaths | DescendentPaths[],
divideShape: UniversalTransitionOption['divideShape'],
seriesModel: SeriesModel,
dataIndex: number,
animateOtherProps: (
fromIndividual: Path,
toIndividual: Path,
rawFrom: Path,
rawTo: Path,
animationCfg: ElementAnimateConfig
) => void
) {
if (!from.length || !to.length) {
return;
}

const updateAnimationCfg = getAnimationConfig(
'update',
seriesModel,
dataIndex
);
if (!(updateAnimationCfg && updateAnimationCfg.duration > 0)) {
return;
}
const animationDelay = (
seriesModel.getModel(
'universalTransition'
) as Model<UniversalTransitionOption>
).get('delay');

const animationCfg = Object.assign(
{
// Need to setToFinal so the further calculation based on the style can be correct.
// Like emphasis color.
setToFinal: true,
} as SeparateConfig,
updateAnimationCfg
);

let many: DescendentPaths[];
let one: DescendentPaths;
if (isMultiple(from)) {
// manyToOne
many = from;
one = to as DescendentPaths;
}
if (isMultiple(to)) {
// oneToMany
many = to;
one = from as DescendentPaths;
}

function morphOneBatch(
batch: MorphingBatch,
fromIsMany: boolean,
animateIndex: number,
animateCount: number,
forceManyOne?: boolean
) {
const batchMany = batch.many;
const batchOne = batch.one;
if (batchMany.length === 1 && !forceManyOne) {
// Is one to one
const batchFrom: Path = fromIsMany ? batchMany[0] : batchOne;
const batchTo: Path = fromIsMany ? batchOne : batchMany[0];

if (isCombineMorphing(batchFrom as Path)) {
// Keep doing combine animation.
morphOneBatch(
{
many: [batchFrom as Path],
one: batchTo as Path,
},
true,
animateIndex,
animateCount,
true
);
} else {
const individualAnimationCfg = animationDelay
? defaults(
{
delay: animationDelay(animateIndex, animateCount),
} as ElementAnimateConfig,
animationCfg
)
: animationCfg;
morphPath(batchFrom, batchTo, individualAnimationCfg);
animateOtherProps(
batchFrom,
batchTo,
batchFrom,
batchTo,
individualAnimationCfg
);
}
} else {
const separateAnimationCfg = defaults(
{
dividePath: pathDividers[divideShape],
individualDelay:
animationDelay &&
function (idx, count, fromPath, toPath) {
return animationDelay(idx + animateIndex, animateCount);
},
} as SeparateConfig,
animationCfg
);

const { fromIndividuals, toIndividuals } = fromIsMany
? combineMorph(batchMany, batchOne, separateAnimationCfg)
: separateMorph(batchOne, batchMany, separateAnimationCfg);

const count = fromIndividuals.length;
for (let k = 0; k < count; k++) {
const individualAnimationCfg = animationDelay
? defaults(
{
delay: animationDelay(k, count),
} as ElementAnimateConfig,
animationCfg
)
: animationCfg;
animateOtherProps(
fromIndividuals[k],
toIndividuals[k],
fromIsMany ? batchMany[k] : batch.one,
fromIsMany ? batch.one : batchMany[k],
individualAnimationCfg
);
}
}
}

const fromIsMany = many
? many === from
: // Is one to one. If the path number not match. also needs do merge and separate morphing.
from.length > to.length;

const morphBatches = many
? prepareMorphBatches(one, many)
: prepareMorphBatches((fromIsMany ? to : from) as DescendentPaths, [
(fromIsMany ? from : to) as DescendentPaths,
]);
let animateCount = 0;
for (let i = 0; i < morphBatches.length; i++) {
animateCount += morphBatches[i].many.length;
}
let animateIndex = 0;
for (let i = 0; i < morphBatches.length; i++) {
morphOneBatch(morphBatches[i], fromIsMany, animateIndex, animateCount);
animateIndex += morphBatches[i].many.length;
}
}
interface MorphingBatch {
one: Path;
many: Path[];
}

function prepareMorphBatches(one: DescendentPaths, many: DescendentPaths[]) {
const batches: MorphingBatch[] = [];
const batchCount = one.length;
for (let i = 0; i < batchCount; i++) {
batches.push({
one: one[i],
many: [],
});
}

for (let i = 0; i < many.length; i++) {
const len = many[i].length;
let k;
for (k = 0; k < len; k++) {
batches[k % batchCount].many.push(many[i][k]);
}
}

let off = 0;
// If one has more paths than each one of many. average them.
for (let i = batchCount - 1; i >= 0; i--) {
if (!batches[i].many.length) {
const moveFrom = batches[off].many;
if (moveFrom.length <= 1) {
// Not enough
// Start from the first one.
if (off) {
off = 0;
} else {
return batches;
}
}
const len = moveFrom.length;
const mid = Math.ceil(len / 2);
batches[i].many = moveFrom.slice(mid, len);
batches[off].many = moveFrom.slice(0, mid);

off++;
}
}

return batches;
}

The code here is extensive and may require sufficient time to fully understand and digest, but the main steps are as follows:

  • Parameter processing: Format the animation parameters and the participants in the transformation (from, to).
  • Split into batches: Split the formatted from and to into batches using the prepareMorphBatches function.
  • Morph each batch: Perform the transformation for each batch using the morphOneBatch function.

The code above is for process control, meaning it doesn't involve the low-level computational code like matrices or paths. These low-level operations are implemented in Zrender.If you're interested, you can view the source code here: Zrender/morphPath.ts

tip

The morphPath file contains some of the most core/low-level code. I haven't fully mastered it yet, but I strongly believe that if you want better morph transition effects, this part is crucial/important.

You can clearly see that ECharts has exposed animation parameters externally, but only to the application layer of the source code, which is the Series/Component layer. It hasn't been directly exposed in the Options that users interact with. This may lower the application threshold, allowing beginners to use ECharts' customized packages/templates with minimal effort. For more in-depth customization of ECharts effects, you can leverage the official extension API for source-level control.

Many-to-many transitions

Many-to-many transformations use a diff-based approach for animation arrangement, which is intuitive. For example, morphing from a scatter plot to a stacked bar chart, as illustrated in the diagram.

We expect the pre- and post-morph graphics to represent the same set of data. Generally, the number of graphical elements before and after the morph is consistent, but occasionally there might be significant changes, such as depicting [evolution], where the data dimensions change over time during the transformation. This could lead to some old elements disappearing and new elements emerging, while elements spanning both periods morph accordingly.

In fact, diff is the core animation mechanism of ECharts. You can see diff-related code in the render function of almost every series view file. Whenever you use setOption on an existing ECharts instance, if the new data has the same ID as the existing data, ECharts will automatically complete the transition animations. This is an ideal design.

Regarding universalTransition, I recommend checking the source code.