@rafaelaugusto/jolt 0.1.0-beta.12 → 0.1.0-beta.13

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 (103) hide show
  1. package/README.md +4 -1
  2. package/dist/animate.d.ts +6 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +3 -1
  5. package/dist/animate.js.map +1 -1
  6. package/dist/drag.js +3 -3
  7. package/dist/drag.js.map +1 -1
  8. package/dist/driver/android.d.ts.map +1 -1
  9. package/dist/driver/android.js +2 -0
  10. package/dist/driver/android.js.map +1 -1
  11. package/dist/driver/frame.d.ts +3 -0
  12. package/dist/driver/frame.d.ts.map +1 -0
  13. package/dist/driver/frame.js +7 -0
  14. package/dist/driver/frame.js.map +1 -0
  15. package/dist/driver/index.d.ts +2 -0
  16. package/dist/driver/index.d.ts.map +1 -1
  17. package/dist/driver/index.js.map +1 -1
  18. package/dist/driver/ios.d.ts.map +1 -1
  19. package/dist/driver/ios.js +2 -0
  20. package/dist/driver/ios.js.map +1 -1
  21. package/dist/engine.d.ts +2 -1
  22. package/dist/engine.d.ts.map +1 -1
  23. package/dist/engine.js +23 -2
  24. package/dist/engine.js.map +1 -1
  25. package/dist/image-effect.d.ts +1 -0
  26. package/dist/image-effect.d.ts.map +1 -1
  27. package/dist/image-effect.js +10 -2
  28. package/dist/image-effect.js.map +1 -1
  29. package/dist/index.d.ts +5 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/motion-scope.d.ts +2 -1
  34. package/dist/motion-scope.d.ts.map +1 -1
  35. package/dist/motion-scope.js +4 -1
  36. package/dist/motion-scope.js.map +1 -1
  37. package/dist/motion-value.d.ts +45 -8
  38. package/dist/motion-value.d.ts.map +1 -1
  39. package/dist/motion-value.js +289 -91
  40. package/dist/motion-value.js.map +1 -1
  41. package/dist/native/index.d.ts +6 -0
  42. package/dist/native/index.d.ts.map +1 -0
  43. package/dist/native/index.js +3 -0
  44. package/dist/native/index.js.map +1 -0
  45. package/dist/native-properties.d.ts +28 -0
  46. package/dist/native-properties.d.ts.map +1 -0
  47. package/dist/native-properties.js +154 -0
  48. package/dist/native-properties.js.map +1 -0
  49. package/dist/native-property-driver.d.ts +19 -0
  50. package/dist/native-property-driver.d.ts.map +1 -0
  51. package/dist/native-property-driver.js +69 -0
  52. package/dist/native-property-driver.js.map +1 -0
  53. package/dist/native-property.d.ts +35 -0
  54. package/dist/native-property.d.ts.map +1 -0
  55. package/dist/native-property.js +277 -0
  56. package/dist/native-property.js.map +1 -0
  57. package/dist/playback-group.d.ts.map +1 -1
  58. package/dist/playback-group.js +17 -1
  59. package/dist/playback-group.js.map +1 -1
  60. package/dist/playback.d.ts +15 -0
  61. package/dist/playback.d.ts.map +1 -1
  62. package/dist/playback.js +23 -0
  63. package/dist/playback.js.map +1 -1
  64. package/dist/sequence.d.ts +24 -0
  65. package/dist/sequence.d.ts.map +1 -1
  66. package/dist/sequence.js +181 -19
  67. package/dist/sequence.js.map +1 -1
  68. package/dist/timed-animation.d.ts +38 -0
  69. package/dist/timed-animation.d.ts.map +1 -0
  70. package/dist/timed-animation.js +177 -0
  71. package/dist/timed-animation.js.map +1 -0
  72. package/dist/value-observers.d.ts +19 -0
  73. package/dist/value-observers.d.ts.map +1 -0
  74. package/dist/value-observers.js +111 -0
  75. package/dist/value-observers.js.map +1 -0
  76. package/dist/value-track.d.ts +13 -0
  77. package/dist/value-track.d.ts.map +1 -0
  78. package/dist/value-track.js +59 -0
  79. package/dist/value-track.js.map +1 -0
  80. package/package.json +6 -2
  81. package/src/android-plugin.d.ts +37 -0
  82. package/src/animate.ts +13 -7
  83. package/src/drag.ts +3 -3
  84. package/src/driver/android.ts +2 -0
  85. package/src/driver/frame.ts +10 -0
  86. package/src/driver/index.ts +2 -0
  87. package/src/driver/ios.ts +2 -0
  88. package/src/engine.ts +23 -2
  89. package/src/image-effect.ts +10 -2
  90. package/src/index.ts +5 -0
  91. package/src/ios-plugin.d.ts +33 -0
  92. package/src/motion-scope.ts +5 -1
  93. package/src/motion-value.ts +331 -99
  94. package/src/native/index.ts +16 -0
  95. package/src/native-properties.ts +188 -0
  96. package/src/native-property-driver.ts +123 -0
  97. package/src/native-property.ts +353 -0
  98. package/src/playback-group.ts +17 -1
  99. package/src/playback.ts +41 -0
  100. package/src/sequence.ts +209 -20
  101. package/src/timed-animation.ts +192 -0
  102. package/src/value-observers.ts +133 -0
  103. package/src/value-track.ts +71 -0
