Home Reference Source

src/ui/canvas.js

// Internal dependencies
import CanvasDatapoint from './datapoint';

/**
 * UI canvas for displaying machine learning results.
 *
 * Listeners:
 *  This class supports event listeners, meaning that the outside world can bind functions to events
 *  triggered explicitly by this class. Listeners can be added using `addListener` and removed by
 * `removeListener`. The `emit` method is not intended for use by the outside world, and is used by
 *  this class to emit an event to the listeners bound to it.
 */
export default class Canvas {
  /**
   * Contructor. Load DOM element and user options.
   *
   * @param {Object} el DOM Canvas element
   * @param {Object} [optionsUser] - User-defined options for the canvas
   * @param {boolean} [optionsUser.continuousClick = false] - Whether the "click" callback should
   *   be called any time the mouse is down (true) or only at the moment the mouse button is first
   *   pressed (false). If true, a click event is emitted every `continuousClickInterval`
   *   milliseconds when the left mouse button is down
   * @param {number} [optionsUser.continuousClickInterval = 50] - Number of milliseconds between
   *   emitting each click event when `continuousClick` is enabled
   * @param {number} [optionsUser.x1 = -2.5] - Left bound of coordinate system for canvas
   * @param {number} [optionsUser.y1 = -2.5] - Bottom bound of coordinate system for canvas
   * @param {number} [optionsUser.x2 = 2.5] - Right bound of coordinate system for canvas
   * @param {number} [optionsUser.y2 = 2.5] - Top bound of coordinate system for canvas
   */
  constructor(el, optionsUser) {
    // Options
    const optionsDefault = {
      continuousClick: false,
      continuousClickInterval: 50,
      x1: -2.5,
      y1: -2.5,
      x2: 2.5,
      y2: 2.5,
    };

    this.options = {
      ...optionsDefault,
      ...optionsUser,
    };

    // Settings for canvas
    this.canvas = {
      element: el,
      context: el.getContext('2d'),
    };

    // Handle canvas resize on window resize
    window.addEventListener('resize', () => this.resize());
    this.resize();

    // Event listeners bound to the canvas
    this.listeners = new Map();

    // Canvas elements to be drawn
    this.elements = [];

    // Class boundaries
    this.classesBoundaries = {};

    // Initialization
    this.handleMouseEvents();

    // Animation
    window.requestAnimationFrame(() => this.refresh());

    // Temporary properties
    this.tmp = {};
    this.tmp.predFeatures = [];
    this.tmp.predLabels = [];
  }

  /**
   * Add an event listener for events of some type emitted from this object.
   *
   * @param {string} label - Event identifier
   * @param {function} callback - Callback function for when the event is emitted
   */
  addListener(label, callback) {
    if (!this.listeners.has(label)) {
      this.listeners.set(label, []);
    }

    this.listeners.get(label).push(callback);
  }

  /**
   * Remove a previously added event listener for events of some type emitted from this object.
   *
   * @param {string} label - Event identifier
   * @param {function} callback - Callback function to remove from event
   */
  removeListener(label, callback) {
    const listeners = this.listeners.get(label);

    if (listeners) {
      this.listeners.set(label, listeners.filter(
        x => !(typeof x === 'function' && x === callback)
      ));
    }
  }

  /**
   * Emit an event, which triggers the listener callback functions bound to it.
   *
   * @param {string} label - Event identifier
   * @param {...mixed} args - Remaining arguments contain arguments that should be passed to the
   *   callback functions
   * @return {boolean} Whether any listener callback functions were executed
   */
  emit(label, ...args) {
    const listeners = this.listeners.get(label);

    if (listeners) {
      listeners.forEach((listener) => { listener(...args); });
      return true;
    }

    return false;
  }

  /**
   * Add a data point element to the canvas, using a dataset datapoint as its model.
   *
   * @param {jsmlt.Dataset.Datapoint} datapoint - Dataset datapoint (model)
   */
  addDatapoint(datapoint) {
    this.elements.push(new CanvasDatapoint(this, datapoint));
  }

  /**
   * Handle mouse events on the canvas, e.g. for adding data points.
   */
  handleMouseEvents() {
    if (this.options.continuousClick) {
      this.mouseStatus = 0;
      this.mouseX = 0;
      this.mouseY = 0;

      this.canvas.element.addEventListener('mousedown', () => {
        this.mouseStatus = 1;
        this.continuousClickIntervalId = setInterval(
          () => this.click(),
          this.options.continuousClickInterval
        );
      });

      document.addEventListener('mouseup', () => {
        this.mouseStatus = 0;
        clearInterval(this.continuousClickIntervalId);
      });

      document.addEventListener('mousemove', (e) => {
        [this.mouseX, this.mouseY] =
          this.transformAbsolutePositionToRelativePosition(e.clientX, e.clientY);
      });
    }

    this.canvas.element.addEventListener('mousedown', (e) => {
      this.click(...this.transformAbsolutePositionToRelativePosition(e.clientX, e.clientY));
    });
  }

