Calling a function at a changing rate according to a ease function?

Viewed 387

I'd like to have the ability to run functions at a certain rate, which can increase or decrease according to a mathematical function such as a curve... much the same way easing functions like easeIn and easeOut work in CSS and JQuery.

Here's a crude illustration of an "easeInOut" type scenario. The line represents time, the os a function call.

o-o--o---o-----o----------o-----o---o--o-o

Implementation could look something like:

trigger(5000, "easeInOut", callback); // Over five seconds, "callback()" is called with an easeInOut ease.

function triggerWithEase(duration, ease, callback){
  // ???
}

function callback(){
  console.log("Heyo!");
}

Is there a Javascript / JQuery solution for this already? If not, how could this be accomplished?

Edit 1:

Some inspiration for the timing graph itself:

http://easings.net/

Edit 2:

I'm impressed with you creative folks who have actually used the little ASCII graph I used in the description as an input to the function! I'm looking for a more mathematical solution to this, though...

Here's a better illustration of what I'm thinking. This assumes we're using a quadratic formula (ax^2 + bx + c) over a duration of 20 seconds.

It'd be really cool to pass a function some parameters and have it trigger callbacks at the correct intervals doing something like this:

10 calls over 20 seconds

enter image description here

20 calls over 20 seconds

enter image description here

Edit 3

Some completely untested, back-of-the-napkin pseudocode I'm playing with:

function easeTrigger(callback, functionType, functionOptions, duration, numberOfCalls){

    switch("functionType"){
        case "quadratic":
            quadtratic(functionOptions);
            break;
        case "sine":
            sine(functionOptions);
            break;
        /* ... */
        default:
            linear(functionOptions);
            break;
    }

    function quadratic(options){

        var x = numberOfCalls;
        var result;
        var delay;
        var timer;

        for (var i = x - 1; i >= 0; i--) {
            result = a * Math.pow(x, 2) + (b * x) + c;
            delay = result * 1000; // using the result to calculate the time between function calls
        }

        // ???

        

        for (var s = numberOfCalls - 1; s >= 0; s--) {
            delay = result * 1000; // probably need to chain this somehow, a for loop might not be the best way to go about this.
            timer = setTimeout(result, caller);
        }

        // ???

        // PROFIT!

    }

    function caller(duration){
        clearTimeout(timer);
        timer = setTimeout(delay, callback);
    }

}

// How this might be implemented...

easeTrigger(callback, "quadratic", {a: 1, b: 2, c:0}, 5000, 20);
easeTrigger(callback, "linear", {a: 1, b: 2}, 5000, 10);

// Callback to call at the end of every period

function callback(){
    console.log("Yo!"); 
}
4 Answers

In the action you should set whatever is that you need to do on that curve tick you have mentioned.

function TimeLine(unit, curve, action) {
  this.unit = unit;
  this.curve = curve;
  this.action = action;
  this.tick = 0;

}
TimeLine.prototype.start = function() {
  var me = this;
  console.log('Start.');
  me.id = setInterval(function() {
    me.onRun();
  }, me.unit);
  me.onRun();
};
TimeLine.prototype.stop = function() {
  var me = this;
  console.log('Stop.');
  clearInterval(me.id);
  delete me.id;
};
TimeLine.prototype.onRun = function() {
  var me = this;

  if (me.curve.charAt(me.tick++) === 'o') {
    me.action && me.action();
  } else {
    console.log('Idle...');
  }
  if (me.tick > me.curve.length) {
    me.stop();
  }
}

var log = function() {
    console.log('Ping:', (new Date).getTime());
  },
  t = new TimeLine(200, 'o----o----o--o-o-o-o-ooo', log);

t.start();

EDIT Related to your last edit, calculating the intervals some function will be called, some corrections:

So you are seeing the axis of time like this:

`t0 -------------- tx --------------------------------------- tN`

and you say that on a time interval duration (=tN-t0) you will call a function a numberOfTimes

so the function will be called in [t0,...,ti,..tx] where x = the numberOfTimes and each of these times is calculated with some function

function quadratic(options){
    var x = numberOfCalls, 
        delay = 0,
        result, , timer;

    for (var i = x - 1; i >= 0; i--) {
        result = a * Math.pow(x, 2) + (b * x) + c;
        delay += result * 1000; // cumulate these time values so you can lunch the call already
        // so you call them in a loop but they will start at the precalculated times
        setTimeout(caller, delay);
    }
}

This will work though I don t see how you will force the duration to be a specific one, unless it is a parameter of the function somehow.

If you are able to use an external lib, then I would suggest you use a reactive lib like xstream. This is typically the kind of problems reactive programming addresses!

There is a great method on observables that allow you to emit things according to a diagram (which is exactly what you have). The method is fromDiagram

So in your case, you could do

