modules/utils/timing.js

/*
 * Copyright (c) 2017-2018 Thomas Otterson
 *
 * 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.
 */

/**
 * A set of channel utilities for changing the timing of inputs being put onto the input channel.
 *
 * @module cispy/utils/timing
 * @private
 */

const { chan, timeout, close, CLOSED } = require('../channel');
const { put, alts } = require('../ops');

function isNumber(x) {
  return Object.prototype.toString.call(x) === '[object Number]' && isFinite(x);
}

/**
 * **Debounces an input channel.**
 *
 * Debouncing is the act of turning several input values into one. For example, debouncing a channel driven by a
 * `mousemove` event would cause only the final `mousemove` event to be put onto the channel, dropping all of the other
 * ones. This can be desirable because `mousemove` events come in bunches, being produced continually while the mouse is
 * moving, and often all that we really care about is to learn where the mouse ended up.
 *
 * This function does this by controlling which values that have been put onto the source channel are made available on
 * the destination channel, and when.
 *
 * The `delay` parameter determines the debounce threshold. Once the first value is put onto the source channel,
 * debouncing is in effect until the number of milliseconds in `delay` passes without any other value being put onto
 * that channel. In other words, the delay will be refreshed if another value is put onto the source channel before the
 * delay elapses. After a full delay interval passes without a value being placed on the source channel, the last value
 * put becomes available on the destination channel.
 *
 * This behavior can be modified through four options: `leading`, `trailing`, `maxDelay`, and `cancel`.
 *
 * If both `leading` and `trailing` are `true`, values will not be duplicated. The first value put onto the source
 * channel will be put onto the destination channel immediately (per `leading`) and the delay will begin, but a value
 * will only be made available on the destination channel at the end of the delay (per `trailing`) if *another* input
 * value was put onto the source channel before the delay expired.
 *
 * @memberOf module:cispy/utils~CispyUtils
 * @param {module:cispy/channel~Channel} src The source channel.
 * @param {(number|module:cispy/buffers~Buffer)} [buffer=0] A buffer used to create the destination channel. If
 *     this is a number, a {@link module:cispy/buffers~FixedBuffer|FixedBuffer} of that size will be used. If this is
 *     `0` or not present, the channel will be unbuffered.
 * @param {number} delay The debouncing delay, in milliseconds.
 * @param {Object} [options={}] A set of options to further configure the debouncing.
 * @param {boolean} [options.leading=false] Instead of making a value available on the destination channel after the
 *     delay passes, the first value put onto the source channel is made available *before* the delay begins. No value
 *     is available on the destination channel after the delay has elapsed (unless `trailing` is also `true`).
 * @param {boolean} [options.trailing=true] The default behavior, where a value is not made available on the destination
 *     channel until the entire delay passes without a new value being put on the source channel.
 * @param {number} [options.maxDelay=0] The maximum delay allowed before a value is put onto the destination channel.
 *     Debouncing can, in theory, go on forever as long as new input values continue to be put onto the source channel
 *     before the delay expires. Setting this option to a positive number sets the maximum number of milliseconds that
 *     debouncing can go on before it's forced to end, even if in the middle of a debouncing delay. Having debouncing
 *     end through the max delay operates exactly as if it had ended because of lack of input; the last input is made
 *     available on the destination channel (if `trailing` is `true`), and the next input will trigger another debounce
 *     operation.
 * @param {module:cispy/channel~Channel} [options.cancel] A channel used to signal a cancellation of the
 *     debouncing. Any value put onto this channel will cancel the current debouncing operation, closing the output
 *     channel and discarding any values that were waiting for the debounce threshold timer to be sent to the output.
 * @return {module:cispy/channel~Channel} The newly-created destination channel, where all of the values will be
 *     debounced from the source channel.
 */
function debounce(src, buffer, delay, options) {
  const defaults = { leading: false, trailing: true, maxDelay: 0, cancel: chan() };
  const buf = isNumber(delay) ? buffer : 0;
  const del = isNumber(delay) ? delay : buffer;
  const opts = Object.assign(defaults, (isNumber(delay) ? options : delay) || {});

  const dest = chan(buf);
  const { leading, trailing, maxDelay, cancel } = opts;

  async function loop() {
    let timer = chan();
    let max = chan();
    let current = CLOSED;

    for (;;) {
      const { value, channel } = await alts([src, timer, max, cancel]);

      if (channel === cancel) {
        close(dest);
        break;
      }
      if (channel === src) {
        if (value === CLOSED) {
          close(dest);
          break;
        }

        const timing = timer.timeout;
        timer = timeout(del);

        if (!timing && maxDelay > 0) {
          max = timeout(maxDelay);
        }

        if (leading) {
          if (!timing) {
            await put(dest, value);
          } else {
            current = value;
          }
        } else if (trailing) {
          current = value;
        }
      } else {
        timer = chan();
        max = chan();
        if (trailing && current !== CLOSED) {
          await put(dest, current);
          current = CLOSED;
        }
      }
    }
  }

  loop();
  return dest;
}

