Source: client/sink/BaseDisplay.js

import BaseLfo from '../../core/BaseLfo';

const commonDefinitions = {
  min: {
    type: 'float',
    default: -1,
    metas: { kind: 'dynamic' },
  },
  max: {
    type: 'float',
    default: 1,
    metas: { kind: 'dynamic' },
  },
  width: {
    type: 'integer',
    default: 300,
    metas: { kind: 'dynamic' },
  },
  height: {
    type: 'integer',
    default: 150,
    metas: { kind: 'dynamic' },
  },
  container: {
    type: 'any',
    default: null,
    constant: true,
  },
  canvas: {
    type: 'any',
    default: null,
    constant: true,
  },
};

const hasDurationDefinitions = {
  duration: {
    type: 'float',
    min: 0,
    max: +Infinity,
    default: 1,
    metas: { kind: 'dynamic' },
  },
  referenceTime: {
    type: 'float',
    default: 0,
    constant: true,
  },
};

/**
 * Base class to extend in order to create graphic sinks.
 *
 * <span class="warning">_This class should be considered abstract and only
 * be used to be extended._</span>
 *
 * @todo - fix float rounding errors (produce decays in sync draws)
 *
 * @memberof module:client.sink
 *
 * @param {Object} options - Override default parameters.
 * @param {Number} [options.min=-1] - Minimum value represented in the canvas.
 *  _dynamic parameter_
 * @param {Number} [options.max=1] - Maximum value represented in the canvas.
 *  _dynamic parameter_
 * @param {Number} [options.width=300] - Width of the canvas.
 *  _dynamic parameter_
 * @param {Number} [options.height=150] - Height of the canvas.
 *  _dynamic parameter_
 * @param {Element|CSSSelector} [options.container=null] - Container element
 *  in which to insert the canvas. _constant parameter_
 * @param {Element|CSSSelector} [options.canvas=null] - Canvas element
 *  in which to draw. _constant parameter_
 * @param {Number} [options.duration=1] - Duration (in seconds) represented in
 *  the canvas. This parameter only exists for operators that display several
 *  consecutive frames on the canvas. _dynamic parameter_
 * @param {Number} [options.referenceTime=null] - Optionnal reference time the
 *  display should considerer as the origin. Is only usefull when synchronizing
 *  several display using the `DisplaySync` class. This parameter only exists
 *  for operators that display several consecutive frames on the canvas.
 */
class BaseDisplay extends BaseLfo {
  constructor(defs, options = {}, hasDuration = true) {
    let commonDefs;

    if (hasDuration)
      commonDefs = Object.assign({}, commonDefinitions, hasDurationDefinitions);
    else
      commonDefs = commonDefinitions

    const definitions = Object.assign({}, commonDefs, defs);

    super(definitions, options);

    if (this.params.get('canvas') === null && this.params.get('container') === null)
      throw new Error('Invalid parameter: `canvas` or `container` not defined');

    const canvasParam = this.params.get('canvas');
    const containerParam = this.params.get('container');

    // prepare canvas
    if (canvasParam) {
      if (typeof canvasParam === 'string')
        this.canvas = document.querySelector(canvasParam);
      else
        this.canvas = canvasParam;
    } else if (containerParam) {
      let container;

      if (typeof containerParam === 'string')
        container = document.querySelector(containerParam);
      else
        container = containerParam;

      this.canvas = document.createElement('canvas');
      container.appendChild(this.canvas);
    }

    this.ctx = this.canvas.getContext('2d');
    this.cachedCanvas = document.createElement('canvas');
    this.cachedCtx = this.cachedCanvas.getContext('2d');

    this.hasDuration = hasDuration;
    this.previousFrame = null;
    this.currentTime = hasDuration ? this.params.get('referenceTime') : null;

    /**
     * Instance of the `DisplaySync` used to synchronize the different displays
     * @private
     */
    this.displaySync = false;

    this._stack = [];
    this._rafId = null;

    this.renderStack = this.renderStack.bind(this);
    this.shiftError = 0;

    // initialize canvas size and y scale transfert function
    this._resize();
  }

