refactor: ♻️ add animation elements (#1520)

* refactor: ♻️ rename Svg to SVG

* refactor: ♻️ add animation elements
This commit is contained in:
问崖
2021-11-08 16:27:42 +08:00
committed by GitHub
parent c70a2d9cf4
commit b0a6ddbdb8
137 changed files with 859 additions and 3783 deletions

View File

@ -1,14 +1,21 @@
import React from 'react'
import { Svg } from '@antv/x6-vector'
import { SVG } from '@antv/x6-vector'
import '../index.less'
console.log(Svg)
export default class Example extends React.Component {
private container: HTMLDivElement
componentDidMount() {
new Svg().appendTo(this.container)
const svg = new SVG()
const rect = svg.rect(100, 100).node
svg.appendTo(this.container)
rect.animate(
[{ fill: '#000000' }, { fill: '#0000FF' }, { fill: '#00FFFF' }],
{
duration: 3000,
iterations: Infinity,
},
)
}
refContainer = (container: HTMLDivElement) => {

View File

@ -1,541 +0,0 @@
import { Entity } from '../../types'
import type { When, Options } from '../types'
import { Easing } from '../stepper/easing'
import { Morpher } from '../morpher/morpher'
import { Stepper } from '../stepper/stepper'
import { Timeline } from '../scheduler/timeline'
import { Controller } from '../stepper/controller'
import {
History,
Executors,
PrepareMethod,
RunMethod,
RetargetMethod,
} from './types'
import { Util } from './util'
export class Animator<
TAnimator,
TOwner extends Animator.Owner = Animator.Owner,
> {
public readonly id: number
public readonly declarative: boolean
public done = false
protected enabled = true
protected reseted = true
protected persisted: number | boolean
protected owner: TOwner
protected stepper: Stepper
protected timeline: Timeline | null = null
protected duration: number
protected times = 1
protected wait = 0
protected swing = false
protected reversal = false
protected currentTime = 0
protected previousStepTime = 0
protected previousStepPosition: number
protected readonly history: History<TAnimator> = {}
protected readonly executors: Executors<TAnimator> = []
protected readonly callbacks: {
[Key in Animator.EventNames]: any[]
} = {
start: [],
step: [],
finished: [],
}
constructor()
constructor(duration: number)
constructor(stepper: Stepper)
constructor(options: number | Stepper = Util.defaults.duration) {
this.id = Util.generateId()
const opts =
typeof options === 'function' ? new Controller(options) : options
this.stepper = opts instanceof Controller ? opts : new Easing()
this.declarative = opts instanceof Controller
this.persisted = this.declarative ? true : 0
this.duration = typeof opts === 'number' ? opts : 0
}
active(): boolean
active(enabled: boolean): this
active(enabled?: boolean) {
if (enabled == null) {
return this.enabled
}
this.enabled = enabled
return this
}
ease(): Stepper
ease(stepper: Stepper): this
ease(stepper?: Stepper) {
if (stepper == null) {
return this.stepper
}
this.stepper = stepper
return this
}
persist(): number
/**
* Make this runner persist on the timeline forever (true) or for a specific
* time. Usually a runner is deleted after execution to clean up memory.
*/
persist(dt: number): this
persist(forever: boolean): this
persist(dtOrForever?: number | boolean) {
if (dtOrForever == null) {
return this.persisted
}
this.persisted = dtOrForever
return this
}
master(): TOwner
master(owner: TOwner): this
master(owner?: TOwner) {
if (owner == null) {
return this.owner
}
this.owner = owner
return this
}
scheduler(): Timeline
scheduler(timeline: Timeline | null): this
scheduler(timeline?: Timeline | null) {
if (typeof timeline === 'undefined') {
return this.timeline
}
this.timeline = timeline
return this
}
/**
* Set the runner back to zero time and all animations with it
*/
reset() {
if (this.reseted) {
return this
}
this.time(0)
this.reseted = true
return this
}
/**
* Returns the duration the runner will run
*/
quantity() {
return this.times * (this.wait + this.duration) - this.wait
}
schedule(delay: number, when: When): this
schedule(timeline: Timeline, delay: number, when: When): this
schedule(timeline: Timeline | number, delay: When | number, when?: When) {
if (typeof timeline === 'number') {
when = delay as When // eslint-disable-line
delay = timeline // eslint-disable-line
timeline = this.timeline! // eslint-disable-line
}
if (timeline == null) {
throw Error('Runner cannot be scheduled without timeline')
}
const scheduler = timeline as Timeline
scheduler.schedule(this, delay as number, when)
return this
}
unschedule() {
const timeline = this.timeline
if (timeline) {
timeline.unschedule(this)
}
return this
}
loop(times?: number | true, swing?: boolean, wait?: number): this
loop(options: { times?: number | true; swing?: boolean; wait?: number }): this
loop(
times?:
| { times?: number | true; swing?: boolean; wait?: number }
| number
| true,
swing?: boolean,
wait?: number,
) {
const o = typeof times === 'object' ? times : { times, swing, wait }
this.times = o.times == null || o.times === true ? Infinity : o.times
this.swing = o.swing || false
this.wait = o.wait || 0
return this
}
reverse(reverse?: boolean) {
this.reversal = reverse == null ? !this.reversal : reverse
return this
}
time(): number
time(time: number): this
time(time?: number) {
if (time == null) {
return this.currentTime
}
const delta = time - this.currentTime
this.step(delta)
return this
}
/**
* Steps the runner to its finished state.
*/
finish() {
return this.step(Infinity)
}
/**
* Returns the current position of the runner including the wait times
* (between 0 and 1).
*/
progress(): number
/**
* Sets the current position of the runner including the wait times
* (between 0 and 1).
*/
progress(p: number): this
progress(p?: number) {
if (p == null) {
return Math.min(1, this.currentTime / this.quantity())
}
return this.time(p * this.quantity())
}
/**
* Get the current iteration of the runner.
*/
loops(): number
/**
* Jump to a specific iteration of the runner.
* e.g. 3.5 for 4th loop half way through
*/
loops(p: number): this
loops(p?: number) {
const duration = this.duration + this.wait
if (p == null) {
const finishedCount = Math.floor(this.currentTime / duration)
const delta = this.currentTime - finishedCount * duration
const position = delta / this.duration
return Math.min(finishedCount + position, this.times)
}
const whole = Math.floor(p)
const partial = p % 1
const total = duration * whole + this.duration * partial
return this.time(total)
}
/**
* Returns the current position of the runner ignoring the wait times
* (between 0 and 1).
*/
position(): number
/**
* Sets the current position of the runner ignoring the wait times
* (between 0 and 1).
*/
position(p: number): this
position(p?: number) {
const current = this.currentTime
const w = this.wait
const t = this.times
const s = this.swing
const r = this.reversal
const d = this.duration
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 = (x: number) => {
const swinging = (s ? 1 : 0) * Math.floor((x % (2 * (w + d))) / (w + d))
const backwards = +((swinging && !r) || (!swinging && r))
const uncliped = ((backwards ? -1 : 1) * (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
const position =
current <= 0
? Math.round(f(1e-5))
: current < endTime
? f(current)
: Math.round(f(endTime - 1e-5))
return position
}
const finishedCount = Math.floor(this.loops())
const swingForward = s && finishedCount % 2 === 0
const forwards = (swingForward && !r) || (r && swingForward)
const position = finishedCount + (forwards ? p : 1 - p)
return this.loops(position)
}
animate(options: Options): this
animate(duration?: number, delay?: number, when?: When): this
animate(duration?: Options | number, delay?: number, when?: When) {
const options = Util.sanitise(duration, delay, when)
const Ctor = this.constructor as new (duration: number) => Animator<
TAnimator,
TOwner
>
const animator = new Ctor(options.duration)
if (this.timeline) {
animator.scheduler(this.timeline)
}
if (this.owner) {
animator.master(this.owner)
}
return animator.loop(options).schedule(options.delay, options.when)
}
delay(delay: number) {
return this.animate(0, delay)
}
/**
* Step the runner by a certain time.
*/
step(delta = 16) {
if (!this.active()) {
return this
}
this.currentTime += delta
// Figure out if we need to run the stepper in this frame
const position = this.position()
const running =
this.previousStepPosition !== position && this.currentTime >= 0
this.previousStepPosition = position
const quantity = this.quantity()
const justStarted = this.previousStepTime <= 0 && this.currentTime > 0
const justFinished =
this.previousStepTime < quantity && this.currentTime >= quantity
this.previousStepTime = this.currentTime
const callback = (cache: any[]) => {
for (let i = 0, l = cache.length; i < l; i += 1) {
const handler = cache[i] as Animator.EventHandler<TAnimator>
const context = cache[i + 1]
if (handler) {
const result = handler.call(context, this)
if (result === false) {
return false
}
}
}
}
if (justStarted) {
if (callback(this.callbacks.start) === false) {
return this
}
}
this.reseted = false
// 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 declared = this.declarative
this.done = !declared && !justFinished && this.currentTime >= quantity
let converged = false
if (running || declared) {
this.prepare(running)
converged = this.run(declared ? delta : position)
if (callback(this.callbacks.step) === false) {
return this
}
}
// correct the done flag here
// declaritive animations itself know when they converged
this.done = this.done || (converged && declared)
if (justFinished) {
if (callback(this.callbacks.finished) === false) {
return this
}
}
return this
}
on(
event: Animator.EventNames,
callback: Animator.EventHandler<TAnimator>,
context?: Entity,
) {
const cache = this.callbacks[event]
cache.push(callback, context)
return this
}
off(
event: Animator.EventNames,
callback?: Animator.EventHandler<TAnimator>,
context?: Entity,
) {
const cache = this.callbacks[event]
if (callback == null && context == null) {
this.callbacks[event] = []
} else {
for (let i = cache.length - 1; i >= 0; i -= 2) {
if (
(callback == null || cache[i - 1] === callback) &&
(context == null || cache[i] === context)
) {
cache.splice(i - 1, 2)
}
}
}
return this
}
protected queue<TTarget, TExtra = any>(
prepare?: PrepareMethod<TAnimator> | null,
run?: RunMethod<TAnimator> | null,
retarget?: RetargetMethod<TAnimator, TTarget, TExtra> | null,
isTransform?: boolean,
) {
this.executors.push({
isTransform,
retarget,
prepare: prepare || (() => undefined),
run: run || (() => undefined),
ready: false,
finished: false,
})
if (this.timeline) {
this.timeline.peek()
}
return this
}
protected prepare(running: boolean) {
if (running || this.declarative) {
for (let i = 0, l = this.executors.length; i < l; i += 1) {
const exe = this.executors[i]
const needInit = this.declarative || (!exe.ready && running)
if (needInit && !exe.finished) {
exe.prepare.call(this, this)
exe.ready = true
}
}
}
}
protected run(positionOrDelta: number) {
let allfinished = true
for (let i = 0, l = this.executors.length; i < l; i += 1) {
const exe = this.executors[i]
const converged = exe.run.call(this, this, positionOrDelta)
exe.finished = exe.finished || converged === true
allfinished = allfinished && exe.finished
}
return allfinished
}
protected remember(method: string, morpher: Morpher<any, any, any>) {
// Save the morpher to the morpher list so that we can retarget it later
this.history[method] = {
morpher,
executor: this.executors[this.executors.length - 1],
}
// We have to resume the timeline in case a controller
// is already done without beeing ever run
// This can happen when e.g. this is done:
// anim = el.animate(new SVG.Spring)
// and later
// anim.move(...)
if (this.declarative) {
if (this.timeline) {
this.timeline.play()
}
}
}
protected retarget<TTarget, TExtra>(
method: string,
target: TTarget,
extra?: TExtra,
) {
if (this.history[method]) {
const { morpher, executor } = this.history[method]
// If the previous executor wasn't even prepared, drop it.
if (!executor.ready) {
const index = this.executors.indexOf(executor)
this.executors.splice(index, 1)
return false
}
if (executor.retarget) {
// For the case of transformations, we use the special
// retarget function which has access to the outer scope
executor.retarget.call(this, this, target, extra)
} else {
morpher.to(target)
}
executor.finished = false
if (this.timeline) {
this.timeline.play()
}
return true
}
return false
}
}
export namespace Animator {
export type Owner = Record<string, any>
export type EventNames = 'start' | 'step' | 'finished'
export type EventHandler<T> = (animator: T) => any
}

View File

@ -1,37 +0,0 @@
import type { Morpher } from '../morpher/morpher'
export type PrepareMethod<TAnimator> = (
this: TAnimator,
animator: TAnimator,
) => any
export type RunMethod<TAnimator> = (
this: TAnimator,
animator: TAnimator,
positionOrDelta: number,
) => any
export type RetargetMethod<TAnimator, TTarget = any, TExtra = any> = (
this: TAnimator,
animator: TAnimator,
target: TTarget,
extra: TExtra,
) => any
interface Executor<TAnimator> {
prepare: PrepareMethod<TAnimator>
run: RunMethod<TAnimator>
retarget?: RetargetMethod<TAnimator> | null
ready: boolean
finished: boolean
isTransform?: boolean
}
export type Executors<TAnimator> = Executor<TAnimator>[]
export interface History<TAnimator> {
[method: string]: {
morpher: Morpher<any, any, any>
executor: Executor<TAnimator>
}
}

View File

@ -1,64 +0,0 @@
import type { Options, When } from '../types'
import { Stepper } from '../stepper/stepper'
export namespace Util {
let id = 0
export function generateId() {
const ret = id
id += 1
return ret
}
export const defaults = {
duration: 400,
delay: 0,
}
export function sanitise(
duration: number | Options = defaults.duration,
delay: number = defaults.delay,
when: When = 'after',
) {
let times = 1
let swing = false
let wait = 0
if (typeof duration === 'object' && !(duration instanceof Stepper)) {
const options = duration
// eslint-disable-next-line no-param-reassign
duration = duration.duration || defaults.duration
// eslint-disable-next-line no-param-reassign
delay = options.delay || delay
// eslint-disable-next-line no-param-reassign
when = options.when || when
swing = options.swing || swing
times = options.times || times
wait = options.wait || wait
}
return {
delay,
swing,
times,
wait,
when,
duration: duration as number,
}
}
// export function create<TType extends typeof Animator>(
// Type: TType,
// element: Primer,
// duration?: Partial<Options> | number,
// delay?: number,
// when?: When,
// ) {
// const o = sanitise(duration, delay, when)
// const timeline = element.timeline()
// return new Type(o.duration)
// .loop(o)
// .element(element)
// .timeline(timeline.play())
// .schedule(o.delay, o.when)
// }
}

View File

@ -1,2 +0,0 @@
export * from './animator/animator'
export * from './scheduler/timeline'

View File

@ -1,19 +0,0 @@
import { Box } from '../../struct/box'
import { Morphable } from './morphable'
export class MorphableBox
extends Box
implements Morphable<Box.BoxArray, Box.BoxArray>
{
fromArray(arr: Box.BoxArray) {
this.x = arr[0]
this.y = arr[0]
this.width = arr[0]
this.height = arr[0]
return this
}
toValue() {
return this.toArray()
}
}

View File

@ -1,16 +0,0 @@
import { Color } from '../../struct/color'
import { Morphable } from './morphable'
export class MorphableColor
extends Color
implements Morphable<Color.RGBA, Color.RGBA>
{
fromArray(arr: Color.RGBA) {
this.set(...arr)
return this
}
toValue() {
return this.toArray()
}
}

View File

@ -1,22 +0,0 @@
import { Morphable } from './morphable'
export class MorphableFallback<T = any> implements Morphable<T[], T> {
value: T
constructor(input: any) {
this.value = Array.isArray(input) ? input[0] : input
}
fromArray(arr: T[]) {
this.value = arr[0]
return this
}
toArray(): T[] {
return [this.value]
}
toValue(): T {
return this.value
}
}

View File

@ -1,21 +0,0 @@
import { Matrix } from '../../struct/matrix'
import { Morphable } from './morphable'
export class MorphableMatrix
extends Matrix
implements Morphable<Matrix.MatrixArray, Matrix.MatrixArray>
{
fromArray(arr: Matrix.MatrixArray) {
this.a = arr[0]
this.b = arr[1]
this.c = arr[2]
this.d = arr[3]
this.e = arr[4]
this.f = arr[5]
return this
}
toValue() {
return this.toArray()
}
}

View File

@ -1,14 +0,0 @@
// eslint-disable-next-line
export class Morphable<TArray extends any[], TValue> {
constructor()
constructor(arg: any)
constructor(...args: any[])
// eslint-disable-next-line
constructor(...args: any[]) {}
}
export interface Morphable<TArray extends any[], TValue> {
fromArray(arr: TArray): this
toArray(): TArray
toValue(): TValue
}

View File

@ -1,103 +0,0 @@
import { Easing } from '../stepper/easing'
import { Stepper } from '../stepper/stepper'
import { Util } from './util'
import { Morphable } from './morphable'
export class Morpher<TArray extends any[], TInput, TValue> {
protected Type: typeof Morphable | null = null
protected stepper: Stepper
protected source: TArray = [] as any
protected target: TArray = [] as any
protected contexts: Stepper.Context[]
protected instance: Morphable<TArray, TValue>
constructor(stepper?: Stepper) {
this.stepper = stepper || new Easing()
}
ease(): Stepper
ease(stepper: Stepper): this
ease(stepper?: Stepper) {
if (stepper == null) {
return this.stepper
}
this.stepper = stepper
return this
}
at(pos: number): TValue {
const current = this.source.map((val, index) =>
this.stepper.step(
val,
this.target[index],
pos,
this.contexts[index],
this.contexts,
),
)
return this.instance.fromArray(current as TArray).toValue()
}
done(): boolean {
return this.contexts
.map((context) => this.stepper.done(context))
.reduce((memo, curr) => memo && curr, true)
}
from(): TArray
from(val: TInput): this
from(val?: TInput) {
if (val == null) {
return this.source
}
this.source = this.set(val)
return this
}
to(): TArray
to(val: TInput): this
to(val?: TInput) {
if (val == null) {
return this.target
}
this.target = this.set(val)
return this
}
type(): typeof Morphable
type(t: typeof Morphable): this
type(t?: typeof Morphable) {
if (t == null) {
return this.Type
}
this.Type = t
return this
}
protected set(value: TInput): TArray {
if (this.Type == null) {
this.type(Util.getClassForType(value))
}
const Ctor = this.type()
const morphable = new Ctor<TArray, TValue>(value)
const arr = morphable.toArray()
if (this.instance == null) {
this.instance = new Ctor()
}
if (this.contexts == null) {
this.contexts = arr.map(() => ({ done: true }))
}
return arr
}
}

View File

@ -1,17 +0,0 @@
import { NumberArray } from '../../struct/number-array'
import { Morphable } from './morphable'
export class MorphableNumberArray
extends NumberArray
implements Morphable<number[], number[]>
{
fromArray(arr: number[]) {
this.length = 0
this.push(...arr)
return this
}
toValue() {
return this.toArray()
}
}

View File

@ -1,57 +0,0 @@
import { Morphable } from './morphable'
import { Util } from './util'
export class MorphableObject<
T extends Record<string, any> = Record<string, any>,
> implements Morphable<any[], T>
{
protected values: any[]
constructor(input?: any[] | T) {
if (input != null) {
if (Array.isArray(input)) {
this.fromArray(input)
} else if (typeof input === 'object') {
const entries: [string, typeof Morphable, number, ...any[]][] = []
Object.keys(input).forEach((key) => {
const Type = Util.getClassForType(input[key])
const val = new Type(input[key]).toArray()
entries.push([key, Type, val.length, ...val])
})
entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
this.values = entries.reduce<any[]>(
(memo, curr) => memo.concat(curr),
[],
)
}
}
if (this.values == null) {
this.values = []
}
}
fromArray(arr: any[]) {
this.values = arr.slice()
return this
}
toArray() {
return this.values.slice()
}
toValue(): T {
const obj: Record<string, any> = {}
const arr = this.values
while (arr.length) {
const key = arr.shift()
const Type = arr.shift()
const len = arr.shift()
const values = arr.splice(0, len)
obj[key] = new Type().formArray(values).toValue()
}
return obj as T
}
}

View File

@ -1,18 +0,0 @@
import type { Path } from '../../vector/path/path'
import { PathArray } from '../../struct/path-array'
import { Morphable } from './morphable'
export class MorphablePathArray
extends PathArray
implements Morphable<Path.Segment[], Path.Segment[]>
{
fromArray(arr: any[]) {
this.length = 0
this.push(...this.parse(arr))
return this
}
toValue() {
return this.toArray()
}
}

View File

@ -1,17 +0,0 @@
import { PointArray } from '../../struct/point-array'
import { Morphable } from './morphable'
export class MorphablePointArray
extends PointArray
implements Morphable<[number, number][], [number, number][]>
{
fromArray(arr: any[]) {
this.length = 0
this.push(...this.parse(arr))
return this
}
toValue() {
return this.toArray()
}
}

View File

@ -1,70 +0,0 @@
import { Morphable } from './morphable'
export class MorphableTransform
implements Morphable<MorphableTransform.Array, MorphableTransform.Array>
{
scaleX: number
scaleY: number
shear: number
rotate: number
translateX: number
translateY: number
originX: number
originY: number
fromArray(arr: MorphableTransform.Array) {
const obj = {
scaleX: arr[0],
scaleY: arr[1],
shear: arr[2],
rotate: arr[3],
translateX: arr[4],
translateY: arr[5],
originX: arr[6],
originY: arr[7],
}
Object.assign(this, MorphableTransform.defaults, obj)
return this
}
toArray(): MorphableTransform.Array {
return [
this.scaleX,
this.scaleY,
this.shear,
this.rotate,
this.translateX,
this.translateY,
this.originX,
this.originY,
]
}
toValue(): MorphableTransform.Array {
return this.toArray()
}
}
export namespace MorphableTransform {
export const defaults = {
scaleX: 1,
scaleY: 1,
shear: 0,
rotate: 0,
translateX: 0,
translateY: 0,
originX: 0,
originY: 0,
}
export type Array = [
number,
number,
number,
number,
number,
number,
number,
number,
]
}

View File

@ -1,26 +0,0 @@
import { UnitNumber } from '../../struct/unit-number'
import { Morphable } from './morphable'
export class MorphableUnitNumber
extends UnitNumber
implements Morphable<UnitNumber.UnitNumberArray, UnitNumber.UnitNumberArray>
{
fromArray(arr: UnitNumber.UnitNumberArray) {
this.unit = arr[1] || ''
if (typeof arr[0] === 'string') {
const obj = UnitNumber.parse(arr[0])
if (obj) {
this.value = obj.value
this.unit = obj.unit
}
} else {
this.value = arr[0]
}
return this
}
toValue() {
return this.toArray()
}
}

View File

@ -1,68 +0,0 @@
import { Morphable } from './morphable'
import { MorphableBox } from './box'
import { MorphableColor } from './color'
import { MorphableMatrix } from './matrix'
import { MorphableObject } from './object'
import { MorphableFallback } from './fallback'
import { MorphablePathArray } from './path-array'
import { MorphablePointArray } from './point-array'
import { MorphableTransform } from './transform'
import { MorphableUnitNumber } from './unit-number'
import { MorphableNumberArray } from './number-array'
export namespace Util {
const delimiter = /[\s,]+/
const isPathLetter = /[MLHVCSQTAZ]/i
const presets = [
MorphableBox,
MorphableColor,
MorphableMatrix,
MorphableFallback,
MorphableNumberArray,
MorphableUnitNumber,
MorphableObject,
MorphablePathArray,
MorphablePointArray,
MorphableTransform,
]
export function getClassForType(value: any): typeof Morphable {
const type = typeof value
if (type === 'number') {
return MorphableUnitNumber
}
if (type === 'string') {
if (MorphableColor.isColor(value)) {
return MorphableColor
}
if (delimiter.test(value)) {
return isPathLetter.test(value)
? MorphablePathArray
: MorphableNumberArray
}
if (MorphableUnitNumber.REGEX_NUMBER_UNIT.test(value)) {
return MorphableUnitNumber
}
return MorphableFallback as any
}
if (presets.includes(value.constructor)) {
return value.constructor as typeof Morphable
}
if (Array.isArray(value)) {
return MorphableNumberArray
}
if (type === 'object') {
return MorphableObject
}
return MorphableFallback as any
}
}

View File

@ -1,55 +0,0 @@
import { Global } from '../../global/global'
export class MockRAF {
protected realRAF: typeof requestAnimationFrame
protected mockRAF: typeof requestAnimationFrame
protected realCAF: typeof cancelAnimationFrame
protected mockCAF: typeof cancelAnimationFrame
protected realPerf: { now: () => number }
protected mockPerf: { now: () => number }
protected callbacks: FrameRequestCallback[] = []
protected nextTime = 0
constructor() {
this.mockRAF = (fn: FrameRequestCallback) => {
this.callbacks.push(fn)
return this.callbacks.length - 1
}
this.mockCAF = (requestID: number) => {
this.callbacks.splice(requestID, 1)
}
this.mockPerf = {
now: () => this.nextTime,
}
}
install(win: Global.WindowType) {
this.realRAF = win.requestAnimationFrame
this.realCAF = win.cancelAnimationFrame
this.realPerf = win.performance
win.requestAnimationFrame = this.mockRAF
win.cancelAnimationFrame = this.mockCAF
const w = win as any
w.performance = this.mockPerf
}
uninstall(win: Global.WindowType) {
const w = win as any
win.requestAnimationFrame = this.realRAF
win.cancelAnimationFrame = this.realCAF
w.performance = this.realPerf
this.nextTime = 0
this.callbacks = []
}
tick(dt = 1) {
this.nextTime += dt
const callbacks = this.callbacks
this.callbacks = []
callbacks.forEach((fn) => fn(this.nextTime))
}
}

View File

@ -1,105 +0,0 @@
import { Queue } from './queue'
describe('Queue', () => {
describe('first()', () => {
it('should return null if no item in the queue', () => {
const queue = new Queue()
expect(queue.first()).toEqual(null)
})
it('should return the first value in the queue', () => {
const queue = new Queue<number>()
queue.push(1)
expect(queue.first()).toBe(1)
queue.push(2)
expect(queue.first()).toBe(1)
})
})
describe('last()', () => {
it('should return null if no item in the queue', () => {
const queue = new Queue()
expect(queue.last()).toEqual(null)
})
it('should return the last value added', () => {
const queue = new Queue()
queue.push(1)
expect(queue.last()).toBe(1)
queue.push(2)
expect(queue.last()).toBe(2)
})
})
describe('push()', () => {
it('should add an element to the end of the queue', () => {
const queue = new Queue()
queue.push(1)
queue.push(2)
queue.push(3)
expect(queue.first()).toBe(1)
expect(queue.last()).toBe(3)
})
it('should add an item to the end of the queue', () => {
const queue = new Queue()
queue.push(1)
const item = queue.push(2)
queue.push(3)
queue.remove(item)
queue.push(item)
expect(queue.first()).toBe(1)
expect(queue.last()).toBe(2)
})
})
describe('remove()', () => {
it('should remove the given item from the queue', () => {
const queue = new Queue()
queue.push(1)
queue.push(2)
const item = queue.push(3)
queue.remove(item)
expect(queue.last()).toBe(2)
expect(queue.first()).toBe(1)
})
it('should remove the given item from the queue', () => {
const queue = new Queue()
const item = queue.push(1)
queue.push(2)
queue.push(3)
queue.remove(item)
expect(queue.last()).toBe(3)
expect(queue.first()).toBe(2)
})
})
describe('shift()', () => {
it('should return nothing if queue is empty', () => {
const queue = new Queue()
const val = queue.shift()
expect(val).toBeFalsy()
})
it('should return the first item of the queue and should remove it', () => {
const queue = new Queue()
queue.push(1)
queue.push(2)
queue.push(3)
const val = queue.shift()
expect(queue.last()).toBe(3)
expect(queue.first()).toBe(2)
expect(val).toBe(1)
})
})
})

View File

@ -1,91 +0,0 @@
export class Queue<T> {
protected firstItem: Queue.Item<T> | null
protected lastItem: Queue.Item<T> | null
constructor() {
this.firstItem = null
this.lastItem = null
}
first() {
return this.firstItem ? this.firstItem.value : null
}
last() {
return this.lastItem ? this.lastItem.value : null
}
shift() {
const remove = this.firstItem
if (!remove) {
return null
}
this.firstItem = remove.next
if (this.firstItem) {
this.firstItem.prev = null
}
this.lastItem = this.firstItem ? this.lastItem : null
return remove.value
}
push(value: T | Queue.Item<T>) {
const o = value as any
const item: Queue.Item<T> =
typeof o === 'object' &&
typeof o.value !== 'undefined' &&
typeof o.prev !== 'undefined' &&
typeof o.next !== 'undefined'
? o
: { value: o, next: null, prev: null }
if (this.lastItem) {
item.prev = this.lastItem
this.lastItem.next = item
this.lastItem = item
} else {
this.lastItem = item
this.firstItem = item
}
return item
}
remove(item: Queue.Item<T>) {
if (item.prev) {
item.prev.next = item.next
}
if (item.next) {
item.next.prev = item.prev
}
if (item === this.lastItem) {
this.lastItem = item.prev
}
if (item === this.firstItem) {
this.firstItem = item.next
}
item.prev = null
item.next = null
}
}
export namespace Queue {
export interface Item<T> {
value: T
prev: Item<T> | null
next: Item<T> | null
}
export function isItem<T>(o: any): o is Item<T> {
return (
typeof o === 'object' &&
typeof o.value !== 'undefined' &&
typeof o.prev !== 'undefined' &&
typeof o.next !== 'undefined'
)
}
}

View File

@ -1,29 +0,0 @@
import { Timeline } from './timeline'
import { Primer } from '../../dom/primer'
import { applyMixins } from '../../util/mixin'
export class ElementExtension {
protected timeline: Timeline
scheduler(): Timeline
scheduler(timeline: Timeline): this
scheduler(timeline?: Timeline) {
if (timeline == null) {
if (this.timeline == null) {
this.timeline = new Timeline()
}
return this.timeline
}
this.timeline = timeline
return this
}
}
declare module '../../dom/primer/primer' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Primer<TElement extends Element = Element>
extends ElementExtension {}
}
applyMixins(Primer, ElementExtension)

View File

@ -1,393 +0,0 @@
import type { When, Now } from '../types'
import type { Animator } from '../animator/animator'
import { Queue } from './queue'
import { Timing } from './timing'
export class Timeline {
public readonly step: () => this
public readonly stepImmediately: () => this
public readonly now: Now
protected paused = true
/**
* The speed of the timeline.
*/
protected v = 1
/**
* The current time of the timeline.
*/
protected t = 0
/**
* The previous step time of the timeline.
*/
protected o = 0
protected persisted: number | boolean = 0
protected timestamp = 0
protected animators: {
animator: Timeline.AnyAnimator
start: number
persist: number | boolean
}[] = []
protected animatorIds: number[] = []
protected previousAnimatorId = -1
protected nextFrame: Queue.Item<Timing.Frame> | null = null
constructor()
constructor(now?: Now)
constructor(now: Now = Timing.timer().now) {
this.now = now
this.step = () => this.stepImpl(false)
this.stepImmediately = () => this.stepImpl(true)
}
isActive() {
return this.nextFrame != null
}
/**
* Get the speed of the timeline.
*/
speed(): number
/**
* Set the speed of the timeline. Negative speeds will reverse the timeline.
*/
speed(v: number): this
speed(v?: number) {
if (v == null) {
return this.v
}
this.v = v
return this
}
/**
* Play the timeline in reverse basically going back in time.
*/
reverse(): this
reverse(yes: boolean): this
reverse(yes?: boolean) {
const speed = this.speed()
if (yes == null) {
return this.speed(-speed)
}
const positive = Math.abs(speed)
return this.speed(yes ? -positive : positive)
}
persist(): number | boolean
persist(dt: number): this
persist(forever: boolean): this
persist(dtOrForever?: number | boolean) {
if (dtOrForever == null) {
return this.persisted
}
this.persisted = dtOrForever
return this
}
/**
* Get the current time of the timeline.
*/
time(): number
/**
* Set the current time of the timeline.
*/
time(t: number): this
time(t?: number) {
if (t == null) {
return this.t
}
this.t = t
return this.peek(true)
}
/**
* Offset the time by a delta.
*/
offset(delta: number) {
return this.time(this.t + delta)
}
/**
* Finishes the whole timeline. All values are set to their corresponding
* end values and every animation gets fullfilled.
*/
finish() {
const ends = this.animators.map(
({ start, animator }) => start + animator.quantity(),
)
const terminal = Math.max(0, ...ends)
this.time(terminal + 1)
return this.pause()
}
/**
* Stops the timeline and sets the time back to zero.
*/
stop() {
this.time(0)
return this.pause()
}
/**
* Pauses the timeline.
*/
pause() {
this.paused = true
return this.peek()
}
/**
* Unpauses the timeline and continue the animation.
*/
play() {
this.paused = false
return this.update().peek()
}
protected update() {
if (!this.isActive()) {
// Makes sure, that after pausing the time doesn't jump
this.updateTimestamp()
}
return this
}
protected updateTimestamp() {
const now = this.now()
const delta = now - this.timestamp
this.timestamp = now
return delta
}
/**
* Checks if we are running and continues the animation.
*/
peek(immediately = false) {
Timing.cancelFrame(this.nextFrame)
this.nextFrame = null
if (immediately) {
return this.stepImmediately()
}
if (this.paused) {
return this
}
this.nextFrame = Timing.frame(this.step)
return this
}
schedule<TAnimator extends Timeline.AnyAnimator>(): {
start: number
end: number
duration: number
animator: TAnimator
}[]
/**
* Schedules a runner on the timeline.
*/
schedule<TAnimator extends Timeline.AnyAnimator>(
animator: TAnimator,
delay: number,
when?: When,
): this
schedule<TAnimator extends Timeline.AnyAnimator>(
animator?: TAnimator,
delay = 0,
when?: When,
) {
if (animator == null) {
return this.animators.map(({ start, animator }) => {
const duration = animator.quantity()
const end = start + duration
return {
start,
end,
duration,
animator,
}
})
}
// The start time for the next animation can either be given explicitly,
// derived from the current timeline time or it can be relative to the
// last start time to chain animations direclty
let start = 0
if (when == null || when === 'after') {
// Plays the animation after the animation which comes last on the
// timeline. If there is none, the animation is played right now.
// Take the last time and increment
start = this.getPreviousEndTime() + delay
} else if (when === 'start') {
// Schedules the animation to run to an absolute time on your timeline.
start = delay
} else if (when === 'now') {
// Plays the animation right now.
start = this.t + delay
} else if (when === 'relative') {
// Schedules the animation to play relative to its old start time.
const info = this.getAnimator(animator.id)
if (info) {
start = info.start + delay
}
} else if (when === 'with') {
const info = this.getPreviousAnimator()
const lastStartTime = info ? info.start : this.t
start = lastStartTime + delay
} else {
throw new Error('Invalid value for the "when" parameter')
}
animator.unschedule()
animator.scheduler(this)
const persist = animator.persist()
const meta = {
start,
animator,
persist: persist == null ? this.persist() : persist,
}
this.previousAnimatorId = animator.id
this.animators.push(meta)
this.animators.sort((a, b) => a.start - b.start)
this.animatorIds = this.animators.map((i) => i.animator.id)
this.update().peek()
return this
}
/**
* Remove the animator from this timeline.
*/
unschedule<TAnimator extends Timeline.AnyAnimator>(animator: TAnimator) {
const index = this.animatorIds.indexOf(animator.id)
if (index < 0) {
return this
}
this.animators.splice(index, 1)
this.animatorIds.splice(index, 1)
animator.scheduler(null)
return this
}
protected stepImpl(immediately = false) {
// Get the time delta from the last time and update the time
let delta = this.updateTimestamp()
if (immediately) {
delta = 0
}
const dtTime = this.v * delta + (this.t - this.o)
if (!immediately) {
this.t += dtTime
this.t = this.t < 0 ? 0 : this.t
}
this.o = this.t
// this.fire('time', this.currentTime)
// However, reseting in insertion order leads to bugs. Considering the case,
// where 2 animators change the same attriute but in different times,
// reseting both of them will lead to the case where the later defined
// animator always wins the reset even if the other animator started earlier
// and therefore should win the attribute battle
// this can be solved by reseting them backwards
for (let i = this.animators.length - 1; i >= 0; i -= 1) {
const { start, animator } = this.animators[i]
const delta = this.t - start
// Dont run animator if not started yet and try to reset it
if (delta <= 0) {
animator.reset()
}
}
let next = false
for (let i = 0, l = this.animators.length; i < l; i += 1) {
// Get and run the current animator and ignore it if its inactive
const { animator, start, persist } = this.animators[i]
if (!animator.active()) {
continue
}
const delta = this.t - start
if (delta <= 0) {
// Dont run animator if not started yet
next = true
continue
}
// Adjust dt to make sure that animation is on point
const dt = delta < dtTime ? delta : dtTime
animator.step(dt)
if (!animator.done) {
next = true
} else if (persist !== true) {
const endTime = animator.quantity() - animator.time() + this.t
const hold = persist === false ? 0 : persist
if (endTime + hold < this.t) {
// Delete animator and correct index
animator.unschedule()
i -= 1
l -= 1
}
}
}
if (
(next && !(this.v < 0 && this.t === 0)) ||
(this.animatorIds.length > 0 && this.v < 0 && this.t > 0)
) {
this.peek()
} else {
this.pause()
// this.fire('finished')
}
return this
}
has(animatorId: number): boolean
has<TAnimator extends Timeline.AnyAnimator>(animator: TAnimator): boolean
has<TAnimator extends Timeline.AnyAnimator>(animatorId: number | TAnimator) {
return this.animatorIds.includes(
typeof animatorId === 'number' ? animatorId : animatorId.id,
)
}
protected getAnimator(animatorId: number) {
return this.animators[this.animatorIds.indexOf(animatorId)] || null
}
protected getPreviousAnimator() {
return this.getAnimator(this.previousAnimatorId)
}
protected getPreviousEndTime() {
const meta = this.getPreviousAnimator()
const duration = meta ? meta.animator.quantity() : 0
const start = meta ? meta.start : this.t
return start + duration
}
}
export namespace Timeline {
export type AnyAnimator = Animator<any, any>
}

View File

@ -1,83 +0,0 @@
import sinon from 'sinon'
import { Timing } from './timing'
import { MockRAF } from './mock-raf'
import { Global } from '../../global'
describe('Timing', () => {
let raf: MockRAF
beforeEach(() => {
raf = new MockRAF()
raf.install(Global.getWindow())
Timing.clean()
})
afterEach(() => {
raf.uninstall(Global.getWindow())
})
describe('timeout()', () => {
it('should call a function after a specific time', () => {
const spy = sinon.spy()
Timing.timeout(spy, 100)
raf.tick(99)
expect(spy.callCount).toBe(0)
raf.tick()
expect(spy.callCount).toBe(1)
})
})
describe('cancelTimeout()', () => {
it('should cancel a timeout which was created with timeout()', () => {
const spy = sinon.spy()
const id = Timing.timeout(spy, 100)
Timing.clearTimeout(id)
expect(spy.called).toBeFalse()
raf.tick(100)
expect(spy.called).toBeFalse()
})
})
describe('frame()', () => {
it('should call a function at the next animationFrame', () => {
const spy = sinon.spy()
Timing.frame(spy)
expect(spy.called).toBeFalse()
raf.tick()
expect(spy.called).toBeTrue()
})
})
describe('cancelFrame()', () => {
it('should cancel a single frame which was created with frame()', () => {
const spy = sinon.spy()
const id = Timing.frame(spy)
Timing.cancelFrame(id)
expect(spy.called).toBeFalse()
raf.tick()
expect(spy.called).toBeFalse()
})
})
describe('immediate()', () => {
it('should call a function at the next animationFrame but after all frames are processed', () => {
const spy = sinon.spy()
Timing.immediate(spy)
expect(spy.called).toBeFalse()
raf.tick()
expect(spy.called).toBeTrue()
})
})
describe('cancelImmediate()', () => {
it('should cancel an immediate cakk which was created with immediate()', () => {
const spy = sinon.spy()
const id = Timing.immediate(spy)
Timing.cancelImmediate(id)
expect(spy.called).toBeFalse()
raf.tick()
expect(spy.called).toBeFalse()
})
})
})

View File

@ -1,116 +0,0 @@
import { Global } from '../../global'
import { Queue } from './queue'
export namespace Timing {
export const timer = () => Global.window.performance || Global.window.Date
let frames = new Queue<Frame>()
let timeouts = new Queue<Timeout>()
let immediates = new Queue<Frame>()
let nextTickId: number | null = null
export function clean() {
frames = new Queue<Frame>()
timeouts = new Queue<Timeout>()
immediates = new Queue<Frame>()
}
export function frame(fn: Callback) {
const node = frames.push({ fn })
tick()
return node
}
export function timeout(fn: Callback, delay = 0) {
const node = timeouts.push({ fn, time: timer().now() + delay })
tick()
return node
}
export function immediate(fn: Callback) {
const node = immediates.push({ fn })
tick()
return node
}
export function cancelFrame(node: Queue.Item<Frame> | null) {
if (node) {
frames.remove(node)
}
}
export function clearTimeout(node: Queue.Item<Timeout> | null) {
if (node) {
timeouts.remove(node)
}
}
export function cancelImmediate(node: Queue.Item<Frame> | null) {
if (node) {
immediates.remove(node)
}
}
function tick() {
if (nextTickId === null) {
nextTickId = requestAnimationFrame()
}
}
function requestAnimationFrame() {
return Global.window.requestAnimationFrame(run)
}
function run(now: number) {
// Run all the timeouts we can run, if they are not ready yet, add them
// to the end of the queue immediately!
let nextTimeout = null
const lastTimeout = timeouts.last()
while ((nextTimeout = timeouts.shift())) {
// Run the timeout if its time, or push it to the end
if (now >= nextTimeout.time) {
nextTimeout.fn(now)
} else {
timeouts.push(nextTimeout)
}
// If we hit the last item, we should stop shifting out more items
if (nextTimeout === lastTimeout) {
break
}
}
// Run all of the animation frames
let nextFrame = null
const lastFrame = frames.last()
while ((nextFrame = frames.shift())) {
nextFrame.fn(now)
// If we hit the last item, we should stop shifting out more items
if (nextFrame === lastFrame) {
break
}
}
let nextImmediate = null
while ((nextImmediate = immediates.shift())) {
nextImmediate.fn(now)
}
// If we have remaining timeouts or frames, run until we don't anymore
const next = timeouts.first() || frames.first()
nextTickId = next ? requestAnimationFrame() : null
}
}
export namespace Timing {
export type Callback = (now: number) => any
export interface Frame {
fn: Callback
}
export interface Timeout {
fn: Callback
time: number
}
}

View File

@ -1,77 +0,0 @@
import { Stepper } from './stepper'
export class PIDController extends Stepper {
private p: number
private i: number
private d: number
private windup: number | false
constructor(p = 0.1, i = 0.01, d = 0, windup = 1000) {
super()
this.p = p
this.i = i
this.d = d
this.windup = windup
}
step(
from: string,
to: string,
delta: number,
context: PIDController.Context,
): string
step(
from: number,
to: number,
delta: number,
context: PIDController.Context,
): number
step(
from: string | number,
to: string | number,
delta: number,
context: PIDController.Context,
) {
if (typeof from === 'string') {
return from
}
context.done = delta === Infinity
const origin = from as number
const target = to as number
if (delta === Infinity) {
return target
}
if (delta === 0) {
return origin
}
const p = target - origin
let i = (context.integral || 0) + p * delta
const d = (p - (context.error || 0)) / delta
const windup = this.windup
// antiwindup
if (windup !== false) {
i = Math.max(-windup, Math.min(i, windup))
}
context.error = p
context.integral = i
context.done = Math.abs(p) < 0.001
return context.done
? target
: origin + (this.p * p + this.i * i + this.d * d)
}
}
export namespace PIDController {
export interface Context extends Stepper.Context {
error: number
integral: number
}
}

View File

@ -1,94 +0,0 @@
import { Stepper } from './stepper'
export class SpringController extends Stepper {
protected d: number
protected k: number
protected readonly duration: number
protected readonly overshoot: number
constructor(duration = 500, overshoot = 0) {
super()
this.duration = duration
this.overshoot = overshoot
}
step(
from: string,
to: string,
delta: number,
context: SpringController.Context,
): string
step(
from: number,
to: number,
delta: number,
context: SpringController.Context,
): number
step(
from: string | number,
to: string | number,
delta: number,
context: SpringController.Context,
) {
if (typeof from === 'string') {
return from
}
const origin = from as number
const target = to as number
context.done = delta === Infinity
if (delta === Infinity) {
return target
}
if (delta === 0) {
return origin
}
if (delta > 100) {
delta = 16 // eslint-disable-line
}
delta /= 1000 // eslint-disable-line
// Get the previous velocity
const velocity = context.velocity || 0
// Apply the control to get the new position and store it
const acceleration = -this.d * velocity - this.k * (origin - target)
const newPosition =
origin + velocity * delta + (acceleration * delta * delta) / 2
// Store the velocity
context.velocity = velocity + acceleration * delta
// Figure out if we have converged, and if so, pass the value
context.done = Math.abs(target - newPosition) + Math.abs(velocity) < 0.002
return context.done ? target : newPosition
}
protected recalculate() {
// Apply the default parameters
const duration = (this.duration || 500) / 1000
const overshoot = this.overshoot || 0
// Calculate the PID natural response
const eps = 1e-10
const pi = Math.PI
const os = Math.log(overshoot / 100 + eps)
const zeta = -os / Math.sqrt(pi * pi + os * os)
const wn = 3.9 / (zeta * duration)
// Calculate the Spring values
this.d = 2 * zeta * wn
this.k = wn * wn
}
}
export namespace SpringController {
export interface Context extends Stepper.Context {
velocity: number
}
}

View File

@ -1,34 +0,0 @@
import { Stepper } from './stepper'
export class Controller extends Stepper {
stepper: typeof Stepper.prototype.step
constructor(fn: typeof Stepper.prototype.step) {
super()
this.stepper = fn
}
step(
from: string,
to: string,
delta: number,
context: Stepper.Context,
contexts: Stepper.Context[],
): string
step(
from: number,
to: number,
delta: number,
context: Stepper.Context,
contexts: Stepper.Context[],
): number
step(
from: string | number,
to: string | number,
delta: number,
context: Stepper.Context,
contexts: Stepper.Context[],
) {
return this.stepper(from, to, delta, context, contexts)
}
}

View File

@ -1,9 +0,0 @@
export type Definition = (t: number) => number
export type Names = keyof typeof presets
export const presets = {
linear: (t: number) => t,
easeOut: (t: number) => Math.sin((t * Math.PI) / 2),
easeIn: (t: number) => -Math.cos((t * Math.PI) / 2) + 1,
easeInOut: (t: number) => -Math.cos(t * Math.PI) / 2 + 0.5,
}

View File

@ -1,116 +0,0 @@
import { Stepper } from './stepper'
export class Easing extends Stepper {
protected readonly ease: Easing.Definition
constructor()
constructor(ease: Easing.Names)
constructor(ease: Easing.Definition)
constructor(ease?: Easing.Names | Easing.Definition)
constructor(ease: Easing.Names | Easing.Definition = 'linear') {
super()
if (typeof ease === 'string') {
this.ease = Easing.presets[ease] || Easing.presets.linear
} else {
this.ease = ease
}
}
step(from: number, to: number, pos: number): number
step(from: string, to: string, pos: number): string
step(from: string | number, to: string | number, pos: number) {
if (typeof from !== 'number') {
return pos < 1 ? from : to
}
return +from + (+to - +from) * this.ease(pos)
}
}
export namespace Easing {
export type Definition = (t: number) => number
export type Names = keyof typeof presets
export const presets = {
linear: (pos: number) => pos,
easeOut: (pos: number) => Math.sin((pos * Math.PI) / 2),
easeIn: (pos: number) => -Math.cos((pos * Math.PI) / 2) + 1,
easeInOut: (pos: number) => -Math.cos(pos * Math.PI) / 2 + 0.5,
}
export const factories = {
// see https://www.w3.org/TR/css-easing-1/#cubic-bezier-algo
bezier(x1: number, y1: number, x2: number, y2: number) {
return (t: number) => {
if (t < 0) {
if (x1 > 0) {
return (y1 / x1) * t
}
if (x2 > 0) {
return (y2 / x2) * t
}
return 0
}
if (t > 1) {
if (x2 < 1) {
return ((1 - y2) / (1 - x2)) * t + (y2 - x2) / (1 - x2)
}
if (x1 < 1) {
return ((1 - y1) / (1 - x1)) * t + (y1 - x1) / (1 - x1)
}
return 1
}
return 3 * t * (1 - t) ** 2 * y1 + 3 * t ** 2 * (1 - t) * y2 + t ** 3
}
},
// see https://www.w3.org/TR/css-easing-1/#step-timing-function-algo
steps(
steps: number,
stepPosition:
| 'start'
| 'end'
| 'both'
| 'none'
| 'jump-start'
| 'jump-end' = 'end',
) {
// deal with "jump-" prefix
const position = stepPosition.split('-').reverse()[0]
let jumps = steps
if (position === 'none') {
jumps -= 1
} else if (position === 'both') {
jumps += 1
}
// The beforeFlag is essentially useless
return (t: number, beforeFlag = false) => {
// Step is called currentStep in referenced url
let step = Math.floor(t * steps)
const jumping = (t * step) % 1 === 0
if (position === 'start' || position === 'both') {
step += 1
}
if (beforeFlag && jumping) {
step -= 1
}
if (t >= 0 && step < 0) {
step = 0
}
if (t <= 1 && step > jumps) {
step = jumps
}
return step / jumps
}
},
}
}

View File

@ -1,19 +0,0 @@
export abstract class Stepper {
done(context: Stepper.Context) {
return context.done
}
abstract step<T>(
from: T,
to: T,
pos: number,
context: Stepper.Context,
contexts: Stepper.Context[],
): T
}
export namespace Stepper {
export interface Context {
done: boolean
}
}

View File

@ -1,13 +0,0 @@
export type Now = () => number
export type When = 'now' | 'start' | 'relative' | 'after' | 'with'
export interface Options {
duration?: number
delay?: number
swing?: boolean
times?: number
wait?: number
ease?: string
when?: When
}

View File

@ -1,36 +0,0 @@
import type { Dom } from '../dom'
import type { AnimatorMap } from './types'
import { applyMixins } from '../util/mixin'
import { Animator } from '../animating/animator/animator'
import { Registry } from './registry'
export class BaseAnimator<
TElement extends Element = Element,
TOwner extends Dom<TElement> = Dom<TElement>,
TAnimator = AnimatorMap<TElement>,
> extends Animator<TAnimator, TOwner> {
element(): TOwner
element(elem: TOwner): this
element(elem?: TOwner) {
if (elem == null) {
return super.master()
}
super.master(elem)
return this
}
}
export namespace BaseAnimator {
export function register(name: string) {
return <TDefinition extends Registry.Definition>(ctor: TDefinition) => {
Registry.register(ctor, name)
}
}
export function mixin(...source: any[]) {
return (ctor: Registry.Definition) => {
applyMixins(ctor, ...source)
}
}
}

View File

@ -1,106 +0,0 @@
import { Morpher } from '../animating/morpher/morpher'
import { MorphableObject } from '../animating/morpher/object'
import { Dom } from '../dom'
import { AttributesMap } from '../dom/attributes'
import { CSSProperties } from '../dom/style'
import { BaseAnimator } from './base'
import { TransformAnimator } from './transform'
@DomAnimator.register('HTML')
@DomAnimator.mixin(TransformAnimator)
export class DomAnimator<
TElement extends Element = Element,
TOwner extends Dom<TElement> = Dom<TElement>,
> extends BaseAnimator<TElement, TOwner> {
attr<T extends AttributesMap<TElement>>(attrs: T): this
attr<T extends AttributesMap<TElement>, K extends keyof T>(
name: K,
value: T[K],
): this
attr<T extends AttributesMap<TElement>, K extends keyof T>(
name: K | T,
value?: T[K],
) {
return this.queueAttrOrCSS('attr', name, value)
}
css<T extends CSSProperties>(style: T): this
css<T extends CSSProperties, K extends keyof T>(name: K, value: T[K]): this
css<T extends CSSProperties, K extends keyof T>(name: K | T, value?: T[K]) {
return this.queueAttrOrCSS('css', name, value)
}
protected queueAttrOrCSS<
M extends 'attr' | 'css',
T extends M extends 'attr' ? AttributesMap<TElement> : CSSProperties,
K extends keyof T,
>(method: M, name: K | T, value?: T[K]): this {
if (typeof name === 'string') {
return this.queueAttrOrCSS(method, { [name]: value } as T)
}
let attrs = name as T
if (this.retarget(method, attrs)) {
return this
}
const morpher = new Morpher<any[], T, T>(this.stepper).to(attrs)
let keys = Object.keys(attrs)
this.queue<T>(
// prepare
(animator) => {
const origin = animator.element()[method](keys as any)
morpher.from(origin as T)
},
// run
(animator, pos) => {
const val = morpher.at(pos)
animator.element()[method](val as any)
return morpher.done()
},
// retarget
(animator, newToAttrs) => {
// Check if any new keys were added
const newKeys = Object.keys(newToAttrs)
const diff = (a: string[], b: string[]) =>
a.filter((x) => !b.includes(x))
const differences = diff(newKeys, keys)
// If their are new keys, initialize them and add them to morpher
if (differences.length) {
const addedFromAttrs = animator
.element()
[method](differences as any) as T
const oldFromAttrs = new MorphableObject<T>(morpher.from()).toValue()
morpher.from({
...oldFromAttrs,
...addedFromAttrs,
})
}
const oldToAttrs = new MorphableObject<T>(morpher.to()).toValue()
morpher.to({
...oldToAttrs,
...newToAttrs,
})
// Save the work we did so we don't need it to do again
keys = newKeys
attrs = newToAttrs
},
)
this.remember(method, morpher)
return this
}
}
export interface DomAnimator<
TElement extends Element = Element,
TOwner extends Dom<TElement> = Dom<TElement>,
> extends TransformAnimator<TElement, TOwner> {}

View File

@ -1 +0,0 @@
import './mixins'

View File

@ -1,57 +0,0 @@
import { applyMixins } from '../util/mixin'
import { Dom } from '../dom/dom'
import { Primer } from '../dom/primer/primer'
import { Util } from '../animating/animator/util'
import { Timeline } from '../animating/scheduler/timeline'
import { When, Options } from '../animating/types'
import { Registry } from './registry'
import { AnimatorMap } from './types'
export class AnimateExtension<
TElement extends Element,
> extends Primer<TElement> {
animate(options: Options): AnimatorMap<TElement>
animate(duration?: number, delay?: number, when?: When): AnimatorMap<TElement>
animate(duration?: Options | number, delay?: number, when?: When) {
const o = Util.sanitise(duration, delay, when)
const timeline = this.scheduler()
const Type = Registry.get(this.node)
return new Type(o.duration)
.loop(o)
.element(this as any)
.scheduler(timeline.play())
.schedule(o.delay, o.when)
}
delay(by: number, when?: When) {
return this.animate(0, by, when)
}
}
const cache: WeakMap<Primer, Timeline> = new WeakMap()
export class TimelineExtension<
TElement extends Element = Element,
> extends Primer<TElement> {
scheduler(): Timeline
scheduler(timeline: Timeline): this
scheduler(timeline?: Timeline) {
if (timeline == null) {
if (!cache.has(this)) {
cache.set(this, new Timeline())
}
return cache.get(this)!
}
cache.set(this, timeline)
return this
}
}
declare module '../dom/dom' {
interface Dom<TElement extends Element = Element>
extends AnimateExtension<TElement>,
TimelineExtension<TElement> {}
}
applyMixins(Dom, AnimateExtension, TimelineExtension)

View File

@ -1,31 +0,0 @@
import type { BaseAnimator } from './base'
export namespace Registry {
export type Definition = { new (...args: any[]): BaseAnimator }
const registry: Record<string, Definition> = {}
export function register(ctor: Definition, name: string) {
registry[name] = ctor
return ctor
}
export function get<TClass extends Definition = Definition>(node: Node) {
if (typeof node === 'string') {
return registry[node] as TClass
}
const nodeName = node.nodeName
let className = nodeName[0].toUpperCase() + nodeName.substring(1)
if (node instanceof SVGElement) {
if (registry[className] == null) {
className = 'SVG'
}
} else {
className = 'HTML'
}
return registry[className] as TClass
}
}

View File

@ -1,134 +0,0 @@
import { Vector } from '../vector/vector/vector'
import { Morpher } from '../animating/morpher/morpher'
import { Morphable } from '../animating/morpher/morphable'
import { MorphableUnitNumber } from '../animating/morpher/unit-number'
import { DomAnimator } from './dom'
@SVGAnimator.register('SVG')
export class SVGAnimator<
TSVGElement extends SVGElement = SVGElement,
TOwner extends Vector<TSVGElement> = Vector<TSVGElement>,
> extends DomAnimator<TSVGElement, TOwner> {
x(x: number | string) {
return this.queueNumber('x', x)
}
y(y: number | string) {
return this.queueNumber('y', y)
}
move(x: number | string = 0, y: number | string = 0) {
return this.x(x).y(y)
}
dx(x: number | string) {
return this.queueDelta('x', x)
}
dy(y: number | string) {
return this.queueDelta('y', y)
}
dmove(x: number | string = 0, y: number | string = 0) {
return this.dx(x).dy(y)
}
cx(x: number | string) {
return this.queueNumber('cx', x)
}
cy(y: number | string) {
return this.queueNumber('cy', y)
}
center(x: number | string = 0, y: number | string = 0) {
return this.cx(x).cy(y)
}
width(width: number | string) {
return this.queueNumber('width', width)
}
height(height: number | string) {
return this.queueNumber('height', height)
}
size(width?: number | string, height?: number | string) {
if (width == null && height == null) {
return this
}
let w = MorphableUnitNumber.toNumber(width!)
let h = MorphableUnitNumber.toNumber(height!)
if (width == null || height == null) {
const box = this.element().bbox()
if (!width) {
w = (box.width / box.height) * h
}
if (!height) {
h = (box.height / box.width) * w
}
}
return this.width(w).height(h)
}
protected queueDelta(method: 'x' | 'y', to: number | string) {
if (this.retarget(method, to)) {
return this
}
const morpher = new Morpher<number[], number | string, number>(
this.stepper,
).to(to)
let from: number
this.queue<string | number>(
(animator) => {
from = animator.element()[method]()
morpher.from(from)
morpher.to(from + MorphableUnitNumber.toNumber(to))
},
(animator, pos) => {
animator.element()[method](morpher.at(pos))
return morpher.done()
},
(animator, newTo) => {
morpher.to(from + MorphableUnitNumber.toNumber(newTo))
},
)
this.remember(method, morpher)
return this
}
protected queueNumber(
method: 'x' | 'y' | 'cx' | 'cy' | 'width' | 'height' | 'leading',
value: number | string,
) {
return this.queueObject(method, new MorphableUnitNumber(value))
}
protected queueObject(method: string, to: Morphable<any[], any>) {
if (this.retarget(method, to)) {
return this
}
const morpher = new Morpher<any[], any, any>(this.stepper).to(to)
this.queue<any>(
(animator) => {
morpher.from(animator.element()[method as 'x']())
},
(animator, pos) => {
animator.element()[method as 'x'](morpher.at(pos))
return morpher.done()
},
)
this.remember(method, morpher)
return this
}
}

View File

@ -1,177 +0,0 @@
import { Dom } from '../../dom'
import { Point } from '../../struct/point'
import { Matrix } from '../../struct/matrix'
import { BaseAnimator } from '../base'
import { Morpher } from '../../animating/morpher/morpher'
import { MorphableMatrix } from '../../animating/morpher/matrix'
import { MorphableTransform } from '../../animating/morpher/transform'
import { getTransformOrigin } from '../../dom/transform/util'
import * as Util from './util'
export class TransformAnimator<
TElement extends Element = Element,
TOwner extends Dom<TElement> = Dom<TElement>,
> extends BaseAnimator<TElement, TOwner> {
transform(
transforms: Matrix.MatrixLike | Matrix.TransformOptions,
relative?: boolean,
affine?: boolean,
) {
//
// 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)
Util.prepareTransform(this.element())
const method = 'transform'
// If we have a declarative function, we should retarget it if possible
if (this.declarative && !relative) {
if (this.retarget(method, transforms)) {
return this
}
}
const isMatrix = MorphableMatrix.isMatrixLike(transforms)
// eslint-disable-next-line no-param-reassign
affine = affine != null ? affine : !isMatrix
const morpher = new Morpher<
Matrix.MatrixArray,
Matrix.TransformOptions,
Matrix.TransformOptions
>(this.stepper).type(affine ? MorphableTransform : MorphableMatrix)
let origin: [number, number]
let element: TOwner
let startTransformMatrix: Matrix
let currentTransformMatrix: Matrix
let currentAngle: number
this.queue<Matrix.TransformOptions>(
// prepare
(animator) => {
// make sure element and origin is defined
element = element || animator.element()
origin =
origin ||
getTransformOrigin(transforms as Matrix.TransformOptions, element)
startTransformMatrix = new Matrix(relative ? undefined : element)
// add the animator to the element so it can merge transformations
Util.addAnimator(element, animator)
// Deactivate all transforms that have run so far if we are absolute
if (!relative) {
Util.clearAnimatorsBefore(element, animator).forEach((animator) => {
if (animator instanceof BaseAnimator) {
if (!animator.done) {
const timeline = animator.scheduler()
if (timeline == null || !timeline.has(animator)) {
const a = animator as any as TransformAnimator
a.clearTransformExecutors()
}
}
}
})
}
},
// run
(animator, pos) => {
if (!relative) {
Util.clearTransform(animator)
}
const ctm = Util.getCurrentTransform(element, animator)
const { x, y } = new Point(origin[0], origin[1]).transform(ctm)
const target = new Matrix({ ...transforms, origin: [x, y] })
const source =
animator.declarative && currentTransformMatrix
? currentTransformMatrix
: startTransformMatrix
const t = target.decompose(x, y)
const s = source.decompose(x, y)
if (affine) {
// Get the current and target angle as it was set
const rt = t.rotate
const rs = s.rotate
// Figure out the shortest path to rotate directly
const possibilities = [rt - 360, rt, rt + 360]
const distances = possibilities.map((a) => Math.abs(a - rs))
const shortest = Math.min(...distances)
const index = distances.indexOf(shortest)
t.rotate = possibilities[index]
}
if (relative) {
// we have to be careful here not to overwrite the rotation
// with the rotate method of Matrix
if (!isMatrix) {
t.rotate = (transforms as Matrix.TransformOptions).rotate || 0
}
if (this.declarative && currentAngle) {
s.rotate = currentAngle
}
}
morpher.from(s)
morpher.to(t)
const affineParameters = morpher.at(pos)
currentAngle = affineParameters.rotate!
currentTransformMatrix = new Matrix(affineParameters)
Util.addTransform(animator, currentTransformMatrix)
Util.addAnimator(element, animator)
return morpher.done()
},
// retarget
(animator, newTransforms) => {
const prev = (transforms as Matrix.TransformOptions).origin || 'center'
const next = (newTransforms.origin || 'center').toString()
// only get a new origin if it changed since the last call
if (prev.toString() !== next.toString()) {
origin = getTransformOrigin(newTransforms, element)
}
// overwrite the old transformations with the new ones
// eslint-disable-next-line no-param-reassign
transforms = { ...newTransforms, origin }
},
true,
)
if (this.declarative) {
this.remember(method, morpher)
}
return this
}
protected clearTransformExecutors() {
const executors = this.executors
for (let i = executors.length - 1; i >= 0; i -= 1) {
if (executors[i].isTransform) {
executors.splice(i, 1)
}
}
}
}

View File

@ -1,81 +0,0 @@
import { DomAnimator } from '../dom'
import { MockedAnimator } from './mock'
export class AnimatorList {
public readonly ids: number[] = []
public readonly animators: (DomAnimator | MockedAnimator)[] = []
add(animator: DomAnimator | MockedAnimator) {
if (this.animators.includes(animator)) {
return this
}
this.ids.push(animator.id)
this.animators.push(animator)
return this
}
remove(id: number) {
const index = this.ids.indexOf(id)
this.ids.splice(index, 1)
this.animators.splice(index, 1)
return this
}
replace(id: number, animator: DomAnimator | MockedAnimator) {
const index = this.ids.indexOf(id)
this.ids.splice(index, 1, id)
this.animators.splice(index, 1, animator)
return this
}
get(id: number) {
return this.animators[this.ids.indexOf(id)]
}
length() {
return this.ids.length
}
clearBefore(id: number) {
const deleteCount = this.ids.indexOf(id) || 1
this.ids.splice(0, deleteCount, 0)
this.animators.splice(0, deleteCount, new MockedAnimator())
return this.animators
}
merge(
mergeFn: (
a1: DomAnimator | MockedAnimator,
a2: DomAnimator | MockedAnimator,
) => MockedAnimator,
) {
let prev: DomAnimator | MockedAnimator | null = null
for (let i = 0; i < this.animators.length; i += 1) {
const curr = this.animators[i]
const currs = curr.scheduler()
const prevs = prev && prev.scheduler()
const needMerge =
prev != null &&
prev.done &&
curr.done &&
// don't merge animator when persisted on timeline
(currs == null || !currs.has(curr.id)) &&
(prevs == null || !prevs.has(prev.id))
if (needMerge) {
this.remove(curr.id)
const merged = mergeFn(curr, prev!)
this.replace(prev!.id, merged)
prev = merged
i -= 1
} else {
prev = curr
}
}
return this
}
}

View File

@ -1,4 +0,0 @@
export class MockedAnimator {
constructor(public id = -1, public done = true) {}
scheduler() {}
}

View File

@ -1,101 +0,0 @@
import { Dom } from '../../dom'
import { Matrix } from '../../struct/matrix'
import { Queue } from '../../animating/scheduler/queue'
import { Timing } from '../../animating/scheduler/timing'
import { BaseAnimator } from '../base'
import { AnimatorList } from './list'
import { MockedAnimator } from './mock'
const animators: WeakMap<Dom, AnimatorList> = new WeakMap()
const frames: WeakMap<Dom, Queue.Item<Timing.Frame>> = new WeakMap()
export function prepareTransform(elem: Dom) {
if (!frames.has(elem)) {
const mock = createMockAnimator(new Matrix(elem))
const list = new AnimatorList().add(mock)
animators.set(elem, list)
}
}
function createMockAnimator(matrix?: Matrix, id?: number) {
const mock = new MockedAnimator(id)
addTransform(mock, matrix)
return mock
}
export function addAnimator(elem: Dom, animator: BaseAnimator) {
animators.get(elem)!.add(animator)
let frameId = frames.get(elem) || null
// Make sure that the animator merge is executed at the very end of
// all animation functions. Thats why we use immediate here to execute
// the merge right after all frames are run
Timing.cancelImmediate(frameId)
frameId = Timing.immediate(() => {
const list = animators.get(elem)!
const next = list.animators
.map((animator) => getTransform(animator)!)
.reduce((memo, curr) => memo.lmultiplyO(curr), new Matrix())
elem.transform(next)
list.merge((a1, a2) => {
const m1 = getTransform(a1)!
const m2 = getTransform(a2)!
clearTransform(a2)
return createMockAnimator(m2.lmultiply(m1), a2.id)
})
if (list.length() === 1) {
frames.delete(elem)
}
})
frames.set(elem, frameId)
}
export function clearAnimatorsBefore(elem: Dom, animator: BaseAnimator) {
const cache = animators.get(elem)!
return cache.clearBefore(animator.id)
}
export function getCurrentTransform(elem: Dom, animator: BaseAnimator) {
const arr = animators.get(elem)!
return (
arr.animators
// 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((item) => item.id <= animator.id)
.map((animator) => getTransform(animator)!)
.reduce((memo, curr) => memo.lmultiplyO(curr), new Matrix())
)
}
const transforms: WeakMap<BaseAnimator | MockedAnimator, Matrix> = new WeakMap()
export function addTransform(
animator: BaseAnimator | MockedAnimator,
transform?: Matrix.Raw,
) {
let ctm = transforms.get(animator)
if (ctm == null) {
ctm = new Matrix()
}
if (transform) {
ctm.lmultiplyO(transform)
}
transforms.set(animator, ctm)
}
export function clearTransform(animator: BaseAnimator | MockedAnimator) {
transforms.set(animator, new Matrix())
}
export function getTransform(animator: BaseAnimator | MockedAnimator) {
return transforms.get(animator)
}

View File

@ -1,61 +0,0 @@
import { DomAnimator } from './dom'
import { SVGAnimator } from './svg'
import { SVGAAnimator } from './vector/a'
import { SVGClipPathAnimator } from './vector/clippath'
import { SVGGAnimator } from './vector/g'
import { SVGDefsAnimator } from './vector/defs'
import { SVGGradientAnimator } from './vector/gradient'
import { SVGMarkerAnimator } from './vector/marker'
import { SVGMaskAnimator } from './vector/mask'
import { SVGPatternAnimator } from './vector/pattern'
import { SVGSVGAnimator } from './vector/svg'
import { SVGSymbolAnimator } from './vector/symbol'
import { SVGCircleAnimator } from './vector/circle'
import { SVGEllipseAnimator } from './vector/ellipse'
import { SVGImageAnimator } from './vector/image'
import { SVGLineAnimator } from './vector/line'
import { SVGPolygonAnimator } from './vector/polygon'
import { SVGPolylineAnimator } from './vector/polyline'
import { SVGRectAnimator } from './vector/rect'
import { SVGStyleAnimator } from './vector/style'
import { SVGTextAnimator } from './vector/text'
import { SVGTSpanAnimator } from './vector/tspan'
import { SVGUseAnimator } from './vector/use'
import { SVGPathAnimator } from './vector/path'
import { SVGForeignObjectAnimator } from './vector/foreignobject'
import { SVGLinearGradientAnimator } from './vector/linear-gradient'
import { SVGRadialGradientAnimator } from './vector/radial-gradient'
// prettier-ignore
export type AnimatorMap<T> =
T extends SVGAElement ? SVGAAnimator
: T extends SVGLineElement ? SVGLineAnimator
: T extends SVGPathElement ? SVGPathAnimator
: T extends SVGCircleElement ? SVGCircleAnimator
: T extends SVGClipPathElement ? SVGClipPathAnimator
: T extends SVGEllipseElement ? SVGEllipseAnimator
: T extends SVGImageElement ? SVGImageAnimator
: T extends SVGRectElement ? SVGRectAnimator
: T extends SVGUseElement ? SVGUseAnimator
: T extends SVGSVGElement ? SVGSVGAnimator
: T extends SVGForeignObjectElement ? SVGForeignObjectAnimator
: T extends SVGTSpanElement ? SVGTSpanAnimator
: T extends SVGTextElement ? SVGTextAnimator
: T extends SVGPolylineElement ? SVGPolylineAnimator
: T extends SVGPolygonElement ? SVGPolygonAnimator
: T extends SVGGElement ? SVGGAnimator
: T extends SVGDefsElement ? SVGDefsAnimator
: T extends SVGGradientElement ? SVGGradientAnimator<T>
: T extends SVGLinearGradientElement ? SVGLinearGradientAnimator
: T extends SVGRadialGradientElement ? SVGRadialGradientAnimator
: T extends SVGMarkerElement ? SVGMarkerAnimator
: T extends SVGMaskElement ? SVGMaskAnimator
: T extends SVGPatternElement ? SVGPatternAnimator
: T extends SVGPolygonElement ? SVGPolygonAnimator
: T extends SVGPolylineElement ? SVGPolylineAnimator
: T extends SVGSymbolElement ? SVGSymbolAnimator
: T extends SVGStyleElement ? SVGStyleAnimator
: T extends SVGElement ? SVGAnimator<T>
: T extends Element ? DomAnimator<T>
: DomAnimator<any>

View File

@ -1,8 +0,0 @@
import { A } from '../../vector/a/a'
import { SVGContainerGeometryAnimator } from './container-geometry'
@SVGAAnimator.register('A')
export class SVGAAnimator extends SVGContainerGeometryAnimator<
SVGAElement,
A
> {}

View File

@ -1,5 +0,0 @@
import { Circle } from '../../vector/circle/circle'
import { SVGAnimator } from '../svg'
@SVGCircleAnimator.register('Circle')
export class SVGCircleAnimator extends SVGAnimator<SVGCircleElement, Circle> {}

View File

@ -1,8 +0,0 @@
import { ClipPath } from '../../vector/clippath/clippath'
import { SVGWrapperAnimator } from './wrapper'
@SVGClipPathAnimator.register('ClipPath')
export class SVGClipPathAnimator extends SVGWrapperAnimator<
SVGClipPathElement,
ClipPath
> {}

View File

@ -1,7 +0,0 @@
import { ContainerGeometry } from '../../vector/container/geometry'
import { SVGContainerAnimator } from './container'
export class SVGContainerGeometryAnimator<
TSVGElement extends SVGAElement | SVGGElement,
TOwner extends ContainerGeometry<TSVGElement> = ContainerGeometry<TSVGElement>,
> extends SVGContainerAnimator<TSVGElement, TOwner> {}

View File

@ -1,44 +0,0 @@
import { Point } from '../../struct/point'
import { Viewbox } from '../../vector/container/viewbox'
import { Morpher } from '../../animating/morpher/morpher'
import { MorphableBox } from '../../animating/morpher/box'
import { SVGAnimator } from '../svg'
export class SVGViewboxAnimator<
TSVGElement extends
| SVGSVGElement
| SVGSymbolElement
| SVGPatternElement
| SVGMarkerElement,
TOwner extends Viewbox<TSVGElement> = Viewbox<TSVGElement>,
> extends SVGAnimator<TSVGElement, TOwner> {
zoom(level: number, point: Point.PointLike) {
if (this.retarget('zoom', level, point)) {
return this
}
const origin = { x: point.x, y: point.y }
const morpher = new Morpher<[number], number, number>(this.stepper).to(
level,
)
this.queue<number, Point.PointLike>(
(animator) => {
morpher.from(animator.element().zoom())
},
(animator, pos) => {
animator.element().zoom(morpher.at(pos), origin)
return morpher.done()
},
(animator, newLevel, newPoint) => {
origin.x = newPoint.x
origin.y = newPoint.y
morpher.to(newLevel)
},
)
this.remember('zoom', morpher)
return this
}
viewbox(x: number, y: number, width: number, height: number) {
return this.queueObject('viewbox', new MorphableBox(x, y, width, height))
}
}

View File

@ -1,7 +0,0 @@
import { Container } from '../../vector/container/container'
import { SVGAnimator } from '../svg'
export class SVGContainerAnimator<
TSVGElement extends SVGElement,
TOwner extends Container<TSVGElement> = Container<TSVGElement>,
> extends SVGAnimator<TSVGElement, TOwner> {}

View File

@ -1,5 +0,0 @@
import { Defs } from '../../vector/defs/defs'
import { SVGAnimator } from '../svg'
@SVGDefsAnimator.register('Defs')
export class SVGDefsAnimator extends SVGAnimator<SVGDefsElement, Defs> {}

View File

@ -1,8 +0,0 @@
import { Ellipse } from '../../vector/ellipse/ellipse'
import { SVGAnimator } from '../svg'
@SVGEllipseAnimator.register('Ellipse')
export class SVGEllipseAnimator extends SVGAnimator<
SVGEllipseElement,
Ellipse
> {}

View File

@ -1,8 +0,0 @@
import { ForeignObject } from '../../vector/foreignobject/foreignobject'
import { SVGAnimator } from '../svg'
@SVGForeignObjectAnimator.register('ForeignObject')
export class SVGForeignObjectAnimator extends SVGAnimator<
SVGForeignObjectElement,
ForeignObject
> {}

View File

@ -1,8 +0,0 @@
import { G } from '../../vector/g/g'
import { SVGContainerGeometryAnimator } from './container-geometry'
@SVGGAnimator.register('G')
export class SVGGAnimator extends SVGContainerGeometryAnimator<
SVGGElement,
G
> {}

View File

@ -1,7 +0,0 @@
import { Gradient } from '../../vector/gradient/gradient'
import { SVGWrapperAnimator } from './wrapper'
export class SVGGradientAnimator<
TSVGElement extends SVGGradientElement,
TOwner extends Gradient<TSVGElement> = Gradient<TSVGElement>,
> extends SVGWrapperAnimator<TSVGElement, TOwner> {}

View File

@ -1,5 +0,0 @@
import { Image } from '../../vector/image/image'
import { SVGAnimator } from '../svg'
@SVGImageAnimator.register('Image')
export class SVGImageAnimator extends SVGAnimator<SVGImageElement, Image> {}

View File

@ -1,55 +0,0 @@
import { Line } from '../../vector/line/line'
import { PointArray } from '../../struct/point-array'
import { Morpher } from '../../animating/morpher/morpher'
import { MorphablePointArray } from '../../animating/morpher/point-array'
import { SVGAnimator } from '../svg'
@SVGLineAnimator.register('Line')
export class SVGLineAnimator extends SVGAnimator<SVGLineElement, Line> {
plot(d: string): this
plot(points: Line.Array | PointArray): this
plot(x1: number, y1: number, x2: number, y2: number): this
plot(
x1: Line.Array | PointArray | number | string,
y1?: number,
x2?: number,
y2?: number,
): this
plot(
x1: Line.Array | PointArray | number | string,
y1?: number,
x2?: number,
y2?: number,
) {
if (typeof x1 === 'number') {
return this.plot([
[x1, y1!],
[x2!, y2!],
])
}
if (this.retarget('plot', x1)) {
return this
}
const morpher = new Morpher<
Line.Array,
Line.Array | PointArray | string,
Line.Array
>(this.stepper)
.type(MorphablePointArray)
.to(x1)
this.queue<Line.Array>(
(animator) => {
morpher.from(animator.element().toArray())
},
(animator, pos) => {
animator.plot(morpher.at(pos))
return morpher.done()
},
)
this.remember('plot', morpher)
return this
}
}

View File

@ -1,8 +0,0 @@
import { LinearGradient } from '../../vector/gradient/linear'
import { SVGGradientAnimator } from './gradient'
@SVGLinearGradientAnimator.register('LinearGradient')
export class SVGLinearGradientAnimator extends SVGGradientAnimator<
SVGLinearGradientElement,
LinearGradient
> {}

View File

@ -1,8 +0,0 @@
import { Marker } from '../../vector/marker/marker'
import { SVGViewboxAnimator } from './container-viewbox'
@SVGMarkerAnimator.register('Marker')
export class SVGMarkerAnimator extends SVGViewboxAnimator<
SVGMarkerElement,
Marker
> {}

View File

@ -1,5 +0,0 @@
import { Mask } from '../../vector/mask/mask'
import { SVGWrapperAnimator } from './wrapper'
@SVGMaskAnimator.register('Mask')
export class SVGMaskAnimator extends SVGWrapperAnimator<SVGMaskElement, Mask> {}

View File

@ -1,37 +0,0 @@
import { Path } from '../../vector/path/path'
import { PathArray } from '../../struct/path-array'
import { Morpher } from '../../animating/morpher/morpher'
import { MorphablePathArray } from '../../animating/morpher/path-array'
import { SVGAnimator } from '../svg'
@SVGPathAnimator.register('Path')
export class SVGPathAnimator extends SVGAnimator<SVGPathElement, Path> {
plot(d: string): this
plot(segments: Path.Segment[]): this
plot(pathArray: PathArray): this
plot(d: string | Path.Segment[] | PathArray) {
if (this.retarget('plot', d)) {
return this
}
const morpher = new Morpher<
Path.Segment[],
string | Path.Segment[] | PathArray,
Path.Segment[]
>(this.stepper)
.type(MorphablePathArray)
.to(d)
this.queue<Path.Segment[]>(
(animator) => {
morpher.from(animator.element().toArray())
},
(animator, pos) => {
animator.plot(morpher.at(pos))
return morpher.done()
},
)
this.remember('plot', morpher)
return this
}
}

View File

@ -1,8 +0,0 @@
import { Pattern } from '../../vector/pattern/pattern'
import { SVGViewboxAnimator } from './container-viewbox'
@SVGPatternAnimator.register('Pattern')
export class SVGPatternAnimator extends SVGViewboxAnimator<
SVGPatternElement,
Pattern
> {}

View File

@ -1,38 +0,0 @@
import { Poly } from '../../vector/poly/poly'
import { MorphablePointArray } from '../../animating/morpher/point-array'
import { Morpher } from '../../animating/morpher/morpher'
import { SVGAnimator } from '../svg'
export class SVGPolyAnimator<
TSVGElement extends SVGPolygonElement | SVGPolylineElement,
TOwner extends Poly<TSVGElement> = Poly<TSVGElement>,
> extends SVGAnimator<TSVGElement, TOwner> {
plot(d: string): this
plot(points: [number, number][]): this
plot(points: string | [number, number][]): this
plot(d: string | [number, number][]) {
if (this.retarget('plot', d)) {
return this
}
const morpher = new Morpher<
[number, number][],
string | [number, number][],
[number, number][]
>(this.stepper)
.type(MorphablePointArray)
.to(d)
this.queue<[number, number][]>(
(animator) => {
morpher.from(animator.element().toArray())
},
(animator, pos) => {
animator.plot(morpher.at(pos))
return morpher.done()
},
)
this.remember('plot', morpher)
return this
}
}

View File

@ -1,8 +0,0 @@
import { Polygon } from '../../vector/polygon/polygon'
import { SVGPolyAnimator } from './poly'
@SVGPolygonAnimator.register('Polygon')
export class SVGPolygonAnimator extends SVGPolyAnimator<
SVGPolygonElement,
Polygon
> {}

View File

@ -1,8 +0,0 @@
import { Polyline } from '../../vector/polyline/polyline'
import { SVGPolyAnimator } from './poly'
@SVGPolylineAnimator.register('Polyline')
export class SVGPolylineAnimator extends SVGPolyAnimator<
SVGPolylineElement,
Polyline
> {}

View File

@ -1,8 +0,0 @@
import { RadialGradient } from '../../vector/gradient/radial'
import { SVGGradientAnimator } from './gradient'
@SVGRadialGradientAnimator.register('RadialGradient')
export class SVGRadialGradientAnimator extends SVGGradientAnimator<
SVGRadialGradientElement,
RadialGradient
> {}

View File

@ -1,5 +0,0 @@
import { Rect } from '../../vector/rect/rect'
import { SVGAnimator } from '../svg'
@SVGRectAnimator.register('Rect')
export class SVGRectAnimator extends SVGAnimator<SVGRectElement, Rect> {}

View File

@ -1,5 +0,0 @@
import { Style } from '../../vector/style/style'
import { SVGAnimator } from '../svg'
@SVGStyleAnimator.register('Style')
export class SVGStyleAnimator extends SVGAnimator<SVGStyleElement, Style> {}

View File

@ -1,5 +0,0 @@
import { Svg } from '../../vector/svg/svg'
import { SVGViewboxAnimator } from './container-viewbox'
@SVGSVGAnimator.register('Svg')
export class SVGSVGAnimator extends SVGViewboxAnimator<SVGSVGElement, Svg> {}

View File

@ -1,8 +0,0 @@
import { Symbol } from '../../vector/symbol/symbol'
import { SVGViewboxAnimator } from './container-viewbox'
@SVGSymbolAnimator.register('Symbol')
export class SVGSymbolAnimator extends SVGViewboxAnimator<
SVGSymbolElement,
Symbol // eslint-disable-line
> {}

View File

@ -1,9 +0,0 @@
import { Text } from '../../vector/text/text'
import { SVGAnimator } from '../svg'
@SVGTextAnimator.register('Text')
export class SVGTextAnimator extends SVGAnimator<SVGTextElement, Text> {
leading(value: number | string) {
return this.queueNumber('leading', value)
}
}

View File

@ -1,5 +0,0 @@
import { TSpan } from '../../vector/tspan/tspan'
import { SVGAnimator } from '../svg'
@SVGTSpanAnimator.register('Tspan')
export class SVGTSpanAnimator extends SVGAnimator<SVGTSpanElement, TSpan> {}

View File

@ -1,5 +0,0 @@
import { Use } from '../../vector/use/use'
import { SVGAnimator } from '../svg'
@SVGUseAnimator.register('Use')
export class SVGUseAnimator extends SVGAnimator<SVGUseElement, Use> {}

View File

@ -1,7 +0,0 @@
import { Wrapper } from '../../vector/container/wrapper'
import { SVGAnimator } from '../svg'
export class SVGWrapperAnimator<
TSVGElement extends SVGElement,
TOwner extends Wrapper<TSVGElement> = Wrapper<TSVGElement>,
> extends SVGAnimator<TSVGElement, TOwner> {}

View File

@ -1,5 +1,5 @@
import { createHTMLNode, createSVGNode } from '../../util/dom'
import { G, Svg } from '../../vector'
import { G, SVG } from '../../vector'
import { Dom } from '../dom'
import { Adopter } from './adopter'
@ -46,9 +46,9 @@ describe('Adopter', () => {
describe('makeInstance()', () => {
it('should create a svg instance with nil arg', () => {
expect(Adopter.makeInstance()).toBeInstanceOf(Svg)
expect(Adopter.makeInstance(null)).toBeInstanceOf(Svg)
expect(Adopter.makeInstance(undefined)).toBeInstanceOf(Svg)
expect(Adopter.makeInstance()).toBeInstanceOf(SVG)
expect(Adopter.makeInstance(null)).toBeInstanceOf(SVG)
expect(Adopter.makeInstance(undefined)).toBeInstanceOf(SVG)
})
})
})

View File

@ -3,7 +3,7 @@ import { Global } from '../../global'
import { Registry } from './registry'
import type { Base } from './base'
import type { Dom } from '../dom'
import type { Svg } from '../../vector'
import type { SVG } from '../../vector'
import type { ElementMap } from '../../types'
import type { HTMLAttributesTagNameMap } from '../types'
import type { SVGAttributesTagNameMap } from '../../vector/types'
@ -47,8 +47,8 @@ export namespace Adopter {
export type Target<T extends Dom = Dom> = T | Node | string
export function makeInstance(): Svg
export function makeInstance(node: undefined | null): Svg
export function makeInstance(): SVG
export function makeInstance(node: undefined | null): SVG
export function makeInstance<T extends Dom>(instance: T): T
export function makeInstance<T extends Dom>(target: Target<T>): T
export function makeInstance<T extends Node>(node: T): ElementMap<T>

View File

@ -1,6 +1,6 @@
import sinon from 'sinon'
import { createSVGNode, namespaces } from '../util/dom'
import { Circle, G, Rect, Svg, TSpan } from '../vector'
import { Circle, G, Rect, SVG, TSpan } from '../vector'
import { Dom } from './dom'
import { Fragment } from '../vector/fragment/fragment'
@ -246,7 +246,7 @@ describe('Dom', () => {
describe('contains()', () => {
it('should return `true` if the element is ancestor of the given element', () => {
const svg = new Svg()
const svg = new SVG()
const group1 = svg.group().addClass('test')
const group2 = group1.group()
const rect = group2.rect()
@ -262,7 +262,7 @@ describe('Dom', () => {
})
it('should return `false` if the element is ancestor of the given element', () => {
expect(new Svg().contains(new G())).toBeFalse()
expect(new SVG().contains(new G())).toBeFalse()
})
})
@ -290,13 +290,13 @@ describe('Dom', () => {
})
describe('parent()', () => {
let svg: Svg
let svg: SVG
let rect: Rect
let group1: G
let group2: G
beforeEach(() => {
svg = new Svg().addTo(document.body)
svg = new SVG().addTo(document.body)
group1 = svg.group().addClass('test')
group2 = group1.group()
rect = group2.rect()
@ -311,7 +311,7 @@ describe('Dom', () => {
})
it('should return the closest parent with the correct type', () => {
expect(rect.parent(Svg)).toBe(svg)
expect(rect.parent(SVG)).toBe(svg)
})
it('should return the closest parent matching the selector', () => {
@ -319,7 +319,7 @@ describe('Dom', () => {
})
it('should return `null` if the element do not have a parent', () => {
expect(new Svg().parent()).toBe(null)
expect(new SVG().parent()).toBe(null)
})
it('should return `null` if it cannot find a parent matching the argument', () => {
@ -328,14 +328,14 @@ describe('Dom', () => {
it('should return `null` if it cannot find a parent matching the argument in a #document-fragment', () => {
const fragment = document.createDocumentFragment()
const svg = new Svg().addTo(fragment)
const svg = new SVG().addTo(fragment)
const rect = svg.rect()
expect(rect.parent('.not-there')).toBe(null)
})
it('should return Dom if parent is #document-fragment', () => {
const fragment = document.createDocumentFragment()
const svg = new Svg().addTo(fragment)
const svg = new SVG().addTo(fragment)
expect(svg.parent()).toBeInstanceOf(Dom)
})
@ -347,7 +347,7 @@ describe('Dom', () => {
describe('parents()', () => {
let div1: Dom
let div2: Dom
let svg: Svg
let svg: SVG
let rect: Rect
let group1: G
let group2: G
@ -355,7 +355,7 @@ describe('Dom', () => {
beforeEach(() => {
div1 = new Dom().appendTo(document.body)
div2 = new Dom().appendTo(div1)
svg = new Svg().appendTo(div2)
svg = new SVG().appendTo(div2)
group1 = svg.group().addClass('test')
group2 = group1.group()
rect = group2.rect()
@ -374,7 +374,7 @@ describe('Dom', () => {
})
it('should return all the ancestors until the specified type', () => {
expect(rect.parents(Svg)).toEqual([group2, group1])
expect(rect.parents(SVG)).toEqual([group2, group1])
})
it('should return all the ancestors until the specified selector', () => {
@ -452,7 +452,7 @@ describe('Dom', () => {
const node = createSVGNode('svg')
g.add(node)
expect(g.children().length).toBe(1)
expect(g.get(0)).toBeInstanceOf(Svg)
expect(g.get(0)).toBeInstanceOf(SVG)
})
})
@ -938,11 +938,11 @@ describe('Dom', () => {
})
describe('wrap()', () => {
let svg: Svg
let svg: SVG
let rect: Rect
beforeEach(function () {
svg = new Svg()
svg = new SVG()
rect = svg.rect()
})
@ -1032,7 +1032,7 @@ describe('Dom', () => {
})
it('should replace the current element with the imported elements with `outerXML` is `true`', () => {
const svg = new Svg()
const svg = new SVG()
const g = svg.group()
g.xml('<rect /><circle />', true, namespaces.svg)
expect(svg.children()[0]).toBeInstanceOf(Rect)
@ -1040,7 +1040,7 @@ describe('Dom', () => {
})
it('should return the parent when `outerXML` is `true`', () => {
const svg = new Svg()
const svg = new SVG()
const g = svg.group()
expect(g.xml('<rect /><circle />', true, namespaces.svg)).toBe(svg)
expect(svg.children()[0]).toBeInstanceOf(Rect)
@ -1048,7 +1048,7 @@ describe('Dom', () => {
})
it('should work without a parent', () => {
const svg = new Svg()
const svg = new SVG()
expect(svg.xml('<rect /><circle />', undefined, namespaces.svg)).toBe(
svg,
)
@ -1062,12 +1062,12 @@ describe('Dom', () => {
})
describe('getter', () => {
let svg: Svg
let svg: SVG
let group: G
let rect: Rect
beforeEach(() => {
svg = new Svg().removeNamespace()
svg = new SVG().removeNamespace()
group = svg.group()
rect = group.rect(123.456, 234.567)
})
@ -1100,7 +1100,7 @@ describe('Dom', () => {
expect(
svg.xml((el) => {
if (el instanceof Rect) return new Circle()
if (el instanceof Svg) el.removeNamespace()
if (el instanceof SVG) el.removeNamespace()
}),
).toBe('<svg><g><circle></circle></g></svg>')
})
@ -1110,7 +1110,7 @@ describe('Dom', () => {
expect(svg.xml(() => false)).toBe('')
expect(
svg.xml((el) => {
if (el instanceof Svg) {
if (el instanceof SVG) {
el.removeNamespace()
} else {
return false

View File

@ -1,6 +1,6 @@
import { Dom } from '../dom'
import { G } from '../../vector/g/g'
import { Svg } from '../../vector/svg/svg'
import { SVG } from '../../vector/svg/svg'
describe('Dom', () => {
describe('constructor', () => {
@ -39,7 +39,7 @@ describe('Dom', () => {
expect(g.type.toLowerCase()).toEqual('g')
expect(g.node).toBeInstanceOf(SVGGElement)
const svg = new Svg({ x: 10, y: 10 })
const svg = new SVG({ x: 10, y: 10 })
expect(svg.type.toLowerCase()).toEqual('svg')
expect(svg.node).toBeInstanceOf(SVGSVGElement)
expect(svg.attr('x')).toEqual(10)

View File

@ -1,6 +1,3 @@
import './animation'
export * from './global/version'
export * from './dom'
export * from './vector'
export * from './animating'

View File

@ -143,6 +143,22 @@ describe('Box', () => {
})
})
describe('toJSON()', () => {
it('should create an object representation of Box', () => {
const obj1 = new Box().toJSON()
expect(obj1.x).toBe(0)
expect(obj1.y).toBe(0)
expect(obj1.width).toBe(0)
expect(obj1.height).toBe(0)
const obj2 = new Box(1, 2, 3, 4).toJSON()
expect(obj2.x).toBe(1)
expect(obj2.y).toBe(2)
expect(obj2.width).toBe(3)
expect(obj2.height).toBe(4)
})
})
describe('toArray()', () => {
it('should return an array representation of the box', () => {
expect(new Box(1, 2, 3, 4).toArray()).toEqual([1, 2, 3, 4])

View File

@ -2,32 +2,32 @@ import { Point } from './point'
import { Matrix } from './matrix'
export class Box implements Box.BoxLike {
x: number
y: number
width: number
height: number
public x: number
public y: number
public width: number
public height: number
get w() {
public get w() {
return this.width
}
get h() {
public get h() {
return this.height
}
get x2() {
public get x2() {
return this.x + this.width
}
get y2() {
public get y2() {
return this.y + this.height
}
get cx() {
public get cx() {
return this.x + this.width / 2
}
get cy() {
public get cy() {
return this.y + this.height / 2
}
@ -66,10 +66,6 @@ export class Box implements Box.BoxLike {
return this
}
isNull() {
return Box.isNull(this)
}
merge(box: Box.BoxLike) {
const x = Math.min(this.x, box.x)
const y = Math.min(this.y, box.y)
@ -78,7 +74,15 @@ export class Box implements Box.BoxLike {
return new Box(x, y, width, height)
}
transform(matrix: Matrix | Matrix.Raw) {
isNull() {
return Box.isNull(this)
}
transform(matrix: Matrix.Raw) {
return this.clone().transformO(matrix)
}
transformO(matrix: Matrix.Raw) {
const m = matrix instanceof Matrix ? matrix : new Matrix(matrix)
let xMin = Number.POSITIVE_INFINITY
@ -101,17 +105,30 @@ export class Box implements Box.BoxLike {
yMax = Math.max(yMax, point.y)
})
return new Box(xMin, yMin, xMax - xMin, yMax - yMin)
this.x = xMin
this.y = yMin
this.width = xMax - xMin
this.height = yMax - yMin
return this
}
toString() {
return `${this.x} ${this.y} ${this.width} ${this.height}`
clone() {
return new Box(this)
}
toJSON(): Box.BoxLike {
return { x: this.x, y: this.y, width: this.width, height: this.height }
}
toArray(): Box.BoxArray {
return [this.x, this.y, this.width, this.height]
}
toString() {
return `${this.x} ${this.y} ${this.width} ${this.height}`
}
valueOf() {
return this.toArray()
}
@ -123,6 +140,8 @@ export namespace Box {
height: number
}
export type BoxArray = [number, number, number, number]
export interface BoxObject {
x?: number
y?: number
@ -132,8 +151,6 @@ export namespace Box {
height?: number
}
export type BoxArray = [number, number, number, number]
export function isNull(box: BoxLike) {
return !box.width && !box.height && !box.x && !box.y
}

View File

@ -307,6 +307,42 @@ describe('Color', () => {
})
})
describe('clone()', () => {
it('should clone a color', () => {
const color1 = new Color()
const clone1 = color1.clone()
expect(clone1).not.toBe(color1)
expect(clone1.r).toBe(255)
expect(clone1.g).toBe(255)
expect(clone1.b).toBe(255)
expect(clone1.a).toBe(1)
const color2 = new Color(4, 3, 2, 1)
const clone2 = color2.clone()
expect(clone2).not.toBe(color2)
expect(clone2.r).toBe(4)
expect(clone2.g).toBe(3)
expect(clone2.b).toBe(2)
expect(clone2.a).toBe(1)
})
})
describe('toJSON()', () => {
it('should create an object representation of Color', () => {
const obj1 = new Color().toJSON()
expect(obj1.r).toBe(255)
expect(obj1.g).toBe(255)
expect(obj1.b).toBe(255)
expect(obj1.a).toBe(1)
const obj2 = new Color(4, 3, 2, 1).toJSON()
expect(obj2.r).toBe(4)
expect(obj2.g).toBe(3)
expect(obj2.b).toBe(2)
expect(obj2.a).toBe(1)
})
})
describe('toArray()', () => {
it('should convert color to rgba array', () => {
expect(new Color().toArray()).toEqual([255, 255, 255, 1])

View File

@ -107,6 +107,14 @@ export class Color implements Color.RGBALike {
return Color.makeGrey(Math.round((this.r + this.g + this.b) / 3), this.a)
}
clone() {
return new Color(this)
}
toJSON(): Color.RGBALike {
return { r: this.r, g: this.g, b: this.b, a: this.a }
}
toArray(): Color.RGBA {
return [this.r, this.g, this.b, this.a]
}

View File

@ -87,10 +87,6 @@ export class Matrix implements Matrix.MatrixLike {
this.f = source.f != null ? source.f : base.f
}
clone() {
return new Matrix(this)
}
equals(other: Matrix.Raw) {
if (other instanceof Matrix && other === this) {
return true
@ -425,6 +421,21 @@ export class Matrix implements Matrix.MatrixLike {
return this
}
clone() {
return new Matrix(this)
}
toJSON(): Matrix.MatrixLike {
return {
a: this.a,
b: this.b,
c: this.c,
d: this.d,
e: this.e,
f: this.f,
}
}
toArray(): Matrix.MatrixArray {
return [this.a, this.b, this.c, this.d, this.e, this.f]
}
@ -434,14 +445,7 @@ export class Matrix implements Matrix.MatrixLike {
}
valueOf(): Matrix.MatrixLike {
return {
a: this.a,
b: this.b,
c: this.c,
d: this.d,
e: this.e,
f: this.f,
}
return this.toJSON()
}
}

View File

@ -6,6 +6,14 @@ import { TypeArray } from './type-array'
import { UnitNumber } from './unit-number'
export class PathArray extends TypeArray<Path.Segment> {
parse(d: string | number[] | PathArray | Path.Segment[] = 'M0 0') {
return d instanceof PathArray
? d.toArray()
: parse(
Array.isArray(d) ? Array.prototype.concat.apply([], d).toString() : d,
)
}
bbox() {
return withPathContext((path) => {
path.setAttribute('d', this.toString())
@ -96,14 +104,6 @@ export class PathArray extends TypeArray<Path.Segment> {
return this
}
parse(d: string | number[] | PathArray | Path.Segment[] = 'M0 0') {
return d instanceof PathArray
? d.toArray()
: parse(
Array.isArray(d) ? Array.prototype.concat.apply([], d).toString() : d,
)
}
toString(): string {
return toString(this)
}

View File

@ -4,6 +4,26 @@ import { TypeArray } from './type-array'
import { UnitNumber } from './unit-number'
export class PointArray extends TypeArray<[number, number]> {
parse(raw: string | [number, number][] | number[] = [0, 0]) {
const array: number[] = Array.isArray(raw)
? Array.prototype.concat.apply([], raw)
: raw
.trim()
.split(/[\s,]+/)
.map((str) => Number.parseFloat(str))
if (array.length % 2 !== 0) {
array.pop()
}
const points: [number, number][] = []
for (let i = 0, l = array.length; i < l; i += 2) {
points.push([array[i], array[i + 1]])
}
return points
}
bbox() {
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
@ -34,26 +54,6 @@ export class PointArray extends TypeArray<[number, number]> {
return this
}
parse(raw: string | [number, number][] | number[] = [0, 0]) {
const array: number[] = Array.isArray(raw)
? Array.prototype.concat.apply([], raw)
: raw
.trim()
.split(/[\s,]+/)
.map((str) => Number.parseFloat(str))
if (array.length % 2 !== 0) {
array.pop()
}
const points: [number, number][] = []
for (let i = 0, l = array.length; i < l; i += 2) {
points.push([array[i], array[i + 1]])
}
return points
}
size(width: number | string, height: number | string) {
const box = this.bbox()
const w = UnitNumber.toNumber(width)

View File

@ -70,6 +70,13 @@ describe('Point', () => {
})
})
describe('valueOf()', () => {
it('should create an array representation of Point', () => {
const p = new Point(1, 2)
expect(p.valueOf()).toEqual([1, 2])
})
})
describe('toJSON()', () => {
it('should create an object representation of Point', () => {
const obj1 = new Point().toJSON()

View File

@ -1,8 +1,8 @@
import { Matrix } from './matrix'
export class Point implements Point.PointLike {
x: number
y: number
public x: number
public y: number
constructor()
constructor(p: Point.PointLike)
@ -32,6 +32,14 @@ export class Point implements Point.PointLike {
return [this.x, this.y]
}
toString() {
return `${this.x} ${this.y}`
}
valueOf() {
return this.toArray()
}
transform(matrix: Matrix.Raw) {
return this.clone().transformO(matrix)
}

View File

@ -1,6 +1,6 @@
import { UnitNumber } from './unit-number'
describe('SVGNumber', () => {
describe('UnitNumber', () => {
let number: UnitNumber
beforeEach(() => {
@ -245,13 +245,29 @@ describe('SVGNumber', () => {
})
})
describe('convert()', () => {
describe('unitize()', () => {
it('should change the unit of the number', () => {
const number = new UnitNumber('12px').convert('%')
const number = new UnitNumber('12px').unitize('%')
expect(number.toString()).toBe('1200%')
})
})
describe('clone()', () => {
it('should clone an UnitNumber', () => {
const num1 = new UnitNumber()
const clone1 = num1.clone()
expect(clone1).not.toBe(num1)
expect(clone1.unit).toBe('')
expect(clone1.value).toBe(0)
const num2 = new UnitNumber('1px')
const clone2 = num2.clone()
expect(clone2).not.toBe(num2)
expect(clone2.unit).toBe('px')
expect(clone2.value).toBe(1)
})
})
describe('Static Methods', () => {
describe('create()', () => {
it('should create a SVGNumber with default values', () => {
@ -353,8 +369,8 @@ describe('SVGNumber', () => {
})
it('should plus with a number and a string take unit into account', () => {
expect(UnitNumber.plus(1, '2')).toBe('3')
expect(UnitNumber.plus('1', 2)).toBe('3')
expect(UnitNumber.plus(1, '2')).toBe(3)
expect(UnitNumber.plus('1', 2)).toBe(3)
expect(UnitNumber.plus(1, '2%')).toBe('102%')
expect(UnitNumber.plus('1%', 2)).toBe('200.999999%')
@ -367,8 +383,8 @@ describe('SVGNumber', () => {
})
it('should plus with two strings take unit into account', () => {
expect(UnitNumber.plus('1', '2')).toBe('3')
expect(UnitNumber.plus('1', '2')).toBe('3')
expect(UnitNumber.plus('1', '2')).toBe(3)
expect(UnitNumber.plus('1', 2)).toBe(3)
expect(UnitNumber.plus('1', '2%')).toBe('102%')
expect(UnitNumber.plus('1%', '2')).toBe('200.999999%')
@ -390,8 +406,8 @@ describe('SVGNumber', () => {
})
it('should minus with a number and a string take unit into account', () => {
expect(UnitNumber.minus(1, '2')).toBe('-1')
expect(UnitNumber.minus('1', 2)).toBe('-1')
expect(UnitNumber.minus(1, '2')).toBe(-1)
expect(UnitNumber.minus('1', 2)).toBe(-1)
expect(UnitNumber.minus(1, '2%')).toBe('98%')
expect(UnitNumber.minus('1%', 2)).toBe('-199%')
@ -404,8 +420,8 @@ describe('SVGNumber', () => {
})
it('should minus with two strings take unit into account', () => {
expect(UnitNumber.minus('1', '2')).toBe('-1')
expect(UnitNumber.minus('1', '2')).toBe('-1')
expect(UnitNumber.minus('1', '2')).toBe(-1)
expect(UnitNumber.minus('1', 2)).toBe(-1)
expect(UnitNumber.minus('1', '2%')).toBe('98%')
expect(UnitNumber.minus('1%', '2')).toBe('-199%')
@ -427,8 +443,8 @@ describe('SVGNumber', () => {
})
it('should times with a number and a string take unit into account', () => {
expect(UnitNumber.times(1, '2')).toBe('2')
expect(UnitNumber.times('1', 2)).toBe('2')
expect(UnitNumber.times(1, '2')).toBe(2)
expect(UnitNumber.times('1', 2)).toBe(2)
expect(UnitNumber.times(1, '2%')).toBe('2%')
expect(UnitNumber.times('1%', 2)).toBe('2%')
@ -441,8 +457,8 @@ describe('SVGNumber', () => {
})
it('should times with two strings take unit into account', () => {
expect(UnitNumber.times('1', '2')).toBe('2')
expect(UnitNumber.times('1', '2')).toBe('2')
expect(UnitNumber.times('1', '2')).toBe(2)
expect(UnitNumber.times('1', 2)).toBe(2)
expect(UnitNumber.times('1', '2%')).toBe('2%')
expect(UnitNumber.times('1%', '2')).toBe('2%')
@ -464,8 +480,8 @@ describe('SVGNumber', () => {
})
it('should divide with a number and a string take unit into account', () => {
expect(UnitNumber.divide(1, '2')).toBe('0.5')
expect(UnitNumber.divide('1', 2)).toBe('0.5')
expect(UnitNumber.divide(1, '2')).toBe(0.5)
expect(UnitNumber.divide('1', 2)).toBe(0.5)
expect(UnitNumber.divide(1, '2%')).toBe('5000%')
expect(UnitNumber.divide('1%', 2)).toBe('0.5%')
@ -478,8 +494,8 @@ describe('SVGNumber', () => {
})
it('should divide with two strings take unit into account', () => {
expect(UnitNumber.divide('1', '2')).toBe('0.5')
expect(UnitNumber.divide('1', '2')).toBe('0.5')
expect(UnitNumber.divide('1', '2')).toBe(0.5)
expect(UnitNumber.divide('1', 2)).toBe(0.5)
expect(UnitNumber.divide('1', '2%')).toBe('5000%')
expect(UnitNumber.divide('1%', '2')).toBe('0.5%')

View File

@ -51,18 +51,26 @@ export class UnitNumber implements UnitNumber.UnitNumberLike {
return new UnitNumber(this.value / input.value, this.unit || input.unit)
}
convert(unit: string) {
unitize(unit: string) {
return new UnitNumber(this.value, unit)
}
toArray(): UnitNumber.UnitNumberArray {
return [this.value, this.unit]
naturalize() {
return this.unit ? this.toString() : this.valueOf()
}
clone() {
return new UnitNumber(this)
}
toJSON(): UnitNumber.UnitNumberLike {
return { value: this.value, unit: this.unit }
}
toArray(): UnitNumber.UnitNumberArray {
return [this.value, this.unit]
}
toString() {
const value =
this.unit === '%'
@ -108,28 +116,28 @@ export namespace UnitNumber {
if (typeof left === 'number' && typeof right === 'number') {
return left + right
}
return create(left).plus(right).toString()
return create(left).plus(right).naturalize()
}
export function minus(left: Raw, right: Raw) {
if (typeof left === 'number' && typeof right === 'number') {
return left - right
}
return create(left).minus(right).toString()
return create(left).minus(right).naturalize()
}
export function times(left: Raw, right: Raw) {
if (typeof left === 'number' && typeof right === 'number') {
return left * right
}
return create(left).times(right).toString()
return create(left).times(right).naturalize()
}
export function divide(left: Raw, right: Raw) {
if (typeof left === 'number' && typeof right === 'number') {
return left / right
}
return create(left).divide(right).toString()
return create(left).divide(right).naturalize()
}
export function normalize(v: number) {
@ -142,11 +150,10 @@ export namespace UnitNumber {
: v
}
export const REGEX_NUMBER_UNIT =
/^([+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?)([a-z%]*)$/i
export const REGEX = /^([+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?)([a-z%]*)$/i
export function parse(str: string): UnitNumberLike | null {
const matches = str.match(REGEX_NUMBER_UNIT)
const matches = str.match(REGEX)
if (matches) {
let value = normalize(Number.parseFloat(matches[1]))
const unit = matches[5] || ''

View File

@ -11,7 +11,7 @@ import { RadialGradient } from '../vector/gradient/radial'
import { Marker } from '../vector/marker/marker'
import { Mask } from '../vector/mask/mask'
import { Pattern } from '../vector/pattern/pattern'
import { Svg } from '../vector/svg/svg'
import { SVG } from '../vector/svg/svg'
import { Symbol } from '../vector/symbol/symbol'
import { Dom } from '../dom/dom'
import { Vector } from '../vector/vector/vector'
@ -44,7 +44,7 @@ export type ElementMap<T> =
T extends SVGMarkerElement ? Marker :
T extends SVGMaskElement ? Mask :
T extends SVGPatternElement ? Pattern :
T extends SVGSVGElement ? Svg :
T extends SVGSVGElement ? SVG :
T extends SVGSymbolElement ? Symbol : // eslint-disable-line
T extends SVGCircleElement ? Circle :

View File

@ -0,0 +1,29 @@
import { G } from '../g/g'
import { Circle } from '../circle/circle'
import { AnimateMotion } from './animate-motion'
describe('Animate', () => {
describe('constructor()', () => {
it('should create an AnimateMotion', () => {
expect(AnimateMotion.create()).toBeInstanceOf(AnimateMotion)
})
it('should create an AnimateMotion with given attributes', () => {
expect(AnimateMotion.create({ id: 'foo' }).id()).toBe('foo')
})
it('should create an AnimateMotion in a group', () => {
const group = new G()
const animate = group.animateMotion({ id: 'foo' })
expect(animate.attr('id')).toBe('foo')
expect(animate).toBeInstanceOf(AnimateMotion)
})
it('should create an AnimateMotion in a circle', () => {
const circle = new Circle()
const animate = circle.animateMotion({ id: 'foo' })
expect(animate.attr('id')).toBe('foo')
expect(animate).toBeInstanceOf(AnimateMotion)
})
})
})

View File

@ -0,0 +1,13 @@
import { Animation } from '../animate/animation'
import { SVGAnimateMotionAttributes } from './types'
@AnimateMotion.register('AnimateMotion')
export class AnimateMotion extends Animation<SVGAnimateMotionElement> {}
export namespace AnimateMotion {
export function create<Attributes extends SVGAnimateMotionAttributes>(
attrs?: Attributes | null,
) {
return new AnimateMotion(attrs)
}
}

View File

@ -0,0 +1,13 @@
import { Base } from '../common/base'
import { AnimateMotion } from './animate-motion'
import { SVGAnimateMotionAttributes } from './types'
export class ContainerExtension<
TSVGElement extends SVGElement,
> extends Base<TSVGElement> {
animateMotion<Attributes extends SVGAnimateMotionAttributes>(
attrs?: Attributes | null,
) {
return AnimateMotion.create(attrs).appendTo(this)
}
}

View File

@ -0,0 +1,29 @@
import { G } from '../g/g'
import { Circle } from '../circle/circle'
import { AnimateTransform } from './animate-transform'
describe('Animate', () => {
describe('constructor()', () => {
it('should create an AnimateTransform', () => {
expect(AnimateTransform.create()).toBeInstanceOf(AnimateTransform)
})
it('should create an AnimateTransform with given attributes', () => {
expect(AnimateTransform.create({ id: 'foo' }).id()).toBe('foo')
})
it('should create an AnimateTransform in a group', () => {
const group = new G()
const animate = group.animateTransform({ id: 'foo' })
expect(animate.attr('id')).toBe('foo')
expect(animate).toBeInstanceOf(AnimateTransform)
})
it('should create an AnimateTransform in a circle', () => {
const circle = new Circle()
const animate = circle.animateTransform({ id: 'foo' })
expect(animate.attr('id')).toBe('foo')
expect(animate).toBeInstanceOf(AnimateTransform)
})
})
})

View File

@ -0,0 +1,13 @@
import { Animation } from '../animate/animation'
import { SVGAnimateTransformAttributes } from './types'
@AnimateTransform.register('AnimateTransform')
export class AnimateTransform extends Animation<SVGAnimateTransformElement> {}
export namespace AnimateTransform {
export function create<Attributes extends SVGAnimateTransformAttributes>(
attrs?: Attributes | null,
) {
return new AnimateTransform(attrs)
}
}

View File

@ -0,0 +1,13 @@
import { Base } from '../common/base'
import { AnimateTransform } from './animate-transform'
import { SVGAnimateTransformAttributes } from './types'
export class ContainerExtension<
TSVGElement extends SVGElement,
> extends Base<TSVGElement> {
animateTransform<Attributes extends SVGAnimateTransformAttributes>(
attrs?: Attributes | null,
) {
return AnimateTransform.create(attrs).appendTo(this)
}
}

View File

@ -0,0 +1,98 @@
import { G } from '../g/g'
import { Circle } from '../circle/circle'
import { Animate } from './animate'
describe('Animate', () => {
describe('constructor()', () => {
it('should create an Animate', () => {
expect(Animate.create()).toBeInstanceOf(Animate)
})
it('should create an Animate with given attributes', () => {
expect(Animate.create({ id: 'foo' }).id()).toBe('foo')
})
it('should create an Animate in a group', () => {
const group = new G()
const animate = group.animate({ id: 'foo' })
expect(animate.attr('id')).toBe('foo')
expect(animate).toBeInstanceOf(Animate)
})
it('should create an Animate in a circle', () => {
const circle = new Circle()
const animate = circle.animate({ id: 'foo' })
expect(animate.attr('id')).toBe('foo')
expect(animate).toBeInstanceOf(Animate)
})
})
describe('sugar methods with attributes', () => {
const methods = [
'from',
'to',
'by',
'calcMode',
'values',
'keyTimes',
'keySplines',
'begin',
'end',
'dur',
'min',
'max',
'repeatCount',
'repeatDur',
'restartMode',
'fillMode',
'additive',
'accumulate',
'attributeName',
'attributeType',
]
const values = [
10,
20,
30,
'linear',
'0 1 2 3 4',
'foo',
'bar',
'1s',
'2s',
'3s',
'0s',
'100s',
3,
3000,
'whenNotActive',
'freeze',
'replace',
'none',
'color',
'CSS',
]
methods.forEach((method, index) => {
const val = values[index] as any
const attrMap = {
restartMode: 'restart',
fillMode: 'fill',
}
const attr = attrMap[method as keyof typeof attrMap] || method
it(`should call attribute with "${method}" and return itself`, () => {
const animate = new Animate()
const spy = spyOn(animate, 'attr').and.callThrough()
expect(animate[method as 'from'](val)).toBe(animate)
expect(spy).toHaveBeenCalledWith(attr, val)
})
it(`should get the "${method}" atribute`, () => {
const animate = Animate.create()
animate[method as 'from'](val)
expect(animate[method as 'from']()).toBe(val)
})
})
})
})

View File

@ -0,0 +1,13 @@
import { Animation } from './animation'
import { SVGAnimateAttributes } from './types'
@Animate.register('Animate')
export class Animate extends Animation<SVGAnimateElement> {}
export namespace Animate {
export function create<Attributes extends SVGAnimateAttributes>(
attrs?: Attributes | null,
) {
return new Animate(attrs)
}
}

View File

@ -0,0 +1,156 @@
import { Base } from '../common/base'
import {
SVGAnimationValueCalcMode,
SVGAnimationTimingRestartMode,
SVGAnimationTimingRepeatCount,
SVGAnimationTimingRepeatDuration,
SVGAnimationTimingFillMode,
AnimationAttributeTargetAttributeType,
SVGAnimationAdditionAttributeAdditive,
SVGAnimationAdditionAttributeAccumulate,
} from '../types'
export class Animation<
TSVGAnimationElement extends
| SVGAnimateElement
| SVGAnimateMotionElement
| SVGAnimateTransformElement,
> extends Base<TSVGAnimationElement> {
// #region SVGAnimationValueAttributes
from(): string | number
from(v: string | number | null): this
from(v?: string | number | null) {
return this.attr('from', v)
}
to(): string | number
to(v: string | number | null): this
to(v?: string | number | null) {
return this.attr('to', v)
}
by(): string | number
by(v: string | number | null): this
by(v?: string | number | null) {
return this.attr('by', v)
}
calcMode(): SVGAnimationValueCalcMode
calcMode(mode: SVGAnimationValueCalcMode | null): this
calcMode(mode?: SVGAnimationValueCalcMode | null) {
return this.attr('calcMode', mode)
}
values(): string
values(v: string | null): this
values(v?: string | null) {
return this.attr('values', v)
}
keyTimes(): string
keyTimes(v: string | null): this
keyTimes(v?: string | null) {
return this.attr('keyTimes', v)
}
keySplines(): string
keySplines(v: string | null): this
keySplines(v?: string | null) {
return this.attr('keySplines', v)
}
// #endregion
// #region SVGAnimationTimingAttributes
begin(): string
begin(v: string | null): this
begin(v?: string | null) {
return this.attr('begin', v)
}
end(): string
end(v: string | null): this
end(v?: string | null) {
return this.attr('end', v)
}
dur(): string
dur(v: string | null): this
dur(v?: string | null) {
return this.attr('dur', v)
}
min(): string
min(v: string | null): this
min(v?: string | null) {
return this.attr('min', v)
}
max(): string
max(v: string | null): this
max(v?: string | null) {
return this.attr('max', v)
}
repeatCount(): SVGAnimationTimingRepeatCount
repeatCount(v: SVGAnimationTimingRepeatCount | null): this
repeatCount(v?: SVGAnimationTimingRepeatCount | null) {
return this.attr('repeatCount', v)
}
repeatDur(): SVGAnimationTimingRepeatDuration
repeatDur(v: SVGAnimationTimingRepeatDuration | null): this
repeatDur(v?: SVGAnimationTimingRepeatDuration | null) {
return this.attr('repeatDur', v)
}
restartMode(): SVGAnimationTimingRestartMode
restartMode(v: SVGAnimationTimingRestartMode | null): this
restartMode(v?: SVGAnimationTimingRestartMode | null) {
return this.attr('restart', v)
}
fillMode(): SVGAnimationTimingFillMode
fillMode(v: SVGAnimationTimingFillMode | null): this
fillMode(v?: SVGAnimationTimingFillMode | null) {
return this.attr('fill', v)
}
// #endregion
// #region SVGAnimationAdditionAttributes
additive(): SVGAnimationAdditionAttributeAdditive
additive(v: SVGAnimationAdditionAttributeAdditive | null): this
additive(v?: SVGAnimationAdditionAttributeAdditive | null) {
return this.attr('additive', v)
}
accumulate(): SVGAnimationAdditionAttributeAccumulate
accumulate(v: SVGAnimationAdditionAttributeAccumulate | null): this
accumulate(v?: SVGAnimationAdditionAttributeAccumulate | null) {
return this.attr('accumulate', v)
}
// #endregion
// #region AnimationAttributeTargetAttributes
attributeName(): string
attributeName(name: string | null): this
attributeName(name?: string | null) {
return this.attr('attributeName', name)
}
attributeType(): AnimationAttributeTargetAttributeType
attributeType(type: AnimationAttributeTargetAttributeType | null): this
attributeType(type?: AnimationAttributeTargetAttributeType | null) {
return this.attr('attributeType', type)
}
// #endregion
}
export namespace Animation {}

View File

@ -0,0 +1,11 @@
import { Base } from '../common/base'
import { Animate } from './animate'
import { SVGAnimateAttributes } from './types'
export class ContainerExtension<
TSVGElement extends SVGElement,
> extends Base<TSVGElement> {
animate<Attributes extends SVGAnimateAttributes>(attrs?: Attributes | null) {
return Animate.create(attrs).appendTo(this)
}
}

Some files were not shown because too many files have changed in this diff Show More