modules/utils/timing.js

  1. /*
  2. * Copyright (c) 2017-2018 Thomas Otterson
  3. *
  4. * Permission is hereby granted, free of charge, to any person obtaining a copy of
  5. * this software and associated documentation files (the "Software"), to deal in
  6. * the Software without restriction, including without limitation the rights to
  7. * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
  8. * the Software, and to permit persons to whom the Software is furnished to do so,
  9. * subject to the following conditions:
  10. *
  11. * The above copyright notice and this permission notice shall be included in all
  12. * copies or substantial portions of the Software.
  13. *
  14. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  16. * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
  17. * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
  18. * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  19. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  20. */
  21. /**
  22. * A set of channel utilities for changing the timing of inputs being put onto the input channel.
  23. *
  24. * @module cispy/utils/timing
  25. * @private
  26. */
  27. const { chan, timeout, close, CLOSED } = require('../channel');
  28. const { put, alts } = require('../ops');
  29. function isNumber(x) {
  30. return Object.prototype.toString.call(x) === '[object Number]' && isFinite(x);
  31. }
  32. /**
  33. * **Debounces an input channel.**
  34. *
  35. * Debouncing is the act of turning several input values into one. For example, debouncing a channel driven by a
  36. * `mousemove` event would cause only the final `mousemove` event to be put onto the channel, dropping all of the other
  37. * ones. This can be desirable because `mousemove` events come in bunches, being produced continually while the mouse is
  38. * moving, and often all that we really care about is to learn where the mouse ended up.
  39. *
  40. * This function does this by controlling which values that have been put onto the source channel are made available on
  41. * the destination channel, and when.
  42. *
  43. * The `delay` parameter determines the debounce threshold. Once the first value is put onto the source channel,
  44. * debouncing is in effect until the number of milliseconds in `delay` passes without any other value being put onto
  45. * that channel. In other words, the delay will be refreshed if another value is put onto the source channel before the
  46. * delay elapses. After a full delay interval passes without a value being placed on the source channel, the last value
  47. * put becomes available on the destination channel.
  48. *
  49. * This behavior can be modified through four options: `leading`, `trailing`, `maxDelay`, and `cancel`.
  50. *
  51. * If both `leading` and `trailing` are `true`, values will not be duplicated. The first value put onto the source
  52. * channel will be put onto the destination channel immediately (per `leading`) and the delay will begin, but a value
  53. * will only be made available on the destination channel at the end of the delay (per `trailing`) if *another* input
  54. * value was put onto the source channel before the delay expired.
  55. *
  56. * @memberOf module:cispy/utils~CispyUtils
  57. * @param {module:cispy/channel~Channel} src The source channel.
  58. * @param {(number|module:cispy/buffers~Buffer)} [buffer=0] A buffer used to create the destination channel. If
  59. * this is a number, a {@link module:cispy/buffers~FixedBuffer|FixedBuffer} of that size will be used. If this is
  60. * `0` or not present, the channel will be unbuffered.
  61. * @param {number} delay The debouncing delay, in milliseconds.
  62. * @param {Object} [options={}] A set of options to further configure the debouncing.
  63. * @param {boolean} [options.leading=false] Instead of making a value available on the destination channel after the
  64. * delay passes, the first value put onto the source channel is made available *before* the delay begins. No value
  65. * is available on the destination channel after the delay has elapsed (unless `trailing` is also `true`).
  66. * @param {boolean} [options.trailing=true] The default behavior, where a value is not made available on the destination
  67. * channel until the entire delay passes without a new value being put on the source channel.
  68. * @param {number} [options.maxDelay=0] The maximum delay allowed before a value is put onto the destination channel.
  69. * Debouncing can, in theory, go on forever as long as new input values continue to be put onto the source channel
  70. * before the delay expires. Setting this option to a positive number sets the maximum number of milliseconds that
  71. * debouncing can go on before it's forced to end, even if in the middle of a debouncing delay. Having debouncing
  72. * end through the max delay operates exactly as if it had ended because of lack of input; the last input is made
  73. * available on the destination channel (if `trailing` is `true`), and the next input will trigger another debounce
  74. * operation.
  75. * @param {module:cispy/channel~Channel} [options.cancel] A channel used to signal a cancellation of the
  76. * debouncing. Any value put onto this channel will cancel the current debouncing operation, closing the output
  77. * channel and discarding any values that were waiting for the debounce threshold timer to be sent to the output.
  78. * @return {module:cispy/channel~Channel} The newly-created destination channel, where all of the values will be
  79. * debounced from the source channel.
  80. */
  81. function debounce(src, buffer, delay, options) {
  82. const defaults = { leading: false, trailing: true, maxDelay: 0, cancel: chan() };
  83. const buf = isNumber(delay) ? buffer : 0;
  84. const del = isNumber(delay) ? delay : buffer;
  85. const opts = Object.assign(defaults, (isNumber(delay) ? options : delay) || {});
  86. const dest = chan(buf);
  87. const { leading, trailing, maxDelay, cancel } = opts;
  88. async function loop() {
  89. let timer = chan();
  90. let max = chan();
  91. let current = CLOSED;
  92. for (;;) {
  93. const { value, channel } = await alts([src, timer, max, cancel]);
  94. if (channel === cancel) {
  95. close(dest);
  96. break;
  97. }
  98. if (channel === src) {
  99. if (value === CLOSED) {
  100. close(dest);
  101. break;
  102. }
  103. const timing = timer.timeout;
  104. timer = timeout(del);
  105. if (!timing && maxDelay > 0) {
  106. max = timeout(maxDelay);
  107. }
  108. if (leading) {
  109. if (!timing) {
  110. await put(dest, value);
  111. } else {
  112. current = value;
  113. }
  114. } else if (trailing) {
  115. current = value;
  116. }
  117. } else {
  118. timer = chan();
  119. max = chan();
  120. if (trailing && current !== CLOSED) {
  121. await put(dest, current);
  122. current = CLOSED;
  123. }
  124. }
  125. }
  126. }
  127. loop();
  128. return dest;
  129. }
  130. /**
  131. * **Throttles an input channel.**
  132. *
  133. * Throttling is the act of ensuring that something only happens once per time interval. In this case, it means that a
  134. * value put onto the source channel is only made available to the destination channel once per a given number of
  135. * milliseconds. An example usage would be with window scroll events; these events are nearly continuous as scrolling is
  136. * happening, and perhaps we don't want to call an expensive UI updating function every time a scroll event is fired. We
  137. * can throttle the input channel and make it only offer up the scroll events once every 100 milliseconds, for instance.
  138. *
  139. * Throttling is effected by creating a new channel as a throttled destination for values put onto the source channel.
  140. * Values will only appear on that destination channel once per delay interval; other values that are put onto the
  141. * source channel in the meantime are discarded.
  142. *
  143. * The `delay` parameter controls how often a value can become available on the destination channel. When the first
  144. * value is put onto the source channel, it is immediately put onto the destination channel as well and the delay
  145. * begins. Any further values put onto the source channel during that delay are *not* passed through; only when the
  146. * delay expires is the last input value made available on the destination channel. The delay then begins again, so that
  147. * further inputs are squelched until *that* delay passes. Throttling continues, only allowing one value through per
  148. * interval, until an entire interval passes without input.
  149. *
  150. * This behavior can be modified by three options: `leading`, `trailing`, and `cancel`.
  151. *
  152. * If both `leading` and `trailing` are `true`, values will not be duplicated. The first value put onto the source
  153. * channel will be put onto the destination channel immediately (per `leading`) and the delay will begin, but a value
  154. * will only be made available on the destination channel at the end of the delay (per `trailing`) if *another* input
  155. * value was put onto the source channel before the delay expired.
  156. *
  157. * @memberOf module:cispy/utils~CispyUtils
  158. * @param {module:cispy/channel~Channel} src The source channel.
  159. * @param {(number|module:cispy/buffers~Buffer)} [buffer=0] A buffer used to create the destination channel. If
  160. * this is a number, a {@link module:cispy/buffers~FixedBuffer|FixedBuffer} of that size will be used. If this is
  161. * `0` or not present, the channel will be unbuffered.
  162. * @param {number} delay The throttling delay, in milliseconds.
  163. * @param {Object} [options={}] A set of options to further configure the throttling.
  164. * @param {boolean} [options.leading=true] Makes the value that triggered the throttling immediately available on the
  165. * destination channel before beginning the delay. If this is `false`, the first value will not be put onto the
  166. * destination channel until a full delay interval passes.
  167. * @param {boolean} [options.trailing=true] Makes the last value put onto the source channel available on the
  168. * destination channel when the delay expires. If this is `false`, any inputs that come in during the delay are
  169. * ignored, and the next value is not put onto the destination channel until the first input *after* the delay
  170. * expires.
  171. * @param {module:cispy/channel~Channel} [options.cancel] A channel used to signal a cancellation of the
  172. * throttling. Any value put onto this channel will cancel the current throttling operation, closing the output
  173. * channel and discarding any values that were waiting for the throttle threshold timer to be sent to the output.
  174. * @return {module:cispy/channel~Channel}} The newly-created destination channel, where all of the values will be
  175. * throttled from the source channel.
  176. */
  177. function throttle(src, buffer, delay, options) {
  178. const defaults = { leading: true, trailing: true, cancel: chan() };
  179. const buf = isNumber(delay) ? buffer : 0;
  180. const del = isNumber(delay) ? delay : buffer;
  181. const opts = Object.assign(defaults, (isNumber(delay) ? options : delay) || {});
  182. const dest = chan(buf);
  183. const { leading, trailing, cancel } = opts;
  184. async function loop() {
  185. let timer = chan();
  186. let current = CLOSED;
  187. for (;;) {
  188. const { value, channel } = await alts([src, timer, cancel]);
  189. if (channel === cancel) {
  190. close(dest);
  191. break;
  192. } else if (channel === src) {
  193. if (value === CLOSED) {
  194. close(dest);
  195. break;
  196. }
  197. const timing = timer.timeout;
  198. if (!timing) {
  199. timer = timeout(del);
  200. }
  201. if (leading) {
  202. if (!timing) {
  203. await put(dest, value);
  204. } else if (trailing) {
  205. current = value;
  206. }
  207. } else if (trailing) {
  208. current = value;
  209. }
  210. } else if (trailing && current !== CLOSED) {
  211. timer = timeout(del);
  212. await put(dest, current);
  213. current = CLOSED;
  214. } else {
  215. timer = chan();
  216. }
  217. }
  218. }
  219. loop();
  220. return dest;
  221. }
  222. module.exports = {
  223. debounce,
  224. throttle
  225. };