NGL@1.0.0-beta.7 Home Manual Reference Source Gallery

src/trajectory/trajectory-player.js

/**
 * @file Trajectory Player
 * @author Alexander Rose <alexander.rose@weirdbyte.de>
 * @private
 */

import Signal from '../../lib/signals.es6.js'

import { defaults } from '../utils.js'

/**
 * Trajectory player parameter object.
 * @typedef {Object} TrajectoryPlayerParameters - parameters
 *
 * @property {Integer} step - how many frames to skip when playing
 * @property {Integer} timeout - how many milliseconds to wait between playing frames
 * @property {Integer} start - first frame to play
 * @property {Integer} end - last frame to play
 * @property {String} interpolateType - one of "" (empty string), "linear" or "spline"
 * @property {Integer} interpolateStep - window size used for interpolation
 * @property {String} mode - either "loop" or "once"
 * @property {String} direction - either "forward", "backward" or "bounce"
 */

/**
 * Trajectory player for animating coordinate frames
 * @example
 * var player = new TrajectoryPlayer(trajectory, {step: 1, timeout: 50});
 * player.play();
 */
class TrajectoryPlayer {
  /**
   * make trajectory player
   * @param {Trajectory} traj - the trajectory
   * @param {TrajectoryPlayerParameters} [params] - parameter object
   */
  constructor (traj, params) {
    this.signals = {
      startedRunning: new Signal(),
      haltedRunning: new Signal()
    }

    traj.signals.playerChanged.add(function (player) {
      if (player !== this) {
        this.pause()
      }
    }, this)

    const p = Object.assign({}, params)
    const n = defaults(traj.frameCount, 1)

    this.traj = traj
    this.start = defaults(p.start, 0)
    this.end = Math.min(defaults(p.end, n - 1), n - 1)

    this.step = defaults(p.step, Math.ceil((n + 1) / 100))
    this.timeout = defaults(p.timeout, 50)
    this.interpolateType = defaults(p.interpolateType, '')
    this.interpolateStep = defaults(p.interpolateStep, 5)
    this.mode = defaults(p.mode, 'loop')  // loop, once
    this.direction = defaults(p.direction, 'forward')  // forward, backward, bounce

    this._run = false
    this._previousTime = 0
    this._currentTime = 0
    this._currentStep = 1
    this._currentFrame = this.start
    this._direction = this.direction === 'bounce' ? 'forward' : this.direction

    traj.signals.countChanged.add(function (n) {
      this.end = Math.min(defaults(this.end, n - 1), n - 1)
    }, this)

    this._animate = this._animate.bind(this)
  }

  get isRunning () { return this._run }

  /**
   * set player parameters
   * @param {TrajectoryPlayerParameters} [params] - parameter object
   */
  setParameters (params) {
    const p = Object.assign({}, params)

    if (p.start !== undefined) this.start = p.start
    if (p.end !== undefined) this.end = p.end

    if (p.step !== undefined) this.step = p.step
    if (p.timeout !== undefined) this.timeout = p.timeout
    if (p.interpolateType !== undefined) this.interpolateType = p.interpolateType
    if (p.interpolateStep !== undefined) this.interpolateStep = p.interpolateStep
    if (p.mode !== undefined) this.mode = p.mode

    if (p.direction !== undefined) {
      this.direction = p.direction
      if (this.direction !== 'bounce') {
        this._direction = this.direction
      }
    }
  }

  _animate () {
    if (!this._run) return

    this._currentTime = window.performance.now()
    const dt = this._currentTime - this._previousTime
    const step = this.interpolateType ? this.interpolateStep : 1
    const timeout = this.timeout / step
    const traj = this.traj

    if (traj && traj.frameCount && !traj.inProgress && dt >= timeout) {
      if (this.interpolateType) {
        if (this._currentStep > this.interpolateStep) {
          this._currentStep = 1
        }
        if (this._currentStep === 1) {
          this._currentFrame = this._nextInterpolated()
        }
        if (traj.hasFrame(this._currentFrame)) {
          this._currentStep += 1
          const t = this._currentStep / (this.interpolateStep + 1)
          traj.setFrameInterpolated(
            ...this._currentFrame, t, this.interpolateType
          )
          this._previousTime = this._currentTime
        } else {
          traj.loadFrame(this._currentFrame)
        }
      } else {
        const i = this._next()
        if (traj.hasFrame(i)) {
          traj.setFrame(i)
          this._previousTime = this._currentTime
        } else {
          traj.loadFrame(i)
        }
      }
    }

    window.requestAnimationFrame(this._animate)
  }

  _next () {
    let i

    if (this._direction === 'forward') {
      i = this.traj.currentFrame + this.step
    } else {
      i = this.traj.currentFrame - this.step
    }

    if (i > this.end || i < this.start) {
      if (this.direction === 'bounce') {
        if (this._direction === 'forward') {
          this._direction = 'backward'
        } else {
          this._direction = 'forward'
        }
      }

      if (this.mode === 'once') {
        this.pause()

        if (this.direction === 'forward') {
          i = this.end
        } else if (this.direction === 'backward') {
          i = this.start
        } else {
          if (this._direction === 'forward') {
            i = this.start
          } else {
            i = this.end
          }
        }
      } else {
        if (this._direction === 'forward') {
          i = this.start
          if (this.interpolateType) {
            i = Math.min(this.end, i + this.step)
          }
        } else {
          i = this.end
          if (this.interpolateType) {
            i = Math.max(this.start, i - this.step)
          }
        }
      }
    }

    return i
  }

  _nextInterpolated () {
    const i = this._next()
    let ip, ipp, ippp

    if (this._direction === 'forward') {
      ip = Math.max(this.start, i - this.step)
      ipp = Math.max(this.start, i - 2 * this.step)
      ippp = Math.max(this.start, i - 3 * this.step)
    } else {
      ip = Math.min(this.end, i + this.step)
      ipp = Math.min(this.end, i + 2 * this.step)
      ippp = Math.min(this.end, i + 3 * this.step)
    }

    return [i, ip, ipp, ippp]
  }

  /**
   * toggle between playing and pausing the animation
   * @return {undefined}
   */
  toggle () {
    if (this._run) {
      this.pause()
    } else {
      this.play()
    }
  }

  /**
   * start the animation
   * @return {undefined}
   */
  play () {
    if (!this._run) {
      if (this.traj.player !== this) {
        this.traj.setPlayer(this)
      }
      this._currentStep = 1

      const frame = this.traj.currentFrame

      // snap to the grid implied by this.step division and multiplication
      // thus minimizing cache misses
      let i = Math.ceil(frame / this.step) * this.step
      // wrap when restarting from the limit (i.e. end or start)
      if (this.direction === 'forward' && frame >= this.end) {
        i = this.start
      } else if (this.direction === 'backward' && frame <= this.start) {
        i = this.end
      }

      this.traj.setFrame(i)

      this._run = true
      this._animate()
      this.signals.startedRunning.dispatch()
    }
  }

  /**
   * pause the animation
   * @return {undefined}
   */
  pause () {
    this._run = false
    this.signals.haltedRunning.dispatch()
  }

  /**
   * stop the animation (pause and go to start-frame)
   * @return {undefined}
   */
  stop () {
    this.pause()
    this.traj.setFrame(this.start)
  }
}

export default TrajectoryPlayer