  /** @private */
  _resize() {
    const width = this.params.get('width');
    const height = this.params.get('height');

    const ctx = this.ctx;
    const cachedCtx = this.cachedCtx;

    const dPR = window.devicePixelRatio || 1;
    const bPR = ctx.webkitBackingStorePixelRatio ||
      ctx.mozBackingStorePixelRatio ||
      ctx.msBackingStorePixelRatio ||
      ctx.oBackingStorePixelRatio ||
      ctx.backingStorePixelRatio || 1;

    this.pixelRatio = dPR / bPR;

    const lastWidth = this.canvasWidth;
    const lastHeight = this.canvasHeight;
    this.canvasWidth = width * this.pixelRatio;
    this.canvasHeight = height * this.pixelRatio;

    cachedCtx.canvas.width = this.canvasWidth;
    cachedCtx.canvas.height = this.canvasHeight;

    // copy current image from ctx (resize)
    if (lastWidth && lastHeight) {
      cachedCtx.drawImage(ctx.canvas,
        0, 0, lastWidth, lastHeight,
        0, 0, this.canvasWidth, this.canvasHeight
      );
    }

    ctx.canvas.width = this.canvasWidth;
    ctx.canvas.height = this.canvasHeight;
    ctx.canvas.style.width = `${width}px`;
    ctx.canvas.style.height = `${height}px`;

    // update scale
    this._setYScale();
  }

  /**
   * Create the transfert function used to map values to pixel in the y axis
   * @private
   */
  _setYScale() {
    const min = this.params.get('min');
    const max = this.params.get('max');
    const height = this.canvasHeight;

    const a = (0 - height) / (max - min);
    const b = height - (a * min);

    this.getYPosition = (x) => a * x + b;
  }

  /**
   * Returns the width in pixel a `vector` frame needs to be drawn.
   * @private
   */
  getMinimumFrameWidth() {
    return 1; // need one pixel to draw the line
  }

  /**
   * Callback function executed when a parameter is updated.
   *
   * @param {String} name - Parameter name.
   * @param {Mixed} value - Parameter value.
   * @param {Object} metas - Metadatas of the parameter.
   * @private
   */
  onParamUpdate(name, value, metas) {
    super.onParamUpdate(name, value, metas);

    switch (name) {
      case 'min':
      case 'max':
        // @todo - make sure that min and max are different
        this._setYScale();
        break;
      case 'width':
      case 'height':
        this._resize();
    }
  }

  /** @private */
  propagateStreamParams() {
    super.propagateStreamParams();
  }

  /** @private */
  resetStream() {
    super.resetStream();

    const width = this.canvasWidth;
    const height = this.canvasHeight;

    this.previousFrame = null;
    this.currentTime = this.hasDuration ? this.params.get('referenceTime') : null;

    this.ctx.clearRect(0, 0, width, height);
    this.cachedCtx.clearRect(0, 0, width, height);
  }

  /** @private */
  finalizeStream(endTime) {
    this.currentTime = null;
    super.finalizeStream(endTime);

    this._rafId = null;

    // clear the stack if not empty
    if (this._stack.length > 0)
      this.renderStack();
  }

  /**
   * Add the current frame to the frames to draw. Should not be overriden.
   * @private
   */
  processFrame(frame) {
    const frameSize = this.streamParams.frameSize;
    const copy = new Float32Array(frameSize);
    const data = frame.data;

    // copy values of the input frame as they might be updated
    // in reference before being consumed in the draw function
    for (let i = 0; i < frameSize; i++)
      copy[i] = data[i];

    this._stack.push({
      time: frame.time,
      data: copy,
      metadata: frame.metadata,
    });

    if (this._rafId === null)
      this._rafId = window.requestAnimationFrame(this.renderStack);
  }

  /**
   * Render the accumulated frames. Method called in `requestAnimationFrame`.
   * @private
   */
  renderStack() {
    if (this.params.has('duration')) {
      // render all frame since last `renderStack` call
      for (let i = 0, l = this._stack.length; i < l; i++)
        this.scrollModeDraw(this._stack[i]);
    } else {
      // only render last received frame if any
      if (this._stack.length > 0) {
        const frame = this._stack[this._stack.length - 1];
        this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
        this.processFunction(frame);
      }
    }

    this._stack.length = 0; // reinit stack for next call
    this._rafId = null;
  }

