@rafaelaugusto/jolt 0.1.0-beta.5 → 0.1.0-beta.6

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 (150) hide show
  1. package/README.md +110 -3
  2. package/dist/animate.d.ts +4 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +48 -11
  5. package/dist/animate.js.map +1 -1
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +34 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/driver/android.d.ts.map +1 -1
  11. package/dist/driver/android.js +7 -0
  12. package/dist/driver/android.js.map +1 -1
  13. package/dist/driver/index.d.ts +2 -0
  14. package/dist/driver/index.d.ts.map +1 -1
  15. package/dist/driver/index.js.map +1 -1
  16. package/dist/driver/ios-color.js +1 -1
  17. package/dist/driver/ios-color.js.map +1 -1
  18. package/dist/driver/ios-decoration.js +1 -1
  19. package/dist/driver/ios-decoration.js.map +1 -1
  20. package/dist/driver/ios-playback.d.ts +1 -1
  21. package/dist/driver/ios-playback.d.ts.map +1 -1
  22. package/dist/driver/ios-playback.js +14 -0
  23. package/dist/driver/ios-playback.js.map +1 -1
  24. package/dist/driver/ios.d.ts.map +1 -1
  25. package/dist/driver/ios.js +1 -0
  26. package/dist/driver/ios.js.map +1 -1
  27. package/dist/engine.d.ts +16 -1
  28. package/dist/engine.d.ts.map +1 -1
  29. package/dist/engine.js +33 -1
  30. package/dist/engine.js.map +1 -1
  31. package/dist/index.d.ts +11 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +5 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/items.d.ts +20 -0
  36. package/dist/items.d.ts.map +1 -0
  37. package/dist/items.js +109 -0
  38. package/dist/items.js.map +1 -0
  39. package/dist/keyboard.d.ts +17 -0
  40. package/dist/keyboard.d.ts.map +1 -0
  41. package/dist/keyboard.js +116 -0
  42. package/dist/keyboard.js.map +1 -0
  43. package/dist/label.d.ts +7 -0
  44. package/dist/label.d.ts.map +1 -0
  45. package/dist/label.js +60 -0
  46. package/dist/label.js.map +1 -0
  47. package/dist/liquid.d.ts.map +1 -1
  48. package/dist/liquid.js +2 -1
  49. package/dist/liquid.js.map +1 -1
  50. package/dist/loader.d.ts.map +1 -1
  51. package/dist/loader.js +2 -1
  52. package/dist/loader.js.map +1 -1
  53. package/dist/particles.d.ts.map +1 -1
  54. package/dist/particles.js +4 -3
  55. package/dist/particles.js.map +1 -1
  56. package/dist/path.d.ts +12 -0
  57. package/dist/path.d.ts.map +1 -0
  58. package/dist/path.js +90 -0
  59. package/dist/path.js.map +1 -0
  60. package/dist/playback-group.d.ts.map +1 -1
  61. package/dist/playback-group.js +29 -1
  62. package/dist/playback-group.js.map +1 -1
  63. package/dist/press.d.ts.map +1 -1
  64. package/dist/press.js +3 -3
  65. package/dist/press.js.map +1 -1
  66. package/dist/reveal.d.ts.map +1 -1
  67. package/dist/reveal.js +3 -2
  68. package/dist/reveal.js.map +1 -1
  69. package/dist/roll-diff.d.ts +22 -0
  70. package/dist/roll-diff.d.ts.map +1 -0
  71. package/dist/roll-diff.js +36 -0
  72. package/dist/roll-diff.js.map +1 -0
  73. package/dist/roll.d.ts +29 -0
  74. package/dist/roll.d.ts.map +1 -0
  75. package/dist/roll.js +442 -0
  76. package/dist/roll.js.map +1 -0
  77. package/dist/scroll-linked.d.ts +10 -0
  78. package/dist/scroll-linked.d.ts.map +1 -0
  79. package/dist/scroll-linked.js +58 -0
  80. package/dist/scroll-linked.js.map +1 -0
  81. package/dist/sequence.d.ts +10 -5
  82. package/dist/sequence.d.ts.map +1 -1
  83. package/dist/sequence.js +202 -14
  84. package/dist/sequence.js.map +1 -1
  85. package/dist/shimmer.d.ts.map +1 -1
  86. package/dist/shimmer.js +2 -1
  87. package/dist/shimmer.js.map +1 -1
  88. package/dist/svg-path.d.ts +14 -0
  89. package/dist/svg-path.d.ts.map +1 -0
  90. package/dist/svg-path.js +281 -0
  91. package/dist/svg-path.js.map +1 -0
  92. package/dist/symbol-effect.d.ts.map +1 -1
  93. package/dist/symbol-effect.js +4 -3
  94. package/dist/symbol-effect.js.map +1 -1
  95. package/dist/text-layout.d.ts +8 -0
  96. package/dist/text-layout.d.ts.map +1 -1
  97. package/dist/text-layout.js +8 -2
  98. package/dist/text-layout.js.map +1 -1
  99. package/dist/text.d.ts +1 -1
  100. package/dist/text.d.ts.map +1 -1
  101. package/dist/text.js +60 -64
  102. package/dist/text.js.map +1 -1
  103. package/dist/track.d.ts +2 -0
  104. package/dist/track.d.ts.map +1 -1
  105. package/dist/track.js +62 -15
  106. package/dist/track.js.map +1 -1
  107. package/dist/types.d.ts +7 -1
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/vue/index.d.ts +89 -0
  110. package/dist/vue/index.d.ts.map +1 -0
  111. package/dist/vue/index.js +134 -0
  112. package/dist/vue/index.js.map +1 -0
  113. package/package.json +15 -3
  114. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  115. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  116. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  117. package/platforms/android/proguard-rules.pro +6 -0
  118. package/src/android-plugin.d.ts +64 -0
  119. package/src/animate.ts +80 -30
  120. package/src/config.ts +46 -0
  121. package/src/driver/android.ts +14 -0
  122. package/src/driver/index.ts +2 -0
  123. package/src/driver/ios-color.ts +1 -1
  124. package/src/driver/ios-decoration.ts +1 -1
  125. package/src/driver/ios-playback.ts +15 -1
  126. package/src/driver/ios.ts +2 -0
  127. package/src/engine.ts +47 -2
  128. package/src/index.ts +11 -1
  129. package/src/items.ts +140 -0
  130. package/src/keyboard.ts +132 -0
  131. package/src/label.ts +76 -0
  132. package/src/liquid.ts +2 -1
  133. package/src/loader.ts +2 -1
  134. package/src/particles.ts +4 -4
  135. package/src/path.ts +124 -0
  136. package/src/playback-group.ts +34 -1
  137. package/src/press.ts +3 -5
  138. package/src/reveal.ts +3 -3
  139. package/src/roll-diff.ts +57 -0
  140. package/src/roll.ts +515 -0
  141. package/src/scroll-linked.ts +73 -0
  142. package/src/sequence.ts +199 -14
  143. package/src/shimmer.ts +2 -1
  144. package/src/svg-path.ts +355 -0
  145. package/src/symbol-effect.ts +4 -3
  146. package/src/text-layout.ts +14 -2
  147. package/src/text.ts +62 -81
  148. package/src/track.ts +89 -24
  149. package/src/types.ts +8 -1
  150. package/src/vue/index.ts +189 -0
