diff --git a/src/animationObservable/animationObservable.ts b/src/animationObservable/animationObservable.ts index e404b28..6febb26 100644 --- a/src/animationObservable/animationObservable.ts +++ b/src/animationObservable/animationObservable.ts @@ -12,6 +12,7 @@ export function animationObservable( let transition = initialTransition let state = transition.getState() + // can be extracted to reuse function notify() { for (const observer of observers) { observer() @@ -20,6 +21,7 @@ export function animationObservable( const scheduleNotifyWithCleanup = createScheduleTaskWithCleanup( notify, + // Should be current frame PRIORITY.FUTURE, ) @@ -48,6 +50,9 @@ export function animationObservable( // } transition.setTarget(target) + // If transition is in progress + // Update to the latest value before setting new target + // for smoother animations if (!transition.isFinished()) { notify() } @@ -57,6 +62,7 @@ export function animationObservable( return { get, + // can be reused observe(observer) { observers.push(observer) diff --git a/src/animationObservable/index.ts b/src/animationObservable/index.ts index 548c3c2..c0ea040 100644 --- a/src/animationObservable/index.ts +++ b/src/animationObservable/index.ts @@ -1 +1,2 @@ export * from './animationObservable' +export * from './inert' diff --git a/src/animationObservable/inert.test.ts b/src/animationObservable/inert.test.ts new file mode 100644 index 0000000..2cfc689 --- /dev/null +++ b/src/animationObservable/inert.test.ts @@ -0,0 +1,347 @@ +/** + * @jest-environment jsdom + */ + +import { observable } from '../observable' +import { groupTransition, transition } from '../transition' +import { inert } from './inert' + +const TRANSITION_DURATION = 100 + +describe('with numbers', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('starts with initial value', () => { + const o = observable(0) + const animation = inert({ duration: TRANSITION_DURATION })(o) + + expect(animation.get()).toStrictEqual(0) + expect(o.get()).toStrictEqual(animation.get()) + }) + + it('does not change over time if the source is stationary', () => { + const o = observable(0) + const animation = inert({ duration: TRANSITION_DURATION })(o) + + jest.advanceTimersByTime(TRANSITION_DURATION * 10) + + expect(animation.get()).toStrictEqual(0) + expect(o.get()).toStrictEqual(animation.get()) + }) + + it("transitions to the end value using the transition but doesn't overshoot", () => { + const o = observable(0) + const animation = inert({ duration: TRANSITION_DURATION })(o) + const endValue = 100 + + o.set(endValue) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(endValue * 0.1) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(endValue * 0.2) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.3) + + expect(animation.get()).toStrictEqual(endValue * 0.5) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.4) + + expect(animation.get()).toStrictEqual(endValue * 0.9) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(endValue * 1.0) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(endValue * 1.0) + + jest.advanceTimersByTime(TRANSITION_DURATION * 10) + + expect(animation.get()).toStrictEqual(endValue * 1.0) + + o.set(200) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(110) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(120) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(130) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(140) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(150) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.5) + + expect(animation.get()).toStrictEqual(200) + + jest.advanceTimersByTime(TRANSITION_DURATION * 10) + + expect(animation.get()).toStrictEqual(200) + }) + + it('transitions to the new end value if the target changed during the animation', () => { + const o = observable(0) + const animation = inert({ duration: TRANSITION_DURATION })(o) + + o.set(1000) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(100) + + o.set(2000) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(290) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(290 + 190) + + jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + + expect(animation.get()).toStrictEqual(290 + 190 + 190) + + jest.advanceTimersByTime(TRANSITION_DURATION) + + expect(animation.get()).toStrictEqual(2000) + }) +}) + +// describe('with objects', () => { +// beforeAll(() => { +// jest.useFakeTimers() +// }) + +// afterAll(() => { +// jest.useRealTimers() +// }) + +// it('starts with initial value', () => { +// const o = observable({ +// a: 0, +// b: 0, +// c: 0, +// d: 0, +// }) +// const t = groupTransition({ +// a: transition(0, TRANSITION_DURATION), +// b: transition(0, TRANSITION_DURATION), +// c: transition(0, TRANSITION_DURATION), +// d: transition(0, TRANSITION_DURATION), +// }) +// const animation = animationObservable(o, t) + +// expect(animation.get()).toStrictEqual({ +// a: 0, +// b: 0, +// c: 0, +// d: 0, +// }) +// }) + +// it('transitions to the end value using the transition', () => { +// const o = observable({ +// a: 0, +// b: 0, +// c: 0, +// d: 0, +// }) +// const t = groupTransition({ +// a: transition(0, TRANSITION_DURATION), +// b: transition(0, TRANSITION_DURATION), +// c: transition(0, TRANSITION_DURATION), +// d: transition(0, TRANSITION_DURATION), +// }) +// const animation = animationObservable(o, t) + +// o.set({ +// a: 10, +// b: 20, +// c: 30, +// d: 40, +// }) + +// expect(o.get()).toStrictEqual({ +// a: 10, +// b: 20, +// c: 30, +// d: 40, +// }) + +// expect(animation.get()).toStrictEqual({ +// a: 0, +// b: 0, +// c: 0, +// d: 0, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 1, +// b: 2, +// c: 3, +// d: 4, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 2, +// b: 4, +// c: 6, +// d: 8, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 3, +// b: 6, +// c: 9, +// d: 12, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 4, +// b: 8, +// c: 12, +// d: 16, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.6) + +// expect(animation.get()).toStrictEqual({ +// a: 10, +// b: 20, +// c: 30, +// d: 40, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION) + +// expect(animation.get()).toStrictEqual({ +// a: 10, +// b: 20, +// c: 30, +// d: 40, +// }) +// }) + +// it('transitions to the end value if the target changed during the animation', () => { +// const o = observable({ +// a: 0, +// b: 0, +// c: 0, +// d: 0, +// }) +// const t = groupTransition({ +// a: transition(0, TRANSITION_DURATION), +// b: transition(0, TRANSITION_DURATION), +// c: transition(0, TRANSITION_DURATION), +// d: transition(0, TRANSITION_DURATION), +// }) +// const animation = animationObservable(o, t) + +// o.set({ +// a: 10, +// b: 20, +// c: 30, +// d: 40, +// }) + +// expect(animation.get()).toStrictEqual({ +// a: 0, +// b: 0, +// c: 0, +// d: 0, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 1, +// b: 2, +// c: 3, +// d: 4, +// }) + +// o.set({ +// a: 101, +// b: 202, +// c: 303, +// d: 404, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 11, +// b: 22, +// c: 33, +// d: 44, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 21, +// b: 42, +// c: 63, +// d: 84, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 31, +// b: 62, +// c: 93, +// d: 124, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION * 0.1) + +// expect(animation.get()).toStrictEqual({ +// a: 41, +// b: 82, +// c: 123, +// d: 164, +// }) + +// jest.advanceTimersByTime(TRANSITION_DURATION) + +// expect(animation.get()).toStrictEqual({ +// a: 101, +// b: 202, +// c: 303, +// d: 404, +// }) +// }) +// }) diff --git a/src/animationObservable/inert.ts b/src/animationObservable/inert.ts new file mode 100644 index 0000000..343e6f4 --- /dev/null +++ b/src/animationObservable/inert.ts @@ -0,0 +1,52 @@ +import { LazyObservable, Gettable, Observable } from '../types' +import { createTransition } from '../transition' +import { createScheduleTaskWithCleanup, PRIORITY } from '@pvorona/scheduling' +import { observe } from '../observe' +import { observable } from '../observable' + +type Easing = (progress: number) => number + +type Options = { + duration: number + easing?: Easing +} + +export function inert(options: Options) { + return function ( + target: Observable & Gettable, + ): LazyObservable & Gettable { + const transition = createTransition({ + ...options, + initialValue: target.get(), + }) + const currentValue = observable.lazy(transition.get) + + const scheduleMarkChangedWithCleanup = createScheduleTaskWithCleanup( + currentValue.markChanged, + PRIORITY.FUTURE, + ) + + observe( + [target], + value => { + transition.setTarget(value) + + if (!transition.isFinished()) { + currentValue.markChanged() + } + }, + { fireImmediately: false }, + ) + + return { + get() { + if (!transition.isFinished()) { + scheduleMarkChangedWithCleanup() + } + + return currentValue.get() + }, + observe: currentValue.observe, + } + } +} diff --git a/src/computeLazy/computeLazy.ts b/src/computeLazy/computeLazy.ts index b06c6af..5f95e27 100644 --- a/src/computeLazy/computeLazy.ts +++ b/src/computeLazy/computeLazy.ts @@ -42,6 +42,7 @@ export function computeLazy< } return value }, + // Can be reused observe(observer: Lambda) { observers.push(observer) diff --git a/src/observable/observable.lazy.ts b/src/observable/observable.lazy.ts new file mode 100644 index 0000000..636d99c --- /dev/null +++ b/src/observable/observable.lazy.ts @@ -0,0 +1,58 @@ +import { removeFirstElementOccurrence } from '../utils' +import { LazyObservable, Gettable, Lambda } from '../types' + +export const lazyObservable = ( + compute: () => T, +): LazyObservable & Gettable & { markChanged: Lambda } => { + const observers: Lambda[] = [] + let value: T + let dirty = true + + return { + get() { + if (dirty) { + value = compute() + dirty = false + } + + return value + }, + markChanged: () => { + dirty = true + + for (const observer of observers) { + observer() + } + }, + observe(observer: Lambda) { + observers.push(observer) + + return () => { + removeFirstElementOccurrence(observers, observer) + } + }, + } +} + +// export const lazyObservableReuse = ( +// compute: () => T, +// ): LazyObservable & Gettable & { markChanged: Lambda } => { +// const holder = observable(undefined) +// let dirty = true + +// return { +// get() { +// if (dirty) { +// holder.set(compute()) +// dirty = false +// } + +// return holder.get() +// }, +// markChanged() { +// dirty = true +// // notify +// }, +// observe: holder.observe, +// } +// } diff --git a/src/observable/observable.ts b/src/observable/observable.ts index dc7caa5..86fae5e 100644 --- a/src/observable/observable.ts +++ b/src/observable/observable.ts @@ -1,5 +1,6 @@ import { removeFirstElementOccurrence } from '../utils' import { Observer, EagerObservable, Settable, Gettable } from '../types' +import { lazyObservable } from './observable.lazy' export function observable( initialValue: T, @@ -42,39 +43,4 @@ export function observable( } } -// export function pureObservable ( -// // initialValue: A, -// ): Observable & Settable { -// const observers: Observer[] = [] - -// return { -// set (value) { -// observers.forEach(observer => observer(value)) -// }, -// // fire immegiately can solve Gettable dependency -// observe (observer: Observer) { -// observers.push(observer) - -// return () => { -// for (let i = 0; i < observers.length; i++) { -// if (observers[i] === observer) { -// observers.splice(i, 1) -// return -// } -// } -// } -// }, -// } -// } - -// function gettable (observable: Observable): Gettable { -// let value: A - -// observable.observe(newValue => value = newValue) - -// function get () { -// return value -// } - -// return { get } -// } +observable.lazy = lazyObservable diff --git a/src/transition.ts b/src/transition.ts index 920c2f0..c78ed78 100644 --- a/src/transition.ts +++ b/src/transition.ts @@ -11,6 +11,49 @@ export type Easing = (progress: number) => number export const linear: Easing = progress => progress +type Options = { + duration: number + initialValue: T + easing?: Easing +} + +export function createTransition({ + initialValue, + duration, + easing = linear, +}: Options) { + let startTime = performance.now() + let startValue = initialValue + let targetValue = initialValue + let finished = true + + const get = () => { + const progress = Math.min((performance.now() - startTime) / duration, 1) + + if (progress === 1) { + finished = true + } + return startValue + (targetValue - startValue) * easing(progress) + } + const isFinished = () => finished + const setTarget = (newTargetValue: number) => { + if (newTargetValue === targetValue) { + return + } + + startValue = get() + targetValue = newTargetValue + finished = false + startTime = performance.now() + } + + return { + get, + isFinished, + setTarget, + } +} + export function transition( initialValue: number, duration: number,