@sweberdev/lagrangian 0.1.0
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/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/index.cjs +1163 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +382 -0
- package/dist/index.d.ts +382 -0
- package/dist/index.js +1112 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
- package/src/animate.ts +172 -0
- package/src/decay.ts +127 -0
- package/src/drag.ts +174 -0
- package/src/index.ts +54 -0
- package/src/integrate.ts +61 -0
- package/src/loop.ts +73 -0
- package/src/reduced-motion.ts +17 -0
- package/src/spring.ts +140 -0
- package/src/value.ts +147 -0
- package/src/velocity.ts +50 -0
- package/src/world.ts +553 -0
package/src/drag.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Drag an element with the pointer and throw it: rubber-band past the bounds, glide with
|
|
2
|
+
// friction on release, settle on snap points. The element moves via its x/y physical values,
|
|
3
|
+
// so `animate()` and a drag can hand over to each other at any moment.
|
|
4
|
+
|
|
5
|
+
import { getValue } from "./animate";
|
|
6
|
+
import { rubberClamp, type Snap } from "./decay";
|
|
7
|
+
import type { SpringOptions } from "./spring";
|
|
8
|
+
import type { PhysicsValue } from "./value";
|
|
9
|
+
|
|
10
|
+
export interface Bounds {
|
|
11
|
+
left?: number;
|
|
12
|
+
right?: number;
|
|
13
|
+
top?: number;
|
|
14
|
+
bottom?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DragInfo {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
velocityX: number;
|
|
21
|
+
velocityY: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DraggableOptions {
|
|
25
|
+
/** Restrict movement to one axis. Default "both". */
|
|
26
|
+
axis?: "x" | "y" | "both";
|
|
27
|
+
/**
|
|
28
|
+
* Movement limits in pixels relative to the start position, or an element the dragged
|
|
29
|
+
* element has to stay inside.
|
|
30
|
+
*/
|
|
31
|
+
bounds?: Bounds | Element | (() => Bounds | Element);
|
|
32
|
+
/** Resistance past the bounds, 0 to 1 (0.55 like iOS), or false to stop hard at the edge. */
|
|
33
|
+
rubberband?: number | false;
|
|
34
|
+
/** Keep moving with friction after release. Default true. */
|
|
35
|
+
inertia?: boolean;
|
|
36
|
+
/** Fraction of speed kept per millisecond during the throw. Default 0.998. */
|
|
37
|
+
deceleration?: number;
|
|
38
|
+
/** Snap points per axis, in pixels relative to the start position. */
|
|
39
|
+
snap?: { x?: Snap; y?: Snap };
|
|
40
|
+
/** Spring for snapping and for bouncing off the bounds. */
|
|
41
|
+
spring?: SpringOptions;
|
|
42
|
+
onStart?: (info: DragInfo) => void;
|
|
43
|
+
onMove?: (info: DragInfo) => void;
|
|
44
|
+
/** Called on release with the throw velocity. */
|
|
45
|
+
onEnd?: (info: DragInfo) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Draggable {
|
|
49
|
+
readonly x: PhysicsValue;
|
|
50
|
+
readonly y: PhysicsValue;
|
|
51
|
+
readonly dragging: boolean;
|
|
52
|
+
destroy(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Range = [number, number];
|
|
56
|
+
const FREE: Range = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
|
|
57
|
+
|
|
58
|
+
function ranges(element: Element, x: number, y: number, bounds: DraggableOptions["bounds"]) {
|
|
59
|
+
const b = typeof bounds === "function" ? bounds() : bounds;
|
|
60
|
+
if (!b) return { x: FREE, y: FREE };
|
|
61
|
+
if (typeof Element !== "undefined" && b instanceof Element) {
|
|
62
|
+
const outer = b.getBoundingClientRect();
|
|
63
|
+
const inner = element.getBoundingClientRect();
|
|
64
|
+
// Origin of the element without its current translation.
|
|
65
|
+
const left = inner.left - x;
|
|
66
|
+
const top = inner.top - y;
|
|
67
|
+
return {
|
|
68
|
+
x: [outer.left - left, outer.right - left - inner.width] as Range,
|
|
69
|
+
y: [outer.top - top, outer.bottom - top - inner.height] as Range,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const box = b as Bounds;
|
|
73
|
+
return {
|
|
74
|
+
x: [box.left ?? FREE[0], box.right ?? FREE[1]] as Range,
|
|
75
|
+
y: [box.top ?? FREE[0], box.bottom ?? FREE[1]] as Range,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Makes an element draggable and throwable. */
|
|
80
|
+
export function draggable(element: HTMLElement, options: DraggableOptions = {}): Draggable {
|
|
81
|
+
const axis = options.axis ?? "both";
|
|
82
|
+
const x = getValue(element, "x");
|
|
83
|
+
const y = getValue(element, "y");
|
|
84
|
+
const moveX = axis !== "y";
|
|
85
|
+
const moveY = axis !== "x";
|
|
86
|
+
let pointer: number | null = null;
|
|
87
|
+
let start = { px: 0, py: 0, x: 0, y: 0 };
|
|
88
|
+
let range = { x: FREE, y: FREE };
|
|
89
|
+
|
|
90
|
+
const info = (): DragInfo => ({
|
|
91
|
+
x: x.get(),
|
|
92
|
+
y: y.get(),
|
|
93
|
+
velocityX: x.velocity,
|
|
94
|
+
velocityY: y.velocity,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const previousTouchAction = element.style.touchAction;
|
|
98
|
+
element.style.touchAction = axis === "x" ? "pan-y" : axis === "y" ? "pan-x" : "none";
|
|
99
|
+
|
|
100
|
+
const place = (value: number, [min, max]: Range, dimension: number) => {
|
|
101
|
+
if (options.rubberband === false) return Math.min(Math.max(value, min), max);
|
|
102
|
+
return rubberClamp(value, min, max, dimension, options.rubberband ?? 0.55);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const down = (event: PointerEvent) => {
|
|
106
|
+
if (pointer !== null || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
107
|
+
pointer = event.pointerId;
|
|
108
|
+
element.setPointerCapture?.(event.pointerId);
|
|
109
|
+
x.stop();
|
|
110
|
+
y.stop();
|
|
111
|
+
start = { px: event.clientX, py: event.clientY, x: x.get(), y: y.get() };
|
|
112
|
+
range = ranges(element, start.x, start.y, options.bounds);
|
|
113
|
+
// Grabbing stops the momentum; the samples from here on give the throw velocity.
|
|
114
|
+
x.jump(start.x);
|
|
115
|
+
y.jump(start.y);
|
|
116
|
+
if (moveX) x.set(start.x);
|
|
117
|
+
if (moveY) y.set(start.y);
|
|
118
|
+
options.onStart?.(info());
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const move = (event: PointerEvent) => {
|
|
122
|
+
if (event.pointerId !== pointer) return;
|
|
123
|
+
const viewport =
|
|
124
|
+
typeof window !== "undefined" ? window : { innerWidth: 1000, innerHeight: 1000 };
|
|
125
|
+
if (moveX) x.set(place(start.x + event.clientX - start.px, range.x, viewport.innerWidth));
|
|
126
|
+
if (moveY) y.set(place(start.y + event.clientY - start.py, range.y, viewport.innerHeight));
|
|
127
|
+
options.onMove?.(info());
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const release = (value: PhysicsValue, [min, max]: Range, snap: Snap | undefined) => {
|
|
131
|
+
const throwOptions = {
|
|
132
|
+
min,
|
|
133
|
+
max,
|
|
134
|
+
bounce: options.spring,
|
|
135
|
+
...(options.deceleration !== undefined ? { deceleration: options.deceleration } : {}),
|
|
136
|
+
...(snap !== undefined ? { snap } : {}),
|
|
137
|
+
};
|
|
138
|
+
if (options.inertia !== false) {
|
|
139
|
+
value.throw(throwOptions);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Without inertia only snap points and bounds move the element after release.
|
|
143
|
+
value.throw({ ...throwOptions, velocity: 0 });
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const up = (event: PointerEvent) => {
|
|
147
|
+
if (event.pointerId !== pointer) return;
|
|
148
|
+
pointer = null;
|
|
149
|
+
element.releasePointerCapture?.(event.pointerId);
|
|
150
|
+
options.onEnd?.(info());
|
|
151
|
+
if (moveX) release(x, range.x, options.snap?.x);
|
|
152
|
+
if (moveY) release(y, range.y, options.snap?.y);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
element.addEventListener("pointerdown", down);
|
|
156
|
+
element.addEventListener("pointermove", move);
|
|
157
|
+
element.addEventListener("pointerup", up);
|
|
158
|
+
element.addEventListener("pointercancel", up);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
x,
|
|
162
|
+
y,
|
|
163
|
+
get dragging() {
|
|
164
|
+
return pointer !== null;
|
|
165
|
+
},
|
|
166
|
+
destroy() {
|
|
167
|
+
element.removeEventListener("pointerdown", down);
|
|
168
|
+
element.removeEventListener("pointermove", move);
|
|
169
|
+
element.removeEventListener("pointerup", up);
|
|
170
|
+
element.removeEventListener("pointercancel", up);
|
|
171
|
+
element.style.touchAction = previousTouchAction;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type AnimateOptions,
|
|
3
|
+
animate,
|
|
4
|
+
getValue,
|
|
5
|
+
type Prop,
|
|
6
|
+
type Props,
|
|
7
|
+
set,
|
|
8
|
+
type TransformProp,
|
|
9
|
+
} from "./animate";
|
|
10
|
+
export {
|
|
11
|
+
type DecayOptions,
|
|
12
|
+
decay,
|
|
13
|
+
nearest,
|
|
14
|
+
projectRest,
|
|
15
|
+
rubberband,
|
|
16
|
+
rubberClamp,
|
|
17
|
+
type Snap,
|
|
18
|
+
timeConstant,
|
|
19
|
+
} from "./decay";
|
|
20
|
+
export {
|
|
21
|
+
type Bounds,
|
|
22
|
+
type Draggable,
|
|
23
|
+
type DraggableOptions,
|
|
24
|
+
type DragInfo,
|
|
25
|
+
draggable,
|
|
26
|
+
} from "./drag";
|
|
27
|
+
export { type Derivatives, rk4, type System, system } from "./integrate";
|
|
28
|
+
export { loop, now, type Task } from "./loop";
|
|
29
|
+
export { type ReducedMotion, reducedMotion, setReducedMotion } from "./reduced-motion";
|
|
30
|
+
export {
|
|
31
|
+
dampingRatio,
|
|
32
|
+
type Motion,
|
|
33
|
+
type MotionState,
|
|
34
|
+
restValue,
|
|
35
|
+
type SpringOptions,
|
|
36
|
+
type SpringParams,
|
|
37
|
+
settleTime,
|
|
38
|
+
spring,
|
|
39
|
+
springParams,
|
|
40
|
+
} from "./spring";
|
|
41
|
+
export { type Animation, type Listener, PhysicsValue, value } from "./value";
|
|
42
|
+
export { VelocityTracker } from "./velocity";
|
|
43
|
+
export {
|
|
44
|
+
type Body,
|
|
45
|
+
type BodyOptions,
|
|
46
|
+
type Grab,
|
|
47
|
+
type Link,
|
|
48
|
+
type LinkOptions,
|
|
49
|
+
type Rect,
|
|
50
|
+
type Vector,
|
|
51
|
+
World,
|
|
52
|
+
type WorldOptions,
|
|
53
|
+
world,
|
|
54
|
+
} from "./world";
|
package/src/integrate.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Runge-Kutta 4 for your own equations of motion, e.g. derived from a Lagrangian.
|
|
2
|
+
// Write the system as first-order derivatives of a state vector and step it.
|
|
3
|
+
|
|
4
|
+
/** Returns d(state)/dt for a state at time t. */
|
|
5
|
+
export type Derivatives = (state: readonly number[], t: number) => number[];
|
|
6
|
+
|
|
7
|
+
/** One classic fourth-order Runge-Kutta step of size `dt`. */
|
|
8
|
+
export function rk4(state: readonly number[], t: number, dt: number, f: Derivatives): number[] {
|
|
9
|
+
const n = state.length;
|
|
10
|
+
const add = (k: readonly number[], h: number) => state.map((s, i) => s + (k[i] ?? 0) * h);
|
|
11
|
+
const k1 = f(state, t);
|
|
12
|
+
const k2 = f(add(k1, dt / 2), t + dt / 2);
|
|
13
|
+
const k3 = f(add(k2, dt / 2), t + dt / 2);
|
|
14
|
+
const k4 = f(add(k3, dt), t + dt);
|
|
15
|
+
const next = new Array<number>(n);
|
|
16
|
+
for (let i = 0; i < n; i++) {
|
|
17
|
+
next[i] =
|
|
18
|
+
(state[i] ?? 0) +
|
|
19
|
+
(dt / 6) * ((k1[i] ?? 0) + 2 * (k2[i] ?? 0) + 2 * (k3[i] ?? 0) + (k4[i] ?? 0));
|
|
20
|
+
}
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface System {
|
|
25
|
+
/** Current state vector. */
|
|
26
|
+
readonly state: readonly number[];
|
|
27
|
+
/** Simulated time in seconds. */
|
|
28
|
+
readonly time: number;
|
|
29
|
+
/** Advances by `seconds` in fixed steps, so results do not depend on the frame rate. */
|
|
30
|
+
advance(seconds: number): readonly number[];
|
|
31
|
+
/** Replaces the state, e.g. after the user grabbed something. */
|
|
32
|
+
reset(state: readonly number[]): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A system of ODEs stepped with RK4 at a fixed step (default 1/240 s). */
|
|
36
|
+
export function system(f: Derivatives, initial: readonly number[], step = 1 / 240): System {
|
|
37
|
+
let state = [...initial];
|
|
38
|
+
let time = 0;
|
|
39
|
+
let pending = 0;
|
|
40
|
+
return {
|
|
41
|
+
get state() {
|
|
42
|
+
return state;
|
|
43
|
+
},
|
|
44
|
+
get time() {
|
|
45
|
+
return time;
|
|
46
|
+
},
|
|
47
|
+
advance(seconds) {
|
|
48
|
+
pending += seconds;
|
|
49
|
+
while (pending >= step) {
|
|
50
|
+
state = rk4(state, time, step, f);
|
|
51
|
+
time += step;
|
|
52
|
+
pending -= step;
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
},
|
|
56
|
+
reset(next) {
|
|
57
|
+
state = [...next];
|
|
58
|
+
pending = 0;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/loop.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// One shared frame loop for every running value and world.
|
|
2
|
+
// Tasks get the frame time step in seconds. A long pause (hidden tab, debugger) is clamped so a
|
|
3
|
+
// simulation never takes one giant step and objects never fly through walls.
|
|
4
|
+
|
|
5
|
+
export type Task = (dt: number, now: number) => void;
|
|
6
|
+
|
|
7
|
+
const MAX_DT = 1 / 20;
|
|
8
|
+
const tasks = new Set<Task>();
|
|
9
|
+
let scheduled = false;
|
|
10
|
+
let manual = false;
|
|
11
|
+
let last = -1;
|
|
12
|
+
let clock = 0;
|
|
13
|
+
|
|
14
|
+
const hasRaf = () => typeof requestAnimationFrame === "function";
|
|
15
|
+
|
|
16
|
+
function schedule(): void {
|
|
17
|
+
if (scheduled || manual || tasks.size === 0) return;
|
|
18
|
+
scheduled = true;
|
|
19
|
+
if (hasRaf()) requestAnimationFrame(frame);
|
|
20
|
+
else setTimeout(() => frame(now()), 16);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function frame(time: number): void {
|
|
24
|
+
scheduled = false;
|
|
25
|
+
if (manual) return;
|
|
26
|
+
const dt = last < 0 ? 1 / 60 : Math.min(Math.max((time - last) / 1000, 0), MAX_DT);
|
|
27
|
+
last = time;
|
|
28
|
+
run(dt, time);
|
|
29
|
+
if (tasks.size > 0) schedule();
|
|
30
|
+
else last = -1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(dt: number, time: number): void {
|
|
34
|
+
for (const task of [...tasks]) {
|
|
35
|
+
if (tasks.has(task)) task(dt, time);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Current time in milliseconds, from the manual clock while the loop is in manual mode. */
|
|
40
|
+
export function now(): number {
|
|
41
|
+
if (manual) return clock;
|
|
42
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const loop = {
|
|
46
|
+
/** Runs `task` on every frame until the returned function is called. */
|
|
47
|
+
add(task: Task): () => void {
|
|
48
|
+
tasks.add(task);
|
|
49
|
+
schedule();
|
|
50
|
+
return () => {
|
|
51
|
+
tasks.delete(task);
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* Switches to manual stepping (for tests, server rendering or video export) or back to
|
|
56
|
+
* requestAnimationFrame.
|
|
57
|
+
*/
|
|
58
|
+
manual(on = true): void {
|
|
59
|
+
manual = on;
|
|
60
|
+
last = -1;
|
|
61
|
+
if (!on) schedule();
|
|
62
|
+
},
|
|
63
|
+
/** Advances the manual clock by `ms` and runs one frame. Only has an effect in manual mode. */
|
|
64
|
+
step(ms = 1000 / 60): void {
|
|
65
|
+
if (!manual) return;
|
|
66
|
+
clock += ms;
|
|
67
|
+
run(Math.min(ms / 1000, MAX_DT), clock);
|
|
68
|
+
},
|
|
69
|
+
/** Number of running tasks. */
|
|
70
|
+
get size(): number {
|
|
71
|
+
return tasks.size;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type ReducedMotion = "user" | "always" | "never";
|
|
2
|
+
|
|
3
|
+
let mode: ReducedMotion = "user";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "user" (default) follows the prefers-reduced-motion setting, "always" skips animated
|
|
7
|
+
* transitions everywhere, "never" ignores the setting. Direct manipulation (dragging, the
|
|
8
|
+
* world simulation) always follows the pointer; only automatic motion is skipped.
|
|
9
|
+
*/
|
|
10
|
+
export function setReducedMotion(value: ReducedMotion): void {
|
|
11
|
+
mode = value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function reducedMotion(): boolean {
|
|
15
|
+
if (mode !== "user") return mode === "always";
|
|
16
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
17
|
+
}
|
package/src/spring.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Damped harmonic oscillator, solved in closed form: m·x'' + c·x' + k·(x − target) = 0.
|
|
2
|
+
// The exact solution means there is no integration error, no frame-rate dependence, and the
|
|
3
|
+
// position and velocity at any moment are known, which is what makes interruptions seamless.
|
|
4
|
+
|
|
5
|
+
/** Position and velocity of a motion at one moment. */
|
|
6
|
+
export interface MotionState {
|
|
7
|
+
value: number;
|
|
8
|
+
velocity: number;
|
|
9
|
+
done: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A motion maps elapsed seconds to a state. Springs, decay and your own functions fit this. */
|
|
13
|
+
export type Motion = (t: number) => MotionState;
|
|
14
|
+
|
|
15
|
+
export interface SpringOptions {
|
|
16
|
+
/** Spring constant k. Higher is snappier. Default 170. */
|
|
17
|
+
stiffness?: number;
|
|
18
|
+
/** Damping coefficient c. Lower bounces more. Default 26. */
|
|
19
|
+
damping?: number;
|
|
20
|
+
/** Mass m. Heavier is slower and carries more momentum. Default 1. */
|
|
21
|
+
mass?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Perceptual duration in seconds, used together with `bounce` instead of
|
|
24
|
+
* stiffness and damping (as in SwiftUI). It is the period of the undamped spring.
|
|
25
|
+
*/
|
|
26
|
+
duration?: number;
|
|
27
|
+
/** -1 to 1. 0 is critically damped, 0.3 is a gentle bounce, negative values are overdamped. */
|
|
28
|
+
bounce?: number;
|
|
29
|
+
/** The motion counts as finished once it is this close to the target... */
|
|
30
|
+
restDelta?: number;
|
|
31
|
+
/** ...and slower than this (units per second). */
|
|
32
|
+
restSpeed?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SpringParams {
|
|
36
|
+
stiffness: number;
|
|
37
|
+
damping: number;
|
|
38
|
+
mass: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolves stiffness, damping and mass, also from `duration` and `bounce`. */
|
|
42
|
+
export function springParams(options: SpringOptions = {}): SpringParams {
|
|
43
|
+
const mass = options.mass ?? 1;
|
|
44
|
+
if (options.duration !== undefined || options.bounce !== undefined) {
|
|
45
|
+
const duration = Math.max(options.duration ?? 0.5, 0.01);
|
|
46
|
+
const bounce = Math.min(Math.max(options.bounce ?? 0, -0.99), 1);
|
|
47
|
+
const omega = (2 * Math.PI) / duration;
|
|
48
|
+
const zeta = bounce >= 0 ? 1 - bounce : 1 / (1 + bounce);
|
|
49
|
+
return { stiffness: mass * omega * omega, damping: 2 * mass * zeta * omega, mass };
|
|
50
|
+
}
|
|
51
|
+
return { stiffness: options.stiffness ?? 170, damping: options.damping ?? 26, mass };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Damping ratio: below 1 the spring overshoots, 1 is critical, above 1 it creeps. */
|
|
55
|
+
export function dampingRatio(options: SpringOptions = {}): number {
|
|
56
|
+
const { stiffness, damping, mass } = springParams(options);
|
|
57
|
+
return damping / (2 * Math.sqrt(stiffness * mass));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function restThresholds(from: number, to: number, options: SpringOptions = {}) {
|
|
61
|
+
const span = Math.max(Math.abs(to - from), Math.abs(to) * 0.01, 1e-3);
|
|
62
|
+
const restDelta = options.restDelta ?? Math.min(Math.max(span * 1e-3, 1e-4), 0.5);
|
|
63
|
+
return { restDelta, restSpeed: options.restSpeed ?? restDelta * 10 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A spring from `from` to `to`, starting with `velocity` (units per second).
|
|
68
|
+
* Returns the exact state for any elapsed time.
|
|
69
|
+
*/
|
|
70
|
+
export function spring(
|
|
71
|
+
from: number,
|
|
72
|
+
to: number,
|
|
73
|
+
velocity = 0,
|
|
74
|
+
options: SpringOptions = {},
|
|
75
|
+
): Motion {
|
|
76
|
+
const { stiffness: k, damping: c, mass: m } = springParams(options);
|
|
77
|
+
const { restDelta, restSpeed } = restThresholds(from, to, options);
|
|
78
|
+
const d0 = from - to;
|
|
79
|
+
const v0 = velocity;
|
|
80
|
+
const w0 = Math.sqrt(k / m);
|
|
81
|
+
const zeta = c / (2 * Math.sqrt(k * m));
|
|
82
|
+
|
|
83
|
+
let displacement: (t: number) => [number, number];
|
|
84
|
+
if (zeta < 1 - 1e-6) {
|
|
85
|
+
const a = zeta * w0;
|
|
86
|
+
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
87
|
+
const B = (v0 + a * d0) / wd;
|
|
88
|
+
displacement = (t) => {
|
|
89
|
+
const e = Math.exp(-a * t);
|
|
90
|
+
const cos = Math.cos(wd * t);
|
|
91
|
+
const sin = Math.sin(wd * t);
|
|
92
|
+
return [e * (d0 * cos + B * sin), e * ((B * wd - a * d0) * cos - (a * B + d0 * wd) * sin)];
|
|
93
|
+
};
|
|
94
|
+
} else if (zeta <= 1 + 1e-6) {
|
|
95
|
+
const B = v0 + w0 * d0;
|
|
96
|
+
displacement = (t) => {
|
|
97
|
+
const e = Math.exp(-w0 * t);
|
|
98
|
+
return [e * (d0 + B * t), e * (v0 - w0 * B * t)];
|
|
99
|
+
};
|
|
100
|
+
} else {
|
|
101
|
+
const s = Math.sqrt(zeta * zeta - 1);
|
|
102
|
+
const r1 = -w0 * (zeta - s);
|
|
103
|
+
const r2 = -w0 * (zeta + s);
|
|
104
|
+
const c1 = (v0 - r2 * d0) / (r1 - r2);
|
|
105
|
+
const c2 = d0 - c1;
|
|
106
|
+
displacement = (t) => {
|
|
107
|
+
const e1 = Math.exp(r1 * t);
|
|
108
|
+
const e2 = Math.exp(r2 * t);
|
|
109
|
+
return [c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2];
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (t) => {
|
|
114
|
+
const [x, v] = displacement(Math.max(t, 0));
|
|
115
|
+
if (Math.abs(x) <= restDelta && Math.abs(v) <= restSpeed) {
|
|
116
|
+
return { value: to, velocity: 0, done: true };
|
|
117
|
+
}
|
|
118
|
+
return { value: to + x, velocity: v, done: false };
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Seconds until a motion settles, sampled at 1 ms. Returns Infinity when it does not settle
|
|
124
|
+
* within `limit` seconds (an undamped spring never does).
|
|
125
|
+
*/
|
|
126
|
+
export function settleTime(motion: Motion, limit = 30): number {
|
|
127
|
+
for (let t = 0; t <= limit; t += 1 / 1000) {
|
|
128
|
+
if (motion(t).done) return t;
|
|
129
|
+
}
|
|
130
|
+
return Number.POSITIVE_INFINITY;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The value a motion comes to rest at, or its value after `limit` seconds. */
|
|
134
|
+
export function restValue(motion: Motion, limit = 30): number {
|
|
135
|
+
for (let t = 0; t <= limit; t += 1 / 60) {
|
|
136
|
+
const state = motion(t);
|
|
137
|
+
if (state.done) return state.value;
|
|
138
|
+
}
|
|
139
|
+
return motion(limit).value;
|
|
140
|
+
}
|
package/src/value.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// A number with momentum. It always knows its velocity, so a new animation, a throw or a drag
|
|
2
|
+
// can take over mid-flight without a jolt: the physics continues from the current state.
|
|
3
|
+
|
|
4
|
+
import { type DecayOptions, decay } from "./decay";
|
|
5
|
+
import { loop, now } from "./loop";
|
|
6
|
+
import { reducedMotion } from "./reduced-motion";
|
|
7
|
+
import { type Motion, restValue, type SpringOptions, spring } from "./spring";
|
|
8
|
+
import { VelocityTracker } from "./velocity";
|
|
9
|
+
|
|
10
|
+
/** A running animation. Await `finished` to know whether it completed. */
|
|
11
|
+
export interface Animation {
|
|
12
|
+
/** Resolves with true when the motion came to rest, false when it was interrupted. */
|
|
13
|
+
readonly finished: Promise<boolean>;
|
|
14
|
+
stop(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Listener = (value: number, velocity: number) => void;
|
|
18
|
+
|
|
19
|
+
export function settled(value: boolean): Animation {
|
|
20
|
+
const finished = Promise.resolve(value);
|
|
21
|
+
return { finished, stop() {} };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class PhysicsValue {
|
|
25
|
+
#value: number;
|
|
26
|
+
#velocity = 0;
|
|
27
|
+
#tracked = false;
|
|
28
|
+
#listeners = new Set<Listener>();
|
|
29
|
+
#tracker = new VelocityTracker();
|
|
30
|
+
#cancel: (() => void) | null = null;
|
|
31
|
+
#resolve: ((completed: boolean) => void) | null = null;
|
|
32
|
+
|
|
33
|
+
constructor(initial = 0) {
|
|
34
|
+
this.#value = initial;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(): number {
|
|
38
|
+
return this.#value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Units per second, from the running motion or from recent `set` calls. */
|
|
42
|
+
get velocity(): number {
|
|
43
|
+
return this.#tracked ? this.#tracker.velocity(now()) : this.#velocity;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get animating(): boolean {
|
|
47
|
+
return this.#cancel !== null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Sets the value directly (e.g. while dragging). Stops any motion and tracks the velocity. */
|
|
51
|
+
set(value: number): void {
|
|
52
|
+
this.#halt(false);
|
|
53
|
+
this.#tracked = true;
|
|
54
|
+
this.#tracker.add(now(), value);
|
|
55
|
+
this.#velocity = this.#tracker.velocity();
|
|
56
|
+
this.#emit(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sets the value and drops all momentum. */
|
|
60
|
+
jump(value: number): void {
|
|
61
|
+
this.#halt(false);
|
|
62
|
+
this.#tracker.reset();
|
|
63
|
+
this.#tracked = false;
|
|
64
|
+
this.#velocity = 0;
|
|
65
|
+
this.#emit(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Springs to `target`, starting from the current position and velocity. */
|
|
69
|
+
to(target: number, options: SpringOptions & { velocity?: number } = {}): Animation {
|
|
70
|
+
const from = this.#value;
|
|
71
|
+
return this.start(spring(from, target, options.velocity ?? this.velocity, options));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Throws the value with friction, optionally into bounds or onto snap points. */
|
|
75
|
+
throw(options: DecayOptions = {}): Animation {
|
|
76
|
+
return this.start(
|
|
77
|
+
decay(this.#value, { ...options, velocity: options.velocity ?? this.velocity }),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Runs any motion. Elapsed time starts at 0 on the next frame. */
|
|
82
|
+
start(motion: Motion): Animation {
|
|
83
|
+
this.#halt(false);
|
|
84
|
+
if (reducedMotion()) {
|
|
85
|
+
this.jump(restValue(motion));
|
|
86
|
+
return settled(true);
|
|
87
|
+
}
|
|
88
|
+
let elapsed = 0;
|
|
89
|
+
let resolve!: (completed: boolean) => void;
|
|
90
|
+
const finished = new Promise<boolean>((r) => {
|
|
91
|
+
resolve = r;
|
|
92
|
+
});
|
|
93
|
+
this.#resolve = resolve;
|
|
94
|
+
const remove = loop.add((dt) => {
|
|
95
|
+
elapsed += dt;
|
|
96
|
+
const state = motion(elapsed);
|
|
97
|
+
this.#velocity = state.velocity;
|
|
98
|
+
this.#emit(state.value);
|
|
99
|
+
if (state.done) this.#halt(true);
|
|
100
|
+
});
|
|
101
|
+
this.#cancel = remove;
|
|
102
|
+
this.#tracked = false;
|
|
103
|
+
this.#tracker.reset();
|
|
104
|
+
return {
|
|
105
|
+
finished,
|
|
106
|
+
stop: () => {
|
|
107
|
+
if (this.#resolve === resolve) this.#halt(false);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Stops where it is. The velocity is kept, so a following `to` or `throw` continues it. */
|
|
113
|
+
stop(): void {
|
|
114
|
+
this.#halt(false);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Calls `listener` on every change. Returns the unsubscribe function. */
|
|
118
|
+
on(listener: Listener): () => void {
|
|
119
|
+
this.#listeners.add(listener);
|
|
120
|
+
return () => {
|
|
121
|
+
this.#listeners.delete(listener);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#emit(value: number): void {
|
|
126
|
+
this.#value = value;
|
|
127
|
+
for (const listener of [...this.#listeners]) listener(value, this.#velocity);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#halt(completed: boolean): void {
|
|
131
|
+
const cancel = this.#cancel;
|
|
132
|
+
const resolve = this.#resolve;
|
|
133
|
+
this.#cancel = null;
|
|
134
|
+
this.#resolve = null;
|
|
135
|
+
if (cancel) {
|
|
136
|
+
cancel();
|
|
137
|
+
// Interrupted motions keep their momentum for whatever comes next.
|
|
138
|
+
if (completed) this.#velocity = 0;
|
|
139
|
+
}
|
|
140
|
+
resolve?.(completed);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Creates a value with momentum. */
|
|
145
|
+
export function value(initial = 0): PhysicsValue {
|
|
146
|
+
return new PhysicsValue(initial);
|
|
147
|
+
}
|