package/src/sequence.ts CHANGED
@@ -1,24 +1,36 @@
1
+ import { timeScale } from './config.js'
1
2
  import { getDriver } from './driver/index.js'
2
3
  import { isDecorationProperty, validateDecorationTarget } from './decoration.js'
3
4
  import type { View } from '@nativescript/core'
4
5
  import { animate, startingValues, toViews } from './animate.js'
5
- import { arrivalOf, setArrival, stopView, type Animation } from './engine.js'
6
+ import {
7
+ arrivalOf,
8
+ arrivalTimingOf,
9
+ setArrival,
10
+ stopView,
11
+ type Animation,
12
+ } from './engine.js'
13
+ import { createClock } from './playback.js'
6
14
  import type { PresetName } from './presets.js'
7
15
  import { writeValue, type Property } from './properties.js'
8
16
  import type { SpringName } from './transition.js'
9
17
  import type { AnimateOptions, Keyframes, Target } from './types.js'
10
18
 
19
+ /** Absolute milliseconds, a signed offset from the previous arrival, or its start. */
20
+ export type SequencePosition = number | '<' | `${'+' | '-'}${number}`
21
+ export type SequenceOptions = AnimateOptions & { at?: SequencePosition }
22
+
11
23
  export type Step =
12
24
  | readonly [
13
25
  target: Target | readonly Target[],
14
26
  animation: Keyframes | PresetName,
15
- options?: AnimateOptions | SpringName,
27
+ options?: SequenceOptions | SpringName,
16
28
  ]
17
29
  | (() => Animation)
18
30
 