  /**
   * Transform the absolute position of the mouse in the viewport to the mouse position relative
   * to the top-left point of the canvas.
   *
   * @param {number} x - Absolute mouse x-coordinate within viewport
   * @param {number} y - Absolute mouse y-coordinate within viewport
   * @return {Array.<number>} Two-dimensional array consisting of relative x- and y-coordinate
   */
  transformAbsolutePositionToRelativePosition(x, y) {
    // Handle screen resizing for obtaining correct coordinates
    this.resize();

    // Properties used for calculating mouse position
    const el = this.canvas.element;
    const rect = el.getBoundingClientRect();

    return [x - rect.left, y - rect.top];
  }

  /**
   * Trigger a click at some position in the canvas.
   *
   * @param {number} [x = -1] - X-coordinate of the click. Defaults to stored mouse position from
   *   mousemove event
   * @param {number} [y = -1] - Y-coordinate of the click. Defaults to stored mouse position from
   *   mousemove event
   */
  click(x = -1, y = -1) {
    // Get click coordinates
    let clickX = x;
    let clickY = y;

    if (x === -1) {
      clickX = this.mouseX;
      clickY = this.mouseY;
    }

    // Calculate normalized coordinates with origin in canvas center
    const [px, py] = this.convertCanvasCoordinatesToFeatures(clickX, clickY);

    this.emit('click', px, py);
  }

  /**
   * Clear the canvas.
   */
  clear() {
    this.canvas.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
  }

  /**
   * Handle the canvas size for different device pixel ratios and on window resizes.
   */
  resize() {
    this.canvas.element.style.width = '100%';
    this.canvas.element.style.height = '100%';
    this.canvas.element.width = this.canvas.element.offsetWidth * window.devicePixelRatio;
    this.canvas.element.height = this.canvas.element.offsetHeight * window.devicePixelRatio;
    this.canvas.width = this.canvas.element.offsetWidth;
    this.canvas.height = this.canvas.element.offsetHeight;
    this.canvas.context.scale(window.devicePixelRatio, window.devicePixelRatio);
  }

  /**
   * Redraw the canvas, clearing it and drawing all elements on it.
   */
  redraw() {
    // Clear canvas
    this.clear();

    // Basic canvas elements
    this.drawGrid();
    this.drawAxes();

    // Draw dynamic canvas elements
    this.elements.forEach((element) => {
      element.draw();
    });

    // Class boundaries
    this.drawClassBoundaries();

    // Refresh again
    window.requestAnimationFrame(() => this.refresh());
  }

  /**
   * Refresh (i.e. redraw) everything on the canvas.
   */
  refresh() {
    // Dynamic canvas elements
    this.elements.forEach((element) => {
      element.update();
    });

    this.redraw();
  }

  /**
   * Set the class boundaries used for drawing the decision regions on the canvas.
   *
   * @param {Object<string, Array.<Array.<Array.<number>>>>} classesBoundaries - Class boundaries
   *   per class label
   */
  setClassBoundaries(classesBoundaries) {
    this.classesBoundaries = classesBoundaries;
  }

  /**
   * Calculate normalized canvas coordinates, i.e. transform mouse coordinates (relative to the
   * canvas origin = top left) to feature space for both x and y. The feature subspace shape is
   * determined by the x1, y1, x2, and y2 parameters in the class options (see constructor).
   *
   * @param {number} x - x-coordinate in canvas
   * @param {number} y - y-coordinate in canvas
   * @return {Array.<number>} Corresponding point in feature space (first element corresponds to x,
   *   second element corresponds to y)
   */
  convertCanvasCoordinatesToFeatures(x, y) {
    // Mouse x- and y-position on [0,1] interval
    let f1 = x / this.canvas.width;
    let f2 = y / this.canvas.height;

    // Convert to [-1,1] interval
    f1 = this.options.x1 + f1 * (this.options.x2 - this.options.x1);
    f2 = this.options.y1 + (1 - f2) * (this.options.y2 - this.options.y1);

    return [f1, f2];
  }

  /**
   * Convert coordinates on a centered, double unit square (i.e., a square from (-1, -1) to (1, 1))
   * to feature space.
   *
   * @param {number} bx - Input x-coordinate in input space
   * @param {number} by - Input y-coordinate in input space
   * @return {Array.<number>} Corresponding point in feature space (first element corresponds to x,
   *   second element corresponds to y)
   */
  convertBoundaryCoordinatesToFeatures(bx, by) {
    const f1 = this.options.x1 + (bx + 1) / 2 * (this.options.x2 - this.options.x1);
    const f2 = this.options.y1 + (by + 1) / 2 * (this.options.y2 - this.options.y1);

    return [f1, f2];
  }