/**
 * **Throttles an input channel.**
 *
 * Throttling is the act of ensuring that something only happens once per time interval. In this case, it means that a
 * value put onto the source channel is only made available to the destination channel once per a given number of
 * milliseconds. An example usage would be with window scroll events; these events are nearly continuous as scrolling is
 * happening, and perhaps we don't want to call an expensive UI updating function every time a scroll event is fired. We
 * can throttle the input channel and make it only offer up the scroll events once every 100 milliseconds, for instance.
 *
 * Throttling is effected by creating a new channel as a throttled destination for values put onto the source channel.
 * Values will only appear on that destination channel once per delay interval; other values that are put onto the
 * source channel in the meantime are discarded.
 *
 * The `delay` parameter controls how often a value can become available on the destination channel. When the first
 * value is put onto the source channel, it is immediately put onto the destination channel as well and the delay
 * begins. Any further values put onto the source channel during that delay are *not* passed through; only when the
 * delay expires is the last input value made available on the destination channel. The delay then begins again, so that
 * further inputs are squelched until *that* delay passes. Throttling continues, only allowing one value through per
 * interval, until an entire interval passes without input.
 *
 * This behavior can be modified by three options: `leading`, `trailing`, and `cancel`.
 *
 * If both `leading` and `trailing` are `true`, values will not be duplicated. The first value put onto the source
 * channel will be put onto the destination channel immediately (per `leading`) and the delay will begin, but a value
 * will only be made available on the destination channel at the end of the delay (per `trailing`) if *another* input
 * value was put onto the source channel before the delay expired.
 *
 * @memberOf module:cispy/utils~CispyUtils
 * @param {module:cispy/channel~Channel} src The source channel.
 * @param {(number|module:cispy/buffers~Buffer)} [buffer=0] A buffer used to create the destination channel. If
 *     this is a number, a {@link module:cispy/buffers~FixedBuffer|FixedBuffer} of that size will be used. If this is
 *     `0` or not present, the channel will be unbuffered.
 * @param {number} delay The throttling delay, in milliseconds.
 * @param {Object} [options={}] A set of options to further configure the throttling.
 * @param {boolean} [options.leading=true] Makes the value that triggered the throttling immediately available on the
 *     destination channel before beginning the delay. If this is `false`, the first value will not be put onto the
 *     destination channel until a full delay interval passes.
 * @param {boolean} [options.trailing=true] Makes the last value put onto the source channel available on the
 *     destination channel when the delay expires. If this is `false`, any inputs that come in during the delay are
 *     ignored, and the next value is not put onto the destination channel until the first input *after* the delay
 *     expires.
 * @param {module:cispy/channel~Channel} [options.cancel] A channel used to signal a cancellation of the
 *     throttling. Any value put onto this channel will cancel the current throttling operation, closing the output
 *     channel and discarding any values that were waiting for the throttle threshold timer to be sent to the output.
 * @return {module:cispy/channel~Channel}} The newly-created destination channel, where all of the values will be
 *     throttled from the source channel.
 */
function throttle(src, buffer, delay, options) {
  const defaults = { leading: true, trailing: true, cancel: chan() };
  const buf = isNumber(delay) ? buffer : 0;
  const del = isNumber(delay) ? delay : buffer;
  const opts = Object.assign(defaults, (isNumber(delay) ? options : delay) || {});

  const dest = chan(buf);
  const { leading, trailing, cancel } = opts;

  async function loop() {
    let timer = chan();
    let current = CLOSED;

    for (;;) {
      const { value, channel } = await alts([src, timer, cancel]);

      if (channel === cancel) {
        close(dest);
        break;
      } else if (channel === src) {
        if (value === CLOSED) {
          close(dest);
          break;
        }

        const timing = timer.timeout;
        if (!timing) {
          timer = timeout(del);
        }

        if (leading) {
          if (!timing) {
            await put(dest, value);
          } else if (trailing) {
            current = value;
          }
        } else if (trailing) {
          current = value;
        }
      } else if (trailing && current !== CLOSED) {
        timer = timeout(del);
        await put(dest, current);
        current = CLOSED;
      } else {
        timer = chan();
      }
    }
  }

  loop();
  return dest;
}

module.exports = {
  debounce,
  throttle
};