@@ -20,14 +20,7 @@ import {
20
20
  validateDecorationTarget,
21
21
  } from './decoration.js'
22
22
  import { getDriver } from './driver/index.js'
23
- import {
24
- setArrival,
25
- setBase,
26
- stopBase,
27
- ViewAnimation,
28
- type Animation,
29
- type Plan,
30
- } from './engine.js'
23
+ import { setBase, stopBase, ViewAnimation, type Animation, type Plan } from './engine.js'
31
24
  import { linear } from './easing.js'
32
25
  import { isProperty, type Property } from './properties.js'
33
26
  import { createTrack, instantTrack, type Track } from './track.js'
@@ -39,19 +32,32 @@ import {
39
32
  type Transition,
40
33
  } from './transition.js'
41
34
  import type { KeyframeProperty, Target } from './types.js'
35
+ import { playbackSource, type PlaybackAnimation, type PlaybackClock } from './playback.js'
36
+ import { TimedAnimation } from './timed-animation.js'
37
+ import { valueTrack } from './value-track.js'
38
+ import {
39
+ ValueObservers,
40
+ type SubscribeOptions,
41
+ type ValueListener,
42
+ } from './value-observers.js'
42
43
 
43
44
  export interface MapOptions {
44
45
  /** Hold the first and last outputs outside the input range. Defaults to `true`. */
45
46
  clamp?: boolean
46
47
  }
47
48
 
49
+ /** Named motion value layers add scalar offsets before derived mappings. */
50
+ export type ValueAnimationOptions = Transition & { layer?: string }
51
+
48
52
  declare const motionValueBrand: unique symbol
49
53
 
50
54
  /** A number derived from a motion value, ready to bind to a view. */
