@solidrt/core 0.0.60 → 0.0.61
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/AGENTS.md +100 -16
- package/agents/performance.md +5 -1
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +16 -2
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +4 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +104 -46
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +7 -6
- package/src/window.ts +16 -9
package/src/transform.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { onSettled } from "@solidjs/signals"
|
|
|
2
2
|
import { on } from "srt:events"
|
|
3
3
|
import type { PointerEvent } from "./types"
|
|
4
4
|
import { arena, type ArenaOwner } from "./arena"
|
|
5
|
+
import { createVelocityTracker, flingVelocity } from "./velocity"
|
|
6
|
+
import type { Velocity } from "./velocity"
|
|
5
7
|
|
|
6
8
|
// Focal travel or span change in logical pixels before the transform
|
|
7
9
|
// activates; the same threshold createPan uses, so the two race fairly.
|
|
@@ -53,7 +55,10 @@ export interface TransformOptions {
|
|
|
53
55
|
onTransformStart?: () => void
|
|
54
56
|
/** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
|
|
55
57
|
onTransformMove?: (t: TransformDelta) => void
|
|
56
|
-
|
|
58
|
+
/** The last finger's lift, with the focal point's velocity at it in
|
|
59
|
+
* parent-frame pixels per second (velocity.ts; zero after a rest or
|
|
60
|
+
* under the fling minimum). */
|
|
61
|
+
onTransformEnd?: (velocity: Velocity) => void
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
// The merged transform recognizer: pan + pinch + rotate as ONE gesture over
|
|
@@ -147,6 +152,10 @@ export function createTransform(options: TransformOptions) {
|
|
|
147
152
|
// immediately would bake a jolt into the next delta. A rebase frame
|
|
148
153
|
// emits nothing.
|
|
149
154
|
let rebase = false
|
|
155
|
+
// Parent-frame focal positions per delivering flush, for the lift's
|
|
156
|
+
// velocity; a rebase translates the history by the focal jump so a
|
|
157
|
+
// finger joining or leaving keeps it continuous.
|
|
158
|
+
let tracker = createVelocityTracker()
|
|
150
159
|
|
|
151
160
|
// Centroids in all three frames, the span in window pixels (it feeds the
|
|
152
161
|
// finger-travel gates), the pair angle in the parent frame.
|
|
@@ -193,6 +202,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
193
202
|
pinch = false
|
|
194
203
|
dirty = false
|
|
195
204
|
rebase = false
|
|
205
|
+
tracker.reset()
|
|
196
206
|
}
|
|
197
207
|
let cancel = reset
|
|
198
208
|
let owner: ArenaOwner = { cancel }
|
|
@@ -204,8 +214,12 @@ export function createTransform(options: TransformOptions) {
|
|
|
204
214
|
if (rebase) {
|
|
205
215
|
// Anchor from same-age positions; emits nothing - an activation or
|
|
206
216
|
// set change must not produce a jump delta. Motion that arrived in
|
|
207
|
-
// the same batch folds into the anchor
|
|
217
|
+
// the same batch folds into the anchor (and into the velocity
|
|
218
|
+
// history's shift, one frame the fit never sees).
|
|
219
|
+
let prev = ref
|
|
208
220
|
ref = measure()
|
|
221
|
+
if (prev) tracker.shift(ref.px - prev.px, ref.py - prev.py)
|
|
222
|
+
tracker.push(ref.px, ref.py)
|
|
209
223
|
spanBase = ref.span
|
|
210
224
|
smoothSpan = ref.span
|
|
211
225
|
rebase = false
|
|
@@ -215,6 +229,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
215
229
|
if (!dirty || !ref) return
|
|
216
230
|
dirty = false
|
|
217
231
|
let m = measure()
|
|
232
|
+
tracker.push(m.px, m.py)
|
|
218
233
|
let prevSpan = smoothSpan
|
|
219
234
|
smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
|
|
220
235
|
spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
|
|
@@ -299,6 +314,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
299
314
|
pinch = Math.abs(m.span - ref.span) >= SLOP
|
|
300
315
|
spanRate = pinch ? 1 : 0
|
|
301
316
|
rebase = true
|
|
317
|
+
tracker.reset()
|
|
302
318
|
options.onTransformStart?.()
|
|
303
319
|
return
|
|
304
320
|
}
|
|
@@ -310,12 +326,16 @@ export function createTransform(options: TransformOptions) {
|
|
|
310
326
|
arena.release(e.pointerId, owner)
|
|
311
327
|
pointers.delete(e.pointerId)
|
|
312
328
|
if (pointers.size === 0) {
|
|
329
|
+
// The lift's own position is not measured (no terminator
|
|
330
|
+
// follows it); the fit over the delivered frames is the speed.
|
|
331
|
+
let velocity = flingVelocity(tracker.velocity())
|
|
313
332
|
active = false
|
|
314
333
|
ref = null
|
|
315
334
|
pinch = false
|
|
316
335
|
dirty = false
|
|
317
336
|
rebase = false
|
|
318
|
-
|
|
337
|
+
tracker.reset()
|
|
338
|
+
options.onTransformEnd?.(velocity)
|
|
319
339
|
} else {
|
|
320
340
|
rebase = true
|
|
321
341
|
pinch = pinch && pointers.size >= 2
|
package/src/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/// <reference path="./runtime-modules.d.ts" />
|
|
3
3
|
|
|
4
4
|
import type { Gradient } from "./color"
|
|
5
|
+
import type { CursorId, CursorName } from "./cursor"
|
|
5
6
|
import type { ProgramId, TextureBindings, TextureId } from "flux:gpu"
|
|
6
7
|
import type { TextInputHints } from "flux:rendertree"
|
|
7
8
|
import type { Element } from "solid-js"
|
|
@@ -172,6 +173,11 @@ export interface PaintProps {
|
|
|
172
173
|
strokeCap?: StrokeCap
|
|
173
174
|
strokeJoin?: StrokeJoin
|
|
174
175
|
strokeMiter?: number
|
|
176
|
+
/**
|
|
177
|
+
* Stroke thickness in logical px; default 1 (SVG's). 0 draws no stroke at
|
|
178
|
+
* all, like CSS `border-width: 0` - never a hairline - so a stroke-only
|
|
179
|
+
* shape (or a line) at 0 draws nothing.
|
|
180
|
+
*/
|
|
175
181
|
strokeWidth?: number
|
|
176
182
|
}
|
|
177
183
|
|
|
@@ -380,6 +386,13 @@ export interface PointerProps {
|
|
|
380
386
|
*/
|
|
381
387
|
focusable?: boolean
|
|
382
388
|
pointerEvents?: "auto" | "none" | "all"
|
|
389
|
+
/**
|
|
390
|
+
* The mouse cursor while this element is hovered: a platform shape by its
|
|
391
|
+
* CSS name, `"none"` to hide it, or a createCursor handle for an image.
|
|
392
|
+
* The innermost hovered element that sets one wins; none on the path
|
|
393
|
+
* means the default arrow. Mouse and pen; touch shows no cursor.
|
|
394
|
+
*/
|
|
395
|
+
cursor?: CursorName | CursorId
|
|
383
396
|
}
|
|
384
397
|
|
|
385
398
|
/**
|
|
@@ -643,17 +656,22 @@ export interface TransitionProps {
|
|
|
643
656
|
*/
|
|
644
657
|
stagger?: number
|
|
645
658
|
/**
|
|
646
|
-
* Layout slide: when a layout moves this element (a row
|
|
647
|
-
* above it, one inserted, a `<For>` reorder, a
|
|
648
|
-
*
|
|
649
|
-
*
|
|
659
|
+
* Layout slide: when a layout moves or resizes this element (a row
|
|
660
|
+
* removed above it, one inserted, a `<For>` reorder, a card growing
|
|
661
|
+
* to fit more content, a window resize), it slides from the box it
|
|
662
|
+
* was painted at to its new box on this motion instead of jumping,
|
|
663
|
+
* position and size, and is painted, clipped, hit-tested and
|
|
664
|
+
* backdrop-filtered at the box it is drawn at. While its size is in
|
|
665
|
+
* flight its children are laid out against that box every frame
|
|
666
|
+
* (text re-wraps, rows re-flow), as under a CSS width transition; a
|
|
667
|
+
* child's own `layout` slide yields to the parent's motion. A reflow
|
|
650
668
|
* mid-slide retargets (a spring keeps its velocity). Its first
|
|
651
669
|
* layout, a reparent and a `display: none` toggle snap. The slide is
|
|
652
670
|
* parent-relative: a parent that moves carries its children at
|
|
653
671
|
* once, so declare it on every level that should glide. `true`
|
|
654
672
|
* borrows the `all` entry's motion (an error without one); `all`
|
|
655
|
-
* alone never slides.
|
|
656
|
-
*
|
|
673
|
+
* alone never slides. Settles with onTransitionEnd property
|
|
674
|
+
* `"layout"`.
|
|
657
675
|
*/
|
|
658
676
|
layout?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand | boolean
|
|
659
677
|
} & {
|
|
@@ -804,7 +822,16 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
|
|
|
804
822
|
* pixels beneath at that point in the frame, so treat it as a deliberate
|
|
805
823
|
* panel, not a casual style. Inside a `repaintBoundary="snapshot"`
|
|
806
824
|
* subtree it sees only that boundary's own offscreen content, never what
|
|
807
|
-
* is behind the boundary (the same containment `blendMode` has there)
|
|
825
|
+
* is behind the boundary (the same containment `blendMode` has there),
|
|
826
|
+
* and under an ancestor with a `filter` it sees that ancestor's
|
|
827
|
+
* composited layer. An ancestor's `opacity` is not a containment: the
|
|
828
|
+
* glass keeps its blur through the fade (the runtime re-filters the
|
|
829
|
+
* window ahead of the fading group at the group's opacity), so a panel
|
|
830
|
+
* entering with `from: { opacity: 0 }` frosts in as it appears. That
|
|
831
|
+
* re-filter is an approximation: the group's sharp pixels then draw over
|
|
832
|
+
* the blurred ones at its opacity, so mid-fade the glass reads partly
|
|
833
|
+
* sharp, most at a low opacity; exact group blending would need the
|
|
834
|
+
* capture inside the group's layer, which the renderer cannot do.
|
|
808
835
|
*/
|
|
809
836
|
backdropFilter?: FilterProps
|
|
810
837
|
}
|
|
@@ -1052,6 +1079,16 @@ export interface TextProps extends PaintProps, PointerProps, TextRunProps {
|
|
|
1052
1079
|
*/
|
|
1053
1080
|
export interface TextureProps extends PaintProps, PointerProps {
|
|
1054
1081
|
src?: TextureId
|
|
1082
|
+
/**
|
|
1083
|
+
* Corner radius, as on a rect: the image is drawn as a rounded rect filled
|
|
1084
|
+
* with the texture, in one draw and with no clip, so a frosted or image
|
|
1085
|
+
* pane keeps rounded corners at the cost of the plain draw (a rounded
|
|
1086
|
+
* `clipRadius` around it costs a tiled GPU a third of the frame per ten
|
|
1087
|
+
* panes while the box is in flight). A single number rounds all four
|
|
1088
|
+
* corners; an array is [top-left, top-right, bottom-right, bottom-left].
|
|
1089
|
+
* Animatable in its single-number form, like a rect's.
|
|
1090
|
+
*/
|
|
1091
|
+
radius?: number | [number, number, number, number]
|
|
1055
1092
|
/**
|
|
1056
1093
|
* How the texture's pixels map to the element box (CSS object-fit).
|
|
1057
1094
|
* "fill" (default) stretches; "cover" and "none" crop; "contain" and
|
package/src/velocity.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The velocity tracker every recognizer reads at a lift: the pointer's
|
|
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
|
|
7
|
+
// estimator whatever recognizer asks (createPan, createTransform, the
|
|
8
|
+
// pointer feed), so the 2d camera and a control after it never re-derive
|
|
9
|
+
// their own.
|
|
10
|
+
//
|
|
11
|
+
// Samples are positions in whatever frame the caller measures (the
|
|
12
|
+
// recognizers push the node's parent frame, so the velocity applies 1:1
|
|
13
|
+
// 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.
|
|
16
|
+
|
|
17
|
+
// Fit horizon: Flutter's and Android's VelocityTracker window.
|
|
18
|
+
const VELOCITY_WINDOW_MS = 100
|
|
19
|
+
// Largest speed reported, px/s (Flutter's kMaxFlingVelocity): a sensor
|
|
20
|
+
// glitch cannot launch the content across the world.
|
|
21
|
+
const VELOCITY_MAX = 8000
|
|
22
|
+
// A position unchanged for this long at the read means the finger rested
|
|
23
|
+
// 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.
|
|
28
|
+
const VELOCITY_REST_MS = 50
|
|
29
|
+
// Ring capacity: a 100 ms window holds 12 samples at 120 Hz; the rest is
|
|
30
|
+
// room for a burst of same-frame samples.
|
|
31
|
+
const VELOCITY_SAMPLES = 20
|
|
32
|
+
/** Release speeds under this (px/s, Flutter's kMinFlingVelocity) are not a
|
|
33
|
+
* fling: a recognizer delivers zero for them, so a finger that slowed to a
|
|
34
|
+
* stop leaves the content where it is. */
|
|
35
|
+
export const FLING_MIN_VELOCITY = 50
|
|
36
|
+
|
|
37
|
+
export type Velocity = { vx: number; vy: number }
|
|
38
|
+
|
|
39
|
+
export interface VelocityTracker {
|
|
40
|
+
/** Record a position; `at` defaults to performance.now(). */
|
|
41
|
+
push(x: number, y: number, at?: number): void
|
|
42
|
+
/** Translate every sample: a recognizer rebasing its reference point
|
|
43
|
+
* (a finger joining or leaving a transform) keeps the history
|
|
44
|
+
* continuous instead of dropping it. */
|
|
45
|
+
shift(dx: number, dy: number): void
|
|
46
|
+
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
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ZERO: Velocity = { vx: 0, vy: 0 }
|
|
53
|
+
|
|
54
|
+
/** Zero under FLING_MIN_VELOCITY, else the velocity itself. */
|
|
55
|
+
export let flingVelocity = (v: Velocity): Velocity => (Math.hypot(v.vx, v.vy) < FLING_MIN_VELOCITY ? ZERO : v)
|
|
56
|
+
|
|
57
|
+
export function createVelocityTracker(): VelocityTracker {
|
|
58
|
+
let xs = new Float64Array(VELOCITY_SAMPLES)
|
|
59
|
+
let ys = new Float64Array(VELOCITY_SAMPLES)
|
|
60
|
+
let ts = new Float64Array(VELOCITY_SAMPLES)
|
|
61
|
+
// Ring: `count` samples ending at index `head - 1`.
|
|
62
|
+
let head = 0
|
|
63
|
+
let count = 0
|
|
64
|
+
// When the position last changed (the rest rule's clock).
|
|
65
|
+
let movedAt = -Infinity
|
|
66
|
+
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
|
+
}
|
|
73
|
+
xs[head] = x
|
|
74
|
+
ys[head] = y
|
|
75
|
+
ts[head] = at
|
|
76
|
+
head = (head + 1) % VELOCITY_SAMPLES
|
|
77
|
+
if (count < VELOCITY_SAMPLES) count++
|
|
78
|
+
},
|
|
79
|
+
shift(dx, dy) {
|
|
80
|
+
for (let i = 0; i < count; i++) {
|
|
81
|
+
let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
|
|
82
|
+
xs[k] = xs[k]! + dx
|
|
83
|
+
ys[k] = ys[k]! + dy
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
reset() {
|
|
87
|
+
head = 0
|
|
88
|
+
count = 0
|
|
89
|
+
},
|
|
90
|
+
velocity(at = performance.now()) {
|
|
91
|
+
if (count < 2) return ZERO
|
|
92
|
+
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
|
|
98
|
+
for (let i = 0; i < count; i++) {
|
|
99
|
+
let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
|
|
100
|
+
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)
|
|
119
|
+
}
|
|
120
|
+
// Every sample the same age (one batched frame): no slope to read.
|
|
121
|
+
if (tt === 0) return ZERO
|
|
122
|
+
// Slopes are px per ms; the result is px per second.
|
|
123
|
+
let vx = (tx / tt) * 1000
|
|
124
|
+
let vy = (ty / tt) * 1000
|
|
125
|
+
let speed = Math.hypot(vx, vy)
|
|
126
|
+
if (speed > VELOCITY_MAX) {
|
|
127
|
+
let f = VELOCITY_MAX / speed
|
|
128
|
+
vx *= f
|
|
129
|
+
vy *= f
|
|
130
|
+
}
|
|
131
|
+
return { vx, vy }
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/video.ts
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
// A plane player (`present: "plane"`, Android) has no texture: the platform
|
|
9
9
|
// composites the picture fullscreen beneath the UI on the video's own clock,
|
|
10
10
|
// and the UI simply draws over it. It exists from open until the owner is
|
|
11
|
-
// disposed
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// disposed. On a platform without a plane the open fails (error() with kind
|
|
12
|
+
// "no-plane"), and an app that runs everywhere falls back to a texture
|
|
13
|
+
// player itself. Both players stream (the source may be a URL, read as it
|
|
14
|
+
// plays) and both run off the frame loop: a texture player's frames are
|
|
15
|
+
// decoded on their own thread and shown at the UI's cadence.
|
|
14
16
|
//
|
|
15
17
|
// The imperative primitive lives in the `flux:video` module; import { open }
|
|
16
18
|
// from "flux:video" for non-reactive use.
|
|
@@ -56,9 +58,8 @@ export type VideoStream = {
|
|
|
56
58
|
/** Pause playback; the current frame stays displayed. */
|
|
57
59
|
pause(): void
|
|
58
60
|
/**
|
|
59
|
-
* Seek to a time in seconds. A no-op while opening
|
|
60
|
-
*
|
|
61
|
-
* target frame is shown.
|
|
61
|
+
* Seek to a time in seconds. A no-op while opening and on a source that
|
|
62
|
+
* cannot seek (see `seekable`). While paused the target frame is shown.
|
|
62
63
|
*/
|
|
63
64
|
seek(seconds: number): void
|
|
64
65
|
/** Whether `seek` does anything (plain read, not a signal). */
|
package/src/window.ts
CHANGED
|
@@ -169,15 +169,22 @@ let refreshRate = 60
|
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
171
|
* Calls `fn` before every frame is painted: `tick` is the time in ms, `frame` is
|
|
172
|
-
* the present count, and `rate` is the current refresh rate in Hz. `tick`
|
|
173
|
-
* by the
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
172
|
+
* the present count, and `rate` is the current refresh rate in Hz. `tick`
|
|
173
|
+
* advances by the display refreshes since the previous frame, as the runtime
|
|
174
|
+
* counts them: exactly one refresh period per frame while the app keeps the
|
|
175
|
+
* refresh rate, and whole multiples of it when a frame takes longer (the
|
|
176
|
+
* requestAnimationFrame contract; a 21 fps app on a 90 Hz display sees
|
|
177
|
+
* 44 and 56 ms deltas, because that is how long each frame was on screen).
|
|
178
|
+
* Deltas are therefore honest and never lag the wall clock; an app that
|
|
179
|
+
* wants motion smoother than its own frame rate steps its simulation at a
|
|
180
|
+
* fixed rate or averages the deltas itself. A gap over half a second (the
|
|
181
|
+
* app was backgrounded) advances one period, not the gap. The timebase is
|
|
182
|
+
* continuous across hot reloads: every tick an instance sees is on one
|
|
183
|
+
* timebase, so dt is well-defined from the second call on. Timers freeze
|
|
184
|
+
* together with frame callbacks under the dev tools' clock control, but
|
|
185
|
+
* measure their delays against the wall clock, not this frame timeline.
|
|
186
|
+
* performance.now() is not on it either: it is real elapsed time for
|
|
187
|
+
* measuring work; Date.now() is calendar time.
|
|
181
188
|
* Returns a cleanup function; also auto-cleans within an owned scope. Outside
|
|
182
189
|
* one - an effect's apply phase, an event handler - the returned cleanup is
|
|
183
190
|
* the only handle, so return it from the apply (`return onFrame(...)`) to run
|