Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add createSpring primitive. #629

Merged
merged 15 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/spring/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 Solid Primitives Working Group

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
61 changes: 61 additions & 0 deletions packages/spring/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<p>
<img width="100%" src="https://assets.solidjs.com/banner?type=Primitives&background=tiles&project=spring" alt="Solid Primitives spring">
</p>

# @solid-primitives/spring

[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/)
[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/spring?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/spring)
[![version](https://img.shields.io/npm/v/@solid-primitives/spring?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/spring)
[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process)

A small SolidJS hook to interpolate signal changes with spring physics. Inspired by & directly forked from [`svelte-motion/spring`](https://svelte.dev/docs/svelte-motion#spring) as such, has a very familiar API design.

With this primitive, you can easily animate values that can be interpolated like `number`, `date`, and collections (arrays or nested objects) of those datatypes.

- `createSpring` - Provides a getter and setter for the spring primitive.
- `createDerivedSpring` - Provides only a getter for the spring primitive deriving from an accessor parameter. Similar to the [@solid-primitives/tween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween) API.

The following physics options are available:

- `stiffness` (number, default `0.15`) — a value between 0 and 1 where higher means a 'tighter' spring
- `damping` (number, default `0.8`) — a value between 0 and 1 where lower means a 'springier' spring
- `precision` (number, default `0.01`) — determines the threshold at which the spring is considered to have 'settled', where lower means more precise

## Installation

```bash
npm install @solid-primitives/spring
# or
yarn add @solid-primitives/spring
# or
pnpm add @solid-primitives/spring
```

## How to use it

```ts
// Basic Example
const [progress, setProgress] = createSpring(0);

// Example with options (less sudden movement)
const [radialProgress, setRadialProgress] = createSpring(0, { stiffness: 0.05 });

// Example with collections (e.g. Object or Array).
const [xy, setXY] = createSpring(
{ x: 50, y: 50 },
{ stiffness: 0.08, damping: 0.2, precision: 0.01 },
);

// Example deriving from an existing signal.
const [myNumber, myNumber] = createSignal(20);
const springedValue = createDerivedSpring(myNumber, { stiffness: 0.03 });
```

## Demo

- [CodeSandbox - Basic Example](https://codesandbox.io/p/devbox/ecstatic-borg-k2wqfr)

## Changelog

See [CHANGELOG.md](./CHANGELOG.md)
63 changes: 63 additions & 0 deletions packages/spring/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"name": "@solid-primitives/spring",
"version": "0.0.100",
"description": "Primitive that creates spring physics functions.",
"author": "Carlo Taleon <[email protected]>",
"contributors": ["Damian Tarnawski <[email protected]>"],
"license": "MIT",
"homepage": "https://primitives.solidjs.community/package/spring",
"repository": {
"type": "git",
"url": "git+https://github.com/solidjs-community/solid-primitives.git"
},
"bugs": {
"url": "https://github.com/solidjs-community/solid-primitives/issues"
},
"primitive": {
"name": "spring",
"stage": 0,
"list": [
"createSpring",
"createDerivedSpring"
],
"category": "Animation"
},
"keywords": [
"animate",
"tween",
"spring",
"solid",
"primitives"
],
"private": false,
"sideEffects": false,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"browser": {},
"exports": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"typesVersions": {},
"scripts": {
"dev": "tsx ../../scripts/dev.ts",
"build": "tsx ../../scripts/build.ts",
"vitest": "vitest -c ../../configs/vitest.config.ts",
"test": "pnpm run vitest",
"test:ssr": "pnpm run vitest --mode ssr"
},
"peerDependencies": {
"solid-js": "^1.6.12"
}
}
215 changes: 215 additions & 0 deletions packages/spring/src/index.ts
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently it allows you to pass a string as an input (or an array of strings, object with string values etc), but you could use a type like this to provide some validation

type SpringTargetPrimitive = number | Date;
type SpringTarget =
  | SpringTargetPrimitive
  | { [key: string]: SpringTargetPrimitive | SpringTarget }
  | SpringTargetPrimitive[]
  | SpringTarget[];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good suggestion. Was also considering this. I just made it close to Svelte's implementation which was more loose with types.

But under the hood it does actually only implement those specific primitives: Date, Number, Array<Date | number>, and { [key: any]: Date | number> }.

Will definitely add it here. Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While implementing this, I ran into a bit of a hiccup. Didn't know generics behaved this way lol. They seem to convert the data primitives into constants. (e.g. passing 0 will make the type 0, 1 will be 1, etc.)

image

Luckily the fix was easy:

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a side note but the "validation" will not catch this:

const [spring, setter] = createSpring({foo: [1,2,3]} as {foo: number[]} | number)
setter(101)
// runtime error: Cannot read properties of undefined (reading '0')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And it seems to break with readonly number[]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also interfaces

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in source code there is a current_value == null check, which I think is impossible with the validation.

Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { Accessor, createEffect, createSignal, onCleanup } from "solid-js";
import { isServer } from "solid-js/web";

// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/motion/utils.js

function is_date(obj: any): obj is Date {
return Object.prototype.toString.call(obj) === "[object Date]";
}

// ===========================================================================
// createSpring hook
// ===========================================================================

export type SpringOptions = {
/**
* Stiffness of the spring. Higher values will create more sudden movement.
* @default 0.15
*/
stiffness?: number;
/**
* Strength of opposing force. If set to 0, spring will oscillate indefinitely.
* @default 0.8
*/
damping?: number;
/**
* Precision is the threshold relative to the target value at which the
* animation will stop based on the current value.
*
* From 0, if the target value is 500, and the precision is 500, it will stop
* the animation instantly (no animation, similar to `hard: true`).
*
* From 0, if the target value is 500, and the precision is 0.01, it will stop the
* animation when the current value reaches 499.99 or 500.01 (longer animation).
*
* @default 0.01
*/
precision?: number;
};

export type SpringTarget =
| number
| Date
| { [key: string]: number | Date | SpringTarget }
| readonly (number | Date)[]
| readonly SpringTarget[];

/**
* "Widen" Utility Type so that number types are not converted to
* literal types when passed to `createSpring`.
*
* e.g. createSpring(0) returns `0`, not `number`.
*/
export type WidenSpringTarget<T> = T extends number ? number : T;

export type SpringSetterOptions = { hard?: boolean; soft?: boolean | number }
export type SpringSetter<T> = (
newValue: T | ((prev: T) => T),
opts?: SpringSetterOptions,
) => Promise<void>;

/**
* Creates a signal and a setter that uses spring physics when interpolating from
* one value to another. This means when the value changes, instead of
* transitioning at a steady rate, it "bounces" like a spring would,
* depending on the physics paramters provided. This adds a level of realism to
* the transitions and can enhance the user experience.
*
* `T` - The type of the signal. It works for the basic data types that can be
* interpolated: `number`, a `Date`, `Array<T>` or a nested object of T.
*
* @param initialValue The initial value of the signal.
* @param options Options to configure the physics of the spring.
* @returns Returns the spring value and a setter.
*
* @example
* const [progress, setProgress] = createSpring(0, { stiffness: 0.15, damping: 0.8 });
*/
export function createSpring<T extends SpringTarget>(
initialValue: T,
options: SpringOptions = {},
): [Accessor<WidenSpringTarget<T>>, SpringSetter<WidenSpringTarget<T>>] {
const [signal, setSignal] = createSignal(initialValue);
const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options;

if (isServer) {
return [signal as any, ((param: any, opts: SpringSetterOptions = {}) => {
if (opts.hard || signal() == null || (stiffness >= 1 && damping >= 1)) {
setSignal(param);
return Promise.resolve();
}
return new Promise(() => {});
}) as any]
}

let value_current = initialValue;
let value_last = initialValue;
let value_target = initialValue;
let inv_mass = 1;
let inv_mass_recovery_rate = 0;
let raf_id = 0
let settled = true
let time_last = 0;
let time_delta = 0
let resolve = () => {}

const cleanup = onCleanup(() => {
cancelAnimationFrame(raf_id)
raf_id = 0
resolve()
})

const frame: FrameRequestCallback = time => {
time_delta = (time - time_last) * 60 / 1000
time_last = time

inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1)
settled = true

let new_value = tick(value_last, value_current, value_target)
value_last = value_current
setSignal(value_current = new_value)

if (settled) {
cleanup()
} else {
raf_id = requestAnimationFrame(frame)
}
}

const set: SpringSetter<T> = (param, opts = {}) => {
value_target = typeof param === "function" ? param(value_current) : param;

if (opts.hard || (stiffness >= 1 && damping >= 1)) {
cleanup()
setSignal(_ => value_current = value_last = value_target);
return Promise.resolve();
}

if (opts.soft) {
inv_mass_recovery_rate = 1 / (typeof opts.soft === "number" ? opts.soft * 60 : 30);
Copy link

@rcoopr rcoopr Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hard-codes an assumed 60 fps into the inverse mass recovery feature. On different hardware, opts.soft will behave differently (inv_mass will return to 1 more quickly with higher framerate) and more importantly the velocity will be incorrect.

I'm not sure the best fix for fps-independence here, but in my own adaptation I have gone for tracking the fps as Math.max(fps, 1000 / (time - timeLast)) in the frame function above

See sveltejs/svelte#10717

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out tracking fps this way is very flawed. At best it gives an inaccurate value (consistently getting ~180 fps when I should really be getting 166) and at worst is can go to extreme values and be very inconsistent.

I can look into frame-independent impls unless somebody else wants to jump in with a solution

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition would be to keep the recovery rate as a constant, only depending on opts.soft.
And use time delta in inv_mass = Math.min(inv_mass + inv_mass_recovery_rate * time_delta, 1)
But I'm curious about other directions.
I'll try to add a test for framerate-independence since we are mocking the raf anyway.

Copy link

@rcoopr rcoopr Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to fix the inv_mass_recovery_rate to actually recover in the correct amount of time, but couldn't accomplish the same for the tick function

const tick = (last: T, current: T, target: T): any => {
if (typeof current === "number" || is_date(current)) {
const delta = +target - +current;
const velocity = (+current - +last) / (time_delta || 1 / 60); // guard div by 0
const spring = stiffness * delta;
const damper = damping * velocity;
const acceleration = (spring - damper) * inv_mass;
const d = (velocity + acceleration) * time_delta;

The frame-dependent time_delta is baked into this implementation in tick. It doesn't quite cancel out even though d is multipled by time_delta in the end either, but I'm not totally sure what is supposed to be going on instead.

https://github.com/pqml/spring/blob/master/lib/Spring.js - this looks promising, it is based on picking a timestep, checking how many of those timesteps have passed since the last frame, and solving for the position that many times

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I'm using the same technique in my force-graph implementation—which has the same problem that it cannot be simply "multiplied by delta_time" to make the simulation frame-independent—and it works pretty well: https://github.com/thetarnav/force-graph/blob/main/index.mjs#L86-L98

inv_mass = 0; // Infinite mass, unaffected by spring forces.
}

if (raf_id === 0) {
time_last = performance.now()
Copy link

@rcoopr rcoopr Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be something like Number(document.timeline.currentTime || performance.now()) ala MDN

Otherwise, in cases where document.timeline.currentTime is available, the first frame could have a negative timeDelta

Copy link
Member

@thetarnav thetarnav Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm youre right
I'm consistently getting negative values with performance.now.

image

But with document.timeline.currentTime it's always zero.
Which makes me think that maybe this would be better:

requestAnimationFrame(prev_time => {
	function frame(time) {
		let delta = time-prev_time
		prev_time = time
		requestAnimationFrame(frame)
	}
	requestAnimationFrame(frame)
})
// or
let prev_time = Infinity
function frame(time) {
	let delta = Math.max(0, time-prev_time)
	prev_time = time
	requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

Since the first iteration will be ran with 0 delta anyway which won't progress the animation at all, so it could be skipped.
Instead of relying on document.timeline.currentTime, which may be and may not be there, and falling back to performance.now which is wrong.

raf_id = requestAnimationFrame(frame)
}

return new Promise<void>(r => resolve = r);
};

const tick = (last: T, current: T, target: T): any => {
if (typeof current === "number" || is_date(current)) {
const delta = +target - +current;
const velocity = (+current - +last) / (time_delta || 1 / 60); // guard div by 0
const spring = stiffness * delta;
const damper = damping * velocity;
const acceleration = (spring - damper) * inv_mass;
const d = (velocity + acceleration) * time_delta;

if (Math.abs(d) < precision && Math.abs(delta) < precision) {
return target; // settled
}

settled = false; // signal loop to keep ticking
return typeof current === "number" ? current + d : new Date(+current + d);
}

if (Array.isArray(current)) {
// @ts-expect-error
return current.map((_, i) => tick(last[i], current[i], target[i]));
}

if (typeof current === "object") {
const next = {...current};
for (const k in current) {
// @ts-expect-error
next[k] = tick(last[k], current[k], target[k]);
}
return next;
}

throw new Error(`Cannot spring ${typeof current} values`);
};

return [signal as any, set as any];
}

// ===========================================================================
// createDerivedSpring hook
// ===========================================================================

/**
* Creates a spring value that interpolates based on changes on a passed signal.
* Works similar to the `@solid-primitives/tween`
*
* @param target Target to be modified.
* @param options Options to configure the physics of the spring.
* @returns Returns the spring value only.
*
* @example
* const percent = createMemo(() => current() / total() * 100);
*
* const springedPercent = createDerivedSignal(percent, { stiffness: 0.15, damping: 0.8 });
*/
export function createDerivedSpring<T extends SpringTarget>(
target: Accessor<T>,
options?: SpringOptions,
) {
const [springValue, setSpringValue] = createSpring(target(), options);

createEffect(() => setSpringValue(target() as WidenSpringTarget<T>));

return springValue;
}
Loading
Loading