@solidrt/core 0.0.64 → 0.0.66

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/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
@@ -1,8 +1,8 @@
1
1
  import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } from "@solidjs/signals"
2
2
  import { requestFrame, setPointerLock } from "flux:rendertree"
3
- import { renderFrame } from "srt:render"
4
- import { on, once } from "srt:events"
5
- import { exit as nativeExit, background as nativeBackground, registerProtocolHandler as nativeRegisterProtocolHandler } from "srt:app"
3
+ import { renderFrame } from "sol:render"
4
+ import { on, once } from "sol:events"
5
+ import { exit as nativeExit, background as nativeBackground, registerProtocolHandler as nativeRegisterProtocolHandler } from "sol:app"
6
6
  import { platform } from "flux:process"
7
7
  import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestRoot } from "./core"
8
8
  import { scanForOrphans, getNodePath } from "./renderer"
@@ -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, `sol
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()