spritejs/sprite-timeline

View on GitHub
src/index.js

Summary

Maintainability
C
1 day
Test Coverage
A
94%
import {createNowTime, formatDelay} from './utils';

const _nowtime = createNowTime();

const defaultOptions = {
  originTime: 0,
  playbackRate: 1.0,
};

const _timeMark = Symbol('timeMark'),
  _playbackRate = Symbol('playbackRate'),
  _timers = Symbol('timers'),
  _originTime = Symbol('originTime'),
  _setTimer = Symbol('setTimer'),
  _parent = Symbol('parent');

class Timeline {
  constructor(options, parent) {
    if(options instanceof Timeline) {
      parent = options;
      options = {};
    }

    options = Object.assign({}, defaultOptions, options);

    if(parent) {
      this[_parent] = parent;
    }

    const nowtime = options.nowtime || _nowtime;
    if(!parent) {
      const createTime = nowtime();
      Object.defineProperty(this, 'globalTime', {
        get() {
          return nowtime() - createTime;
        },
      });
    } else {
      Object.defineProperty(this, 'globalTime', {
        get() {
          return parent.currentTime;
        },
      });
    }

    // timeMark records the reference points on timeline
    // Each time we change the playbackRate or currentTime or entropy
    // A new timeMark will be generated
    // timeMark sorted by entropy
    // If you reset entropy, all the timeMarks behind the new entropy
    // should be dropped
    this[_timeMark] = [{
      globalTime: this.globalTime,
      localTime: -options.originTime,
      entropy: -options.originTime,
      playbackRate: options.playbackRate,
      globalEntropy: 0,
    }];

    if(this[_parent]) {
      this[_timeMark][0].globalEntropy = this[_parent].entropy;
    }

    this[_originTime] = options.originTime;
    this[_playbackRate] = options.playbackRate;
    this[_timers] = new Map();
  }

  get parent() {
    return this[_parent];
  }

  get lastTimeMark() {
    return this[_timeMark][this[_timeMark].length - 1];
  }

  markTime({time = this.currentTime, entropy = this.entropy, playbackRate = this.playbackRate} = {}) {
    const timeMark = {
      globalTime: this.globalTime,
      localTime: time,
      entropy,
      playbackRate,
      globalEntropy: this.globalEntropy,
    };
    this[_timeMark].push(timeMark);
  }

  get currentTime() {
    const {localTime, globalTime} = this.lastTimeMark;
    return localTime + (this.globalTime - globalTime) * this.playbackRate;
  }

  set currentTime(time) {
    const from = this.currentTime,
      to = time,
      timers = this[_timers];

    this.markTime({time})
    ;[...timers].forEach(([id, timer]) => {
      if(!timers.has(id)) return; // Need check because it maybe clearTimeout by former handler().
      const {isEntropy, delay, heading} = timer.time,
        {handler, startTime} = timer;

      if(!isEntropy) {
        const endTime = startTime + delay;
        if(delay === 0
          || heading !== false && (to - from) * delay <= 0
          || from <= endTime && endTime <= to
          || from >= endTime && endTime >= to) {
          handler();
          this.clearTimeout(id);
        }
      } else if(delay === 0) {
        handler();
        this.clearTimeout(id);
      }
    });
    this.updateTimers();
  }

  // Both currentTime and entropy should be influenced by playbackRate.
  // If current playbackRate is negative, the currentTime should go backwards
  // while the entropy remain to go forwards.
  // Both of the initial values is set to -originTime
  get entropy() {
    const {entropy, globalEntropy} = this.lastTimeMark;
    return entropy + Math.abs((this.globalEntropy - globalEntropy) * this.playbackRate);
  }

  get globalEntropy() {
    return this[_parent] ? this[_parent].entropy : this.globalTime;
  }

  // get globalTime() {
  //   if(this[_parent]) {
  //     return this[_parent].currentTime;
  //   }

  //   return nowtime();
  // }

  // change entropy will NOT cause currentTime changing but may influence the pass
  // and the future of the timeline. (It may change the result of seek***Time)
  // While entropy is set, all the marks behind will be droped
  set entropy(entropy) {
    if(this.entropy > entropy) {
      const idx = this.seekTimeMark(entropy);
      this[_timeMark].length = idx + 1;
    }
    this.markTime({entropy});
    this.updateTimers();
  }

  fork(options) {
    return new Timeline(options, this);
  }

