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,328 @@
|
|
|
1
|
+
import { Animator, globalAnimator } from './core/Animator'
|
|
2
|
+
import type { AnimationConfig, AnimationHandle, AnimationState } from './core/types'
|
|
3
|
+
import { copyAnimationValue, validateAnimationConfig } from './core/validation'
|
|
4
|
+
import { animationError, notify, reportAnimationError } from './core/errors'
|
|
5
|
+
import { createAnimationOwner } from './core/owner'
|
|
6
|
+
import { createAnimationHandle } from './core/handle'
|
|
7
|
+
import type {
|
|
8
|
+
AnimationObjectKey,
|
|
9
|
+
WaveformAnimationControllerOptions,
|
|
10
|
+
AnimationBinding,
|
|
11
|
+
ClipAnimationState,
|
|
12
|
+
LineDrawingOptions,
|
|
13
|
+
StaggeredLineDrawingOptions,
|
|
14
|
+
GeometryTransitionInput,
|
|
15
|
+
GeometryTransitionFrame,
|
|
16
|
+
DataUpdateOptions,
|
|
17
|
+
AnnotationAnimationState,
|
|
18
|
+
AnnotationAnimationOptions,
|
|
19
|
+
} from './types'
|
|
20
|
+
import {
|
|
21
|
+
createLineDrawingAnimation,
|
|
22
|
+
createStaggeredLineDrawingAnimations,
|
|
23
|
+
} from './presets/lineDrawing'
|
|
24
|
+
import { createPathTransitionAnimation, createYAxisScaleAnimation } from './presets/transitions'
|
|
25
|
+
import {
|
|
26
|
+
createAnnotationAppearAnimation,
|
|
27
|
+
createAnnotationDisappearAnimation,
|
|
28
|
+
} from './presets/annotation'
|
|
29
|
+
|
|
30
|
+
interface RecordEntry {
|
|
31
|
+
key: AnimationObjectKey
|
|
32
|
+
config: AnimationConfig
|
|
33
|
+
id: string
|
|
34
|
+
raw?: AnimationHandle
|
|
35
|
+
state?: AnimationState
|
|
36
|
+
handle: AnimationHandle
|
|
37
|
+
release: () => void
|
|
38
|
+
manualPaused: boolean
|
|
39
|
+
}
|
|
40
|
+
function conflicts(a: AnimationObjectKey, b: AnimationObjectKey): boolean {
|
|
41
|
+
if (a.kind !== b.kind || a.id !== b.id) return false
|
|
42
|
+
if (
|
|
43
|
+
a.kind === 'annotation' &&
|
|
44
|
+
['enter', 'leave'].includes(a.scope) &&
|
|
45
|
+
['enter', 'leave'].includes(b.scope)
|
|
46
|
+
)
|
|
47
|
+
return true
|
|
48
|
+
if (
|
|
49
|
+
a.kind === 'series' &&
|
|
50
|
+
['enter', 'update'].includes(a.scope) &&
|
|
51
|
+
['enter', 'update'].includes(b.scope)
|
|
52
|
+
)
|
|
53
|
+
return true
|
|
54
|
+
return a.scope === b.scope
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Owner-scoped coordination; it never installs browser listeners or writes DOM. */
|
|
58
|
+
export class WaveformAnimationController {
|
|
59
|
+
#records = new Map<string, RecordEntry>()
|
|
60
|
+
#owner = createAnimationOwner()
|
|
61
|
+
#enabled: boolean
|
|
62
|
+
#animator: NonNullable<WaveformAnimationControllerOptions['animator']>
|
|
63
|
+
#barrier = false
|
|
64
|
+
#disposed = false
|
|
65
|
+
#paused = new Set<string>()
|
|
66
|
+
constructor(private options: WaveformAnimationControllerOptions) {
|
|
67
|
+
if (!options || typeof options.commitFinalState !== 'function')
|
|
68
|
+
throw new TypeError('commitFinalState is required')
|
|
69
|
+
this.#enabled = options.enabled ?? true
|
|
70
|
+
this.#animator = options.animator ?? globalAnimator
|
|
71
|
+
}
|
|
72
|
+
#assertOpen(): void {
|
|
73
|
+
if (this.#barrier) throw animationError('animation-terminating')
|
|
74
|
+
if (this.#disposed) throw animationError('animation-disposed')
|
|
75
|
+
}
|
|
76
|
+
animate(key: AnimationObjectKey, config: AnimationConfig): AnimationHandle {
|
|
77
|
+
this.#assertOpen()
|
|
78
|
+
if (
|
|
79
|
+
!key ||
|
|
80
|
+
!['series', 'annotation', 'axis', 'custom'].includes(key.kind) ||
|
|
81
|
+
typeof key.id !== 'string' ||
|
|
82
|
+
!['enter', 'update', 'leave', 'domain'].includes(key.scope)
|
|
83
|
+
)
|
|
84
|
+
throw new TypeError('Invalid animation object key')
|
|
85
|
+
if (this.#animator instanceof Animator) this.#animator.validate(config)
|
|
86
|
+
else validateAnimationConfig(config)
|
|
87
|
+
// Old terminal callbacks may mutate caller-owned arrays before manager registration.
|
|
88
|
+
// Preserve the group-aware preflight contract without sharing those endpoints.
|
|
89
|
+
const snapshot = {
|
|
90
|
+
...config,
|
|
91
|
+
tracks: config.tracks?.map((track) => ({
|
|
92
|
+
...track,
|
|
93
|
+
from: track.from === undefined ? undefined : copyAnimationValue(track.from, 'from'),
|
|
94
|
+
to: copyAnimationValue(track.to, 'to'),
|
|
95
|
+
})),
|
|
96
|
+
}
|
|
97
|
+
const id = this.#owner(key)
|
|
98
|
+
const olds = [...this.#records.values()].filter((record) => conflicts(record.key, key))
|
|
99
|
+
const record = { key: { ...key }, config: snapshot, id, manualPaused: false } as RecordEntry
|
|
100
|
+
const { handle, release } = createAnimationHandle(
|
|
101
|
+
id,
|
|
102
|
+
() => record.state ?? record.raw?.state ?? 'PENDING',
|
|
103
|
+
(method) => {
|
|
104
|
+
if (method === 'pause') {
|
|
105
|
+
record.manualPaused = true
|
|
106
|
+
record.raw?.pause()
|
|
107
|
+
} else if (method === 'resume') {
|
|
108
|
+
record.manualPaused = false
|
|
109
|
+
if (this.#animator instanceof Animator || !this.#paused.size) record.raw?.resume()
|
|
110
|
+
} else if (method === 'abort') {
|
|
111
|
+
if (record.raw) record.raw.abort()
|
|
112
|
+
else this.#finishRecord(record, 'ABORTED')
|
|
113
|
+
} else record.raw?.finish()
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
record.handle = handle
|
|
117
|
+
record.release = release
|
|
118
|
+
this.#records.set(id, record)
|
|
119
|
+
for (const old of olds) {
|
|
120
|
+
if (key.kind === 'series' && key.scope === 'update' && old.key.scope === 'enter') {
|
|
121
|
+
try {
|
|
122
|
+
old.config.during?.(1, {
|
|
123
|
+
rawProgress: 1,
|
|
124
|
+
directedProgress: 1,
|
|
125
|
+
iteration: 0,
|
|
126
|
+
terminal: true,
|
|
127
|
+
})
|
|
128
|
+
} catch (error) {
|
|
129
|
+
reportAnimationError(old.config, error)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
old.handle.abort()
|
|
133
|
+
}
|
|
134
|
+
if (record.state) return record.handle
|
|
135
|
+
const wrapped: AnimationConfig = {
|
|
136
|
+
...snapshot,
|
|
137
|
+
initialize:
|
|
138
|
+
this.#animator instanceof Animator
|
|
139
|
+
? snapshot.initialize
|
|
140
|
+
: () => {
|
|
141
|
+
snapshot.initialize?.()
|
|
142
|
+
if (this.#paused.size) this.#animator.pause(id)
|
|
143
|
+
},
|
|
144
|
+
duration: this.#enabled ? snapshot.duration : 0,
|
|
145
|
+
loop: this.#enabled ? snapshot.loop : 1,
|
|
146
|
+
done: () => {
|
|
147
|
+
if (!this.#barrier) this.#finishRecord(record, 'FINISHED')
|
|
148
|
+
},
|
|
149
|
+
aborted: () => {
|
|
150
|
+
if (!this.#barrier) this.#finishRecord(record, 'ABORTED')
|
|
151
|
+
},
|
|
152
|
+
onError: (error) => {
|
|
153
|
+
reportAnimationError(snapshot, error)
|
|
154
|
+
if (!this.#barrier && !this.#disposed) this.finishAll()
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
record.raw =
|
|
159
|
+
this.#animator instanceof Animator
|
|
160
|
+
? this.#animator.animatePaused(
|
|
161
|
+
wrapped,
|
|
162
|
+
id,
|
|
163
|
+
[...this.#paused].map((reason) => this.#ownerPause(reason)),
|
|
164
|
+
)
|
|
165
|
+
: this.#animator.animate(wrapped, id)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.#finishRecord(record, 'ABORTED')
|
|
168
|
+
throw error
|
|
169
|
+
}
|
|
170
|
+
if (this.#animator instanceof Animator)
|
|
171
|
+
for (const reason of this.#paused) {
|
|
172
|
+
this.#animator.setPauseReason(this.#ownerPause(reason), true, [id])
|
|
173
|
+
}
|
|
174
|
+
else if (this.#paused.size) record.raw.pause()
|
|
175
|
+
return record.handle
|
|
176
|
+
}
|
|
177
|
+
#finishRecord(record: RecordEntry, state: 'FINISHED' | 'ABORTED'): void {
|
|
178
|
+
if (record.state === 'FINISHED' || record.state === 'ABORTED') return
|
|
179
|
+
record.state = state
|
|
180
|
+
if (this.#records.get(record.id) === record) this.#records.delete(record.id)
|
|
181
|
+
record.release()
|
|
182
|
+
notify(record.config, state === 'FINISHED' ? record.config.done : record.config.aborted)
|
|
183
|
+
}
|
|
184
|
+
animateSeriesEnter(
|
|
185
|
+
id: string,
|
|
186
|
+
binding: AnimationBinding<ClipAnimationState>,
|
|
187
|
+
width: number,
|
|
188
|
+
options?: LineDrawingOptions,
|
|
189
|
+
): AnimationHandle {
|
|
190
|
+
this.#assertOpen()
|
|
191
|
+
return this.animate(
|
|
192
|
+
{ kind: 'series', id, scope: 'enter' },
|
|
193
|
+
createLineDrawingAnimation(binding, width, options),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
animateMultiSeriesEnter(
|
|
197
|
+
series: readonly { id: string; binding: AnimationBinding<ClipAnimationState>; width: number }[],
|
|
198
|
+
options?: StaggeredLineDrawingOptions,
|
|
199
|
+
): AnimationHandle[] {
|
|
200
|
+
this.#assertOpen()
|
|
201
|
+
const configs = createStaggeredLineDrawingAnimations(series, options)
|
|
202
|
+
return configs.map((config, index) =>
|
|
203
|
+
this.animate({ kind: 'series', id: series[index].id, scope: 'enter' }, config),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
animateDataUpdate(
|
|
207
|
+
id: string,
|
|
208
|
+
input: GeometryTransitionInput,
|
|
209
|
+
write: (frame: GeometryTransitionFrame) => void,
|
|
210
|
+
options?: DataUpdateOptions,
|
|
211
|
+
): AnimationHandle {
|
|
212
|
+
this.#assertOpen()
|
|
213
|
+
return this.animate(
|
|
214
|
+
{ kind: 'series', id, scope: 'update' },
|
|
215
|
+
createPathTransitionAnimation(input, write, options),
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
animateYAxisScale(
|
|
219
|
+
id: string,
|
|
220
|
+
binding: AnimationBinding<[number, number]>,
|
|
221
|
+
to: [number, number],
|
|
222
|
+
options?: DataUpdateOptions,
|
|
223
|
+
): AnimationHandle {
|
|
224
|
+
this.#assertOpen()
|
|
225
|
+
return this.animate(
|
|
226
|
+
{ kind: 'axis', id, scope: 'domain' },
|
|
227
|
+
createYAxisScaleAnimation(binding, to, options),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
animateAnnotationAppear(
|
|
231
|
+
id: string,
|
|
232
|
+
binding: AnimationBinding<AnnotationAnimationState>,
|
|
233
|
+
options?: AnnotationAnimationOptions,
|
|
234
|
+
): AnimationHandle {
|
|
235
|
+
this.#assertOpen()
|
|
236
|
+
return this.animate(
|
|
237
|
+
{ kind: 'annotation', id, scope: 'enter' },
|
|
238
|
+
createAnnotationAppearAnimation(binding, options),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
animateAnnotationDisappear(
|
|
242
|
+
id: string,
|
|
243
|
+
binding: AnimationBinding<AnnotationAnimationState>,
|
|
244
|
+
options?: AnnotationAnimationOptions,
|
|
245
|
+
): AnimationHandle {
|
|
246
|
+
this.#assertOpen()
|
|
247
|
+
return this.animate(
|
|
248
|
+
{ kind: 'annotation', id, scope: 'leave' },
|
|
249
|
+
createAnnotationDisappearAnimation(binding, options),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
cancel(key: AnimationObjectKey): void {
|
|
253
|
+
this.#records.get(this.#owner(key))?.handle.abort()
|
|
254
|
+
}
|
|
255
|
+
cancelAll(): void {
|
|
256
|
+
this.#bulk(false, false)
|
|
257
|
+
}
|
|
258
|
+
finishAll(): void {
|
|
259
|
+
this.#bulk(true, true)
|
|
260
|
+
}
|
|
261
|
+
/** Adapter category commits are separate from the whole-chart final-state commit. */
|
|
262
|
+
finishWhere(predicate: (key: AnimationObjectKey) => boolean, commit: () => void): void {
|
|
263
|
+
this.#bulk(true, true, predicate, commit)
|
|
264
|
+
}
|
|
265
|
+
#bulk(
|
|
266
|
+
finish: boolean,
|
|
267
|
+
commit: boolean,
|
|
268
|
+
predicate: (key: AnimationObjectKey) => boolean = () => true,
|
|
269
|
+
submit = this.options.commitFinalState,
|
|
270
|
+
): void {
|
|
271
|
+
if (this.#barrier || this.#disposed) return
|
|
272
|
+
this.#barrier = true
|
|
273
|
+
const records = [...this.#records.values()].filter((record) => predicate(record.key))
|
|
274
|
+
try {
|
|
275
|
+
for (const record of records) {
|
|
276
|
+
if (this.#records.get(record.id) === record) this.#records.delete(record.id)
|
|
277
|
+
record.raw?.abort()
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
if (commit) submit()
|
|
281
|
+
} catch (error) {
|
|
282
|
+
for (const record of records) {
|
|
283
|
+
this.#finishRecord(record, 'ABORTED')
|
|
284
|
+
reportAnimationError(record.config, error)
|
|
285
|
+
}
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
for (const record of records) this.#finishRecord(record, finish ? 'FINISHED' : 'ABORTED')
|
|
289
|
+
} finally {
|
|
290
|
+
this.#barrier = false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
pauseAll(): void {
|
|
294
|
+
this.setPauseReason('manual-all', true)
|
|
295
|
+
}
|
|
296
|
+
resumeAll(): void {
|
|
297
|
+
this.setPauseReason('manual-all', false)
|
|
298
|
+
}
|
|
299
|
+
#ownerPause(reason: string) {
|
|
300
|
+
return `${this.#owner({ kind: 'custom', id: 'pause', scope: 'domain' })}:${reason}`
|
|
301
|
+
}
|
|
302
|
+
setPauseReason(reason: string, pause: boolean): void {
|
|
303
|
+
if (this.#disposed) return
|
|
304
|
+
if (pause) this.#paused.add(reason)
|
|
305
|
+
else this.#paused.delete(reason)
|
|
306
|
+
if (this.#animator instanceof Animator)
|
|
307
|
+
this.#animator.setPauseReason(this.#ownerPause(reason), pause, [...this.#records.keys()])
|
|
308
|
+
else if (pause) this.#records.forEach((record) => record.raw?.pause())
|
|
309
|
+
else if (!this.#paused.size)
|
|
310
|
+
this.#records.forEach((record) => {
|
|
311
|
+
if (!record.manualPaused) record.raw?.resume()
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
setEnabled(enabled: boolean): void {
|
|
315
|
+
if (this.#disposed || this.#enabled === enabled) return
|
|
316
|
+
this.#enabled = enabled
|
|
317
|
+
if (!enabled) this.finishAll()
|
|
318
|
+
}
|
|
319
|
+
getActiveAnimationCount(): number {
|
|
320
|
+
return this.#records.size
|
|
321
|
+
}
|
|
322
|
+
destroy(): void {
|
|
323
|
+
if (this.#disposed || this.#barrier) return
|
|
324
|
+
this.#bulk(false, false)
|
|
325
|
+
this.#disposed = true
|
|
326
|
+
this.#paused.clear()
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AnimationConfig, AnimationState } from './types'
|
|
2
|
+
import { validateAnimationConfig } from './validation'
|
|
3
|
+
import { PropertyGroups } from './propertyGroups'
|
|
4
|
+
import { AnimationTask } from './task'
|
|
5
|
+
|
|
6
|
+
/** Standalone manual timeline; use Animator for concurrent writers and additive groups. */
|
|
7
|
+
export class Animation {
|
|
8
|
+
readonly #task: AnimationTask
|
|
9
|
+
constructor(config: AnimationConfig) {
|
|
10
|
+
const groups = new PropertyGroups()
|
|
11
|
+
const validated = validateAnimationConfig(config)
|
|
12
|
+
validated.tracks = groups.capture(validated)
|
|
13
|
+
this.#task = new AnimationTask(validated, groups)
|
|
14
|
+
groups.bind(this.#task)
|
|
15
|
+
}
|
|
16
|
+
get state(): AnimationState {
|
|
17
|
+
return this.#task.state
|
|
18
|
+
}
|
|
19
|
+
start(time: number): void {
|
|
20
|
+
this.#task.start(time)
|
|
21
|
+
}
|
|
22
|
+
tick(time: number): boolean {
|
|
23
|
+
return this.#task.tick(time)
|
|
24
|
+
}
|
|
25
|
+
pause(time: number): void {
|
|
26
|
+
this.#task.pause(time)
|
|
27
|
+
}
|
|
28
|
+
resume(time: number): void {
|
|
29
|
+
this.#task.resume(time)
|
|
30
|
+
}
|
|
31
|
+
abort(): void {
|
|
32
|
+
this.#task.abort()
|
|
33
|
+
}
|
|
34
|
+
finish(): void {
|
|
35
|
+
this.#task.finish()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { AnimationConfig, AnimationHandle, AnimationScheduler } from './types'
|
|
2
|
+
import { validateAnimationConfig, validateAnimationScheduler } from './validation'
|
|
3
|
+
import { PropertyGroups } from './propertyGroups'
|
|
4
|
+
import { AnimationTask } from './task'
|
|
5
|
+
import { animationError } from './errors'
|
|
6
|
+
import { createAnimationHandle } from './handle'
|
|
7
|
+
|
|
8
|
+
function defaultScheduler(): AnimationScheduler {
|
|
9
|
+
// Constructed only on first animate, never during module import.
|
|
10
|
+
const raf =
|
|
11
|
+
typeof globalThis.requestAnimationFrame === 'function' &&
|
|
12
|
+
typeof globalThis.cancelAnimationFrame === 'function'
|
|
13
|
+
return {
|
|
14
|
+
now: () => globalThis.performance?.now() ?? Date.now(),
|
|
15
|
+
requestFrame: raf ? (callback) => globalThis.requestAnimationFrame(callback) : undefined,
|
|
16
|
+
cancelFrame: raf ? (id) => globalThis.cancelAnimationFrame(id) : undefined,
|
|
17
|
+
setTimer: (callback, delay) => setTimeout(callback, delay),
|
|
18
|
+
clearTimer: (id) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shared numeric task scheduler. Empty, delayed-only and paused queues never spin RAF. */
|
|
23
|
+
export class Animator {
|
|
24
|
+
#scheduler?: AnimationScheduler
|
|
25
|
+
readonly #injected: boolean
|
|
26
|
+
#tasks = new Map<string, AnimationTask>()
|
|
27
|
+
#groups = new PropertyGroups()
|
|
28
|
+
#reasons = new Set<string>()
|
|
29
|
+
#frameId?: number
|
|
30
|
+
#timerId?: unknown
|
|
31
|
+
#hasTimer = false
|
|
32
|
+
#sequence = 0
|
|
33
|
+
#barrier = false
|
|
34
|
+
#frameRunning = false
|
|
35
|
+
constructor(scheduler?: AnimationScheduler) {
|
|
36
|
+
if (scheduler) validateAnimationScheduler(scheduler)
|
|
37
|
+
this.#scheduler = scheduler
|
|
38
|
+
this.#injected = !!scheduler
|
|
39
|
+
}
|
|
40
|
+
#clock() {
|
|
41
|
+
return (this.#scheduler ??= defaultScheduler())
|
|
42
|
+
}
|
|
43
|
+
validate(config: AnimationConfig): void {
|
|
44
|
+
validateAnimationConfig(config, this.#groups.context)
|
|
45
|
+
}
|
|
46
|
+
animate(config: AnimationConfig, id?: string): AnimationHandle {
|
|
47
|
+
return this.animatePaused(config, id, [])
|
|
48
|
+
}
|
|
49
|
+
/** Adapter registration with inherited owner reasons, before any scheduling occurs. */
|
|
50
|
+
animatePaused(
|
|
51
|
+
config: AnimationConfig,
|
|
52
|
+
id: string | undefined,
|
|
53
|
+
reasons: readonly string[],
|
|
54
|
+
): AnimationHandle {
|
|
55
|
+
if (this.#barrier) throw animationError('animation-terminating')
|
|
56
|
+
if (id !== undefined && typeof id !== 'string')
|
|
57
|
+
throw new TypeError('Animation id must be string')
|
|
58
|
+
const validated = validateAnimationConfig(config, this.#groups.context)
|
|
59
|
+
const conflicts = this.#groups.conflicts(validated) as Set<AnimationTask>
|
|
60
|
+
if (id !== undefined && this.#tasks.has(id)) conflicts.add(this.#tasks.get(id)!)
|
|
61
|
+
validated.tracks = this.#groups.capture(validated)
|
|
62
|
+
const task = new AnimationTask(validated, this.#groups)
|
|
63
|
+
let key = id
|
|
64
|
+
if (key === undefined) {
|
|
65
|
+
do {
|
|
66
|
+
key = `animation-${++this.#sequence}`
|
|
67
|
+
} while (this.#tasks.has(key))
|
|
68
|
+
}
|
|
69
|
+
const callbacks = [...conflicts].map((old) => old.settle('ABORTED', false))
|
|
70
|
+
this.#tasks.set(key, task)
|
|
71
|
+
this.#groups.bind(task)
|
|
72
|
+
const scheduler = this.#clock()
|
|
73
|
+
const { handle, release } = createAnimationHandle(
|
|
74
|
+
key,
|
|
75
|
+
() => task.state,
|
|
76
|
+
(method) => {
|
|
77
|
+
task[method](scheduler.now())
|
|
78
|
+
this.#schedule()
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
task.setTermination(() => {
|
|
82
|
+
if (this.#tasks.get(key) === task) this.#tasks.delete(key)
|
|
83
|
+
release()
|
|
84
|
+
this.#schedule()
|
|
85
|
+
})
|
|
86
|
+
for (const reason of new Set([...this.#reasons, ...reasons]))
|
|
87
|
+
task.pause(scheduler.now(), reason)
|
|
88
|
+
// The new owner is visible to reentrant requests before old user notifications.
|
|
89
|
+
callbacks.forEach((callback) => callback())
|
|
90
|
+
if (!task.ended) task.start(scheduler.now(), !scheduler.requestFrame)
|
|
91
|
+
this.#schedule()
|
|
92
|
+
return handle
|
|
93
|
+
}
|
|
94
|
+
remove(id: string): void {
|
|
95
|
+
this.#tasks.get(id)?.abort()
|
|
96
|
+
this.#schedule()
|
|
97
|
+
}
|
|
98
|
+
pause(id: string): void {
|
|
99
|
+
this.#tasks.get(id)?.pause(this.#clock().now())
|
|
100
|
+
this.#schedule()
|
|
101
|
+
}
|
|
102
|
+
resume(id: string): void {
|
|
103
|
+
this.#tasks.get(id)?.resume(this.#clock().now())
|
|
104
|
+
this.#schedule()
|
|
105
|
+
}
|
|
106
|
+
pauseAll(): void {
|
|
107
|
+
this.setPauseReason('manual-all', true)
|
|
108
|
+
}
|
|
109
|
+
resumeAll(): void {
|
|
110
|
+
this.setPauseReason('manual-all', false)
|
|
111
|
+
}
|
|
112
|
+
/** Independent adapter-owned pause reasons, including document-hidden and owner scopes. */
|
|
113
|
+
setPauseReason(reason: string, paused: boolean, ids?: readonly string[]): void {
|
|
114
|
+
if (!ids) {
|
|
115
|
+
if (paused) this.#reasons.add(reason)
|
|
116
|
+
else this.#reasons.delete(reason)
|
|
117
|
+
}
|
|
118
|
+
const tasks = ids
|
|
119
|
+
? ids.map((id) => this.#tasks.get(id)).filter((task) => !!task)
|
|
120
|
+
: [...this.#tasks.values()]
|
|
121
|
+
const time = this.#clock().now()
|
|
122
|
+
for (const task of tasks) {
|
|
123
|
+
if (paused) task.pause(time, reason)
|
|
124
|
+
else task.resume(time, reason)
|
|
125
|
+
}
|
|
126
|
+
this.#schedule()
|
|
127
|
+
}
|
|
128
|
+
finishAll(): void {
|
|
129
|
+
this.#bulk('finish')
|
|
130
|
+
}
|
|
131
|
+
clear(): void {
|
|
132
|
+
this.#bulk('abort')
|
|
133
|
+
}
|
|
134
|
+
destroy(): void {
|
|
135
|
+
if (this.#barrier) return
|
|
136
|
+
this.#bulk('abort')
|
|
137
|
+
this.#reasons.clear()
|
|
138
|
+
this.#groups = new PropertyGroups()
|
|
139
|
+
if (!this.#injected) this.#scheduler = undefined
|
|
140
|
+
}
|
|
141
|
+
getAnimationCount(): number {
|
|
142
|
+
return this.#tasks.size
|
|
143
|
+
}
|
|
144
|
+
#bulk(method: 'finish' | 'abort'): void {
|
|
145
|
+
if (this.#barrier) return
|
|
146
|
+
this.#barrier = true
|
|
147
|
+
try {
|
|
148
|
+
// oxlint-disable-next-line unicorn/no-useless-spread -- Callbacks mutate tasks; snapshot membership.
|
|
149
|
+
for (const task of [...this.#tasks.values()]) {
|
|
150
|
+
if (method === 'finish') task.finish(this.#clock().now())
|
|
151
|
+
else task.abort()
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
this.#barrier = false
|
|
155
|
+
this.#schedule()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
#stop(): void {
|
|
159
|
+
if (this.#frameId !== undefined) this.#scheduler?.cancelFrame?.(this.#frameId)
|
|
160
|
+
if (this.#hasTimer) this.#scheduler?.clearTimer(this.#timerId)
|
|
161
|
+
this.#frameId = undefined
|
|
162
|
+
this.#timerId = undefined
|
|
163
|
+
this.#hasTimer = false
|
|
164
|
+
}
|
|
165
|
+
#schedule(): void {
|
|
166
|
+
if (this.#frameRunning || this.#barrier) return
|
|
167
|
+
this.#stop()
|
|
168
|
+
if (!this.#tasks.size) return
|
|
169
|
+
const scheduler = this.#clock()
|
|
170
|
+
if (!scheduler.requestFrame) return
|
|
171
|
+
const now = scheduler.now()
|
|
172
|
+
const deadline = Math.min(...[...this.#tasks.values()].map((task) => task.deadline(now)))
|
|
173
|
+
if (deadline === Infinity) return
|
|
174
|
+
if (deadline > now) {
|
|
175
|
+
this.#hasTimer = true
|
|
176
|
+
this.#timerId = scheduler.setTimer(() => {
|
|
177
|
+
this.#hasTimer = false
|
|
178
|
+
this.#schedule()
|
|
179
|
+
}, deadline - now)
|
|
180
|
+
} else this.#frameId = scheduler.requestFrame(() => this.#frame())
|
|
181
|
+
}
|
|
182
|
+
#frame(): void {
|
|
183
|
+
this.#frameId = undefined
|
|
184
|
+
this.#frameRunning = true
|
|
185
|
+
const time = this.#clock().now()
|
|
186
|
+
try {
|
|
187
|
+
const frames = [...this.#tasks].map(([id, task]) => ({ id, task, next: task.prepare(time) }))
|
|
188
|
+
this.#groups.flush()
|
|
189
|
+
for (const { id, task, next } of frames) {
|
|
190
|
+
if (next && this.#tasks.get(id) === task) task.deliver(next.progress, next.frame)
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
this.#frameRunning = false
|
|
194
|
+
this.#schedule()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Construction is side-effect free; browser capabilities are read lazily on animate. */
|
|
200
|
+
export const globalAnimator = /* @__PURE__ */ new Animator()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
easeLinear,
|
|
3
|
+
easeQuadIn,
|
|
4
|
+
easeQuadOut,
|
|
5
|
+
easeQuadInOut,
|
|
6
|
+
easeCubicIn,
|
|
7
|
+
easeCubicOut,
|
|
8
|
+
easeCubicInOut,
|
|
9
|
+
easePolyIn,
|
|
10
|
+
easePolyOut,
|
|
11
|
+
easePolyInOut,
|
|
12
|
+
easeSinIn,
|
|
13
|
+
easeSinOut,
|
|
14
|
+
easeSinInOut,
|
|
15
|
+
easeExpIn,
|
|
16
|
+
easeExpOut,
|
|
17
|
+
easeExpInOut,
|
|
18
|
+
easeCircleIn,
|
|
19
|
+
easeCircleOut,
|
|
20
|
+
easeCircleInOut,
|
|
21
|
+
easeBackIn,
|
|
22
|
+
easeBackOut,
|
|
23
|
+
easeBackInOut,
|
|
24
|
+
easeElasticIn,
|
|
25
|
+
easeElasticOut,
|
|
26
|
+
easeElasticInOut,
|
|
27
|
+
easeBounceIn,
|
|
28
|
+
easeBounceOut,
|
|
29
|
+
easeBounceInOut,
|
|
30
|
+
} from 'd3'
|
|
31
|
+
import type { EasingFunction, EasingType } from './types'
|
|
32
|
+
|
|
33
|
+
const easings: Record<EasingType, EasingFunction> = {
|
|
34
|
+
linear: easeLinear,
|
|
35
|
+
quadIn: easeQuadIn,
|
|
36
|
+
quadOut: easeQuadOut,
|
|
37
|
+
quadInOut: easeQuadInOut,
|
|
38
|
+
cubicIn: easeCubicIn,
|
|
39
|
+
cubicOut: easeCubicOut,
|
|
40
|
+
cubicInOut: easeCubicInOut,
|
|
41
|
+
quartIn: easePolyIn.exponent(4),
|
|
42
|
+
quartOut: easePolyOut.exponent(4),
|
|
43
|
+
quartInOut: easePolyInOut.exponent(4),
|
|
44
|
+
quintIn: easePolyIn.exponent(5),
|
|
45
|
+
quintOut: easePolyOut.exponent(5),
|
|
46
|
+
quintInOut: easePolyInOut.exponent(5),
|
|
47
|
+
sineIn: easeSinIn,
|
|
48
|
+
sineOut: easeSinOut,
|
|
49
|
+
sineInOut: easeSinInOut,
|
|
50
|
+
expoIn: easeExpIn,
|
|
51
|
+
expoOut: easeExpOut,
|
|
52
|
+
expoInOut: easeExpInOut,
|
|
53
|
+
circIn: easeCircleIn,
|
|
54
|
+
circOut: easeCircleOut,
|
|
55
|
+
circInOut: easeCircleInOut,
|
|
56
|
+
backIn: easeBackIn,
|
|
57
|
+
backOut: easeBackOut,
|
|
58
|
+
backInOut: easeBackInOut,
|
|
59
|
+
elasticIn: easeElasticIn,
|
|
60
|
+
elasticOut: easeElasticOut,
|
|
61
|
+
elasticInOut: easeElasticInOut,
|
|
62
|
+
bounceIn: easeBounceIn,
|
|
63
|
+
bounceOut: easeBounceOut,
|
|
64
|
+
bounceInOut: easeBounceInOut,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Preserve custom functions and overshoot; execution errors belong to the task runtime. */
|
|
68
|
+
export function getEasingFunction(easing: EasingType | EasingFunction): EasingFunction {
|
|
69
|
+
if (typeof easing === 'function') return easing
|
|
70
|
+
if (typeof easing !== 'string' || !Object.hasOwn(easings, easing)) {
|
|
71
|
+
throw new TypeError(`Unknown animation easing: ${String(easing)}`)
|
|
72
|
+
}
|
|
73
|
+
return easings[easing]
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnimationConfig } from './types'
|
|
2
|
+
|
|
3
|
+
export function animationError(code: 'animation-terminating' | 'animation-disposed'): Error {
|
|
4
|
+
return Object.assign(new Error(code), { code })
|
|
5
|
+
}
|
|
6
|
+
export function reportAnimationError(config: AnimationConfig, error: unknown): void {
|
|
7
|
+
try {
|
|
8
|
+
if (config.onError) config.onError(error)
|
|
9
|
+
else console.error(error)
|
|
10
|
+
} catch (reporterError) {
|
|
11
|
+
try {
|
|
12
|
+
console.error(reporterError)
|
|
13
|
+
} catch {
|
|
14
|
+
/* Reporting must never interrupt peers. */
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function notify(config: AnimationConfig, callback?: () => void): void {
|
|
19
|
+
try {
|
|
20
|
+
callback?.()
|
|
21
|
+
} catch (error) {
|
|
22
|
+
reportAnimationError(config, error)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AnimationHandle, AnimationState } from './types'
|
|
2
|
+
|
|
3
|
+
export type AnimationCommand = 'pause' | 'resume' | 'abort' | 'finish'
|
|
4
|
+
|
|
5
|
+
/** Terminal handles keep only identity/state, never their task, clock or owner. */
|
|
6
|
+
export function createAnimationHandle(
|
|
7
|
+
id: string,
|
|
8
|
+
read: () => AnimationState,
|
|
9
|
+
command: (method: AnimationCommand) => void,
|
|
10
|
+
): { handle: AnimationHandle; release: () => void } {
|
|
11
|
+
let access: { read: () => AnimationState; command: typeof command } | undefined = {
|
|
12
|
+
read,
|
|
13
|
+
command,
|
|
14
|
+
}
|
|
15
|
+
let terminalState: AnimationState = 'PENDING'
|
|
16
|
+
const invoke = (method: AnimationCommand) => access?.command(method)
|
|
17
|
+
return {
|
|
18
|
+
handle: {
|
|
19
|
+
id,
|
|
20
|
+
get state() {
|
|
21
|
+
return access?.read() ?? terminalState
|
|
22
|
+
},
|
|
23
|
+
pause: () => invoke('pause'),
|
|
24
|
+
resume: () => invoke('resume'),
|
|
25
|
+
abort: () => invoke('abort'),
|
|
26
|
+
finish: () => invoke('finish'),
|
|
27
|
+
},
|
|
28
|
+
release: () => {
|
|
29
|
+
if (!access) return
|
|
30
|
+
terminalState = access.read()
|
|
31
|
+
access = undefined
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|