51
55
  export interface DerivedValue {
52
56
  /** The value now, while it animates too. */
53
57
  get(): number
54
- /** Maps through input and output ranges, like React Native's `interpolate`. */
58
+ /** Observe changes in JavaScript on the native frame clock. Unsubscribe when finished. */
59
+ subscribe(listener: ValueListener<number>, options?: SubscribeOptions): () => void
60
+ /** Maps through ascending or descending input ranges, like React Native's `interpolate`. */
55
61
  to(
56
62
  input: readonly number[],
57
63
  output: readonly number[],
@@ -63,6 +69,8 @@ export interface DerivedValue {
63
69
  /** A color derived from a motion value, for `backgroundColor`, `borderColor` or `color`. */
64
70
  export interface DerivedColor {
65
71
  get(): Color
72
+ /** Observe mapped color changes in JavaScript on the native frame clock. */
73
+ subscribe(listener: ValueListener<Color>, options?: SubscribeOptions): () => void
66
74
  }
67
75
 
68
76
  /** A number that animates natively and drives every view property bound to it. */
@@ -88,12 +96,13 @@ interface Run {
88
96
  readonly track: Track
89
97
  readonly base: number
90
98
  readonly span: number
91
- readonly animation: Animation
99
+ readonly animation: TimedAnimation
92
100
  readonly startedAt: number
93
- readonly settle: (finished: boolean) => void
94
- readonly cancel: () => void
101
+ readonly clock?: Pick<PlaybackClock, 'time'>
95
102
  }
96
103
 
104
+ type Presentation = Pick<Run, 'track' | 'base' | 'span' | 'startedAt' | 'clock'>
105
+
97
106
  const bound = new WeakMap<View, Map<Property, Binding>>()
98
107
 
99
108
  class Derived {
@@ -112,6 +121,10 @@ class Derived {
112
121
  return this.color ? new Color(value) : value
113
122
  }
114
123
 
124
+ subscribe(listener: ValueListener<number | Color>, options?: SubscribeOptions) {
125
+ return this.root.observers.subscribe(this.read, this.color, listener, options)
126
+ }
127
+
115
128
  to(
116
129
  input: readonly number[],
117
130
  output: readonly (number | ColorValue)[],
@@ -126,46 +139,71 @@ class Derived {
126
139
 
127
140
  class Root extends Derived {
128
141
  readonly bindings = new Set<Binding>()
129
- private current: number
130
- private run: Run | undefined
142
+ readonly listeners = new Set<() => void>()
143
+ readonly observers = new ValueObservers((time) => this.raw(time))
144
+ private readonly rests = new Map<string, number>()
145
+ private readonly runs = new Map<string, Run>()
146
+ private presentation: Presentation | undefined
147
+ private cancelPresentation: (() => void) | undefined
131
148
 
132
149
  constructor(initial: number) {
133
150
  super(undefined, (value) => value, false)
134
151
  if (!Number.isFinite(initial))
135
152
  throw new RangeError(`A motion value starts from a finite number, got ${initial}.`)
136
- this.current = initial
153
+ this.rests.set('', initial)
154
+ }
155
+
156
+ raw(time = getDriver().now()): number {
157
+ let result = 0
158
+ for (const key of new Set([...this.rests.keys(), ...this.runs.keys()]))
159
+ result += this.part(key, time)
160
+ return result
161
+ }
162
+
163
+ /** Drag input owns only the base; bounds and snap points stay in base units. */
164
+ base(): number {
165
+ return this.part('')
137
166
  }
138
167
 
139
- raw(): number {
140
- const run = this.run
141
- if (!run) return this.current
142
- return run.base + run.span * run.track.valueAt(getDriver().now() - run.startedAt)
168
+ private part(key: string, time = getDriver().now()): number {
169
+ const run = this.runs.get(key)
170
+ return run
171
+ ? run.base + run.span * run.track.valueAt(this.runTime(run, time))
172
+ : (this.rests.get(key) ?? 0)
143
173
  }
144
174
 
145
- private velocity(): number {
146
- const run = this.run
147
- if (!run) return 0
148
- return run.span * run.track.velocityAt(getDriver().now() - run.startedAt)
175
+ private runTime(run: Presentation, time = getDriver().now()) {
176
+ return run.clock?.time() ?? time - run.startedAt
177
+ }
178
+
179
+ private velocity(key: string): number {
180
+ const run = this.runs.get(key)
181
+ if (!run || run.animation.paused) return 0
182
+ return run.span * run.track.velocityAt(this.runTime(run))
149
183
  }
150
184
 
151
185
  animate(
152
186
  target: number | readonly (number | null)[],
153
- transition: Transition | SpringName = {},
154
- ): Animation {
155
- const from = this.raw()
187
+ transition: ValueAnimationOptions | SpringName = {},
188
+ ): PlaybackAnimation {
189
+ const options: ValueAnimationOptions =
190
+ typeof transition === 'string' ? springOf(transition) : transition
191
+ const key = options.layer === undefined ? '' : layerName(options.layer)
192
+ const from = this.part(key)
156
193
  const frames = (typeof target === 'number' ? [null, target] : [...target]).map(
157
194
  (frame) => (frame === null ? from : frame),
158
195
  )
159
196
  if (frames.length < 2) frames.unshift(from)
160
197
  if (frames.some((frame) => typeof frame !== 'number' || !Number.isFinite(frame)))
161
198
  throw new RangeError(`Invalid motion value keyframes: ${JSON.stringify(target)}.`)
162
- const options = typeof transition === 'string' ? springOf(transition) : transition
163
199
  const resolved = resolveTransition(options, 'x', frames.length)
164
200
  const base = Math.min(...frames)
165
201
  const span = Math.max(...frames) - base
166
- const velocity = this.velocity()
167
- this.halt()
168
- if (span === 0) return this.jump(frames.at(-1)!)
202
+ const velocity = this.velocity(key)
203
+ if (span === 0) {
204
+ this.halt(key)
205
+ return this.jump(frames.at(-1)!, key)
206
+ }
169
207
 
170
208
  // Sampled in the value's own range so springs settle relative to the distance.
171
209
  const track = createTrack(
@@ -174,99 +212,158 @@ class Root extends Derived {
174
212
  resolved,
175
213
  velocity / span,
176
214
  )
177
- return this.start(track, base, span)
215
+ this.halt(key)
216
+ return track.end === 0
217
+ ? this.jump(base + span * track.to, key)
218
+ : this.start(track, base, span, key)
178
219
  }
179
220
 
180
221
  /** Plays a track sampled in the value's own units, such as a drag release. */
181
- play(track: Track): Animation {
182
- this.halt()
183
- return track.end === 0 ? this.jump(track.to) : this.start(track, 0, 1)
222
+ play(track: Track): PlaybackAnimation {
223
+ this.halt('')
224
+ return track.end === 0 ? this.jump(track.to, '') : this.start(track, 0, 1, '')
184
225
  }
185
226
 
186
- private jump(value: number): Animation {
187
- const { animation, settle } = this.handle()
188
- this.current = value
189
- for (const binding of this.bindings) binding.sync()
190
- settle(true)
191
- setArrival(animation, Promise.resolve())
227
+ private jump(value: number, key: string): PlaybackAnimation {
228
+ this.rests.set(key, value)
229
+ this.sync()
230
+ const animation = new TimedAnimation(
231
+ 0,
232
+ 0,
233
+ () => {},
234
+ () => {},
235
+ )
236
+ animation.start()
192
237
  return animation
193
238
  }
194
239
 
195
- private handle() {
196
- let settle!: (finished: boolean) => void
197
- const finished = new Promise<boolean>((resolve) => (settle = resolve))
198
- const animation: Animation = {
199
- finished,
200
- stop: () => {
201
- if (this.run?.animation === animation) this.stop()
202
- },
203
- then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
204
- }
205
- return { animation, settle }
206
- }
207
-
208
- private start(track: Track, base: number, span: number): Animation {
209
- const { animation, settle } = this.handle()
210
- let arrive: () => void = () => {}
211
- let run: Run | undefined
240
+ private start(
241
+ track: Track,
242
+ base: number,
243
+ span: number,
244
+ key: string,
245
+ ): PlaybackAnimation {
212
246
  const driver = getDriver()
213
- setArrival(animation, new Promise<void>((resolve) => (arrive = resolve)))
214
- const timers = [
215
- Number.isFinite(track.end)
216
- ? driver.after(track.end, () => {
217
- if (this.run !== run) return
218
- this.current = base + span * track.to
219
- this.run = undefined
220
- arrive()
221
- settle(true)
222
- })
223
- : undefined,
224
- Number.isFinite(track.arrival)
225
- ? driver.after(track.arrival, () => arrive())
226
- : undefined,
227
- ]
228
- this.run = run = {
247
+ const animation = new TimedAnimation(
248
+ track.end,
249
+ track.arrival,
250
+ () => this.sync(),
251
+ (completed) => {
252
+ if (this.runs.get(key)?.animation !== animation) return
253
+ const held = base + span * track.valueAt(animation.time)
254
+ this.runs.delete(key)
255
+ if (!completed && key !== '') this.rests.delete(key)
256
+ else this.rests.set(key, completed ? base + span * track.to : held)
257
+ this.sync()
258
+ },
259
+ )
260
+ const run: Run = {
229
261
  track,
230
262
  base,
231
263
  span,
232
264
  animation,
233
265
  startedAt: driver.now(),
234
- settle: (completed) => {
235
- arrive()
236
- settle(completed)
237
- },
238
- cancel: () => timers.forEach((cancel) => cancel?.()),
266
+ clock: { time: () => animation.time },
239
267
  }
240
- for (const binding of this.bindings) binding.sync()
268
+ this.runs.set(key, run)
269
+ this.sync()
270
+ animation.start()
241
271
  return animation
242
272
  }
243
273
 
244
274
  set(value: number) {
245
275
  if (!Number.isFinite(value))
246
276
  throw new RangeError(`A motion value takes a finite number, got ${value}.`)
247
- this.halt()
248
- this.current = value
249
- for (const binding of this.bindings) binding.sync()
277
+ for (const key of [...this.runs.keys()]) this.halt(key)
278
+ this.rests.clear()
279
+ this.rests.set('', value)
280
+ this.sync()
281
+ }
282
+
283
+ setBase(value: number) {
284
+ if (!Number.isFinite(value))
285
+ throw new RangeError('A motion value takes a finite number.')
286
+ this.halt('')
287
+ this.rests.set('', value)
288
+ this.sync()
289
+ }
290
+
291
+ stopBase() {
292
+ this.stopChannel('')
250
293
  }
251
294
 
252
- stop() {
253
- if (!this.run) return
295
+ stop(layer?: string) {
296
+ if (layer !== undefined) return this.stopChannel(layerName(layer))
297
+ if (!this.runs.size && this.rests.size === 1) return
254
298
  const value = this.raw()
255
- this.halt()
256
- this.current = value
299
+ for (const key of [...this.runs.keys()]) this.halt(key)
300
+ this.rests.clear()
301
+ this.rests.set('', value)
302
+ this.sync()
303
+ }
304
+
305
+ private stopChannel(key: string) {
306
+ if (!this.runs.has(key) && (key === '' || !this.rests.has(key))) return
307
+ const held = this.part(key)
308
+ this.halt(key)
309
+ if (key === '') this.rests.set(key, held)
310
+ else this.rests.delete(key)
311
+ this.sync()
312
+ }
313
+
314
+ private sync() {
315
+ this.cancelPresentation?.()
316
+ this.cancelPresentation = undefined
317
+ const now = getDriver().now()
318
+ let rest = 0
319
+ for (const [key, value] of this.rests) if (!this.runs.has(key)) rest += value
320
+ const runs = [...this.runs.values()].filter((run) => !run.animation.paused)
321
+ for (const [key, run] of this.runs)
322
+ if (run.animation.paused) rest += this.part(key, now)
323
+ if (!runs.length) this.presentation = undefined
324
+ else if (runs.length === 1) {
325
+ const run = runs[0]!
326
+ this.presentation = { ...run, base: run.base + rest }
327
+ } else {
328
+ const { track, windowed } = valueTrack(
329
+ runs.map((run) => ({
330
+ track: run.track,
331
+ base: run.base,
332
+ span: run.span,
333
+ elapsed: this.runTime(run, now),
334
+ })),
335
+ rest,
336
+ )
337
+ const driver = getDriver()
338
+ const clock = driver.clock?.(track.end)
339
+ this.presentation = { track, base: 0, span: 1, startedAt: now, clock }
340
+ const cancelWindow = windowed
341
+ ? driver.after(track.end, () => this.sync())
342
+ : undefined
343
+ this.cancelPresentation = () => {
344
+ cancelWindow?.()
345
+ clock?.cancel()
346
+ }
347
+ }
348
+ this.changed()
349
+ }
350
+
351
+ protected changed() {
257
352
  for (const binding of this.bindings) binding.sync()
353
+ for (const listener of [...this.listeners]) listener()
354
+ this.observers.changed(this.running)
258
355
  }
259
356
 
260
357
  /** The plan that plays this value's run, mapped, on one bound property. */
261
358
  plan(binding: Binding): Plan {
262
- const run = this.run
359
+ const run = this.presentation
263
360
  const { property, read } = binding
264
361
  const write = (value: number) =>
265
362
  isDecorationProperty(property)
266
363
  ? clampDecoration(property, read(value))
267
364
  : read(value)
268
365
  if (!run) {
269
- const value = write(this.current)
366
+ const value = write(this.raw())
270
367
  return {
271
368
  property,
272
369
  keyframes: [],
@@ -313,22 +410,31 @@ class Root extends Derived {
313
410
  }
314
411
 
315
412
  elapsed(): number {
316
- return this.run ? getDriver().now() - this.run.startedAt : 0
413
+ return this.presentation ? this.runTime(this.presentation) : 0
414
+ }
415
+
416
+ get paused(): boolean {
417
+ return this.runs.size > 0 && !this.running
317
418
  }
318
419
 
319
420
  get running(): boolean {
320
- return this.run !== undefined
421
+ return [...this.runs.values()].some((run) => !run.animation.paused)
321
422
  }
322
423
 
323
- private halt() {
324
- const run = this.run
424
+ private halt(key: string) {
425
+ const run = this.runs.get(key)
325
426
  if (!run) return
326
- this.run = undefined
327
- run.cancel()
328
- run.settle(false)
427
+ this.runs.delete(key)
428
+ run.animation.stop()
329
429
  }
330
430
  }
331
431
 
432
+ function layerName(name: string): string {
433
+ if (typeof name !== 'string' || !name.length)
434
+ throw new TypeError('A layer is named by a nonempty string.')
435
+ return name
436
+ }
437
+
332
438
  const still: ResolvedTransition = {
333
439
  curve: { type: 'tween', duration: 0, ease: [linear], times: undefined },
334
440
  delay: 0,
@@ -424,9 +530,13 @@ function attach(view: View, property: Property, source: Derived) {
424
530
  getDriver().sync?.(view)
425
531
  return
426
532
  }
427
- const elapsed = root.elapsed()
428
533
  const animation = new ViewAnimation(view, [root.plan(binding)])
429
- if (elapsed > 1 && animation.duration > 0) animation.seekTime(elapsed)
534
+ // Native keyframe setup can take time, especially for a composite track on Android.
535
+ // Catch up after construction so each binding joins the source's current phase.
536
+ const elapsed = root.elapsed()
537
+ if (root.paused) animation.pause()
538
+ if ((elapsed > 1 || root.paused) && animation.duration > 0)
539
+ animation.seekTime(elapsed)
430
540
  binding.animation = animation
431
541
  },
432
542
  }
@@ -444,6 +554,118 @@ function attach(view: View, property: Property, source: Derived) {
444
554
  }
445
555
  }
446
556
 
557
+ /** A read-only value on an existing finite animation's clock. No second animation or timer. */
558
+ export function playbackProgress(animation: Animation): DerivedValue {
559
+ const source = playbackSource(animation)
560
+ if (!Number.isFinite(source.read().duration))
561
+ throw new RangeError('Progress requires a finite animation.')
562
+ const root = new PlaybackValue(source)
563
+ return new Derived(root, (value) => value, false) as unknown as DerivedValue
564
+ }
565
+
566
+ class PlaybackValue extends Root {
567
+ constructor(private readonly playback: ReturnType<typeof playbackSource>) {
568
+ super(0)
569
+ const source = playback
570
+ const off = source.subscribe(() => {
571
+ this.changed()
572
+ if (source.read().settled) off()
573
+ })
574
+ if (source.read().settled) off()
575
+ this.observers.changed(this.running)
576
+ }
577
+
578
+ override raw() {
579
+ const state = this.playback.read()
580
+ return (
581
+ state.position ??
582
+ (state.completed
583
+ ? 1
584
+ : state.duration > 0
585
+ ? Math.max(0, Math.min(1, state.time / state.duration))
586
+ : 0)
587
+ )
588
+ }
589
+
590
+ override get running() {
591
+ const state = this.playback.read()
592
+ return state.ready && !state.settled && !state.paused && state.duration > 0
593
+ }
594
+
595
+ override get paused() {
596
+ return this.playback.read().paused
597
+ }
598
+ override elapsed() {
599
+ return this.playback.read().time
600
+ }
601
+
602
+ override plan(binding: Binding): Plan {
603
+ const state = this.playback.read()
604
+ if (!state.ready || state.settled || state.duration === 0)
605
+ return {
606
+ property: binding.property,
607
+ keyframes: [],
608
+ transition: still,
609
+ track: () => instantTrack(binding.property, binding.read(this.raw())),
610
+ }
611
+ // Bypass timeScale: the source duration is already in platform playback milliseconds.
612
+ const duration = state.duration
613
+ const at = (ms: number) => binding.read(Math.max(0, Math.min(1, ms / duration)))
614
+ const track: Track = {
615
+ property: binding.property,
616
+ delay: 0,
617
+ duration,
618
+ repeat: 0,
619
+ reverse: false,
620
+ times: [0, 1],
621
+ values: [at(0), at(duration)],
622
+ to: at(duration),
623
+ end: duration,
624
+ arrival: duration,
625
+ valueAt: at,
626
+ velocityAt: (ms) => (at(ms + 0.5) - at(ms - 0.5)) * 1000,
627
+ }
628
+ // Piecewise/color derivations need intermediate samples, not only the two endpoints.
629
+ const steps = Math.max(1, Math.min(6000, Math.ceil((duration * 60) / 1000)))
630
+ const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
631
+ return {
632
+ property: binding.property,
633
+ keyframes: [],
634
+ transition: still,
635
+ track: () =>
636
+ reducedMotion() &&
637
+ binding.property !== 'opacity' &&
638
+ !isColorProperty(binding.property)
639
+ ? instantTrack(binding.property, at(duration))
640
+ : { ...track, times, values: times.map((time) => at(time * duration)) },
641
+ }
642
+ }
643
+ }
644
+
645
+ /** Internal native-adapter bridge. Mappings are sampled at boundaries, never per frame. */
646
+ export function valuePlayback(source: DerivedValue) {
647
+ if (!(source instanceof Derived) || source.color)
648
+ throw new TypeError('A native binding needs a numeric Jolt value.')
649
+ const { root, read } = source
650
+ return {
651
+ snapshot() {
652
+ return {
653
+ track: root.plan({ property: 'opacity', read } as Binding).track!(),
654
+ elapsed: root.elapsed(),
655
+ paused: root.paused,
656
+ running: root.running,
657
+ value: read(root.raw()),
658
+ }
659
+ },
660
+ subscribe(listener: () => void) {
661
+ root.listeners.add(listener)
662
+ return () => {
663
+ root.listeners.delete(listener)
664
+ }
665
+ },
666
+ }
667
+ }
668
+
447
669
  function release(binding: Binding) {
448
670
  binding.root.bindings.delete(binding)
449
671
  binding.view.off('loaded', binding.sync)
@@ -472,12 +694,22 @@ function mapping(
472
694
  throw new RangeError(
473
695
  `to() has ${input.length} inputs but ${output.length} outputs; give one output per input.`,
474
696
  )
697
+ if (input.some((value) => !Number.isFinite(value)))
698
+ throw new RangeError('to() inputs must all be finite numbers.')
699
+ const descending = input[input.length - 1]! < input[0]!
475
700
  if (
476
701
  input.some(
477
- (value, i) => !Number.isFinite(value) || value < (input[i - 1] ?? -Infinity),
702
+ (value, i) => i > 0 && (descending ? value > input[i - 1]! : value < input[i - 1]!),
478
703
  )
479
704
  )
480
- throw new RangeError('to() inputs must be finite and never decrease.')
705
+ throw new RangeError(
706
+ 'to() inputs must be monotonic (ascending or descending); ranges cannot change direction.',
707
+ )
708
+ // Normalize pairs together, without changing caller-owned arrays or duplicate-stop rules.
709
+ if (descending) {
710
+ input = [...input].reverse()
711
+ output = [...output].reverse()
712
+ }
481
713
  const color = typeof output[0] !== 'number'
482
714
  const numbers = color ? output.map((value) => parseColor(value)) : (output as number[])
483
715
  if (
@@ -0,0 +1,16 @@
1
+ export { registerNativeProperty, checkNativeProperty } from '../native-property.js'
2
+ export type { NativeProperty, NativePropertyDefinition } from '../native-property.js'
3
+ export type { NativeSink } from '../native-property-driver.js'
4
+ export {
5
+ mapKitCoordinate,
6
+ mapKitCamera,
7
+ mapLibreCoordinate,
8
+ mapLibreCamera,
9
+ sceneKitPose,
10
+ } from '../native-properties.js'
11
+ export type {
12
+ Coordinate,
13
+ MapKitCamera,
14
+ MapLibreCamera,
15
+ ScenePose,
16
+ } from '../native-properties.js'