19
31
  /**
20
- * Plays steps one after the other. A step starts once the previous one looks
21
- * done: a spring is close to its target, even if it hasn't fully settled.
32
+ * By default a step starts once the previous one looks done. `at` can overlap
33
+ * that arrival, share the previous start, or select an absolute timeline time.
22
34
  * Views in later steps take their first absolute keyframe right away, so a title
23
35
  * that fades in third doesn't show up before its turn. Relative and null keyframes
24
36
  * read the current value when their step starts.
@@ -26,26 +38,180 @@ export type Step =
26
38
  * ```ts
27
39
  * await sequence([
28
40
  * [logo, 'popIn'],
29
- * [title, 'fadeInUp'],
30
- * [buttons, 'fadeInUp', { stagger: 50 }],
41
+ * [title, 'fadeInUp', { at: '-150' }],
42
+ * [buttons, 'fadeInUp', { stagger: 50, at: '<' }],
31
43
  * ])
32
44
  * ```
33
45
  */
34
46
  export function sequence(steps: readonly Step[]): Animation {
35
- applyStartingValues(steps)
47
+ const prepared = steps.map((step) => {
48
+ if (typeof step === 'function') return { step, position: undefined }
49
+ const [target, animation, options] = step
50
+ if (typeof options !== 'object' || options === null)
51
+ return { step, position: undefined }
52
+ const { at, ...transition } = options
53
+ const position = parsePosition(at)
54
+ return { step: [target, animation, transition] as Step, position }
55
+ })
56
+ applyStartingValues(prepared.map(({ step }) => step))
36
57
 
58
+ const driver = getDriver()
59
+ const slowdown = 1 / timeScale()
60
+ // Absolute positions share the platform clock, including Android duration scale.
61
+ const clock = prepared.some(({ position }) => typeof position === 'number')
62
+ ? (driver.clock?.(Infinity) ?? createClock(() => driver.now()))
63
+ : undefined
37
64
  const started: Animation[] = []
65
+ const cancellations = new Set<() => void>()
38
66
  let stopped = false
67
+ let resolveScheduled!: () => void
68
+ let rejectScheduled!: (reason: unknown) => void
69
+ const scheduled = new Promise<void>((resolve, reject) => {
70
+ resolveScheduled = resolve
71
+ rejectScheduled = reject
72
+ })
73
+ let pending = prepared.length
74
+ const states = prepared.map(({ step, position }) => {
75
+ return {
76
+ step,
77
+ position,
78
+ onStart: new Set<() => void>(),
79
+ animation: undefined as Animation | undefined,
80
+ }
81
+ })
82
+ const cancelSchedule = () => {
83
+ for (const cancel of cancellations) cancel()
84
+ cancellations.clear()
85
+ clock?.cancel()
86
+ }
87
+ const fail = (error: unknown) => {
88
+ if (stopped) return
89
+ stopped = true
90
+ cancelSchedule()
91
+ states.forEach((state) => state.onStart.clear())
92
+ started.forEach((child) => child.stop())
93
+ rejectScheduled(error)
94
+ }
95
+ const launch = (index: number) => {
96
+ const state = states[index]!
97
+ if (stopped || state.animation) return
98
+ try {
99
+ const step = state.step
100
+ const child = typeof step === 'function' ? step() : animate(...step)
101
+ state.animation = child
102
+ started.push(child)
103
+ // Observe setup failures immediately, even while other steps are still
104
+ // scheduled. Nested sequences can reject before this one reaches arrival.
105
+ void child.finished.catch(fail)
106
+ pending--
107
+ for (const callback of [...state.onStart]) callback()
108
+ state.onStart.clear()
109
+ if (pending === 0) {
110
+ cancelSchedule()
111
+ resolveScheduled()
112
+ }
113
+ } catch (error) {
114
+ fail(error)
115
+ }
116
+ }
117
+ const wait = (ms: number, callback: () => void) => {
118
+ if (stopped) return
119
+ if (ms <= 0) {
120
+ callback()
121
+ return
122
+ }
123
+ let cancel = () => {}
124
+ let fired = false
125
+ const off = driver.after(ms, () => {
126
+ fired = true
127
+ cancellations.delete(cancel)
128
+ if (!stopped) callback()
129
+ })
130
+ cancel = off
131
+ if (!fired) cancellations.add(cancel)
132
+ }
39
133
 
40
- const arrived = (async () => {
41
- for (const step of steps) {
42
- if (stopped) return
43
- const animation = typeof step === 'function' ? step() : animate(...step)
44
- started.push(animation)
45
- await arrivalOf(animation)
134
+ for (let index = 0; index < states.length; index++) {
135
+ const { position } = states[index]!
136
+ const begin = () => launch(index)
137
+ if (typeof position === 'number') wait(position * slowdown - clock!.time(), begin)
138
+ else if (index === 0)
139
+ wait(
140
+ typeof position === 'object' ? Math.max(0, position.offset) * slowdown : 0,
141
+ begin,
142
+ )
143
+ else {
144
+ const previous = states[index - 1]!
145
+ const follow = () => {
146
+ if (stopped || !previous.animation) return
147
+ if (position === '<') {
148
+ begin()
149
+ return
150
+ }
151
+ const offset = (position?.offset ?? 0) * slowdown
152
+ if (offset < 0) {
153
+ const timing = arrivalTimingOf(previous.animation)
154
+ if (!timing) {
155
+ fail(
156
+ new TypeError(
157
+ 'A negative at offset needs an animate() or decay() arrival forecast.',
158
+ ),
159
+ )
160
+ return
161
+ }
162
+ let off = () => {}
163
+ let cancelTimer = () => {}
164
+ let fired = false
165
+ let disposed = false
166
+ let queued = false
167
+ const dispose = () => {
168
+ disposed = true
169
+ off()
170
+ cancelTimer()
171
+ cancellations.delete(dispose)
172
+ }
173
+ const update = () => {
174
+ cancelTimer()
175
+ if (stopped || fired || disposed) return
176
+ const state = timing.read()
177
+ if (!state.arrived && (state.paused || state.remaining === undefined)) return
178
+ const remaining = state.arrived ? 0 : state.remaining! + offset
179
+ if (!Number.isFinite(remaining)) return
180
+ const fire = () => {
181
+ if (stopped || fired || disposed) return
182
+ fired = true
183
+ dispose()
184
+ begin()
185
+ }
186
+ if (remaining <= 0) fire()
187
+ else {
188
+ const cancel = driver.after(remaining, fire)
189
+ if (disposed) cancel()
190
+ else cancelTimer = cancel
191
+ }
192
+ }
193
+ off = timing.subscribe(() => {
194
+ cancelTimer()
195
+ // An interruption can notify from inside another animation's
196
+ // constructor. Start followers after its ownership changes finish.
197
+ if (queued || disposed) return
198
+ queued = true
199
+ void Promise.resolve().then(() => {
200
+ queued = false
201
+ update()
202
+ })
203
+ })
204
+ cancellations.add(dispose)
205
+ update()
206
+ } else void arrivalOf(previous.animation).then(() => wait(offset, begin), fail)
207
+ }
208
+ if (previous.animation) follow()
209
+ else previous.onStart.add(follow)
46
210
  }
47
- })()
211
+ }
212
+ if (pending === 0) resolveScheduled()
48
213
 
214
+ const arrived = scheduled.then(() => Promise.all(started.map(arrivalOf)))
49
215
  const finished = arrived.then(async () => {
50
216
  const results = await Promise.all(started.map((animation) => animation.finished))
51
217
  return !stopped && results.length === steps.length && results.every(Boolean)
@@ -54,7 +220,11 @@ export function sequence(steps: readonly Step[]): Animation {
54
220
  const animation: Animation = {
55
221
  finished,
56
222
  stop() {
223
+ if (stopped) return
57
224
  stopped = true
225
+ cancelSchedule()
226
+ states.forEach((state) => state.onStart.clear())
227
+ resolveScheduled()
58
228
  started.forEach((animation) => animation.stop())
59
229
  },
60
230
  then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
@@ -63,6 +233,21 @@ export function sequence(steps: readonly Step[]): Animation {
63
233
  return animation
64
234
  }
65
235
 
236
+ type Position = number | '<' | { offset: number } | undefined
237
+ function parsePosition(at: SequencePosition | undefined): Position {
238
+ if (at === undefined || at === '<') return at
239
+ if (typeof at === 'number' && Number.isFinite(at) && at >= 0) return at
240
+ if (
241
+ typeof at === 'string' &&
242
+ /^[+-](?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(at) &&
243
+ Number.isFinite(Number(at))
244
+ )
245
+ return { offset: Number(at) }
246
+ throw new RangeError(
247
+ 'Sequence at must be a nonnegative finite time, "<", or a signed finite offset.',
248
+ )
249
+ }
250
+
66
251
  function applyStartingValues(steps: readonly Step[]) {
67
252
  const claimed = new Map<View, Set<Property>>()
68
253
  for (const step of steps) {
package/src/shimmer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Application, isIOS, type View } from '@nativescript/core'
2
2
  import { toView } from './animate.js'
3
3
  import type { Target } from './types.js'
4
+ import { reducedMotion } from './config.js'
4
5
 
5
6
  export interface ShimmerOptions {
6
7
  /** Milliseconds per sweep. Defaults to 1500. */
@@ -98,7 +99,7 @@ class Shimmer {
98
99
  UIAccessibilityReduceMotionStatusDidChangeNotification,
99
100
  this.update,
100
101
  )
101
- if (UIAccessibilityIsReduceMotionEnabled()) return this.remove()
102
+ if (reducedMotion()) return this.remove()
102
103
  const bounds = native.layer.bounds
103
104
  if (bounds.size.width <= 0 || bounds.size.height <= 0) return this.remove()
104
105
  if (this.native && this.native !== native) this.remove()
@@ -0,0 +1,355 @@
1
+ /** SVG path geometry in local DIPs. No DOM or platform path API is used. */
2
+ export interface PathPoint {
3
+ x: number
4
+ y: number
5
+ }
6
+ export interface PathPose extends PathPoint {
7
+ rotate: number
8
+ }
9
+ type Segment = { point(t: number): PathPoint; tangent(t: number): PathPoint }
10
+ type Vertex = PathPoint & {
11
+ segment: number
12
+ t: number
13
+ distance: number
14
+ heading: number
15
+ }
16
+ export interface MotionPath {
17
+ readonly length: number
18
+ at(progress: number): PathPose
19
+ }
20
+ const LIMIT = 1_000_000
21
+ const MAX_SEGMENTS = 1024
22
+ const MAX_VERTICES = 8192
23
+ const TOLERANCE = 0.025
24
+ const distance = (a: PathPoint, b: PathPoint) => Math.hypot(b.x - a.x, b.y - a.y)
25
+ const mix = (a: PathPoint, b: PathPoint, t: number): PathPoint => ({
26
+ x: a.x + (b.x - a.x) * t,
27
+ y: a.y + (b.y - a.y) * t,
28
+ })
29
+ const checked = (p: PathPoint) => {
30
+ if (![p.x, p.y].every((n) => Number.isFinite(n) && Math.abs(n) <= LIMIT))
31
+ throw new RangeError('Path coordinates must stay within ±1000000 DIPs.')
32
+ return p
33
+ }
34
+ const unwrap = (angle: number, reference: number) =>
35
+ angle + 360 * Math.round((reference - angle) / 360)
36
+
37
+ class Scanner {
38
+ index = 0
39
+ private hasNumber = false
40
+ constructor(readonly text: string) {}
41
+ peek() {
42
+ while (/[\t\n\r ]/.test(this.text[this.index] ?? '\0')) this.index++
43
+ return this.text[this.index]
44
+ }
45
+ command() {
46
+ const value = this.peek()!
47
+ this.index++
48
+ this.hasNumber = false
49
+ return value
50
+ }
51
+ number(flag = false) {
52
+ this.peek()
53
+ if (this.text[this.index] === ',') {
54
+ if (!this.hasNumber) this.error()
55
+ this.index++
56
+ this.peek()
57
+ }
58
+ const rest = this.text.slice(this.index)
59
+ const token = flag
60
+ ? /^[01]/.exec(rest)?.[0]
61
+ : /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i.exec(rest)?.[0]
62
+ if (!token) this.error()
63
+ const value = Number(token)
64
+ if (!Number.isFinite(value) || Math.abs(value) > LIMIT)
65
+ throw new RangeError('Path numbers must be finite and within ±1000000.')
66
+ this.index += token!.length
67
+ this.hasNumber = true
68
+ return value
69
+ }
70
+ error(): never {
71
+ throw new SyntaxError(`Invalid SVG path at character ${this.index}.`)
72
+ }
73
+ }
74
+
75
+ function line(a: PathPoint, b: PathPoint): Segment {
76
+ return { point: (t) => mix(a, b, t), tangent: () => ({ x: b.x - a.x, y: b.y - a.y }) }
77
+ }
78
+ function bezier(points: readonly PathPoint[]): Segment {
79
+ const evaluate = (values: readonly PathPoint[], t: number): PathPoint => {
80
+ let row = [...values]
81
+ while (row.length > 1) row = row.slice(1).map((p, i) => mix(row[i]!, p, t))
82
+ return row[0]!
83
+ }
84
+ const derivative = points.slice(1).map((p, i) => ({
85
+ x: (p.x - points[i]!.x) * (points.length - 1),
86
+ y: (p.y - points[i]!.y) * (points.length - 1),
87
+ }))
88
+ return { point: (t) => evaluate(points, t), tangent: (t) => evaluate(derivative, t) }
89
+ }
90
+
91
+ /** Endpoint-to-center conversion: https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter */
92
+ function arc(
93
+ a: PathPoint,
94
+ b: PathPoint,
95
+ rx: number,
96
+ ry: number,
97
+ degrees: number,
98
+ large: boolean,
99
+ sweep: boolean,
100
+ ): Segment {
101
+ rx = Math.abs(rx)
102
+ ry = Math.abs(ry)
103
+ if (!rx || !ry || distance(a, b) === 0) return line(a, b)
104
+ const phi = ((degrees % 360) * Math.PI) / 180,
105
+ cos = Math.cos(phi),
106
+ sin = Math.sin(phi)
107
+ const dx = (a.x - b.x) / 2,
108
+ dy = (a.y - b.y) / 2
109
+ const xp = cos * dx + sin * dy,
110
+ yp = -sin * dx + cos * dy
111
+ const correction = Math.max(1, Math.hypot(xp / rx, yp / ry))
112
+ rx *= correction
113
+ ry *= correction
114
+ const u = xp / rx,
115
+ v = yp / ry,
116
+ norm = u * u + v * v
117
+ const factor = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, (1 - norm) / norm))
118
+ const cxp = factor * rx * v,
119
+ cyp = -factor * ry * u
120
+ const cx = cos * cxp - sin * cyp + (a.x + b.x) / 2,
121
+ cy = sin * cxp + cos * cyp + (a.y + b.y) / 2
122
+ const ux = (xp - cxp) / rx,
123
+ uy = (yp - cyp) / ry,
124
+ vx = (-xp - cxp) / rx,
125
+ vy = (-yp - cyp) / ry
126
+ const start = Math.atan2(uy, ux)
127
+ let delta = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
128
+ if (!sweep && delta > 0) delta -= 2 * Math.PI
129
+ if (sweep && delta < 0) delta += 2 * Math.PI
130
+ if (![cx, cy, rx, ry, start, delta].every(Number.isFinite))
131
+ throw new RangeError('Path arc exceeds finite geometry limits.')
132
+ return {
133
+ point(t) {
134
+ if (t === 0) return a
135
+ if (t === 1) return b
136
+ const angle = start + t * delta,
137
+ x = rx * Math.cos(angle),
138
+ y = ry * Math.sin(angle)
139
+ return checked({ x: cx + cos * x - sin * y, y: cy + sin * x + cos * y })
140
+ },
141
+ tangent(t) {
142
+ const angle = start + t * delta,
143
+ x = -rx * Math.sin(angle) * delta,
144
+ y = ry * Math.cos(angle) * delta
145
+ return { x: cos * x - sin * y, y: sin * x + cos * y }
146
+ },
147
+ }
148
+ }
149
+
150
+ export function parsePath(data: string): MotionPath {
151
+ if (typeof data !== 'string' || !data.trim())
152
+ throw new TypeError('Path must be a nonempty SVG path string.')
153
+ if (data.length > 100_000)
154
+ throw new RangeError('SVG path is too long (100000 characters maximum).')
155
+ const scanner = new Scanner(data)
156
+ const segments: Segment[] = []
157
+ let current: PathPoint = { x: 0, y: 0 },
158
+ first: PathPoint | undefined
159
+ let command = '',
160
+ previous = '',
161
+ control: PathPoint = current
162
+ const add = (segment: Segment, end: PathPoint) => {
163
+ if (segments.length >= MAX_SEGMENTS)
164
+ throw new RangeError('SVG path has too many segments (1024 maximum).')
165
+ segments.push(segment)
166
+ current = checked(end)
167
+ }
168
+ while (scanner.peek() !== undefined) {
169
+ const token = scanner.peek()!
170
+ if (/[a-z]/i.test(token)) {
171
+ command = scanner.command()
172
+ if (!/[mlhvcsqtaz]/i.test(command)) scanner.error()
173
+ } else if (!command) scanner.error()
174
+ const upper = command.toUpperCase(),
175
+ relative = command !== upper,
176
+ origin = current
177
+ const point = () =>
178
+ checked({
179
+ x: scanner.number() + (relative ? origin.x : 0),
180
+ y: scanner.number() + (relative ? origin.y : 0),
181
+ })
182
+ if (!first && upper !== 'M') throw new SyntaxError('SVG path must start with M or m.')
183
+ if (upper === 'M') {
184
+ if (first)
185
+ throw new SyntaxError(
186
+ 'Motion paths support one continuous subpath; use a single M command.',
187
+ )
188
+ first = current = point()
189
+ command = relative ? 'l' : 'L'
190
+ } else if (upper === 'Z') {
191
+ add(line(current, first!), first!)
192
+ command = ''
193
+ } else if (upper === 'L') {
194
+ const end = point()
195
+ add(line(current, end), end)
196
+ } else if (upper === 'H' || upper === 'V') {
197
+ const n = scanner.number(),
198
+ end = checked(
199
+ upper === 'H'
200
+ ? { x: n + (relative ? origin.x : 0), y: current.y }
201
+ : { x: current.x, y: n + (relative ? origin.y : 0) },
202
+ )
203
+ add(line(current, end), end)
204
+ } else if (upper === 'C' || upper === 'S') {
205
+ const c1 =
206
+ upper === 'C'
207
+ ? point()
208
+ : /[CS]/.test(previous)
209
+ ? { x: 2 * current.x - control.x, y: 2 * current.y - control.y }
210
+ : current
211
+ const c2 = point(),
212
+ end = point()
213
+ checked(c1)
214
+ add(bezier([current, c1, c2, end]), end)
215
+ control = c2
216
+ } else if (upper === 'Q' || upper === 'T') {
217
+ const c =
218
+ upper === 'Q'
219
+ ? point()
220
+ : /[QT]/.test(previous)
221
+ ? { x: 2 * current.x - control.x, y: 2 * current.y - control.y }
222
+ : current
223
+ const end = point()
224
+ checked(c)
225
+ add(bezier([current, c, end]), end)
226
+ control = c
227
+ } else if (upper === 'A') {
228
+ const rx = scanner.number(),
229
+ ry = scanner.number(),
230
+ rotation = scanner.number(),
231
+ large = scanner.number(true),
232
+ sweep = scanner.number(true),
233
+ end = point()
234
+ add(arc(current, end, rx, ry, rotation, !!large, !!sweep), end)
235
+ }
236
+ previous = upper
237
+ }
238
+ return measure(first!, segments)
239
+ }
240
+
241
+ function measure(first: PathPoint, segments: readonly Segment[]): MotionPath {
242
+ const vertices: Vertex[] = [{ ...first, segment: -1, t: 0, distance: 0, heading: 0 }]
243
+ const heading = (segment: Segment, t: number, fallback: PathPoint) => {
244
+ let v = segment.tangent(t)
245
+ if (Math.hypot(v.x, v.y) < 1e-9) {
246
+ const a = segment.point(Math.max(0, t - 1e-5)),
247
+ b = segment.point(Math.min(1, t + 1e-5))
248
+ v = { x: b.x - a.x, y: b.y - a.y }
249
+ if (Math.hypot(v.x, v.y) < 1e-9) v = fallback
250
+ }
251
+ return (Math.atan2(v.y, v.x) * 180) / Math.PI
252
+ }
253
+ const append = (p: PathPoint, index: number, t: number) => {
254
+ const last = vertices.at(-1)!,
255
+ length = distance(last, p)
256
+ if (!length) return
257
+ if (vertices.length >= MAX_VERTICES)
258
+ throw new RangeError('Path needs too many geometry samples (8192 maximum).')
259
+ const direction = { x: p.x - last.x, y: p.y - last.y }
260
+ const start = heading(
261
+ segments[index]!,
262
+ last.segment === index ? last.t : 0,
263
+ direction,
264
+ )
265
+ if (vertices.length === 1) last.heading = start
266
+ const angle = unwrap(
267
+ heading(segments[index]!, t, direction),
268
+ unwrap(start, last.heading),
269
+ )
270
+ vertices.push({
271
+ ...checked(p),
272
+ segment: index,
273
+ t,
274
+ distance: last.distance + length,
275
+ heading: angle,
276
+ })
277
+ }
278
+ segments.forEach((segment, index) => {
279
+ const flatten = (
280
+ t0: number,
281
+ t1: number,
282
+ a: PathPoint,
283
+ b: PathPoint,
284
+ depth: number,
285
+ ) => {
286
+ const span = t1 - t0,
287
+ q1 = segment.point(t0 + span * 0.25),
288
+ mid = segment.point(t0 + span * 0.5),
289
+ q3 = segment.point(t0 + span * 0.75)
290
+ const poly =
291
+ distance(a, q1) + distance(q1, mid) + distance(mid, q3) + distance(q3, b),
292
+ chord = distance(a, b)
293
+ const deviation = Math.max(
294
+ distance(q1, mix(a, b, 0.25)),
295
+ distance(mid, mix(a, b, 0.5)),
296
+ distance(q3, mix(a, b, 0.75)),
297
+ )
298
+ const directions = [
299
+ t0,
300
+ t0 + span * 0.25,
301
+ t0 + span * 0.5,
302
+ t0 + span * 0.75,
303
+ t1,
304
+ ].map((t) => segment.tangent(t))
305
+ const turn = directions.some((v, i) => {
306
+ const before = directions[i - 1]
307
+ return (
308
+ before &&
309
+ Math.hypot(v.x, v.y) > 1e-12 &&
310
+ Math.hypot(before.x, before.y) > 1e-12 &&
311
+ Math.abs(
312
+ Math.atan2(before.x * v.y - before.y * v.x, before.x * v.x + before.y * v.y),
313
+ ) >
314
+ Math.PI / 8
315
+ )
316
+ })
317
+ const flat = poly - chord <= TOLERANCE && deviation <= TOLERANCE
318
+ if (flat && (!turn || depth >= 18)) {
319
+ append(b, index, t1)
320
+ return
321
+ }
322
+ if (depth >= 18)
323
+ throw new RangeError('Path geometry exceeds the sampling precision limit.')
324
+ flatten(t0, (t0 + t1) / 2, a, mid, depth + 1)
325
+ flatten((t0 + t1) / 2, t1, mid, b, depth + 1)
326
+ }
327
+ flatten(0, 1, segment.point(0), segment.point(1), 0)
328
+ })
329
+ const length = vertices.at(-1)!.distance
330
+ return {
331
+ length,
332
+ at(progress) {
333
+ if (!length) return { ...first, rotate: 0 }
334
+ const target = Math.max(0, Math.min(1, progress)) * length
335
+ let low = 1,
336
+ high = vertices.length - 1
337
+ while (low < high) {
338
+ const middle = (low + high) >>> 1
339
+ if (vertices[middle]!.distance <= target) low = middle + 1
340
+ else high = middle
341
+ }
342
+ const b = vertices[low]!,
343
+ a = vertices[low - 1]!,
344
+ fraction = Math.max(
345
+ 0,
346
+ Math.min(1, (target - a.distance) / (b.distance - a.distance)),
347
+ )
348
+ const point = mix(a, b, fraction),
349
+ t0 = a.segment === b.segment ? a.t : 0,
350
+ t = t0 + (b.t - t0) * fraction
351
+ const angle = heading(segments[b.segment]!, t, { x: b.x - a.x, y: b.y - a.y })
352
+ return { ...point, rotate: unwrap(angle, b.heading) }
353
+ },
354
+ }
355
+ }
@@ -2,6 +2,7 @@ import { Image, ImageSource, isIOS } from '@nativescript/core'
2
2
  import { toView } from './animate.js'
3
3
  import type { Animation } from './engine.js'
4
4
  import type { Target } from './types.js'
5
+ import { reducedMotion } from './config.js'
5
6
 
6
7
  export type SymbolEffectName = 'bounce' | 'pulse' | 'scale' | 'variableColor'
7
8
 
@@ -121,7 +122,7 @@ class SymbolAnimation implements Animation {
121
122
  return
122
123
  this.started = true
123
124
  const native = (this.native = view.nativeViewProtected as UIImageView)
124
- const reducedMotion = UIAccessibilityIsReduceMotionEnabled()
125
+ const reduced = reducedMotion()
125
126
 
126
127
  if (typeof effect !== 'string') {
127
128
  // Resolve first so a symbol absent on this OS doesn't clear the current image.
@@ -145,7 +146,7 @@ class SymbolAnimation implements Animation {
145
146
  const image = native.image
146
147
  if (!image) return this.end(false)
147
148
  if (
148
- reducedMotion ||
149
+ reduced ||
149
150
  !previous?.symbolImage ||
150
151
  !native.respondsToSelector(
151
152
  'setSymbolImage:withContentTransition:options:completion:',
@@ -165,7 +166,7 @@ class SymbolAnimation implements Animation {
165
166
  }
166
167
 
167
168
  if (
168
- reducedMotion ||
169
+ reduced ||
169
170
  !native.image?.symbolImage ||
170
171
  !native.respondsToSelector('addSymbolEffect:options:animated:completion:')
171
172
  )