import fromDiagram from 'xstream/extra/fromDiagram'

const stream = fromDiagram('o-o--o---o-----o----------o-----o---o--o-o', {
  timeUnit: 20 // the number of ms, would have to be computed manually
})

stream.addListener({
  next: callback
})

The only thing left to do is to compute the timeUnit option according to the duration of your animation and the number of steps.

Add to this an object that associate a name of an easing function to a diagram and wrap everything up in a function, and you are good to go :)

Hope it helps.

Assumptions

  • keep function names and signatures identical to the ones from the given example
  • no external libraries
  • all easing functions have the same signature f(current time, start value, change in value, duration)
  • invocation rate accuracy can fluctuate within a couple of milliseconds

Google was quick to answer with Flash-era easing equations. I borrowed some from Robert Penner.

const ease = {
  inQuad: (t, b, c, d) => {
    t /= d
    return c * t * t + b
  },
  outQuad: (t, b, c, d) => {
    t /= d
    return -c * t * (t - 2) + b
  }
}

let triggerWithEase = (duration, ease, callback) => {
  const rate = 12 // Lower value makes easing more distinguishable to naked eye
  const totalFrames = Math.floor((duration / 1000) * rate)

  for (let currentFrame = 0; currentFrame < totalFrames; currentFrame += 1) {
    const delay = ease(currentFrame, 0, duration, totalFrames)
    setTimeout(callback, delay)
  }
}

triggerWithEase(
  10000,
  ease.outQuad,
  () => console.log(Date.now())
)

Edit: Just realised I'm another Why Can't Programmers Read example. Made an edit to correct this.

I liked both the question and how it is presented. So just for fun i try to answer in the same fashion.

I have a bunch of easing formulas obtained from here and applied them for this case.

It's quite simple. I setup the easing object to hold all formulas. All radio buttons' "change" event listener is assigned with the drawScale function. Once the "change" event is fired it looks up for the appropriate function and performs it's show. The code is commented so hopefully easier to understand.

It's probably best to watch it working in full screen mode.

function drawScale(e) {

  // inserting span elements at appropriate time
  function insertSpan() {
    window.requestAnimationFrame(function() {
      se = document.createElement("span");
      se.textContent = ">";
      se.classList.add("tick");
      scale.appendChild(se);
    });
  }

  // recursively registers functions at the event looop according to their corresping delay
  // returns setTimeout ids array (stoids) to be cleared if invoked before previous finishes
  function registerSetTimeOut(t, ...ts) {
    return ts.length ? registerSetTimeOut(...ts).concat(setTimeout(insertSpan, t)) :
      [setTimeout(insertSpan, t)];
  }

  var datar = base.map((v, i) => easing[e.currentTarget.value](v, 0, 5000, 5000));
  stoids.forEach(stoid => clearTimeout(stoid)); // clear all awaiting setTimeouts if any
  stoids = []; // resets setTimeout ids array
  stoids = registerSetTimeOut(...datar); // stoids array gets populated with new setTimeout ids

  // clears previously appended span elements at appropriate time
  window.requestAnimationFrame(function() {
    while (scale.firstChild) scale.removeChild(scale.firstChild);
  });
}

var easing = {
    "linear": (t, b, c, d) => c * t / d + b,
    "inQuad": (t, b, c, d) => (t /= d, c * t * t + b),
    "outQuad": (t, b, c, d) => (t /= d, -c * t * (t - 2) + b),
    "inOutQuad": (t, b, c, d) => (t /= d / 2, t < 1 ? c / 2 * t * t + b : (t--, -c / 2 * (t * (t - 2) - 1) + b)),
    "inCubic": (t, b, c, d) => (t /= d, c * t * t * t + b),
    "outCubic": (t, b, c, d) => (t /= d, t--, c * (t * t * t + 1) + b),
    "inOutCubic": (t, b, c, d) => (t /= d / 2, t < 1 ? c / 2 * t * t * t + b : (t -= 2, c / 2 * (t * t * t + 2) + b)),
    "inQuart": (t, b, c, d) => (t /= d, c * t * t * t * t + b),
    "outQuart": (t, b, c, d) => (t /= d, t--, -c * (t * t * t * t - 1) + b),
    "inOutQuart": (t, b, c, d) => (t /= d / 2, t < 1 ? c / 2 * t * t * t * t + b : (t -= 2, -c / 2 * (t * t * t * t - 2) + b)),
    "inQuint": (t, b, c, d) => (t /= d, c * t * t * t * t * t + b),
    "outQuint": (t, b, c, d) => (t /= d, t--, c * (t * t * t * t * t + 1) + b),
    "inOutQuint": (t, b, c, d) => (t /= d / 2, t < 1 ? c / 2 * t * t * t * t * t + b : (t -= 2, c / 2 * (t * t * t * t * t + 2) + b)),
    "inSine": (t, b, c, d) => -c * Math.cos(t / d * (Math.PI / 2)) + c + b,
    "outSine": (t, b, c, d) => c * Math.sin(t / d * (Math.PI / 2)) + b,
    "inOutSine": (t, b, c, d) => -c / 2 * (Math.cos(Math.PI * t / d) - 1) + b,
    "inExpo": (t, b, c, d) => c * Math.pow(2, 10 * (t / d - 1)) + b,
    "outExpo": (t, b, c, d) => c * (-Math.pow(2, -10 * t / d) + 1) + b,
    "inOutExpo": (t, b, c, d) => (t /= d / 2, t < 1 ? c / 2 * Math.pow(2, 10 * (t - 1)) + b : (t--, c / 2 * (-Math.pow(2, -10 * t) + 2) + b)),
    "inCirc": (t, b, c, d) => (t /= d, -c * (Math.sqrt(1 - t * t) - 1) + b),
    "outCirc": (t, b, c, d) => (t /= d, t--, c * Math.sqrt(1 - t * t) + b),
    "inOutCirc": (t, b, c, d) => (t /= d / 2, t < 1 ? -c / 2 * (Math.sqrt(1 - t * t) - 1) + b : (t -= 2, c / 2 * (Math.sqrt(1 - t * t) + 1) + b))
  },
  base = Array.from({
    length: 21
  }).map((_, i) => i * 250),
  stoids = [],
  scale = document.getElementById("scale"),
  radios = document.querySelectorAll('td [type="radio"]');

