Files
igny8/frontend/node_modules/@svgdotjs/svg.js/src/animation/Runner.js
2025-11-09 10:27:02 +00:00

1086 lines
28 KiB
JavaScript

import { Controller, Ease, Stepper } from './Controller.js'
import { extend, register } from '../utils/adopter.js'
import { from, to } from '../modules/core/gradiented.js'
import { getOrigin } from '../utils/utils.js'
import { noop, timeline } from '../modules/core/defaults.js'
import { registerMethods } from '../utils/methods.js'
import { rx, ry } from '../modules/core/circled.js'
import Animator from './Animator.js'
import Box from '../types/Box.js'
import EventTarget from '../types/EventTarget.js'
import Matrix from '../types/Matrix.js'
import Morphable, { TransformBag, ObjectBag } from './Morphable.js'
import Point from '../types/Point.js'
import SVGNumber from '../types/SVGNumber.js'
import Timeline from './Timeline.js'
export default class Runner extends EventTarget {
constructor(options) {
super()
// Store a unique id on the runner, so that we can identify it later
this.id = Runner.id++
// Ensure a default value
options = options == null ? timeline.duration : options
// Ensure that we get a controller
options = typeof options === 'function' ? new Controller(options) : options
// Declare all of the variables
this._element = null
this._timeline = null
this.done = false
this._queue = []
// Work out the stepper and the duration
this._duration = typeof options === 'number' && options
this._isDeclarative = options instanceof Controller
this._stepper = this._isDeclarative ? options : new Ease()
// We copy the current values from the timeline because they can change
this._history = {}
// Store the state of the runner
this.enabled = true
this._time = 0
this._lastTime = 0
// At creation, the runner is in reset state
this._reseted = true
// Save transforms applied to this runner
this.transforms = new Matrix()
this.transformId = 1
// Looping variables
this._haveReversed = false
this._reverse = false
this._loopsDone = 0
this._swing = false
this._wait = 0
this._times = 1
this._frameId = null
// Stores how long a runner is stored after being done
this._persist = this._isDeclarative ? true : null
}
static sanitise(duration, delay, when) {
// Initialise the default parameters
let times = 1
let swing = false
let wait = 0
duration = duration ?? timeline.duration
delay = delay ?? timeline.delay
when = when || 'last'
// If we have an object, unpack the values
if (typeof duration === 'object' && !(duration instanceof Stepper)) {
delay = duration.delay ?? delay
when = duration.when ?? when
swing = duration.swing || swing
times = duration.times ?? times
wait = duration.wait ?? wait
duration = duration.duration ?? timeline.duration
}
return {
duration: duration,
delay: delay,
swing: swing,
times: times,
wait: wait,
when: when
}
}
active(enabled) {
if (enabled == null) return this.enabled
this.enabled = enabled
return this
}
/*
Private Methods
===============
Methods that shouldn't be used externally
*/
addTransform(transform) {
this.transforms.lmultiplyO(transform)
return this
}
after(fn) {
return this.on('finished', fn)
}
animate(duration, delay, when) {
const o = Runner.sanitise(duration, delay, when)
const runner = new Runner(o.duration)
if (this._timeline) runner.timeline(this._timeline)
if (this._element) runner.element(this._element)
return runner.loop(o).schedule(o.delay, o.when)
}
clearTransform() {
this.transforms = new Matrix()
return this
}
// TODO: Keep track of all transformations so that deletion is faster
clearTransformsFromQueue() {
if (
!this.done ||
!this._timeline ||
!this._timeline._runnerIds.includes(this.id)
) {
this._queue = this._queue.filter((item) => {
return !item.isTransform
})
}
}
delay(delay) {
return this.animate(0, delay)
}
duration() {
return this._times * (this._wait + this._duration) - this._wait
}
during(fn) {
return this.queue(null, fn)
}
ease(fn) {
this._stepper = new Ease(fn)
return this
}
/*
Runner Definitions
==================
These methods help us define the runtime behaviour of the Runner or they
help us make new runners from the current runner
*/
element(element) {
if (element == null) return this._element
this._element = element
element._prepareRunner()
return this
}
finish() {
return this.step(Infinity)
}
loop(times, swing, wait) {
// Deal with the user passing in an object
if (typeof times === 'object') {
swing = times.swing
wait = times.wait
times = times.times
}
// Sanitise the values and store them
this._times = times || Infinity
this._swing = swing || false
this._wait = wait || 0
// Allow true to be passed
if (this._times === true) {
this._times = Infinity
}
return this
}
loops(p) {
const loopDuration = this._duration + this._wait
if (p == null) {
const loopsDone = Math.floor(this._time / loopDuration)
const relativeTime = this._time - loopsDone * loopDuration
const position = relativeTime / this._duration
return Math.min(loopsDone + position, this._times)
}
const whole = Math.floor(p)
const partial = p % 1
const time = loopDuration * whole + this._duration * partial
return this.time(time)
}
persist(dtOrForever) {
if (dtOrForever == null) return this._persist
this._persist = dtOrForever
return this
}
position(p) {
// Get all of the variables we need
const x = this._time
const d = this._duration
const w = this._wait
const t = this._times
const s = this._swing
const r = this._reverse
let position
if (p == null) {
/*
This function converts a time to a position in the range [0, 1]
The full explanation can be found in this desmos demonstration
https://www.desmos.com/calculator/u4fbavgche
The logic is slightly simplified here because we can use booleans
*/
// Figure out the value without thinking about the start or end time
const f = function (x) {
const swinging = s * Math.floor((x % (2 * (w + d))) / (w + d))
const backwards = (swinging && !r) || (!swinging && r)
const uncliped =
(Math.pow(-1, backwards) * (x % (w + d))) / d + backwards
const clipped = Math.max(Math.min(uncliped, 1), 0)
return clipped
}
// Figure out the value by incorporating the start time
const endTime = t * (w + d) - w
position =
x <= 0
? Math.round(f(1e-5))
: x < endTime
? f(x)
: Math.round(f(endTime - 1e-5))
return position
}
// Work out the loops done and add the position to the loops done
const loopsDone = Math.floor(this.loops())
const swingForward = s && loopsDone % 2 === 0
const forwards = (swingForward && !r) || (r && swingForward)
position = loopsDone + (forwards ? p : 1 - p)
return this.loops(position)
}
progress(p) {
if (p == null) {
return Math.min(1, this._time / this.duration())
}
return this.time(p * this.duration())
}
/*
Basic Functionality
===================
These methods allow us to attach basic functions to the runner directly
*/
queue(initFn, runFn, retargetFn, isTransform) {
this._queue.push({
initialiser: initFn || noop,
runner: runFn || noop,
retarget: retargetFn,
isTransform: isTransform,
initialised: false,
finished: false
})
const timeline = this.timeline()
timeline && this.timeline()._continue()
return this
}
reset() {
if (this._reseted) return this
this.time(0)
this._reseted = true
return this
}
reverse(reverse) {
this._reverse = reverse == null ? !this._reverse : reverse
return this
}
schedule(timeline, delay, when) {
// The user doesn't need to pass a timeline if we already have one
if (!(timeline instanceof Timeline)) {
when = delay
delay = timeline
timeline = this.timeline()
}
// If there is no timeline, yell at the user...
if (!timeline) {
throw Error('Runner cannot be scheduled without timeline')
}
// Schedule the runner on the timeline provided
timeline.schedule(this, delay, when)
return this
}
step(dt) {
// If we are inactive, this stepper just gets skipped
if (!this.enabled) return this
// Update the time and get the new position
dt = dt == null ? 16 : dt
this._time += dt
const position = this.position()
// Figure out if we need to run the stepper in this frame
const running = this._lastPosition !== position && this._time >= 0
this._lastPosition = position
// Figure out if we just started
const duration = this.duration()
const justStarted = this._lastTime <= 0 && this._time > 0
const justFinished = this._lastTime < duration && this._time >= duration
this._lastTime = this._time
if (justStarted) {
this.fire('start', this)
}
// Work out if the runner is finished set the done flag here so animations
// know, that they are running in the last step (this is good for
// transformations which can be merged)
const declarative = this._isDeclarative
this.done = !declarative && !justFinished && this._time >= duration
// Runner is running. So its not in reset state anymore
this._reseted = false
let converged = false
// Call initialise and the run function
if (running || declarative) {
this._initialise(running)
// clear the transforms on this runner so they dont get added again and again
this.transforms = new Matrix()
converged = this._run(declarative ? dt : position)
this.fire('step', this)
}
// correct the done flag here
// declarative animations itself know when they converged
this.done = this.done || (converged && declarative)
if (justFinished) {
this.fire('finished', this)
}
return this
}
/*
Runner animation methods
========================
Control how the animation plays
*/
time(time) {
if (time == null) {
return this._time
}
const dt = time - this._time
this.step(dt)
return this
}
timeline(timeline) {
// check explicitly for undefined so we can set the timeline to null
if (typeof timeline === 'undefined') return this._timeline
this._timeline = timeline
return this
}
unschedule() {
const timeline = this.timeline()
timeline && timeline.unschedule(this)
return this
}
// Run each initialise function in the runner if required
_initialise(running) {
// If we aren't running, we shouldn't initialise when not declarative
if (!running && !this._isDeclarative) return
// Loop through all of the initialisers
for (let i = 0, len = this._queue.length; i < len; ++i) {
// Get the current initialiser
const current = this._queue[i]
// Determine whether we need to initialise
const needsIt = this._isDeclarative || (!current.initialised && running)
running = !current.finished
// Call the initialiser if we need to
if (needsIt && running) {
current.initialiser.call(this)
current.initialised = true
}
}
}
// Save a morpher to the morpher list so that we can retarget it later
_rememberMorpher(method, morpher) {
this._history[method] = {
morpher: morpher,
caller: this._queue[this._queue.length - 1]
}
// We have to resume the timeline in case a controller
// is already done without being ever run
// This can happen when e.g. this is done:
// anim = el.animate(new SVG.Spring)
// and later
// anim.move(...)
if (this._isDeclarative) {
const timeline = this.timeline()
timeline && timeline.play()
}
}
// Try to set the target for a morpher if the morpher exists, otherwise
// Run each run function for the position or dt given
_run(positionOrDt) {
// Run all of the _queue directly
let allfinished = true
for (let i = 0, len = this._queue.length; i < len; ++i) {
// Get the current function to run
const current = this._queue[i]
// Run the function if its not finished, we keep track of the finished
// flag for the sake of declarative _queue
const converged = current.runner.call(this, positionOrDt)
current.finished = current.finished || converged === true
allfinished = allfinished && current.finished
}
// We report when all of the constructors are finished
return allfinished
}
// do nothing and return false
_tryRetarget(method, target, extra) {
if (this._history[method]) {
// if the last method wasn't even initialised, throw it away
if (!this._history[method].caller.initialised) {
const index = this._queue.indexOf(this._history[method].caller)
this._queue.splice(index, 1)
return false
}
// for the case of transformations, we use the special retarget function
// which has access to the outer scope
if (this._history[method].caller.retarget) {
this._history[method].caller.retarget.call(this, target, extra)
// for everything else a simple morpher change is sufficient
} else {
this._history[method].morpher.to(target)
}
this._history[method].caller.finished = false
const timeline = this.timeline()
timeline && timeline.play()
return true
}
return false
}
}
Runner.id = 0
export class FakeRunner {
constructor(transforms = new Matrix(), id = -1, done = true) {
this.transforms = transforms
this.id = id
this.done = done
}
clearTransformsFromQueue() {}
}
extend([Runner, FakeRunner], {
mergeWith(runner) {
return new FakeRunner(
runner.transforms.lmultiply(this.transforms),
runner.id
)
}
})
// FakeRunner.emptyRunner = new FakeRunner()
const lmultiply = (last, curr) => last.lmultiplyO(curr)
const getRunnerTransform = (runner) => runner.transforms
function mergeTransforms() {
// Find the matrix to apply to the element and apply it
const runners = this._transformationRunners.runners
const netTransform = runners
.map(getRunnerTransform)
.reduce(lmultiply, new Matrix())
this.transform(netTransform)
this._transformationRunners.merge()
if (this._transformationRunners.length() === 1) {
this._frameId = null
}
}
export class RunnerArray {
constructor() {
this.runners = []
this.ids = []
}
add(runner) {
if (this.runners.includes(runner)) return
const id = runner.id + 1
this.runners.push(runner)
this.ids.push(id)
return this
}
clearBefore(id) {
const deleteCnt = this.ids.indexOf(id + 1) || 1
this.ids.splice(0, deleteCnt, 0)
this.runners
.splice(0, deleteCnt, new FakeRunner())
.forEach((r) => r.clearTransformsFromQueue())
return this
}
edit(id, newRunner) {
const index = this.ids.indexOf(id + 1)
this.ids.splice(index, 1, id + 1)
this.runners.splice(index, 1, newRunner)
return this
}
getByID(id) {
return this.runners[this.ids.indexOf(id + 1)]
}
length() {
return this.ids.length
}
merge() {
let lastRunner = null
for (let i = 0; i < this.runners.length; ++i) {
const runner = this.runners[i]
const condition =
lastRunner &&
runner.done &&
lastRunner.done &&
// don't merge runner when persisted on timeline
(!runner._timeline ||
!runner._timeline._runnerIds.includes(runner.id)) &&
(!lastRunner._timeline ||
!lastRunner._timeline._runnerIds.includes(lastRunner.id))
if (condition) {
// the +1 happens in the function
this.remove(runner.id)
const newRunner = runner.mergeWith(lastRunner)
this.edit(lastRunner.id, newRunner)
lastRunner = newRunner
--i
} else {
lastRunner = runner
}
}
return this
}
remove(id) {
const index = this.ids.indexOf(id + 1)
this.ids.splice(index, 1)
this.runners.splice(index, 1)
return this
}
}
registerMethods({
Element: {
animate(duration, delay, when) {
const o = Runner.sanitise(duration, delay, when)
const timeline = this.timeline()
return new Runner(o.duration)
.loop(o)
.element(this)
.timeline(timeline.play())
.schedule(o.delay, o.when)
},
delay(by, when) {
return this.animate(0, by, when)
},
// this function searches for all runners on the element and deletes the ones
// which run before the current one. This is because absolute transformations
// overwrite anything anyway so there is no need to waste time computing
// other runners
_clearTransformRunnersBefore(currentRunner) {
this._transformationRunners.clearBefore(currentRunner.id)
},
_currentTransform(current) {
return (
this._transformationRunners.runners
// we need the equal sign here to make sure, that also transformations
// on the same runner which execute before the current transformation are
// taken into account
.filter((runner) => runner.id <= current.id)
.map(getRunnerTransform)
.reduce(lmultiply, new Matrix())
)
},
_addRunner(runner) {
this._transformationRunners.add(runner)
// Make sure that the runner merge is executed at the very end of
// all Animator functions. That is why we use immediate here to execute
// the merge right after all frames are run
Animator.cancelImmediate(this._frameId)
this._frameId = Animator.immediate(mergeTransforms.bind(this))
},
_prepareRunner() {
if (this._frameId == null) {
this._transformationRunners = new RunnerArray().add(
new FakeRunner(new Matrix(this))
)
}
}
}
})
// Will output the elements from array A that are not in the array B
const difference = (a, b) => a.filter((x) => !b.includes(x))
extend(Runner, {
attr(a, v) {
return this.styleAttr('attr', a, v)
},
// Add animatable styles
css(s, v) {
return this.styleAttr('css', s, v)
},
styleAttr(type, nameOrAttrs, val) {
if (typeof nameOrAttrs === 'string') {
return this.styleAttr(type, { [nameOrAttrs]: val })
}
let attrs = nameOrAttrs
if (this._tryRetarget(type, attrs)) return this
let morpher = new Morphable(this._stepper).to(attrs)
let keys = Object.keys(attrs)
this.queue(
function () {
morpher = morpher.from(this.element()[type](keys))
},
function (pos) {
this.element()[type](morpher.at(pos).valueOf())
return morpher.done()
},
function (newToAttrs) {
// Check if any new keys were added
const newKeys = Object.keys(newToAttrs)
const differences = difference(newKeys, keys)
// If their are new keys, initialize them and add them to morpher
if (differences.length) {
// Get the values
const addedFromAttrs = this.element()[type](differences)
// Get the already initialized values
const oldFromAttrs = new ObjectBag(morpher.from()).valueOf()
// Merge old and new
Object.assign(oldFromAttrs, addedFromAttrs)
morpher.from(oldFromAttrs)
}
// Get the object from the morpher
const oldToAttrs = new ObjectBag(morpher.to()).valueOf()
// Merge in new attributes
Object.assign(oldToAttrs, newToAttrs)
// Change morpher target
morpher.to(oldToAttrs)
// Make sure that we save the work we did so we don't need it to do again
keys = newKeys
attrs = newToAttrs
}
)
this._rememberMorpher(type, morpher)
return this
},
zoom(level, point) {
if (this._tryRetarget('zoom', level, point)) return this
let morpher = new Morphable(this._stepper).to(new SVGNumber(level))
this.queue(
function () {
morpher = morpher.from(this.element().zoom())
},
function (pos) {
this.element().zoom(morpher.at(pos), point)
return morpher.done()
},
function (newLevel, newPoint) {
point = newPoint
morpher.to(newLevel)
}
)
this._rememberMorpher('zoom', morpher)
return this
},
/**
** absolute transformations
**/
//
// M v -----|-----(D M v = F v)------|-----> T v
//
// 1. define the final state (T) and decompose it (once)
// t = [tx, ty, the, lam, sy, sx]
// 2. on every frame: pull the current state of all previous transforms
// (M - m can change)
// and then write this as m = [tx0, ty0, the0, lam0, sy0, sx0]
// 3. Find the interpolated matrix F(pos) = m + pos * (t - m)
// - Note F(0) = M
// - Note F(1) = T
// 4. Now you get the delta matrix as a result: D = F * inv(M)
transform(transforms, relative, affine) {
// If we have a declarative function, we should retarget it if possible
relative = transforms.relative || relative
if (
this._isDeclarative &&
!relative &&
this._tryRetarget('transform', transforms)
) {
return this
}
// Parse the parameters
const isMatrix = Matrix.isMatrixLike(transforms)
affine =
transforms.affine != null
? transforms.affine
: affine != null
? affine
: !isMatrix
// Create a morpher and set its type
const morpher = new Morphable(this._stepper).type(
affine ? TransformBag : Matrix
)
let origin
let element
let current
let currentAngle
let startTransform
function setup() {
// make sure element and origin is defined
element = element || this.element()
origin = origin || getOrigin(transforms, element)
startTransform = new Matrix(relative ? undefined : element)
// add the runner to the element so it can merge transformations
element._addRunner(this)
// Deactivate all transforms that have run so far if we are absolute
if (!relative) {
element._clearTransformRunnersBefore(this)
}
}
function run(pos) {
// clear all other transforms before this in case something is saved
// on this runner. We are absolute. We dont need these!
if (!relative) this.clearTransform()
const { x, y } = new Point(origin).transform(
element._currentTransform(this)
)
let target = new Matrix({ ...transforms, origin: [x, y] })
let start = this._isDeclarative && current ? current : startTransform
if (affine) {
target = target.decompose(x, y)
start = start.decompose(x, y)
// Get the current and target angle as it was set
const rTarget = target.rotate
const rCurrent = start.rotate
// Figure out the shortest path to rotate directly
const possibilities = [rTarget - 360, rTarget, rTarget + 360]
const distances = possibilities.map((a) => Math.abs(a - rCurrent))
const shortest = Math.min(...distances)
const index = distances.indexOf(shortest)
target.rotate = possibilities[index]
}
if (relative) {
// we have to be careful here not to overwrite the rotation
// with the rotate method of Matrix
if (!isMatrix) {
target.rotate = transforms.rotate || 0
}
if (this._isDeclarative && currentAngle) {
start.rotate = currentAngle
}
}
morpher.from(start)
morpher.to(target)
const affineParameters = morpher.at(pos)
currentAngle = affineParameters.rotate
current = new Matrix(affineParameters)
this.addTransform(current)
element._addRunner(this)
return morpher.done()
}
function retarget(newTransforms) {
// only get a new origin if it changed since the last call
if (
(newTransforms.origin || 'center').toString() !==
(transforms.origin || 'center').toString()
) {
origin = getOrigin(newTransforms, element)
}
// overwrite the old transformations with the new ones
transforms = { ...newTransforms, origin }
}
this.queue(setup, run, retarget, true)
this._isDeclarative && this._rememberMorpher('transform', morpher)
return this
},
// Animatable x-axis
x(x) {
return this._queueNumber('x', x)
},
// Animatable y-axis
y(y) {
return this._queueNumber('y', y)
},
ax(x) {
return this._queueNumber('ax', x)
},
ay(y) {
return this._queueNumber('ay', y)
},
dx(x = 0) {
return this._queueNumberDelta('x', x)
},
dy(y = 0) {
return this._queueNumberDelta('y', y)
},
dmove(x, y) {
return this.dx(x).dy(y)
},
_queueNumberDelta(method, to) {
to = new SVGNumber(to)
// Try to change the target if we have this method already registered
if (this._tryRetarget(method, to)) return this
// Make a morpher and queue the animation
const morpher = new Morphable(this._stepper).to(to)
let from = null
this.queue(
function () {
from = this.element()[method]()
morpher.from(from)
morpher.to(from + to)
},
function (pos) {
this.element()[method](morpher.at(pos))
return morpher.done()
},
function (newTo) {
morpher.to(from + new SVGNumber(newTo))
}
)
// Register the morpher so that if it is changed again, we can retarget it
this._rememberMorpher(method, morpher)
return this
},
_queueObject(method, to) {
// Try to change the target if we have this method already registered
if (this._tryRetarget(method, to)) return this
// Make a morpher and queue the animation
const morpher = new Morphable(this._stepper).to(to)
this.queue(
function () {
morpher.from(this.element()[method]())
},
function (pos) {
this.element()[method](morpher.at(pos))
return morpher.done()
}
)
// Register the morpher so that if it is changed again, we can retarget it
this._rememberMorpher(method, morpher)
return this
},
_queueNumber(method, value) {
return this._queueObject(method, new SVGNumber(value))
},
// Animatable center x-axis
cx(x) {
return this._queueNumber('cx', x)
},
// Animatable center y-axis
cy(y) {
return this._queueNumber('cy', y)
},
// Add animatable move
move(x, y) {
return this.x(x).y(y)
},
amove(x, y) {
return this.ax(x).ay(y)
},
// Add animatable center
center(x, y) {
return this.cx(x).cy(y)
},
// Add animatable size
size(width, height) {
// animate bbox based size for all other elements
let box
if (!width || !height) {
box = this._element.bbox()
}
if (!width) {
width = (box.width / box.height) * height
}
if (!height) {
height = (box.height / box.width) * width
}
return this.width(width).height(height)
},
// Add animatable width
width(width) {
return this._queueNumber('width', width)
},
// Add animatable height
height(height) {
return this._queueNumber('height', height)
},
// Add animatable plot
plot(a, b, c, d) {
// Lines can be plotted with 4 arguments
if (arguments.length === 4) {
return this.plot([a, b, c, d])
}
if (this._tryRetarget('plot', a)) return this
const morpher = new Morphable(this._stepper)
.type(this._element.MorphArray)
.to(a)
this.queue(
function () {
morpher.from(this._element.array())
},
function (pos) {
this._element.plot(morpher.at(pos))
return morpher.done()
}
)
this._rememberMorpher('plot', morpher)
return this
},
// Add leading method
leading(value) {
return this._queueNumber('leading', value)
},
// Add animatable viewbox
viewbox(x, y, width, height) {
return this._queueObject('viewbox', new Box(x, y, width, height))
},
update(o) {
if (typeof o !== 'object') {
return this.update({
offset: arguments[0],
color: arguments[1],
opacity: arguments[2]
})
}
if (o.opacity != null) this.attr('stop-opacity', o.opacity)
if (o.color != null) this.attr('stop-color', o.color)
if (o.offset != null) this.attr('offset', o.offset)
return this
}
})
extend(Runner, { rx, ry, from, to })
register(Runner, 'Runner')