If an <animate>
animation begins while another one is running, the animated attribute jumps to some weird intermediate value (and animates from there). How is that value computed?
And the speed of the animation becomes weird and then becomes normal again midway.
Click for example "Go left" (optionally wait for the animation to finish), "Go right" (don't wait for this one to finish), "Go left":
<svg>
<circle cx="10" cy="10" r="5" />
<circle cx="60" cy="10" r="5" />
<circle cx="110" cy="10" r="5" />
<circle cx="60" cy="10" r="5" fill="orange">
<animate id="go-right" attributeName="cx" to="110" dur="2s" begin="indefinite" fill="freeze" additive="replace" accumulate="none" />
<animate id="go-left" attributeName="cx" to="10" dur="2s" begin="indefinite" fill="freeze" additive="replace" accumulate="none" />
</circle>
</svg>
<script>
goLeft = document.getElementById('go-left');
goRight = document.getElementById('go-right');
</script>
<button onclick="goLeft.beginElement()">Go left</button>
<button onclick="goRight.beginElement()">Go right</button>
Spec Section 12.4.3 The animation sandwich model says
A non-additive animation simply overrides the result of all lower sandwich layers.
Spec Section 12.4.5 The animation effect function F(t,u) says
Higher priority animations that are not additive will override all earlier (lower priority) animations, and simply set the attribute value.
And I explicitly set these animations to be neither additive nor cumulative.
So why do the animations influence each other? Specifically, how is the value computed?
Adding calls to endElement()
makes the values even weirder.
This is because when there is no from
attribute, the so-called to-animation can override the additive
attribute in a "mix of additive and non-additive" behavior. This is all described in https://www.w3.org/TR/2001/REC-smil-animation-20010904/#FromToByAndAdditive
- If
to
is used withoutfrom
, (to animation) and if the attribute supports addition, the animation is defined to be a kind of mix of additive and non-additive. The underlying value is used as a starting point as with additive animation, however the ending value specified by theto
attribute overrides the underlying value as though the animation was non-additive.For the hybrid case of a to-animation, the animation function
f(t)
is defined in terms of the underlying value, the specifiedto
value, and the current value oft
(i.e. time) relative to the simple durationd
.d is the simple duration
t is a time within the simple duration (0 <= t <= d)
vcur is the current base value (at time t)
vto is the defined "to" value
f(t) = vcur + ((vto - vcur) * (t/d))
Note that if no other (lower priority) animations are active or frozen, this defines simple interpolation. However if another animation is manipulating the base value, the to-animation will add to the effect of the lower priority, but will dominate it as it nears the end of the simple duration, eventually overriding it completely. The value for
F(t)
when a to-animation is frozen (at the end of the simple duration) is just theto
value. If a to-animation is frozen anywhere within the simple duration (e.g., using a repeatCount of "2.5"), the value forF(t)
when the animation is frozen is the value computed for the end of the active duration. Even if other, lower priority animations are active while a to-animation is frozen, the value forF(t)
does not change.Multiple to-animations will also combine according to these semantics. The higher-priority animation will "win", and the end result will be to set the attribute to the final value of the higher-priority to-animation.
Now, when you start the same animation again, its animated value is reset, its layer in the "sandwich" model is moved to top, and thus the underlying animation, which was the winning one before, now doesn't mix with it anymore; instead its vcur
value is set back to the original attribute value. The animation will thus jump to that position, before starting mixing the newly reset animation with it.
Below snippet, using easier to compare [-100 ~ 0 ~ 100] animations and controlled by timeout might help visualize it better:
const goLeft = document.getElementById('go-left');
const goRight = document.getElementById('go-right');
const getVal = () => target.cx.animVal.value;
goRight.beginElement();
/*
layers left right
to 100 -100
t 0 -
vcur 0 -
pos 0 -
*/
setTimeout(() => {
const before = getVal();
goLeft.beginElement();
/*
layers left right
to 100 -100
t 5 0
vcur 0 50(= previous layer's current pos)
pos 50 50 + ((-100 - 50) * (0/10)) = 50
*/
// We wrap in rAF to let beginElement apply its value (in Chrome)
requestAnimationFrame(() => {
console.log({
expectedBefore: 0 + ((100 - 0) * (5/10)), // 50
before,
expectedAfter: 50 + ((-100 - 50) * (0/10)), // 50
after: getVal(),
});
});
}, 5000);
setTimeout(() => {
/* Before
layers left right
to 100 -100
t 9 4
vcur 0 90
pos 90 90 + ((-100 - 90) * (4/10)) = 14
*/
const before = getVal();
goRight.beginElement();
/* After (beware layers have switched)
layers right left
to 100 -100
t 4 0
vcur 0 -40
pos -40 -40 + ((100 - -40) * (0/10)) = -40
*/
requestAnimationFrame(() => {
console.log({
expectedBefore: 90 + ((-100 - 90) * (4/10)), // 14
before,
after: getVal(),
expectedAfter: -40 + ((100 - -40) * (0/10)), // -40
});
});
}, 9000);
setTimeout(() => {
/*
layers right left
to 100 -100
t 8 4
vcur 0 -80
pos -80 -80 + ((100 - -80) * (4/10)) = -8
*/
console.log({
expected: -80 + ((100 - -80) * (4/10)),
actual: getVal()
});
}, 13000);
<svg viewBox="-110 0 220 500">
<circle cx="-100" cy="10" r="5" />
<circle cx="0" cy="10" r="5" />
<circle cx="100" cy="10" r="5" />
<circle cx="0" cy="10" r="5" fill="orange" id="target">
<animate id="go-right" attributeName="cx" to="100" dur="10s" begin="indefinite" fill="freeze" additive="replace" accumulate="none" />
<animate id="go-left" attributeName="cx" to="-100" dur="10s" begin="indefinite" fill="freeze" additive="replace" accumulate="none" />
</circle>
</svg>
And here you can find a simple function that will try to maintain the list of active layers for every animated elements and allow to calculate their expected current value (for linear animations only).
const elementsMap = new WeakMap();
const getLayers = (animElem) => {
const target = animElem.targetElement;
if (!target) {
return;
}
const attribute = animElem.getAttribute("attributeName");
if (!elementsMap.has(target)) {
elementsMap.set(target, {});
}
const dict = elementsMap.get(target);
dict[attribute] ??= new Set();
return dict[attribute];
};
addEventListener("beginEvent", (evt) => {
const layers = getLayers(evt.target);
if (!layers) return;
layers.delete(evt.target);
layers.add(evt.target);
}, true);
addEventListener("endEvent", (evt) => {
if (evt.target.getAttribute("fill") === "freeze") {
return;
}
const layers = getLayers(evt.target);
if (!layers) return;
layers.delete(evt.target);
}, true);
function calcVal(element, attribute) {
const layers = elementsMap.get(element)?.[attribute] || new Set();
let vCur = element[attribute].baseVal.value;
for (const layer of layers) {
if (layer.hasAttribute("from") && layer.getAttribute("additive") !== "sum") {
vCur = +layer.getAttribute("from");
}
const vTo = +layer.getAttribute("to");
const d = layer.getSimpleDuration();
let t;
try {
t = layer.getCurrentTime() - layer.getStartTime();
} catch (err) {
t = d; // fill="freeze" & ended -> getStartTime() throws
}
// to-do: handle calcMode
vCur = vCur + ((vTo - vCur) * (t/d))
}
return vCur;
}
const target = document.getElementById('target');
const getVal = () => target.cx.animVal.value;
function activate(direction) {
console.log("before", { actual: getVal(), expected: calcVal(target, "cx") });
document.getElementById(`go-${direction}`).beginElement();
requestAnimationFrame(() => {
console.log("after", { actual: getVal(), expected: calcVal(target, "cx") });
});
}
.as-console-wrapper { position: static!important };
<svg>
<circle cx="10" cy="10" r="5" />
<circle cx="60" cy="10" r="5" />
<circle cx="110" cy="10" r="5" />
<circle cx="60" cy="10" r="5" fill="orange" id="target">
<animate id="go-right" attributeName="cx" to="110" dur="2s" begin="indefinite" additive="replace" accumulate="none" fill="freeze" />
<animate id="go-left" attributeName="cx" to="10" dur="2s" begin="indefinite" additive="replace" accumulate="none" fill="freeze" />
</circle>
</svg>
<button onclick="activate('left')">Go left</button>
<button onclick="activate('right')">Go right</button>