waveform-analysis 0.1.74 → 0.1.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +246 -39
- package/dist/assets/workerRuntime-BJiRmIZD.js +2446 -0
- package/dist/index.cjs +1 -2
- package/dist/index.js +1 -7119
- package/dist/style.css +1 -1
- package/dist/types/animation/WaveformAnimationController.d.ts +30 -0
- package/dist/types/animation/core/Animation.d.ts +13 -0
- package/dist/types/animation/core/Animator.d.ts +23 -0
- package/dist/types/animation/core/easing.d.ts +3 -0
- package/dist/types/animation/core/errors.d.ts +4 -0
- package/dist/types/animation/core/handle.d.ts +7 -0
- package/dist/types/animation/core/owner.d.ts +3 -0
- package/dist/types/animation/core/propertyGroups.d.ts +17 -0
- package/dist/types/animation/core/task.d.ts +29 -0
- package/dist/types/animation/core/types.d.ts +74 -0
- package/dist/types/animation/core/validation.d.ts +30 -0
- package/dist/types/animation/core/values.d.ts +8 -0
- package/dist/types/animation/presets/annotation.d.ts +4 -0
- package/dist/types/animation/presets/lineDrawing.d.ts +7 -0
- package/dist/types/animation/presets/shared.d.ts +14 -0
- package/dist/types/animation/presets/transitions.d.ts +4 -0
- package/dist/types/animation/types.d.ts +84 -0
- package/dist/types/components/WaveformChart.vue.d.ts +3 -2
- package/dist/types/components/animation/annotationPresentation.d.ts +17 -0
- package/dist/types/components/animation/chartExportVersion.d.ts +2 -0
- package/dist/types/components/animation/config.d.ts +31 -0
- package/dist/types/components/animation/context.d.ts +20 -0
- package/dist/types/components/animation/geometry.d.ts +10 -0
- package/dist/types/components/animation/paginationPresentation.d.ts +13 -0
- package/dist/types/components/animation/pathBuffer.d.ts +4 -0
- package/dist/types/components/animation/trackPresentation.d.ts +35 -0
- package/dist/types/components/animation/useWaveformAnimations.d.ts +27 -0
- package/dist/types/components/annotation/WaveformAnnotationContextMenu.vue.d.ts +2 -2
- package/dist/types/components/annotation/types.d.ts +7 -0
- package/dist/types/components/controls/useChartImageExport.d.ts +2 -0
- package/dist/types/components/core/types.d.ts +8 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +25 -0
- package/dist/types/components/core/useWaveformRenderSampling.d.ts +2 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +2 -1
- package/dist/types/components/rendering/WaveformSeriesLayer.vue.d.ts +1 -0
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +4 -3
- package/dist/types/components/rendering/updateAxis.d.ts +5 -0
- package/dist/types/components/rendering/updateGrid.d.ts +3 -0
- package/dist/types/components/rendering/yAxisTickVisibility.d.ts +2 -0
- package/dist/types/components/utils/assignElement.d.ts +2 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/views/AnimationDemo.vue.d.ts +3 -0
- package/package.json +5 -2
- package/src/DemoRouterApp.vue +1 -0
- package/src/animation/WaveformAnimationController.ts +328 -0
- package/src/animation/core/Animation.ts +37 -0
- package/src/animation/core/Animator.ts +200 -0
- package/src/animation/core/easing.ts +74 -0
- package/src/animation/core/errors.ts +24 -0
- package/src/animation/core/handle.ts +34 -0
- package/src/animation/core/owner.ts +9 -0
- package/src/animation/core/propertyGroups.ts +143 -0
- package/src/animation/core/task.ts +153 -0
- package/src/animation/core/types.ts +81 -0
- package/src/animation/core/validation.ts +173 -0
- package/src/animation/core/values.ts +38 -0
- package/src/animation/presets/annotation.ts +66 -0
- package/src/animation/presets/lineDrawing.ts +51 -0
- package/src/animation/presets/shared.ts +58 -0
- package/src/animation/presets/transitions.ts +77 -0
- package/src/animation/types.ts +81 -0
- package/src/components/WaveformChart.vue +1 -0
- package/src/components/WaveformChartView.vue +13 -4
- package/src/components/animation/annotationPresentation.ts +145 -0
- package/src/components/animation/chartExportVersion.ts +5 -0
- package/src/components/animation/config.ts +87 -0
- package/src/components/animation/context.ts +56 -0
- package/src/components/animation/geometry.ts +148 -0
- package/src/components/animation/paginationPresentation.ts +114 -0
- package/src/components/animation/pathBuffer.ts +61 -0
- package/src/components/animation/trackPresentation.ts +172 -0
- package/src/components/animation/useWaveformAnimations.ts +420 -0
- package/src/components/annotation/WaveformAnnotationLayer.vue +43 -30
- package/src/components/annotation/types.ts +3 -0
- package/src/components/controls/useChartImageExport.ts +4 -0
- package/src/components/core/trackLayoutBuilder.ts +9 -18
- package/src/components/core/types.ts +4 -0
- package/src/components/core/useWaveformChartController.ts +24 -20
- package/src/components/core/useWaveformRenderSampling.ts +23 -1
- package/src/components/core/waveformChartTypes.ts +2 -1
- package/src/components/rendering/WaveformSeriesLayer.vue +13 -1
- package/src/components/rendering/WaveformTrack.vue +6 -1
- package/src/components/rendering/WaveformTrackAxes.vue +64 -51
- package/src/components/rendering/WaveformTrackBackdrop.vue +35 -49
- package/src/components/rendering/updateAxis.ts +57 -0
- package/src/components/rendering/updateGrid.ts +42 -0
- package/src/components/rendering/yAxisTickVisibility.ts +45 -18
- package/src/components/utils/assignElement.ts +7 -0
- package/src/index.ts +52 -0
- package/src/router.ts +1 -0
- package/src/views/AnimationDemo.vue +129 -0
- package/dist/assets/workerRuntime-5p-EI-GL.js +0 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AnimationObjectKey } from '../types'
|
|
2
|
+
|
|
3
|
+
/** Each controller owns one closure; external IDs are never shared scheduler keys. */
|
|
4
|
+
export function createAnimationOwner(): (key: AnimationObjectKey) => string {
|
|
5
|
+
const owner = ++nextOwner
|
|
6
|
+
return (key) => JSON.stringify([owner, key.kind, key.id, key.scope])
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let nextOwner = 0
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { AnimationTrack, AnimationValue } from './types'
|
|
2
|
+
import type { ValidatedAnimationConfig, AnimationTrackContext } from './validation'
|
|
3
|
+
import { copyAnimationValue } from './validation'
|
|
4
|
+
import { addValues, subtractValues, mapValue, interpolateValue, directedProgress } from './values'
|
|
5
|
+
|
|
6
|
+
export interface PropertyTask {
|
|
7
|
+
config: ValidatedAnimationConfig
|
|
8
|
+
fail(error: unknown): void
|
|
9
|
+
}
|
|
10
|
+
interface Entry {
|
|
11
|
+
task: PropertyTask
|
|
12
|
+
from: AnimationValue
|
|
13
|
+
to: AnimationValue
|
|
14
|
+
value: AnimationValue
|
|
15
|
+
committed: AnimationValue
|
|
16
|
+
}
|
|
17
|
+
interface Group {
|
|
18
|
+
target: Record<string, AnimationValue>
|
|
19
|
+
property: string
|
|
20
|
+
base: AnimationValue
|
|
21
|
+
offset: AnimationValue
|
|
22
|
+
ordinary?: Entry
|
|
23
|
+
additive: Set<Entry>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Object identity, never user series IDs, determines numeric property ownership. */
|
|
27
|
+
export class PropertyGroups {
|
|
28
|
+
#groups = new Map<object, Map<string, Group>>()
|
|
29
|
+
#entries = new Map<PropertyTask, [Group, Entry][]>()
|
|
30
|
+
|
|
31
|
+
#get(track: AnimationTrack): Group | undefined {
|
|
32
|
+
return this.#groups.get(track.target)?.get(track.property)
|
|
33
|
+
}
|
|
34
|
+
#end(entry: Entry): AnimationValue {
|
|
35
|
+
const { direction, loop } = entry.task.config
|
|
36
|
+
return directedProgress(1, loop === Infinity ? 0 : loop - 1, direction) === 1
|
|
37
|
+
? entry.to
|
|
38
|
+
: entry.from
|
|
39
|
+
}
|
|
40
|
+
context = (track: AnimationTrack): AnimationTrackContext | undefined => {
|
|
41
|
+
const group = this.#get(track)
|
|
42
|
+
if (!group) return undefined
|
|
43
|
+
let from = addValues(group.ordinary ? this.#end(group.ordinary) : group.base, group.offset)
|
|
44
|
+
for (const entry of group.additive)
|
|
45
|
+
from = addValues(from, subtractValues(this.#end(entry), entry.from))
|
|
46
|
+
return { additiveFrom: from, baseLoop: group.ordinary?.task.config.loop ?? 1 }
|
|
47
|
+
}
|
|
48
|
+
conflicts(config: ValidatedAnimationConfig): Set<PropertyTask> {
|
|
49
|
+
const tasks = new Set<PropertyTask>()
|
|
50
|
+
if (!config.additive)
|
|
51
|
+
for (const track of config.tracks) {
|
|
52
|
+
const group = this.#get(track)
|
|
53
|
+
if (group?.ordinary) tasks.add(group.ordinary.task)
|
|
54
|
+
group?.additive.forEach((entry) => tasks.add(entry.task))
|
|
55
|
+
}
|
|
56
|
+
return tasks
|
|
57
|
+
}
|
|
58
|
+
capture(config: ValidatedAnimationConfig): AnimationTrack[] {
|
|
59
|
+
return config.tracks.map((track) => ({
|
|
60
|
+
...track,
|
|
61
|
+
from: copyAnimationValue(
|
|
62
|
+
config.additive
|
|
63
|
+
? (this.context(track)?.additiveFrom ?? track.target[track.property])
|
|
64
|
+
: this.#get(track)
|
|
65
|
+
? track.target[track.property]
|
|
66
|
+
: (track.from ?? track.target[track.property]),
|
|
67
|
+
),
|
|
68
|
+
}))
|
|
69
|
+
}
|
|
70
|
+
bind(task: PropertyTask): void {
|
|
71
|
+
const entries: [Group, Entry][] = []
|
|
72
|
+
for (const track of task.config.tracks) {
|
|
73
|
+
let group = this.#get(track)
|
|
74
|
+
if (!group) {
|
|
75
|
+
const base = copyAnimationValue(track.target[track.property])
|
|
76
|
+
group = {
|
|
77
|
+
target: track.target,
|
|
78
|
+
property: track.property,
|
|
79
|
+
base,
|
|
80
|
+
offset: mapValue(base, () => 0),
|
|
81
|
+
additive: new Set(),
|
|
82
|
+
}
|
|
83
|
+
const properties = this.#groups.get(track.target) ?? new Map<string, Group>()
|
|
84
|
+
properties.set(track.property, group)
|
|
85
|
+
this.#groups.set(track.target, properties)
|
|
86
|
+
}
|
|
87
|
+
const from = copyAnimationValue(track.from!)
|
|
88
|
+
const entry = {
|
|
89
|
+
task,
|
|
90
|
+
from,
|
|
91
|
+
to: track.to,
|
|
92
|
+
value: task.config.additive ? mapValue(from, () => 0) : from,
|
|
93
|
+
committed: task.config.additive ? mapValue(from, () => 0) : group.base,
|
|
94
|
+
}
|
|
95
|
+
if (task.config.additive) group.additive.add(entry)
|
|
96
|
+
else group.ordinary = entry
|
|
97
|
+
entries.push([group, entry])
|
|
98
|
+
}
|
|
99
|
+
this.#entries.set(task, entries)
|
|
100
|
+
}
|
|
101
|
+
setProgress(task: PropertyTask, progress: number): void {
|
|
102
|
+
for (const [, entry] of this.#entries.get(task) ?? []) {
|
|
103
|
+
const value = interpolateValue(entry.from, entry.to, progress)
|
|
104
|
+
entry.value = copyAnimationValue(
|
|
105
|
+
task.config.additive ? subtractValues(value, entry.from) : value,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
flush(): void {
|
|
110
|
+
for (const properties of this.#groups.values())
|
|
111
|
+
for (const group of properties.values()) {
|
|
112
|
+
try {
|
|
113
|
+
let value = addValues(group.ordinary?.value ?? group.base, group.offset)
|
|
114
|
+
for (const entry of group.additive) value = addValues(value, entry.value)
|
|
115
|
+
group.target[group.property] = copyAnimationValue(value)
|
|
116
|
+
// Private values are replaced, never mutated or exposed through the target snapshot.
|
|
117
|
+
if (group.ordinary) group.ordinary.committed = group.ordinary.value
|
|
118
|
+
for (const entry of group.additive) entry.committed = entry.value
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const tasks = new Set([
|
|
121
|
+
...(group.ordinary ? [group.ordinary.task] : []),
|
|
122
|
+
...[...group.additive].map((entry) => entry.task),
|
|
123
|
+
])
|
|
124
|
+
for (const task of tasks) task.fail(error)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
detach(task: PropertyTask): void {
|
|
129
|
+
for (const [group, entry] of this.#entries.get(task) ?? []) {
|
|
130
|
+
if (group.ordinary === entry) {
|
|
131
|
+
group.base = entry.committed
|
|
132
|
+
group.ordinary = undefined
|
|
133
|
+
}
|
|
134
|
+
if (group.additive.delete(entry)) group.offset = addValues(group.offset, entry.committed)
|
|
135
|
+
if (!group.ordinary && !group.additive.size) {
|
|
136
|
+
// The last flush already contains committed contributions. Release the whole group.
|
|
137
|
+
this.#groups.get(group.target)?.delete(group.property)
|
|
138
|
+
if (!this.#groups.get(group.target)?.size) this.#groups.delete(group.target)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this.#entries.delete(task)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { AnimationFrame, AnimationState } from './types'
|
|
2
|
+
import type { ValidatedAnimationConfig } from './validation'
|
|
3
|
+
import { directedProgress, equalValues } from './values'
|
|
4
|
+
import { PropertyGroups } from './propertyGroups'
|
|
5
|
+
import { notify, reportAnimationError } from './errors'
|
|
6
|
+
|
|
7
|
+
/** Internal timeline; standalone and managed animations share exactly the same semantics. */
|
|
8
|
+
export class AnimationTask {
|
|
9
|
+
state: AnimationState = 'PENDING'
|
|
10
|
+
#started = false
|
|
11
|
+
#startTime = 0
|
|
12
|
+
#pauseTime = 0
|
|
13
|
+
#pausedTotal = 0
|
|
14
|
+
#reasons = new Set<string>()
|
|
15
|
+
#previousState: AnimationState = 'PENDING'
|
|
16
|
+
#iteration = 0
|
|
17
|
+
#terminate?: () => void
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
readonly config: ValidatedAnimationConfig,
|
|
21
|
+
private groups: PropertyGroups,
|
|
22
|
+
) {}
|
|
23
|
+
get ended() {
|
|
24
|
+
return this.state === 'FINISHED' || this.state === 'ABORTED'
|
|
25
|
+
}
|
|
26
|
+
get unchanged() {
|
|
27
|
+
return !this.config.force && this.config.tracks.every((t) => equalValues(t.from!, t.to))
|
|
28
|
+
}
|
|
29
|
+
setTermination(callback: () => void) {
|
|
30
|
+
this.#terminate = callback
|
|
31
|
+
}
|
|
32
|
+
start(time: number, synchronous = false): void {
|
|
33
|
+
if (this.#started || this.ended) throw new Error('Animation can only start once')
|
|
34
|
+
this.#started = true
|
|
35
|
+
this.#startTime = time
|
|
36
|
+
if (this.#reasons.size) this.#pauseTime = time
|
|
37
|
+
if (synchronous || this.config.duration === 0 || this.unchanged) {
|
|
38
|
+
this.finish()
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
this.config.initialize?.()
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.fail(error)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
deadline(time: number): number {
|
|
48
|
+
if (this.ended || this.#reasons.size) return Infinity
|
|
49
|
+
return Math.max(time, this.#startTime + this.config.delay + this.#pausedTotal)
|
|
50
|
+
}
|
|
51
|
+
prepare(time: number): { progress: number; frame: AnimationFrame } | undefined {
|
|
52
|
+
if (!this.#started || this.ended || this.#reasons.size) return
|
|
53
|
+
const elapsed = time - this.#startTime - this.config.delay - this.#pausedTotal
|
|
54
|
+
if (elapsed < 0) return
|
|
55
|
+
this.state = 'RUNNING'
|
|
56
|
+
const { duration, loop, direction } = this.config
|
|
57
|
+
const terminal = elapsed >= duration * loop
|
|
58
|
+
this.#iteration = terminal ? loop - 1 : Math.floor(elapsed / duration)
|
|
59
|
+
const rawProgress = terminal ? 1 : (elapsed % duration) / duration
|
|
60
|
+
const directed = directedProgress(rawProgress, this.#iteration, direction)
|
|
61
|
+
const frame = { rawProgress, directedProgress: directed, iteration: this.#iteration, terminal }
|
|
62
|
+
try {
|
|
63
|
+
const progress = terminal ? directed : this.config.easing(directed)
|
|
64
|
+
if (!Number.isFinite(progress)) throw new RangeError('Easing returned a nonfinite value')
|
|
65
|
+
this.groups.setProgress(this, progress)
|
|
66
|
+
return { progress, frame }
|
|
67
|
+
} catch (error) {
|
|
68
|
+
this.fail(error)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
deliver(progress: number, frame: AnimationFrame): void {
|
|
72
|
+
if (this.ended) return
|
|
73
|
+
try {
|
|
74
|
+
this.config.during?.(progress, frame)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.fail(error)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (frame.terminal && !this.ended) this.#complete('FINISHED')
|
|
80
|
+
}
|
|
81
|
+
tick(time: number): boolean {
|
|
82
|
+
const next = this.prepare(time)
|
|
83
|
+
if (next) {
|
|
84
|
+
this.groups.flush()
|
|
85
|
+
this.deliver(next.progress, next.frame)
|
|
86
|
+
}
|
|
87
|
+
return this.ended
|
|
88
|
+
}
|
|
89
|
+
pause(time: number, reason = 'manual'): void {
|
|
90
|
+
if (this.ended || this.#reasons.has(reason)) return
|
|
91
|
+
if (!this.#reasons.size) {
|
|
92
|
+
this.#previousState = this.state
|
|
93
|
+
this.#pauseTime = time
|
|
94
|
+
this.state = 'PAUSED'
|
|
95
|
+
}
|
|
96
|
+
this.#reasons.add(reason)
|
|
97
|
+
}
|
|
98
|
+
resume(time: number, reason = 'manual'): void {
|
|
99
|
+
if (this.ended || !this.#reasons.delete(reason) || this.#reasons.size) return
|
|
100
|
+
this.#pausedTotal += time - this.#pauseTime
|
|
101
|
+
this.state = this.#previousState
|
|
102
|
+
}
|
|
103
|
+
finish(time?: number): void {
|
|
104
|
+
if (this.ended) return
|
|
105
|
+
const { loop, direction } = this.config
|
|
106
|
+
if (loop === Infinity && time !== undefined && this.#started) {
|
|
107
|
+
const elapsed = Math.max(
|
|
108
|
+
0,
|
|
109
|
+
(this.#reasons.size ? this.#pauseTime : time) -
|
|
110
|
+
this.#startTime -
|
|
111
|
+
this.config.delay -
|
|
112
|
+
this.#pausedTotal,
|
|
113
|
+
)
|
|
114
|
+
this.#iteration = Math.floor(elapsed / this.config.duration)
|
|
115
|
+
}
|
|
116
|
+
const iteration = loop === Infinity ? this.#iteration : loop - 1
|
|
117
|
+
const progress = directedProgress(1, iteration, direction)
|
|
118
|
+
try {
|
|
119
|
+
this.groups.setProgress(this, progress)
|
|
120
|
+
this.groups.flush()
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.fail(error)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
this.deliver(progress, {
|
|
126
|
+
rawProgress: 1,
|
|
127
|
+
directedProgress: progress,
|
|
128
|
+
iteration,
|
|
129
|
+
terminal: true,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
abort(): void {
|
|
133
|
+
if (!this.ended) this.#complete('ABORTED')
|
|
134
|
+
}
|
|
135
|
+
/** Detach without callbacks/old terminal frames for owner-level commit barriers. */
|
|
136
|
+
settle(state: 'FINISHED' | 'ABORTED', notifyUser = true): () => void {
|
|
137
|
+
if (this.ended) return () => {}
|
|
138
|
+
this.state = state
|
|
139
|
+
this.groups.detach(this)
|
|
140
|
+
this.#terminate?.()
|
|
141
|
+
const callback = () =>
|
|
142
|
+
notify(this.config, state === 'FINISHED' ? this.config.done : this.config.aborted)
|
|
143
|
+
if (notifyUser) callback()
|
|
144
|
+
return callback
|
|
145
|
+
}
|
|
146
|
+
#complete(state: 'FINISHED' | 'ABORTED') {
|
|
147
|
+
this.settle(state)
|
|
148
|
+
}
|
|
149
|
+
fail(error: unknown) {
|
|
150
|
+
this.abort()
|
|
151
|
+
reportAnimationError(this.config, error)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Numeric output only: geometry strings and typed arrays are not animation values. */
|
|
2
|
+
export type AnimationValue = number | number[] | number[][]
|
|
3
|
+
export type AnimationDirection = 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'
|
|
4
|
+
export type AnimationState = 'PENDING' | 'RUNNING' | 'PAUSED' | 'FINISHED' | 'ABORTED'
|
|
5
|
+
export type EasingFunction = (progress: number) => number
|
|
6
|
+
export type EasingFamily =
|
|
7
|
+
'quad' | 'cubic' | 'quart' | 'quint' | 'sine' | 'expo' | 'circ' | 'back' | 'elastic' | 'bounce'
|
|
8
|
+
export type EasingType = 'linear' | `${EasingFamily}${'In' | 'Out' | 'InOut'}`
|
|
9
|
+
|
|
10
|
+
export interface AnimationFrame {
|
|
11
|
+
rawProgress: number
|
|
12
|
+
directedProgress: number
|
|
13
|
+
/** Zero-based iteration. */
|
|
14
|
+
iteration: number
|
|
15
|
+
/** Natural completion or explicit finish; abort never produces a terminal frame. */
|
|
16
|
+
terminal: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AnimationTrack {
|
|
20
|
+
target: Record<string, AnimationValue>
|
|
21
|
+
property: string
|
|
22
|
+
/** Omitted ordinary starts are captured at registration; additive starts are derived. */
|
|
23
|
+
from?: AnimationValue
|
|
24
|
+
to: AnimationValue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** All time values are milliseconds. Validation precedes initialization and replacement. */
|
|
28
|
+
export interface AnimationConfig {
|
|
29
|
+
initialize?: () => void
|
|
30
|
+
tracks?: AnimationTrack[]
|
|
31
|
+
/** Defaults to 1000ms; zero completes synchronously without delay. */
|
|
32
|
+
duration?: number
|
|
33
|
+
/** First iteration only, defaults to 0ms. */
|
|
34
|
+
delay?: number
|
|
35
|
+
/** Defaults to linear; custom functions may overshoot. */
|
|
36
|
+
easing?: EasingType | EasingFunction
|
|
37
|
+
/** Defaults to 1; false means once, true means infinite, numbers are positive integers. */
|
|
38
|
+
loop?: number | boolean
|
|
39
|
+
/** Defaults to normal; direction is applied before easing. */
|
|
40
|
+
direction?: AnimationDirection
|
|
41
|
+
/** Defaults to false; does not override component disable/fallback policies. */
|
|
42
|
+
force?: boolean
|
|
43
|
+
/** Defaults to false; additive tracks and their bases must not loop. */
|
|
44
|
+
additive?: boolean
|
|
45
|
+
during?: (easedProgress: number, frame: AnimationFrame) => void
|
|
46
|
+
done?: () => void
|
|
47
|
+
aborted?: () => void
|
|
48
|
+
onError?: (error: unknown) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AnimationHandle {
|
|
52
|
+
readonly id: string
|
|
53
|
+
readonly state: AnimationState
|
|
54
|
+
pause(): void
|
|
55
|
+
resume(): void
|
|
56
|
+
abort(): void
|
|
57
|
+
finish(): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** RAF functions must be provided together, or both omitted for static completion. */
|
|
61
|
+
export interface AnimationScheduler {
|
|
62
|
+
now(): number
|
|
63
|
+
requestFrame?: (callback: (time: number) => void) => number
|
|
64
|
+
cancelFrame?: (id: number) => void
|
|
65
|
+
setTimer(callback: () => void, delay: number): unknown
|
|
66
|
+
clearTimer(id: unknown): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Structural contract for injected Animator implementations (ENG-09). */
|
|
70
|
+
export interface Animator {
|
|
71
|
+
animate(config: AnimationConfig, id?: string): AnimationHandle
|
|
72
|
+
remove(id: string): void
|
|
73
|
+
pause(id: string): void
|
|
74
|
+
resume(id: string): void
|
|
75
|
+
pauseAll(): void
|
|
76
|
+
resumeAll(): void
|
|
77
|
+
finishAll(): void
|
|
78
|
+
clear(): void
|
|
79
|
+
getAnimationCount(): number
|
|
80
|
+
destroy(): void
|
|
81
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { getEasingFunction } from './easing'
|
|
2
|
+
import { equalValues } from './values'
|
|
3
|
+
import type {
|
|
4
|
+
AnimationConfig,
|
|
5
|
+
AnimationDirection,
|
|
6
|
+
AnimationScheduler,
|
|
7
|
+
AnimationTrack,
|
|
8
|
+
AnimationValue,
|
|
9
|
+
EasingFunction,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
export interface ValidatedAnimationConfig extends AnimationConfig {
|
|
13
|
+
tracks: AnimationTrack[]
|
|
14
|
+
duration: number
|
|
15
|
+
delay: number
|
|
16
|
+
easing: EasingFunction
|
|
17
|
+
/** Infinity represents loop=true internally; numeric Infinity is rejected as input. */
|
|
18
|
+
loop: number
|
|
19
|
+
direction: AnimationDirection
|
|
20
|
+
force: boolean
|
|
21
|
+
additive: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A future property-group registry supplies this read-only snapshot before registration. */
|
|
25
|
+
export interface AnimationTrackContext {
|
|
26
|
+
/** Direction-aware baseEnd + committedOffset + sum(activeEndDelta). */
|
|
27
|
+
additiveFrom: AnimationValue
|
|
28
|
+
baseLoop: number | boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function object(value: unknown, label: string): void {
|
|
32
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
33
|
+
throw new TypeError(`${label} must be an object`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function time(value: unknown, label: string): asserts value is number {
|
|
38
|
+
if (typeof value !== 'number') throw new TypeError(`${label} must be a number`)
|
|
39
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
40
|
+
throw new RangeError(`${label} must be finite and nonnegative`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function iterations(value: unknown): number {
|
|
45
|
+
if (value === true) return Infinity
|
|
46
|
+
if (value === false) return 1
|
|
47
|
+
if (typeof value !== 'number') throw new TypeError('loop must be a number or boolean')
|
|
48
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
49
|
+
throw new RangeError('loop must be a finite positive integer')
|
|
50
|
+
}
|
|
51
|
+
return value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Validate and copy without changing any input array, including empty and nested arrays. */
|
|
55
|
+
export function copyAnimationValue(value: unknown, label = 'value'): AnimationValue {
|
|
56
|
+
const number = (item: unknown): number => {
|
|
57
|
+
if (typeof item !== 'number') throw new TypeError(`${label} must contain numbers`)
|
|
58
|
+
if (!Number.isFinite(item)) throw new RangeError(`${label} must contain finite numbers`)
|
|
59
|
+
return item
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === 'number') return number(value)
|
|
62
|
+
if (!Array.isArray(value)) throw new TypeError(`${label} must be numeric`)
|
|
63
|
+
const nested = value.length > 0 && Array.isArray(value[0])
|
|
64
|
+
if (!nested) return Array.from(value, number)
|
|
65
|
+
return Array.from(value, (item: unknown) => {
|
|
66
|
+
if (!Array.isArray(item)) throw new TypeError(`${label} has mixed dimensions`)
|
|
67
|
+
return Array.from(item, number)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Compare already validated numeric values without allocating another endpoint snapshot. */
|
|
72
|
+
export function sameShape(a: AnimationValue, b: AnimationValue): boolean {
|
|
73
|
+
if (typeof a === 'number' || typeof b === 'number') {
|
|
74
|
+
return typeof a === 'number' && typeof b === 'number'
|
|
75
|
+
}
|
|
76
|
+
return a.length === b.length && a.every((item, i) => sameShape(item, b[i]))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pure preflight: no target writes, callback execution, ownership changes or scheduling.
|
|
81
|
+
* The caller must finish preflight before cancelling any existing task.
|
|
82
|
+
* Omitted ordinary from remains omitted so replacement can capture the current composite.
|
|
83
|
+
*/
|
|
84
|
+
export function validateAnimationConfig(
|
|
85
|
+
config: AnimationConfig,
|
|
86
|
+
context?: (track: AnimationTrack) => AnimationTrackContext | undefined,
|
|
87
|
+
): ValidatedAnimationConfig {
|
|
88
|
+
object(config, 'config')
|
|
89
|
+
const duration = config.duration === undefined ? 1000 : config.duration
|
|
90
|
+
const delay = config.delay === undefined ? 0 : config.delay
|
|
91
|
+
time(duration, 'duration')
|
|
92
|
+
time(delay, 'delay')
|
|
93
|
+
const loop = iterations(config.loop === undefined ? 1 : config.loop)
|
|
94
|
+
if (duration === 0 && loop === Infinity) {
|
|
95
|
+
throw new RangeError('Zero duration cannot loop infinitely')
|
|
96
|
+
}
|
|
97
|
+
const direction = config.direction === undefined ? 'normal' : config.direction
|
|
98
|
+
if (!['normal', 'reverse', 'alternate', 'alternate-reverse'].includes(direction)) {
|
|
99
|
+
throw new TypeError('Unknown animation direction')
|
|
100
|
+
}
|
|
101
|
+
for (const field of ['force', 'additive'] as const) {
|
|
102
|
+
if (config[field] !== undefined && typeof config[field] !== 'boolean') {
|
|
103
|
+
throw new TypeError(`${field} must be boolean`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const field of ['initialize', 'during', 'done', 'aborted', 'onError'] as const) {
|
|
107
|
+
if (config[field] !== undefined && typeof config[field] !== 'function') {
|
|
108
|
+
throw new TypeError(`${field} must be a function`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const easing = getEasingFunction(config.easing === undefined ? 'linear' : config.easing)
|
|
112
|
+
const additive = config.additive === undefined ? false : config.additive
|
|
113
|
+
if (additive && loop !== 1) throw new RangeError('Additive tracks cannot loop')
|
|
114
|
+
if (config.tracks !== undefined && !Array.isArray(config.tracks)) {
|
|
115
|
+
throw new TypeError('tracks must be an array')
|
|
116
|
+
}
|
|
117
|
+
const seen = new Map<object, Set<string>>()
|
|
118
|
+
const tracks: AnimationTrack[] = []
|
|
119
|
+
for (const track of config.tracks ?? []) {
|
|
120
|
+
object(track, 'track')
|
|
121
|
+
object(track.target, 'track.target')
|
|
122
|
+
if (typeof track.property !== 'string') throw new TypeError('property must be a string')
|
|
123
|
+
const properties = seen.get(track.target) ?? new Set<string>()
|
|
124
|
+
if (properties.has(track.property)) throw new TypeError('Duplicate target/property')
|
|
125
|
+
properties.add(track.property)
|
|
126
|
+
seen.set(track.target, properties)
|
|
127
|
+
const current = copyAnimationValue(track.target[track.property], 'target value')
|
|
128
|
+
const to = copyAnimationValue(track.to, 'to')
|
|
129
|
+
const from = track.from === undefined ? undefined : copyAnimationValue(track.from, 'from')
|
|
130
|
+
if (!sameShape(current, to) || (from !== undefined && !sameShape(from, to))) {
|
|
131
|
+
throw new TypeError('Track values must have identical shapes')
|
|
132
|
+
}
|
|
133
|
+
const group = additive ? context?.(track) : undefined
|
|
134
|
+
if (group) {
|
|
135
|
+
if (iterations(group.baseLoop) !== 1) throw new RangeError('Additive base cannot loop')
|
|
136
|
+
const start = copyAnimationValue(group.additiveFrom, 'additiveFrom')
|
|
137
|
+
if (!sameShape(start, to) || (from !== undefined && !equalValues(from, start))) {
|
|
138
|
+
throw new TypeError('Additive from must match the derived start')
|
|
139
|
+
}
|
|
140
|
+
} else if (additive && from !== undefined && !equalValues(from, current)) {
|
|
141
|
+
throw new TypeError('Additive from must match the fixed base')
|
|
142
|
+
}
|
|
143
|
+
tracks.push({ target: track.target, property: track.property, from, to })
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
...config,
|
|
147
|
+
tracks,
|
|
148
|
+
duration,
|
|
149
|
+
delay,
|
|
150
|
+
loop,
|
|
151
|
+
direction,
|
|
152
|
+
easing,
|
|
153
|
+
additive,
|
|
154
|
+
force: config.force === undefined ? false : config.force,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Validate injection without calling the clock, requesting frames or installing timers. */
|
|
159
|
+
export function validateAnimationScheduler(scheduler: AnimationScheduler): void {
|
|
160
|
+
object(scheduler, 'scheduler')
|
|
161
|
+
for (const field of ['now', 'setTimer', 'clearTimer'] as const) {
|
|
162
|
+
if (typeof scheduler[field] !== 'function') throw new TypeError(`${field} must be a function`)
|
|
163
|
+
}
|
|
164
|
+
const hasRequest = scheduler.requestFrame !== undefined
|
|
165
|
+
const hasCancel = scheduler.cancelFrame !== undefined
|
|
166
|
+
if (
|
|
167
|
+
hasRequest !== hasCancel ||
|
|
168
|
+
(hasRequest &&
|
|
169
|
+
(typeof scheduler.requestFrame !== 'function' || typeof scheduler.cancelFrame !== 'function'))
|
|
170
|
+
) {
|
|
171
|
+
throw new TypeError('requestFrame and cancelFrame must be supplied together as functions')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AnimationDirection, AnimationValue } from './types'
|
|
2
|
+
|
|
3
|
+
export function mapValue(a: AnimationValue, fn: (value: number) => number): AnimationValue {
|
|
4
|
+
return combineValues(a, a, fn)
|
|
5
|
+
}
|
|
6
|
+
export function combineValues(
|
|
7
|
+
a: AnimationValue,
|
|
8
|
+
b: AnimationValue,
|
|
9
|
+
fn: (a: number, b: number) => number,
|
|
10
|
+
): AnimationValue {
|
|
11
|
+
if (typeof a === 'number') return fn(a, b as number)
|
|
12
|
+
return a.map((value, i) =>
|
|
13
|
+
typeof value === 'number'
|
|
14
|
+
? fn(value, (b as number[])[i])
|
|
15
|
+
: value.map((item, j) => fn(item, (b as number[][])[i][j])),
|
|
16
|
+
) as AnimationValue
|
|
17
|
+
}
|
|
18
|
+
export const addValues = (a: AnimationValue, b: AnimationValue) =>
|
|
19
|
+
combineValues(a, b, (x, y) => x + y)
|
|
20
|
+
export const subtractValues = (a: AnimationValue, b: AnimationValue) =>
|
|
21
|
+
combineValues(a, b, (x, y) => x - y)
|
|
22
|
+
export const interpolateValue = (a: AnimationValue, b: AnimationValue, p: number) =>
|
|
23
|
+
combineValues(a, b, (x, y) => (p === 0 ? x : p === 1 ? y : (1 - p) * x + p * y))
|
|
24
|
+
export function equalValues(a: AnimationValue, b: AnimationValue): boolean {
|
|
25
|
+
if (typeof a === 'number' || typeof b === 'number') return a === b
|
|
26
|
+
return a.length === b.length && a.every((value, i) => equalValues(value, b[i]))
|
|
27
|
+
}
|
|
28
|
+
export function directedProgress(
|
|
29
|
+
p: number,
|
|
30
|
+
iteration: number,
|
|
31
|
+
direction: AnimationDirection,
|
|
32
|
+
): number {
|
|
33
|
+
const reverse =
|
|
34
|
+
direction === 'reverse' ||
|
|
35
|
+
(direction === 'alternate' && iteration % 2 === 1) ||
|
|
36
|
+
(direction === 'alternate-reverse' && iteration % 2 === 0)
|
|
37
|
+
return reverse ? 1 - p : p
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AnimationConfig } from '../core/types'
|
|
2
|
+
import type {
|
|
3
|
+
AnimationBinding,
|
|
4
|
+
AnnotationAnimationOptions,
|
|
5
|
+
AnnotationAnimationState,
|
|
6
|
+
} from '../types'
|
|
7
|
+
import {
|
|
8
|
+
annotationEasing,
|
|
9
|
+
isAnnotationEffect,
|
|
10
|
+
clampProgress,
|
|
11
|
+
presetConfig,
|
|
12
|
+
validateBinding,
|
|
13
|
+
} from './shared'
|
|
14
|
+
|
|
15
|
+
function annotationConfig(
|
|
16
|
+
binding: AnimationBinding<AnnotationAnimationState>,
|
|
17
|
+
options: AnnotationAnimationOptions,
|
|
18
|
+
appear: boolean,
|
|
19
|
+
): AnimationConfig {
|
|
20
|
+
validateBinding(binding)
|
|
21
|
+
const type = options.type ?? 'scale'
|
|
22
|
+
if (!isAnnotationEffect(type)) throw new TypeError('Unknown annotation effect')
|
|
23
|
+
const easing = options.easing ?? annotationEasing[type]
|
|
24
|
+
const final = {
|
|
25
|
+
opacity: appear ? 1 : 0,
|
|
26
|
+
scale: !appear && (type === 'scale' || type === 'bounce') ? 0 : 1,
|
|
27
|
+
offsetY: !appear && type === 'slide-in' ? 8 : 0,
|
|
28
|
+
}
|
|
29
|
+
let from: AnnotationAnimationState | undefined
|
|
30
|
+
return presetConfig(
|
|
31
|
+
{ ...options, easing },
|
|
32
|
+
{
|
|
33
|
+
initialize: () => {
|
|
34
|
+
from = { ...binding.read() }
|
|
35
|
+
if (!Object.values(from).every(Number.isFinite))
|
|
36
|
+
throw new RangeError('Invalid annotation state')
|
|
37
|
+
},
|
|
38
|
+
during: (p, frame) => {
|
|
39
|
+
if (frame.terminal) {
|
|
40
|
+
binding.write({ ...final })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const start = from ?? final
|
|
44
|
+
const interpolate = (key: keyof AnnotationAnimationState) =>
|
|
45
|
+
(1 - p) * start[key] + p * final[key]
|
|
46
|
+
binding.write({
|
|
47
|
+
opacity: clampProgress(interpolate('opacity')),
|
|
48
|
+
scale: Math.max(0, interpolate('scale')),
|
|
49
|
+
offsetY: interpolate('offsetY'),
|
|
50
|
+
})
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
export function createAnnotationAppearAnimation(
|
|
56
|
+
binding: AnimationBinding<AnnotationAnimationState>,
|
|
57
|
+
options: AnnotationAnimationOptions = {},
|
|
58
|
+
): AnimationConfig {
|
|
59
|
+
return annotationConfig(binding, options, true)
|
|
60
|
+
}
|
|
61
|
+
export function createAnnotationDisappearAnimation(
|
|
62
|
+
binding: AnimationBinding<AnnotationAnimationState>,
|
|
63
|
+
options: AnnotationAnimationOptions = {},
|
|
64
|
+
): AnimationConfig {
|
|
65
|
+
return annotationConfig(binding, options, false)
|
|
66
|
+
}
|