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.
Files changed (97) hide show
  1. package/README.md +246 -39
  2. package/dist/assets/workerRuntime-BJiRmIZD.js +2446 -0
  3. package/dist/index.cjs +1 -2
  4. package/dist/index.js +1 -7119
  5. package/dist/style.css +1 -1
  6. package/dist/types/animation/WaveformAnimationController.d.ts +30 -0
  7. package/dist/types/animation/core/Animation.d.ts +13 -0
  8. package/dist/types/animation/core/Animator.d.ts +23 -0
  9. package/dist/types/animation/core/easing.d.ts +3 -0
  10. package/dist/types/animation/core/errors.d.ts +4 -0
  11. package/dist/types/animation/core/handle.d.ts +7 -0
  12. package/dist/types/animation/core/owner.d.ts +3 -0
  13. package/dist/types/animation/core/propertyGroups.d.ts +17 -0
  14. package/dist/types/animation/core/task.d.ts +29 -0
  15. package/dist/types/animation/core/types.d.ts +74 -0
  16. package/dist/types/animation/core/validation.d.ts +30 -0
  17. package/dist/types/animation/core/values.d.ts +8 -0
  18. package/dist/types/animation/presets/annotation.d.ts +4 -0
  19. package/dist/types/animation/presets/lineDrawing.d.ts +7 -0
  20. package/dist/types/animation/presets/shared.d.ts +14 -0
  21. package/dist/types/animation/presets/transitions.d.ts +4 -0
  22. package/dist/types/animation/types.d.ts +84 -0
  23. package/dist/types/components/WaveformChart.vue.d.ts +3 -2
  24. package/dist/types/components/animation/annotationPresentation.d.ts +17 -0
  25. package/dist/types/components/animation/chartExportVersion.d.ts +2 -0
  26. package/dist/types/components/animation/config.d.ts +31 -0
  27. package/dist/types/components/animation/context.d.ts +20 -0
  28. package/dist/types/components/animation/geometry.d.ts +10 -0
  29. package/dist/types/components/animation/paginationPresentation.d.ts +13 -0
  30. package/dist/types/components/animation/pathBuffer.d.ts +4 -0
  31. package/dist/types/components/animation/trackPresentation.d.ts +35 -0
  32. package/dist/types/components/animation/useWaveformAnimations.d.ts +27 -0
  33. package/dist/types/components/annotation/WaveformAnnotationContextMenu.vue.d.ts +2 -2
  34. package/dist/types/components/annotation/types.d.ts +7 -0
  35. package/dist/types/components/controls/useChartImageExport.d.ts +2 -0
  36. package/dist/types/components/core/types.d.ts +8 -0
  37. package/dist/types/components/core/useWaveformChartController.d.ts +25 -0
  38. package/dist/types/components/core/useWaveformRenderSampling.d.ts +2 -0
  39. package/dist/types/components/core/waveformChartTypes.d.ts +2 -1
  40. package/dist/types/components/rendering/WaveformSeriesLayer.vue.d.ts +1 -0
  41. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +4 -3
  42. package/dist/types/components/rendering/updateAxis.d.ts +5 -0
  43. package/dist/types/components/rendering/updateGrid.d.ts +3 -0
  44. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +2 -0
  45. package/dist/types/components/utils/assignElement.d.ts +2 -0
  46. package/dist/types/index.d.ts +9 -0
  47. package/dist/types/views/AnimationDemo.vue.d.ts +3 -0
  48. package/package.json +5 -2
  49. package/src/DemoRouterApp.vue +1 -0
  50. package/src/animation/WaveformAnimationController.ts +328 -0
  51. package/src/animation/core/Animation.ts +37 -0
  52. package/src/animation/core/Animator.ts +200 -0
  53. package/src/animation/core/easing.ts +74 -0
  54. package/src/animation/core/errors.ts +24 -0
  55. package/src/animation/core/handle.ts +34 -0
  56. package/src/animation/core/owner.ts +9 -0
  57. package/src/animation/core/propertyGroups.ts +143 -0
  58. package/src/animation/core/task.ts +153 -0
  59. package/src/animation/core/types.ts +81 -0
  60. package/src/animation/core/validation.ts +173 -0
  61. package/src/animation/core/values.ts +38 -0
  62. package/src/animation/presets/annotation.ts +66 -0
  63. package/src/animation/presets/lineDrawing.ts +51 -0
  64. package/src/animation/presets/shared.ts +58 -0
  65. package/src/animation/presets/transitions.ts +77 -0
  66. package/src/animation/types.ts +81 -0
  67. package/src/components/WaveformChart.vue +1 -0
  68. package/src/components/WaveformChartView.vue +13 -4
  69. package/src/components/animation/annotationPresentation.ts +145 -0
  70. package/src/components/animation/chartExportVersion.ts +5 -0
  71. package/src/components/animation/config.ts +87 -0
  72. package/src/components/animation/context.ts +56 -0
  73. package/src/components/animation/geometry.ts +148 -0
  74. package/src/components/animation/paginationPresentation.ts +114 -0
  75. package/src/components/animation/pathBuffer.ts +61 -0
  76. package/src/components/animation/trackPresentation.ts +172 -0
  77. package/src/components/animation/useWaveformAnimations.ts +420 -0
  78. package/src/components/annotation/WaveformAnnotationLayer.vue +43 -30
  79. package/src/components/annotation/types.ts +3 -0
  80. package/src/components/controls/useChartImageExport.ts +4 -0
  81. package/src/components/core/trackLayoutBuilder.ts +9 -18
  82. package/src/components/core/types.ts +4 -0
  83. package/src/components/core/useWaveformChartController.ts +24 -20
  84. package/src/components/core/useWaveformRenderSampling.ts +23 -1
  85. package/src/components/core/waveformChartTypes.ts +2 -1
  86. package/src/components/rendering/WaveformSeriesLayer.vue +13 -1
  87. package/src/components/rendering/WaveformTrack.vue +6 -1
  88. package/src/components/rendering/WaveformTrackAxes.vue +64 -51
  89. package/src/components/rendering/WaveformTrackBackdrop.vue +35 -49
  90. package/src/components/rendering/updateAxis.ts +57 -0
  91. package/src/components/rendering/updateGrid.ts +42 -0
  92. package/src/components/rendering/yAxisTickVisibility.ts +45 -18
  93. package/src/components/utils/assignElement.ts +7 -0
  94. package/src/index.ts +52 -0
  95. package/src/router.ts +1 -0
  96. package/src/views/AnimationDemo.vue +129 -0
  97. 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
+ }