  /**
   * Calculate canvas coordinates (origin at (0,0)) for a 2-dimensional data point's features
   *
   * @param {number} f1 First feature
   * @param {number} f2 Second feature
   * @return {Array.<number>} Corresponding point in the canvas (first element corresponds to x,
   *   second element corresponds to y)
   */
  convertFeaturesToCanvasCoordinates(f1, f2) {
    const x = (f1 - this.options.x1) / (this.options.x2 - this.options.x1);
    const y = 1 - ((f2 - this.options.y1) / (this.options.y2 - this.options.y1));

    return [x * this.canvas.width, y * this.canvas.height];
  }

  /**
   * Draw a grid on the canvas
   */
  drawGrid() {
    const canvas = this.canvas;
    const context = canvas.context;

    // Loop over all line offsets
    for (let i = 1; i < 10; i += 1) {
      // Horizontal
      context.beginPath();
      context.moveTo(0, i / 10 * canvas.height);
      context.lineTo(canvas.width, i / 10 * canvas.height);
      context.lineWidth = 1;
      context.strokeStyle = '#EAEAEA';
      context.stroke();

      // Vertical
      context.beginPath();
      context.moveTo(i / 10 * canvas.width, 0);
      context.lineTo(i / 10 * canvas.width, canvas.height);
      context.lineWidth = 1;
      context.strokeStyle = '#EAEAEA';
      context.stroke();
    }
  }

  /**
   * Draw the axes on the canvas
   */
  drawAxes() {
    const canvas = this.canvas;
    const context = canvas.context;

    // Origin coordinates
    const [originX, originY] = this.convertFeaturesToCanvasCoordinates(0, 0);

    // Horizontal
    context.beginPath();
    context.moveTo(0, originY);
    context.lineTo(canvas.width, originY);
    context.lineWidth = 2;
    context.strokeStyle = '#CCC';
    context.stroke();

    // Vertical
    context.beginPath();
    context.moveTo(originX, 0);
    context.lineTo(originX, canvas.height);
    context.lineWidth = 2;
    context.strokeStyle = '#CCC';
    context.stroke();
  }

  /**
   * Draw class boundaries
   */
  drawClassBoundaries() {
    const context = this.canvas.context;

    Object.keys(this.classesBoundaries).forEach((classLabel) => {
      const classBoundaries = this.classesBoundaries[classLabel];

      // The path delineates the decision region for this class
      context.beginPath();

      classBoundaries.forEach((classBoundary) => {
        let firstpoint = true;

        classBoundary.forEach((boundaryPoint) => {
          const [xx, yy] = this.convertFeaturesToCanvasCoordinates(
            ...this.convertBoundaryCoordinatesToFeatures(boundaryPoint[0], boundaryPoint[1])
          );

          if (firstpoint) {
            firstpoint = false;
            context.moveTo(xx, yy);
          } else {
            context.lineTo(xx, yy);
          }

          if (Math.abs(boundaryPoint[0]) !== 1 && Math.abs(boundaryPoint[1]) !== 1) {
            context.fillStyle = this.getClassColor(classLabel);
            context.fillStyle = '#000';
            context.globalAlpha = 0.25;
            context.globalAlpha = 1;
          }
        });

        context.closePath();
      });

      context.fillStyle = '#5DA5DA';
      context.strokeStyle = '#5DA5DA';
      context.fillStyle = this.getClassColor(classLabel);
      context.strokeStyle = this.getClassColor(classLabel);
      context.globalAlpha = 0.5;
      context.fill();
      context.globalAlpha = 1;
    });
  }

  /**
   * Get drawing color for a class index.
   *
   * @param {number} classIndex - Class index
   * @return {string} Color in HEX with '#' prefix
   */
  getClassColor(classIndex) {
    const colors = this.getColors();
    return classIndex === null ? '#DDDDDD' : colors[Object.keys(colors)[parseInt(classIndex, 10)]];
  }

  /**
   * Get available drawing colors.
   *
   * @return <Array.{string}> Colors in HEX with '#' prefix; array keys are color names
   */
  getColors() {
    return {
      blue: '#5DA5DA',
      orange: '#FAA43A',
      green: '#60BD68',
      pink: '#F17CB0',
      brown: '#B2912F',
      purple: '#B276B2',
      yellow: '#DECF3F',
      red: '#F15854',
      gray: '#4D4D4D',
    };
  }
}