From 9ee12a71d108ba022fd676b0b7327df0594173e9 Mon Sep 17 00:00:00 2001 From: Andre Staltz Date: Fri, 1 Jul 2016 14:08:22 +0300 Subject: [PATCH] feat(extra): add new extra factory tween() This is a port of RxTween (https://github.com/staltz/rxtween) to xstream. --- EXTRA_DOCS.md | 83 +++++++++ src/extra/tween.ts | 224 ++++++++++++++++++++++++ tests/extra/tween.ts | 407 +++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 4 files changed, 715 insertions(+) create mode 100644 src/extra/tween.ts create mode 100644 tests/extra/tween.ts diff --git a/EXTRA_DOCS.md b/EXTRA_DOCS.md index 2ea87a3..d3c9de8 100644 --- a/EXTRA_DOCS.md +++ b/EXTRA_DOCS.md @@ -532,3 +532,86 @@ result.addListener({ - - - +### `tween(config)` + +Creates a stream of numbers emitted in a quick burst, following a numeric +function like sine or elastic or quadratic. tween() is meant for creating +streams for animations. + +Example: + +```js +import tween from 'xstream/extra/tween' + +const stream = tween({ + from: 20, + to: 100, + ease: tween.exponential.easeIn, + duration: 1000, // milliseconds +}) + +stream.addListener({ + next: (x) => console.log(x), + error: (err) => console.error(err), + complete: () => console.log('concat completed'), +}) +``` + +The stream would behave like the plot below: + +```text +100 # +| +| +| +| +80 # +| +| +| +| # +60 +| +| # +| +| # +40 +| # +| # +| ## +| ### +20######## ++---------------------> time +``` + +Provide a configuration object with **from**, **to**, **duration**, **ease**, +**interval** (optional), and this factory function will return a stream of +numbers following that pattern. The first number emitted will be `from`, and +the last number will be `to`. The numbers in between follow the easing +function you specify in `ease`, and the stream emission will last in total +`duration` milliseconds. + +The easing functions are attached to `tween` too, such as +`tween.linear.ease`, `tween.power2.easeIn`, `tween.exponential.easeOut`, etc. +Here is a list of all the available easing options: + +- `tween.linear` with ease +- `tween.power2` with easeIn, easeOut, easeInOut +- `tween.power3` with easeIn, easeOut, easeInOut +- `tween.power4` with easeIn, easeOut, easeInOut +- `tween.exponential` with easeIn, easeOut, easeInOut +- `tween.back` with easeIn, easeOut, easeInOut +- `tween.bounce` with easeIn, easeOut, easeInOut +- `tween.circular` with easeIn, easeOut, easeInOut +- `tween.elastic` with easeIn, easeOut, easeInOut +- `tween.sine` with easeIn, easeOut, easeInOut + +#### Arguments: + +- `config: TweenConfig` An object with properties `from: number`, `to: number`, `duration: number`, `ease: function` (optional, defaults to +linear), `interval: number` (optional, defaults to 15). + +#### Returns: Stream + +- - - + diff --git a/src/extra/tween.ts b/src/extra/tween.ts new file mode 100644 index 0000000..cabbe82 --- /dev/null +++ b/src/extra/tween.ts @@ -0,0 +1,224 @@ +import {Stream} from '../core'; +import concat from './concat'; + +export type Ease = (x: number, from: number, to: number) => number; +export type Easings = { + easeIn: Ease; + easeOut: Ease; + easeInOut: Ease; +} + +export type NumericFunction = (input: number) => number; + +export interface TweenConfig { + from: number; + to: number; + duration: number; + ease?: Ease; + interval?: number; +} + +function interpolate(y: number, from: number, to: number): number { + return (from * (1 - y) + to * y); +} + +function flip(fn: NumericFunction): NumericFunction { + return x => 1 - fn(1 - x); +} + +function createEasing(fn: NumericFunction): Easings { + let fnFlipped = flip(fn); + return { + easeIn(x, from, to) { + return interpolate(fn(x), from, to); + }, + easeOut(x, from, to) { + return interpolate(fnFlipped(x), from, to); + }, + easeInOut(x, from, to) { + const y = (x < 0.5) ? + (fn(2 * x) * 0.5) : + (0.5 + fnFlipped(2 * (x - 0.5)) * 0.5); + return interpolate(y, from, to); + } + }; +}; + +let easingPower2 = createEasing(x => x * x); +let easingPower3 = createEasing(x => x * x * x); +let easingPower4 = createEasing(x => { + const xx = x * x; + return xx * xx; +}); + +const EXP_WEIGHT = 6; +const EXP_MAX = Math.exp(EXP_WEIGHT) - 1; +function expFn(x: number): number { + return (Math.exp(x * EXP_WEIGHT) - 1) / EXP_MAX; +} +let easingExponential = createEasing(expFn); + +const OVERSHOOT = 1.70158; +let easingBack = createEasing(x => x * x * ((OVERSHOOT + 1) * x - OVERSHOOT)); + +const PARAM1 = 7.5625; +const PARAM2 = 2.75; +function easeOutFn(x: number): number { + let z = x; + if (z < 1 / PARAM2) { + return (PARAM1 * z * z); + } else if (z < 2 / PARAM2) { + return (PARAM1 * (z -= 1.5 / PARAM2) * z + 0.75); + } else if (z < 2.5 / PARAM2) { + return (PARAM1 * (z -= 2.25 / PARAM2) * z + 0.9375); + } else { + return (PARAM1 * (z -= 2.625 / PARAM2) * z + 0.984375); + } +} +let easingBounce = createEasing(x => 1 - easeOutFn(1 - x)); + +let easingCirc = createEasing(x => -(Math.sqrt(1 - x * x) - 1)); + +const PERIOD = 0.3; +const OVERSHOOT_ELASTIC = PERIOD / 4; +const AMPLITUDE = 1; +function elasticIn(x: number): number { + let z = x; + if (z <= 0) { + return 0; + } else if (z >= 1) { + return 1; + } else { + z -= 1; + return -(AMPLITUDE * Math.pow(2, 10 * z)) + * Math.sin((z - OVERSHOOT_ELASTIC) * (2 * Math.PI) / PERIOD); + } +} +let easingElastic = createEasing(elasticIn); + +const HALF_PI = Math.PI * 0.5; +let easingSine = createEasing(x => 1 - Math.cos(x * HALF_PI)); + +const DEFAULT_INTERVAL: number = 15; + +export interface TweenFactory { + (config: TweenConfig): Stream; + linear: { ease: Ease }; + power2: Easings; + power3: Easings; + power4: Easings; + exponential: Easings; + back: Easings; + bounce: Easings; + circular: Easings; + elastic: Easings; + sine: Easings; +} + +/** + * Creates a stream of numbers emitted in a quick burst, following a numeric + * function like sine or elastic or quadratic. tween() is meant for creating + * streams for animations. + * + * Example: + * + * ```js + * import tween from 'xstream/extra/tween' + * + * const stream = tween({ + * from: 20, + * to: 100, + * ease: tween.exponential.easeIn, + * duration: 1000, // milliseconds + * }) + * + * stream.addListener({ + * next: (x) => console.log(x), + * error: (err) => console.error(err), + * complete: () => console.log('concat completed'), + * }) + * ``` + * + * The stream would behave like the plot below: + * + * ```text + * 100 # + * | + * | + * | + * | + * 80 # + * | + * | + * | + * | # + * 60 + * | + * | # + * | + * | # + * 40 + * | # + * | # + * | ## + * | ### + * 20######## + * +---------------------> time + * ``` + * + * Provide a configuration object with **from**, **to**, **duration**, **ease**, + * **interval** (optional), and this factory function will return a stream of + * numbers following that pattern. The first number emitted will be `from`, and + * the last number will be `to`. The numbers in between follow the easing + * function you specify in `ease`, and the stream emission will last in total + * `duration` milliseconds. + * + * The easing functions are attached to `tween` too, such as + * `tween.linear.ease`, `tween.power2.easeIn`, `tween.exponential.easeOut`, etc. + * Here is a list of all the available easing options: + * + * - `tween.linear` with ease + * - `tween.power2` with easeIn, easeOut, easeInOut + * - `tween.power3` with easeIn, easeOut, easeInOut + * - `tween.power4` with easeIn, easeOut, easeInOut + * - `tween.exponential` with easeIn, easeOut, easeInOut + * - `tween.back` with easeIn, easeOut, easeInOut + * - `tween.bounce` with easeIn, easeOut, easeInOut + * - `tween.circular` with easeIn, easeOut, easeInOut + * - `tween.elastic` with easeIn, easeOut, easeInOut + * - `tween.sine` with easeIn, easeOut, easeInOut + * + * @factory true + * @param {TweenConfig} config An object with properties `from: number`, + * `to: number`, `duration: number`, `ease: function` (optional, defaults to + * linear), `interval: number` (optional, defaults to 15). + * @return {Stream} + */ +function tween({ + from, + to, + duration, + ease = tweenFactory.linear.ease, + interval = DEFAULT_INTERVAL +}): Stream { + const totalTicks = Math.round(duration / interval); + return Stream.periodic(interval) + .take(totalTicks) + .map(tick => ease(tick / totalTicks, from, to)) + .compose(s => concat(s, Stream.of(to))); +} + +const tweenFactory: TweenFactory = tween; + +tweenFactory.linear = { ease: interpolate }; +tweenFactory.power2 = easingPower2; +tweenFactory.power3 = easingPower3; +tweenFactory.power4 = easingPower4; +tweenFactory.exponential = easingExponential; +tweenFactory.back = easingBack; +tweenFactory.bounce = easingBounce; +tweenFactory.circular = easingCirc; +tweenFactory.elastic = easingElastic; +tweenFactory.sine = easingSine; + +export default tweenFactory; diff --git a/tests/extra/tween.ts b/tests/extra/tween.ts new file mode 100644 index 0000000..935c536 --- /dev/null +++ b/tests/extra/tween.ts @@ -0,0 +1,407 @@ +/// +/// +import xs, {Stream} from '../../src/index'; +import tween from '../../src/extra/tween'; +import * as assert from 'assert'; + +const STEPS = 20; +const DURATION = 1000; + +const plotTweenConfigs = { + from: 0, + to: STEPS, + duration: DURATION, + interval: DURATION / STEPS +}; + +function setCharAt(str: string, idx: number, chr: string): string { + if (idx > str.length - 1){ + return str.toString(); + } else { + return str.substr(0, idx) + chr + str.substr(idx + 1); + } +} + +function rotate(lines: Array): Array { + let len = lines[0].length; + return lines[0].split('') + .map((col, i) => + lines + .map(row => row.split('')[len-i-1]) + ) + .map(row => row.join('')); +} + +function stutter(char: string, length: number): string { + return new Array(length + 1).join(char); +} + +function plot(position$: Stream): Stream { + return position$ + .fold((acc, curr) => { + acc.push(curr); + return acc; + }, []) + .last() + .map(arr => { + let coords = arr.map((y, x) => [x, y]); + let lines = coords.reduce((lines, [x, y]) => { + let newline: string; + if (y < 0) { + newline = setCharAt(stutter(' ', STEPS + 1), 0, '_'); + } else { + newline = setCharAt(stutter(' ', STEPS + 1), Math.round(y), '#'); + } + lines.push(newline); + return lines; + }, []); + return rotate(lines) + .map(line => '|'.concat(line.replace(/ *$/g, '')).concat('\n')) + .reduce((lines, line) => lines.concat(line), '') + .concat('+' + stutter('-', STEPS + 1)); + }); +} + +function makeAssertPlot(done: MochaDone, assert: any, expected: string) { + return { + next: function assertPlot(actual: string) { + assert.equal('\n' + actual, expected); + }, + error: (err: any) => done(err), + complete: () => { + done(); + }, + }; +} + +describe('tween (extra)', () => { + it('should do linear tweening', (done) => { + let position$ = tween({ + ease: tween.linear.ease, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +| # +|# ++---------------------`)); + }); + + it('should do power of 2 easing (ease in)', (done) => { + let position$ = tween({ + ease: tween.power2.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| # +| +| # +| +| # +| +| # +| # +| +| # +| # +| +| # +| # +| # +| # +| # +| # +| ## +| ## +|#### ++---------------------`)); + }); + + it("should do power of 3 easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.power3.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| # +| +| +| # +| +| # +| +| +| # +| +| # +| +| # +| # +| +| # +| # +| ## +| # +| ### +|###### ++---------------------`)); + }); + + it("should do power of 4 easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.power4.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| # +| +| +| +| # +| +| +| # +| +| +| # +| +| # +| +| # +| # +| # +| # +| # +| ### +|######## ++---------------------`)); + }); + + it("should do exponential easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.exponential.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| # +| +| +| +| +| # +| +| +| +| # +| +| +| # +| +| # +| +| # +| # +| ## +| ### +|######### ++---------------------`)); + }); + + it("should do back easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.back.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| # +| +| +| +| # +| +| +| +| # +| +| +| # +| +| +| # +| +| # +| +| # +| +|#____________# ++---------------------`)); + }); + + it("should do bounce easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.bounce.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| ## +| +| # +| # +| +| +| # +| +| +| # +| +| +| +| +| # +| ### +| # +| # +| # +| #### # +|### ++---------------------`)); + }); + + it("should do circular easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.circular.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done,assert, ` +| # +| +| +| +| +| +| # +| +| +| # +| +| # +| # +| # +| # +| # +| # +| ## +| ## +| ### +|##### ++---------------------`)); + }); + + it("should do elastic easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.elastic.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done,assert, ` +| # +| +| +| +| +| +| +| +| +| +| +| +| +| # +| +| +| +| +| ## +| # +|####___###___ ___ ++---------------------`)); + }); + + it("should do sine easing (ease in)", function (done) { + let position$ = tween({ + ease: tween.sine.easeIn, + from: plotTweenConfigs.from, + to: plotTweenConfigs.to, + duration: plotTweenConfigs.duration, + interval: plotTweenConfigs.interval, + }); + plot(position$).addListener(makeAssertPlot(done, assert, ` +| # +| +| # +| # +| +| # +| # +| +| # +| # +| # +| +| # +| # +| # +| # +| # +| # +| ## +| ## +|### ++---------------------`)); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 5e5fc70..31d2a7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "src/extra/fromEvent.ts", "src/extra/pairwise.ts", "src/extra/split.ts", + "src/extra/tween.ts", "src/index.ts" ], "filesGlob": [