@solidrt/core 0.0.64 → 0.0.65

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/src/transform.ts CHANGED
@@ -21,8 +21,8 @@ const SPAN_SMOOTH = 0.28
21
21
  // thresholds is the hysteresis that keeps the gate from chattering.
22
22
  // All three step-based constants assume a ~65Hz frame rate (the captured
23
23
  // tablet); a 120Hz panel halves per-step deltas. If that ever bites,
24
- // convert the thresholds to px/s with performance.now() read at the
25
- // pointerFrame terminator.
24
+ // convert the thresholds to px/s with the pointerFrame terminator's
25
+ // timeStamp.
26
26
  const QUIET = 0.3
27
27
  const ENGAGE = 0.6
28
28
  const QUIET_SMOOTH = 0.19
@@ -147,6 +147,8 @@ export function createTransform(options: TransformOptions) {
147
147
  let spanRate = 0
148
148
  // Motion arrived this frame; measured and emitted at the terminator.
149
149
  let dirty = false
150
+ // A move of the current batch was predicted (see flush).
151
+ let predicted = false
150
152
  // The anchor must be retaken at the terminator (activation and set-change
151
153
  // rebases): mid-batch the pointer map is mixed-age, so anchoring
152
154
  // immediately would bake a jolt into the next delta. A rebase frame
@@ -208,8 +210,14 @@ export function createTransform(options: TransformOptions) {
208
210
  let owner: ArenaOwner = { cancel }
209
211
 
210
212
  // The per-frame measure point: runs at the pointerFrame terminator, when
211
- // every tracked pointer's position is the same age.
212
- let flush = () => {
213
+ // every tracked pointer's move of the batch has dispatched: the
214
+ // terminator's timeStamp (the latest of theirs) is the time of what is
215
+ // measured here.
216
+ let flush = (frame: { timeStamp: number }) => {
217
+ // The measure follows a predicted position; the velocity history takes
218
+ // real ones only.
219
+ let real = !predicted
220
+ predicted = false
213
221
  if (!active) return
214
222
  if (rebase) {
215
223
  // Anchor from same-age positions; emits nothing - an activation or
@@ -219,7 +227,7 @@ export function createTransform(options: TransformOptions) {
219
227
  let prev = ref
220
228
  ref = measure()
221
229
  if (prev) tracker.shift(ref.px - prev.px, ref.py - prev.py)
222
- tracker.push(ref.px, ref.py)
230
+ if (real) tracker.push(ref.px, ref.py, frame.timeStamp)
223
231
  spanBase = ref.span
224
232
  smoothSpan = ref.span
225
233
  rebase = false
@@ -229,7 +237,7 @@ export function createTransform(options: TransformOptions) {
229
237
  if (!dirty || !ref) return
230
238
  dirty = false
231
239
  let m = measure()
232
- tracker.push(m.px, m.py)
240
+ if (real) tracker.push(m.px, m.py, frame.timeStamp)
233
241
  let prevSpan = smoothSpan
234
242
  smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
235
243
  spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
@@ -285,6 +293,7 @@ export function createTransform(options: TransformOptions) {
285
293
  onPointerMove: (e: PointerEvent) => {
286
294
  if (!pointers.has(e.pointerId) || !ref) return
287
295
  pointers.set(e.pointerId, at(e))
296
+ if (e.predicted) predicted = true
288
297
  if (!active) {
289
298
  // Arming runs per event: the slop test tolerates a mixed-age
290
299
  // measure (8px against <=1 frame of staleness), and the arena
@@ -328,7 +337,7 @@ export function createTransform(options: TransformOptions) {
328
337
  if (pointers.size === 0) {
329
338
  // The lift's own position is not measured (no terminator
330
339
  // follows it); the fit over the delivered frames is the speed.
331
- let velocity = flingVelocity(tracker.velocity())
340
+ let velocity = flingVelocity(tracker.velocity(e.timeStamp))
332
341
  active = false
333
342
  ref = null
334
343
  pinch = false
package/src/types.d.ts CHANGED
@@ -280,6 +280,33 @@ export interface TransformProps {
280
280
  // the DOM MouseEvent). pointerType distinguishes mouse from touch; button is the
281
281
  // pressed button on down/up (0 = primary); the modifier flags mirror the DOM.
282
282
  export interface PointerEvent {
283
+ /**
284
+ * When the event happened, in milliseconds. Every input event carries one,
285
+ * on one clock, so two stamps subtract to the time between two events: a
286
+ * tap's length, the gap of a double tap, the age of the last move at a
287
+ * lift. It is the input's own time, not its delivery's: a move carries the
288
+ * time the pointer was at the position it reports, however late the
289
+ * frame that delivers it, and a down, an up, a wheel or a key the time
290
+ * the platform recorded for it. A `predicted` move runs ahead of its
291
+ * pointer, so the real move after it can carry an earlier time. Use it
292
+ * where you would reach for `performance.now()` in a handler: that one
293
+ * measures when the handler ran, not when the input happened, and it does
294
+ * not follow the dev clock, `srt render` or a test. The origin is
295
+ * arbitrary (compare stamps with each other, not with `performance.now()`,
296
+ * a timer or the `onFrame` tick).
297
+ */
298
+ timeStamp: number
299
+ /**
300
+ * True for a move whose position the pointer was never reported at. Touch
301
+ * is delivered in batches, and a batch can miss the frame it belongs to;
302
+ * the runtime then bridges the gap with one predicted step, so a drag
303
+ * does not stall and jump. Draw with it, as with any move. Do not measure
304
+ * with it: leave it out of a velocity or a path. If the finger had in
305
+ * fact stopped, the next move reports where it really is, with the time
306
+ * it got there, which is earlier than the predicted move's. False on
307
+ * every other event.
308
+ */
309
+ predicted: boolean
283
310
  clientX: number
284
311
  clientY: number
285
312
  /**
@@ -340,6 +367,9 @@ export interface WheelEvent extends PointerEvent {
340
367
  // to the window root alone. <window onKeyDown> is therefore the app-global
341
368
  // shortcut point.
342
369
  export interface KeyEvent {
370
+ /** When the key event happened, in milliseconds, on the clock of
371
+ * `PointerEvent.timeStamp`. */
372
+ timeStamp: number
343
373
  key: string
344
374
  code: string
345
375
  repeat: boolean
@@ -363,7 +393,19 @@ export interface TextEvent {
363
393
  text: string
364
394
  }
365
395
 
366
- export interface PointerProps {
396
+ export interface NodeProps {
397
+ /**
398
+ * A stable name for the node, for whoever has to find it again with
399
+ * nothing visible to find it by: a test (`find({ label })` from
400
+ * `@solidrt/test`) or a dev tool (the tree record carries it and the
401
+ * tree query matches it). Never affects layout, paint or input. Prefer
402
+ * finding a node by its text where it has one: such a test breaks when
403
+ * what the user sees breaks.
404
+ */
405
+ label?: string
406
+ }
407
+
408
+ export interface PointerProps extends NodeProps {
367
409
  onPointerDown?: (event: PointerEvent) => void
368
410
  onPointerUp?: (event: PointerEvent) => void
369
411
  onPointerMove?: (event: PointerEvent) => void
package/src/velocity.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  // The velocity tracker every recognizer reads at a lift: the pointer's
2
2
  // speed from the positions of its last VELOCITY_WINDOW_MS as a
3
- // least-squares line, not the last two samples. The final sample before a
4
- // lift is often stationary, and frame batching makes the last delta a
5
- // whole frame old, so a two-point estimate reads anything from zero to a
6
- // jolt; a fit over the window reads the finger's actual speed. One
3
+ // least-squares quadratic read at the newest sample (Flutter's
4
+ // VelocityTracker, Android's LSQ2), not the last two samples. The final
5
+ // sample before a lift is often stationary, and frame batching makes the
6
+ // last delta a whole frame old, so a two-point estimate reads anything from
7
+ // zero to a jolt; a fit over the window reads the finger's actual speed.
8
+ // Quadratic, not a line: a flick accelerates into the lift, and a line
9
+ // reads the window's average speed, well under the speed at the lift. The
10
+ // line stays as the guard: a quadratic overshoots under a hard brake and
11
+ // can point backward, so an axis where the two disagree in direction
12
+ // reads zero. One
7
13
  // estimator whatever recognizer asks (createPan, createTransform, the
8
14
  // pointer feed), so the 2d camera and a control after it never re-derive
9
15
  // their own.
@@ -11,8 +17,11 @@
11
17
  // Samples are positions in whatever frame the caller measures (the
12
18
  // recognizers push the node's parent frame, so the velocity applies 1:1
13
19
  // as their deltas do; a swipe classifier pushes window pixels, the
14
- // finger's own travel) with performance.now() at handler time, the
15
- // precedent in transform.ts: PointerEvent carries no timestamp.
20
+ // finger's own travel), each with the `timeStamp` of the pointer event it
21
+ // came from. The tracker reads no clock: the read at a lift takes the
22
+ // up's `timeStamp`, and a move's stamp is the time of the position it
23
+ // carries, so the age of the last one is how long the pointer had been
24
+ // there, at any frame rate.
16
25
 
17
26
  // Fit horizon: Flutter's and Android's VelocityTracker window.
18
27
  const VELOCITY_WINDOW_MS = 100
@@ -21,11 +30,26 @@ const VELOCITY_WINDOW_MS = 100
21
30
  const VELOCITY_MAX = 8000
22
31
  // A position unchanged for this long at the read means the finger rested
23
32
  // before lifting: the velocity is zero, whatever the window still holds.
24
- // Measured from the last sample that MOVED, not the newest sample's age:
25
- // the runtime's resampler delivers an extrapolated step and its correction
26
- // up to two frames after the finger stopped, and any same-position
27
- // re-delivery must not restart the clock either.
33
+ // Measured from when the pointer reached the position it is at, not from
34
+ // the newest sample's age: a same-position re-delivery must not restart
35
+ // the clock.
28
36
  const VELOCITY_REST_MS = 50
37
+ // A gap this long (ms) between two consecutive samples means the pointer
38
+ // stopped in between (Flutter's and Android's assume-stopped time): what
39
+ // came before the gap is an earlier motion and stays out of the fit. A
40
+ // slow drag delivers a sample only when the finger moves, so its history
41
+ // is full of such pauses.
42
+ const VELOCITY_STOP_GAP_MS = 40
43
+ // Travel (px) the fitted samples must cover to be a motion at all. A finger
44
+ // leaving the panel shifts its contact by a pixel or two in its last
45
+ // samples (measured: up to 2.7 px over 8 to 25 ms on a tablet), which a
46
+ // fit over so short a span reads as 60 to 450 px/s. The pan slop's
47
+ // distance: under it nothing was dragged.
48
+ const VELOCITY_MIN_TRAVEL = 8
49
+ // Samples closer in time than this (ms) are one instant to the fit: two
50
+ // samples of one frame carry the same stamp, and a curve pinned by such a
51
+ // pair reads a jolt.
52
+ const VELOCITY_MIN_STEP_MS = 1
29
53
  // Ring capacity: a 100 ms window holds 12 samples at 120 Hz; the rest is
30
54
  // room for a burst of same-frame samples.
31
55
  const VELOCITY_SAMPLES = 20
@@ -37,16 +61,20 @@ export const FLING_MIN_VELOCITY = 50
37
61
  export type Velocity = { vx: number; vy: number }
38
62
 
39
63
  export interface VelocityTracker {
40
- /** Record a position; `at` defaults to performance.now(). */
41
- push(x: number, y: number, at?: number): void
64
+ /** Record a position at time `at`, ms: the `timeStamp` of the event
65
+ * that carried it. Real positions only: a `predicted` move is not where
66
+ * the pointer was, and is left out. */
67
+ push(x: number, y: number, at: number): void
42
68
  /** Translate every sample: a recognizer rebasing its reference point
43
69
  * (a finger joining or leaving a transform) keeps the history
44
70
  * continuous instead of dropping it. */
45
71
  shift(dx: number, dy: number): void
46
72
  reset(): void
47
- /** The speed at `at` (default now), px/s per axis: a least-squares fit
48
- * over the window, zero after a rest, clamped to VELOCITY_MAX. */
49
- velocity(at?: number): Velocity
73
+ /** The speed at time `at` (the lift's `timeStamp`), px/s per axis: a
74
+ * least-squares quadratic over the window read at its newest sample,
75
+ * zero after a rest or when the motion since the last pause is a
76
+ * twitch, clamped to VELOCITY_MAX. */
77
+ velocity(at: number): Velocity
50
78
  }
51
79
 
52
80
  const ZERO: Velocity = { vx: 0, vy: 0 }
@@ -61,15 +89,8 @@ export function createVelocityTracker(): VelocityTracker {
61
89
  // Ring: `count` samples ending at index `head - 1`.
62
90
  let head = 0
63
91
  let count = 0
64
- // When the position last changed (the rest rule's clock).
65
- let movedAt = -Infinity
66
92
  return {
67
- push(x, y, at = performance.now()) {
68
- if (count === 0) movedAt = at
69
- else {
70
- let last = (head - 1 + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
71
- if (xs[last] !== x || ys[last] !== y) movedAt = at
72
- }
93
+ push(x, y, at) {
73
94
  xs[head] = x
74
95
  ys[head] = y
75
96
  ts[head] = at
@@ -87,41 +108,83 @@ export function createVelocityTracker(): VelocityTracker {
87
108
  head = 0
88
109
  count = 0
89
110
  },
90
- velocity(at = performance.now()) {
111
+ velocity(at) {
91
112
  if (count < 2) return ZERO
113
+ let newest = (head - 1 + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
114
+ // The rest rule's clock: when the pointer reached the position it is
115
+ // at, the oldest of the newest samples that share it.
116
+ let movedAt = ts[newest]!
117
+ for (let i = 1; i < count; i++) {
118
+ let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
119
+ if (xs[k] !== xs[newest] || ys[k] !== ys[newest]) break
120
+ movedAt = ts[k]!
121
+ }
92
122
  if (at - movedAt > VELOCITY_REST_MS) return ZERO
93
- // Means over the window, then the slope of each axis against time.
94
- let n = 0
95
- let tm = 0
96
- let xm = 0
97
- let ym = 0
123
+ // Sums over the window, time and position measured from the newest
124
+ // sample so the fit's slope at t = 0 is the answer: the power sums
125
+ // of t, each axis's moments against them, and how many distinct
126
+ // times (VELOCITY_MIN_STEP_MS apart) the window holds.
127
+ let s0 = 0
128
+ let s1 = 0
129
+ let s2 = 0
130
+ let s3 = 0
131
+ let s4 = 0
132
+ let x0 = 0
133
+ let x1 = 0
134
+ let x2 = 0
135
+ let y0 = 0
136
+ let y1 = 0
137
+ let y2 = 0
138
+ let times = 0
139
+ let prev = Infinity
140
+ // The sample after this one in time, and the fit's oldest position.
141
+ let later = ts[newest]!
142
+ let farX = 0
143
+ let farY = 0
98
144
  for (let i = 0; i < count; i++) {
99
145
  let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
100
146
  if (at - ts[k]! > VELOCITY_WINDOW_MS) break
101
- n++
102
- tm += ts[k]!
103
- xm += xs[k]!
104
- ym += ys[k]!
105
- }
106
- if (n < 2) return ZERO
107
- tm /= n
108
- xm /= n
109
- ym /= n
110
- let tt = 0
111
- let tx = 0
112
- let ty = 0
113
- for (let i = 0; i < n; i++) {
114
- let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
115
- let dt = ts[k]! - tm
116
- tt += dt * dt
117
- tx += dt * (xs[k]! - xm)
118
- ty += dt * (ys[k]! - ym)
147
+ if (later - ts[k]! > VELOCITY_STOP_GAP_MS) break
148
+ later = ts[k]!
149
+ let t = ts[k]! - ts[newest]!
150
+ if (prev - t >= VELOCITY_MIN_STEP_MS) {
151
+ times++
152
+ prev = t
153
+ }
154
+ let x = xs[k]! - xs[newest]!
155
+ let y = ys[k]! - ys[newest]!
156
+ farX = x
157
+ farY = y
158
+ let tt = t * t
159
+ s0 += 1
160
+ s1 += t
161
+ s2 += tt
162
+ s3 += tt * t
163
+ s4 += tt * tt
164
+ x0 += x
165
+ x1 += t * x
166
+ x2 += tt * x
167
+ y0 += y
168
+ y1 += t * y
169
+ y2 += tt * y
119
170
  }
120
171
  // Every sample the same age (one batched frame): no slope to read.
121
- if (tt === 0) return ZERO
172
+ if (times < 2) return ZERO
173
+ if (Math.hypot(farX, farY) < VELOCITY_MIN_TRAVEL) return ZERO
174
+ // One axis's slope at t = 0, px per ms: the quadratic
175
+ // c0 + c1 t + c2 t^2 (c1 by Cramer's rule) once three distinct times
176
+ // pin it, the line before that; zero where the quadratic points
177
+ // against the line.
178
+ let det = s0 * (s2 * s4 - s3 * s3) - s1 * (s1 * s4 - s2 * s3) + s2 * (s1 * s3 - s2 * s2)
179
+ let slope = (m0: number, m1: number, m2: number) => {
180
+ let line = (s0 * m1 - s1 * m0) / (s0 * s2 - s1 * s1)
181
+ if (times < 3) return line
182
+ let curve = (s0 * (m1 * s4 - s3 * m2) - m0 * (s1 * s4 - s3 * s2) + s2 * (s1 * m2 - m1 * s2)) / det
183
+ return curve * line < 0 ? 0 : curve
184
+ }
122
185
  // Slopes are px per ms; the result is px per second.
123
- let vx = (tx / tt) * 1000
124
- let vy = (ty / tt) * 1000
186
+ let vx = slope(x0, x1, x2) * 1000
187
+ let vy = slope(y0, y1, y2) * 1000
125
188
  let speed = Math.hypot(vx, vy)
126
189
  if (speed > VELOCITY_MAX) {
127
190
  let f = VELOCITY_MAX / speed
package/src/window.ts CHANGED
@@ -181,6 +181,21 @@ let animationFrames = new Map<number, Function>()
181
181
  // callbacks. Defaults to 60 until the first displayRefreshRate event arrives.
182
182
  let refreshRate = 60
183
183
 
184
+ // The tick of the latest frame the runtime delivered (see frameTime).
185
+ let latestTick = 0
186
+
187
+ export interface FrameOptions {
188
+ /**
189
+ * Whether the callback is a request for frames (default true). With
190
+ * `demand: false` it asks for nothing: it still runs before every frame
191
+ * the runtime delivers, painted or not, but it does not keep the app
192
+ * rendering. What it writes requests its frame as any write does, so a
193
+ * loop that changes something a few times a second presents a few times
194
+ * a second.
195
+ */
196
+ demand?: boolean
197
+ }
198
+
184
199
  /**
185
200
  * Calls `fn` before every frame is painted: `tick` is the time in ms, `frame` is
186
201
  * the present count, and `rate` is the current refresh rate in Hz. `tick`
@@ -203,8 +218,16 @@ let refreshRate = 60
203
218
  * one - an effect's apply phase, an event handler - the returned cleanup is
204
219
  * the only handle, so return it from the apply (`return onFrame(...)`) to run
205
220
  * a loop only while a condition holds.
221
+ *
222
+ * A registered callback is a standing request for the next frame: the app
223
+ * renders and presents every refresh while it is registered, whatever the
224
+ * body does. `{ demand: false }` registers it without that request, for a
225
+ * loop that follows time and only sometimes changes the picture (a sprite
226
+ * clip stepping at 8 fps, a countdown that ticks once a second): it is
227
+ * called on the same ticks, and the app presents only when it writes.
206
228
  */
207
- export function onFrame(fn: (tick: number, frame: number, rate: number) => void) {
229
+ export function onFrame(fn: (tick: number, frame: number, rate: number) => void, options?: FrameOptions) {
230
+ let demand = options?.demand !== false
208
231
  let frameId: number = null!
209
232
  // Cancellation is a flag, not map membership: while a frame runs the whole
210
233
  // callback map is swapped out, so a cleanup() called from another onFrame
@@ -219,7 +242,7 @@ export function onFrame(fn: (tick: number, frame: number, rate: number) => void)
219
242
  // pending onFrame callback is a standing request for the next frame.
220
243
  frameId = nextFrameId++
221
244
  animationFrames.set(frameId, extendedFn)
222
- requestFrame()
245
+ if (demand) requestFrame()
223
246
  try {
224
247
  fn(tick, frame, rate)
225
248
  } catch (err) {
@@ -229,7 +252,7 @@ export function onFrame(fn: (tick: number, frame: number, rate: number) => void)
229
252
 
230
253
  frameId = nextFrameId++
231
254
  animationFrames.set(frameId, extendedFn)
232
- requestFrame()
255
+ if (demand) requestFrame()
233
256
 
234
257
  let cleanup = () => {
235
258
  cancelled = true
@@ -239,6 +262,20 @@ export function onFrame(fn: (tick: number, frame: number, rate: number) => void)
239
262
  return cleanup
240
263
  }
241
264
 
265
+ /**
266
+ * The time of the current frame in ms: the `tick` its onFrame callbacks
267
+ * receive, readable anywhere without registering one. Inside a frame (a
268
+ * frame callback, an effect, the render pass) it is that frame's tick;
269
+ * between frames (an event handler, a timer) it is the latest frame's. It
270
+ * is 0 before the first frame. Not reactive, and reading it requests no
271
+ * frame. Use it to time things against the app's own clock from code that
272
+ * has no tick in hand: it follows the dev tools' clock control, `srt
273
+ * render` and a test, where performance.now() does not.
274
+ */
275
+ export function frameTime(): number {
276
+ return latestTick
277
+ }
278
+
242
279
  // ------ Resize ----------------
243
280
 
244
281
  // Insets: each value is the distance from the corresponding window edge, like
@@ -538,6 +575,7 @@ export function attachWindow(nodeId: number) {
538
575
  // registered and run on the first real render event, before the first
539
576
  // paint with their writes applied.
540
577
  function runFrame(t: number, frame: number, bootstrap = false) {
578
+ if (!bootstrap) latestTick = t
541
579
  if (!bootstrap && animationFrames.size > 0) {
542
580
  let frames = animationFrames
543
581
  animationFrames = new Map()