  /**
   * Draw data from right to left with scrolling
   * @private
   * @todo - check possibility of maintaining all values from one place to
   *         minimize float error tracking.
   */
  scrollModeDraw(frame) {
    const frameType = this.streamParams.frameType;
    const frameRate = this.streamParams.frameRate;
    const frameSize = this.streamParams.frameSize;
    const sourceSampleRate = this.streamParams.sourceSampleRate;

    const canvasDuration = this.params.get('duration');
    const ctx = this.ctx;
    const canvasWidth = this.canvasWidth;
    const canvasHeight = this.canvasHeight;

    const previousFrame = this.previousFrame;

    // current time at the left of the canvas
    const currentTime = (this.currentTime !== null) ? this.currentTime : frame.time;
    const frameStartTime = frame.time;
    const lastFrameTime = previousFrame ? previousFrame.time : 0;
    const lastFrameDuration = this.lastFrameDuration ? this.lastFrameDuration : 0;

    let frameDuration;

    if (frameType === 'scalar' || frameType === 'vector') {
      const pixelDuration = canvasDuration / canvasWidth;
      frameDuration = this.getMinimumFrameWidth() * pixelDuration;
    } else if (this.streamParams.frameType === 'signal') {
      frameDuration = frameSize / sourceSampleRate;
    }

    const frameEndTime = frameStartTime + frameDuration;
    // define if we need to shift the canvas
    const shiftTime = frameEndTime - currentTime;

    // if the canvas is not synced, should never go to `else`
    if (shiftTime > 0) {
      // shift the canvas of shiftTime in pixels
      const fShift = (shiftTime / canvasDuration) * canvasWidth - this.shiftError;
      const iShift = Math.floor(fShift + 0.5);
      this.shiftError = fShift - iShift;

      const currentTime = frameStartTime + frameDuration;
      this.shiftCanvas(iShift, currentTime);

      // if siblings, share the information
      if (this.displaySync)
        this.displaySync.shiftSiblings(iShift, currentTime, this);
    }

    // width of the frame in pixels
    const floatFrameWidth = (frameDuration / canvasDuration) * canvasWidth;
    const frameWidth = Math.floor(floatFrameWidth + 0.5);

    // define position of the head in the canvas
    const canvasStartTime = this.currentTime - canvasDuration;
    const startTimeRatio = (frameStartTime - canvasStartTime) / canvasDuration;
    const startTimePosition = startTimeRatio * canvasWidth;

    // number of pixels since last frame
    let pixelsSinceLastFrame = this.lastFrameWidth;

    if ((frameType === 'scalar' || frameType === 'vector') && previousFrame) {
      const frameInterval = frame.time - previousFrame.time;
      pixelsSinceLastFrame = (frameInterval / canvasDuration) * canvasWidth;
    }

    // draw current frame
    ctx.save();
    ctx.translate(startTimePosition, 0);
    this.processFunction(frame, frameWidth, pixelsSinceLastFrame);
    ctx.restore();

    // save current canvas state into cached canvas
    this.cachedCtx.clearRect(0, 0, canvasWidth, canvasHeight);
    this.cachedCtx.drawImage(this.canvas, 0, 0, canvasWidth, canvasHeight);

    // update lastFrameDuration, lastFrameWidth
    this.lastFrameDuration = frameDuration;
    this.lastFrameWidth = frameWidth;
    this.previousFrame = frame;
  }

  /**
   * Shift canvas, also called from `DisplaySync`
   * @private
   */
  shiftCanvas(iShift, time) {
    const ctx = this.ctx;
    const cache = this.cachedCanvas;
    const cachedCtx = this.cachedCtx;
    const width = this.canvasWidth;
    const height = this.canvasHeight;
    const croppedWidth = width - iShift;
    this.currentTime = time;

    ctx.clearRect(0, 0, width, height);
    ctx.drawImage(cache, iShift, 0, croppedWidth, height, 0, 0, croppedWidth, height);
    // save current canvas state into cached canvas
    cachedCtx.clearRect(0, 0, width, height);
    cachedCtx.drawImage(this.canvas, 0, 0, width, height);
  }

  // @todo - Fix trigger mode
  // allow to witch easily between the 2 modes
  // setTrigger(bool) {
  //   this.params.trigger = bool;
  //   // clear canvas and cache
  //   this.ctx.clearRect(0, 0, this.params.width, this.params.height);
  //   this.cachedCtx.clearRect(0, 0, this.params.width, this.params.height);
  //   // reset _currentXPosition
  //   this._currentXPosition = 0;
  //   this.lastShiftError = 0;
  // }

  // /**
  //  * Alternative drawing mode.
  //  * Draw from left to right, go back to left when > width
  //  */
  // triggerModeDraw(time, frame) {
  //   const width  = this.params.width;
  //   const height = this.params.height;
  //   const duration = this.params.duration;
  //   const ctx = this.ctx;

  //   const dt = time - this.previousTime;
  //   const fShift = (dt / duration) * width - this.lastShiftError; // px
  //   const iShift = Math.round(fShift);
  //   this.lastShiftError = iShift - fShift;

  //   this.currentXPosition += iShift;

  //   // draw the right part
  //   ctx.save();
  //   ctx.translate(this.currentXPosition, 0);
  //   ctx.clearRect(-iShift, 0, iShift, height);
  //   this.drawCurve(frame, iShift);
  //   ctx.restore();

  //   // go back to the left of the canvas and redraw the same thing
  //   if (this.currentXPosition > width) {
  //     // go back to start
  //     this.currentXPosition -= width;

  //     ctx.save();
  //     ctx.translate(this.currentXPosition, 0);
  //     ctx.clearRect(-iShift, 0, iShift, height);
  //     this.drawCurve(frame, this.previousFrame, iShift);
  //     ctx.restore();
  //   }
  // }

}

export default BaseDisplay;