radios.forEach(r => r.addEventListener("change", drawScale));
table {
  width: 33.3vw;
  font-size: 0.8vw;
  margin: auto;
}

#scale {
  margin-left: 33vw;
  display: inline-block;
  border: solid 1px black;
  overflow: hidden;
}

th {
  vertical-align: top;
  padding: 0.5em;
  border: solid black 1px;
  border-radius: 0.5em;
  background-color: #d7c0c0;
}

tr:nth-child(2n+1){
  background-color: #ccc;
}

td:nth-child(1) {
  text-align: right;
  font-weight: bold;
  padding: 0 0.5em;
}

td:nth-child(n+2) {
  text-align: center;
  width: 25%;
}

.desc {
  display: block;
  font-family: monospace;
  font-size: 0.8em;
}

.tick {
  float:left;
  width: 1.57vw;
  text-align: center;
  font-weight: bold;
  background-color: #d7c0c0;
}
<div id="container">
  <table>
    <tr>
      <th>Math</th>
      <th>Ease In<span class="desc">Accelerating from zero velocity</span></th>
      <th>Ease Out<span class="desc">Decelerating to zero velocity</span></th>
      <th>Ease In/Out<span class="desc">Acceleration until halfway, then deceleration</span></th>
    </tr>
    <tr>
      <td>Linear</td>
      <td colspan=3><input type="radio" name="math" value="linear"></td>
    </tr>
    <tr>
      <td>Quadratic</td>
      <td><input type="radio" name="math" value="inQuad"></td>
      <td><input type="radio" name="math" value="outQuad"></td>
      <td><input type="radio" name="math" value="inOutQuad"></td>
    </tr>
    <tr>
      <td>Cubic</td>
      <td><input type="radio" name="math" value="inCubic"></td>
      <td><input type="radio" name="math" value="outCubic"></td>
      <td><input type="radio" name="math" value="inOutCubic"></td>
    </tr>
    <tr>
      <td>Quartic</td>
      <td><input type="radio" name="math" value="inQuart"></td>
      <td><input type="radio" name="math" value="outQuart"></td>
      <td><input type="radio" name="math" value="inOutQuart"></td>
    </tr>
    <tr>
      <td>Quintic</td>
      <td><input type="radio" name="math" value="inQuint"></td>
      <td><input type="radio" name="math" value="outQuint"></td>
      <td><input type="radio" name="math" value="inOutQuint"></td>
    </tr>
    <tr>
      <td>Sinusoidal</td>
      <td><input type="radio" name="math" value="inSine"></td>
      <td><input type="radio" name="math" value="outSine"></td>
      <td><input type="radio" name="math" value="inOutSine"></td>
    </tr>
    <tr>
      <td>Exponential</td>
      <td><input type="radio" name="math" value="inExpo"></td>
      <td><input type="radio" name="math" value="outExpo"></td>
      <td><input type="radio" name="math" value="inOutExpo"></td>
    </tr>
    <tr>
      <td>Circular</td>
      <td><input type="radio" name="math" value="inCirc"></td>
      <td><input type="radio" name="math" value="outCirc"></td>
      <td><input type="radio" name="math" value="inOutCirc"></td>
    </tr>
  </table>
  <div id="scale"></div>
</div>

Related