  seekGlobalTime(seekEntropy) {
    const idx = this.seekTimeMark(seekEntropy),
      timeMark = this[_timeMark][idx];

    const {entropy, playbackRate, globalTime} = timeMark;

    return globalTime + (seekEntropy - entropy) / Math.abs(playbackRate);
  }

  seekLocalTime(seekEntropy) {
    const idx = this.seekTimeMark(seekEntropy),
      timeMark = this[_timeMark][idx];

    const {localTime, entropy, playbackRate} = timeMark;

    if(playbackRate > 0) {
      return localTime + (seekEntropy - entropy);
    }
    return localTime - (seekEntropy - entropy);
  }

  seekTimeMark(entropy) {
    const timeMark = this[_timeMark];

    let l = 0,
      r = timeMark.length - 1;

    if(entropy <= timeMark[l].entropy) {
      return l;
    }
    if(entropy >= timeMark[r].entropy) {
      return r;
    }

    let m = Math.floor((l + r) / 2); // binary search

    while(m > l && m < r) {
      if(entropy === timeMark[m].entropy) {
        return m;
      } if(entropy < timeMark[m].entropy) {
        r = m;
      } else if(entropy > timeMark[m].entropy) {
        l = m;
      }
      m = Math.floor((l + r) / 2);
    }

    return l;
  }

  get playbackRate() {
    return this[_playbackRate];
  }

  set playbackRate(rate) {
    if(rate !== this.playbackRate) {
      this.markTime({playbackRate: rate});
      this[_playbackRate] = rate;
      this.updateTimers();
    }
  }

  get paused() {
    if(this.playbackRate === 0) return true;
    let parent = this.parent;
    while(parent) {
      if(parent.playbackRate === 0) return true;
      parent = parent.parent;
    }
    return false;
  }

  updateTimers() {
    const timers = [...this[_timers]];
    timers.forEach(([id, timer]) => {
      this[_setTimer](timer.handler, timer.time, id);
    });
  }

  clearTimeout(id) {
    const timer = this[_timers].get(id);

    if(timer && timer.timerID != null) {
      if(this[_parent]) {
        this[_parent].clearTimeout(timer.timerID);
      } else {
        clearTimeout(timer.timerID);
      }
    }
    this[_timers].delete(id);
  }

  clearInterval(id) {
    return this.clearTimeout(id);
  }

  clear() {
    // clear all running timers
    const timers = this[_timers]
    ;[...timers.keys()].forEach((id) => {
      this.clearTimeout(id);
    });
  }

  /*
    setTimeout(func, {delay: 100, isEntropy: true})
    setTimeout(func, {entropy: 100})
    setTimeout(func, 100})
   */
  setTimeout(handler, time = {delay: 0}) {
    return this[_setTimer](handler, time);
  }

  setInterval(handler, time = {delay: 0}) {
    const that = this;
    const id = this[_setTimer](function step() {
      // reset timer before handler cause we may clearTimeout in handler()
      that[_setTimer](step, time, id);
      handler();
    }, time);

    return id;
  }

  [_setTimer](handler, time, id = Symbol('timerID')) {
    time = formatDelay(time);

    const timer = this[_timers].get(id);
    let delay,
      timerID = null,
      startTime,
      startEntropy;

    if(timer) {
      this.clearTimeout(id);
      if(time.isEntropy) {
        delay = (time.delay - (this.entropy - timer.startEntropy)) / Math.abs(this.playbackRate);
      } else {
        delay = (time.delay - (this.currentTime - timer.startTime)) / this.playbackRate;
      }
      startTime = timer.startTime;
      startEntropy = timer.startEntropy;
    } else {
      delay = time.delay / (time.isEntropy ? Math.abs(this.playbackRate) : this.playbackRate);
      startTime = this.currentTime;
      startEntropy = this.entropy;
    }

    const parent = this[_parent],
      globalTimeout = parent ? parent.setTimeout.bind(parent) : setTimeout;

    const heading = time.heading;
    // console.log(heading, parent, delay)
    if(!parent && heading === false && delay < 0) {
      delay = Infinity;
    }

    // if playbackRate is zero, delay will be infinity.
    // For wxapp bugs, cannot use Number.isFinite yet.
    if(isFinite(delay) || parent) { // eslint-disable-line no-restricted-globals
      delay = Math.ceil(delay);
      if(globalTimeout !== setTimeout) {
        delay = {delay, heading};
      }
      timerID = globalTimeout(() => {
        this[_timers].delete(id);
        handler();
      }, delay);
    }

    this[_timers].set(id, {
      timerID,
      handler,
      time,
      startTime,
      startEntropy,
    });

    return id;
  }
}

export default Timeline;