src_gui_layerGroup.js

import {getIdentityMat33, getCoronalMat33} from '../math/matrix';
import {Index} from '../math/index';
import {Point} from '../math/point';
import {Vector3D} from '../math/vector';
import {viewEventNames} from '../image/view';
import {ListenerHandler} from '../utils/listen';
import {logger} from '../utils/logger';
import {ViewLayer} from './viewLayer';
import {DrawLayer} from './drawLayer';

// doc imports
/* eslint-disable no-unused-vars */
import {Matrix33} from '../math/matrix';
import {Point3D} from '../math/point';
/* eslint-enable no-unused-vars */

/**
 * Get the layer div id.
 *
 * @param {string} groupDivId The layer group div id.
 * @param {number} layerId The lyaer id.
 * @returns {string} A string id.
 */
export function getLayerDivId(groupDivId, layerId) {
  return groupDivId + '-layer-' + layerId;
}

/**
 * Get the layer details from a div id.
 *
 * @param {string} idString The layer div id.
 * @returns {object} The layer details as {groupDivId, layerId}.
 */
export function getLayerDetailsFromLayerDivId(idString) {
  const split = idString.split('-layer-');
  if (split.length !== 2) {
    logger.warn('Not the expected layer div id format...');
  }
  return {
    groupDivId: split[0],
    layerId: split[1]
  };
}

/**
 * Get the layer details from a mouse event.
 *
 * @param {object} event The event to get the layer div id from. Expecting
 * an event origininating from a canvas inside a layer HTML div
 * with the 'layer' class and id generated with `getLayerDivId`.
 * @returns {object} The layer details as {groupDivId, layerId}.
 */
export function getLayerDetailsFromEvent(event) {
  let res = null;
  // get the closest element from the event target and with the 'layer' class
  const layerDiv = event.target.closest('.layer');
  if (layerDiv && typeof layerDiv.id !== 'undefined') {
    res = getLayerDetailsFromLayerDivId(layerDiv.id);
  }
  return res;
}

/**
 * Get the view orientation according to an image and target orientation.
 * The view orientation is used to go from target to image space.
 *
 * @param {Matrix33} imageOrientation The image geometry.
 * @param {Matrix33} targetOrientation The target orientation.
 * @returns {Matrix33} The view orientation.
 */
export function getViewOrientation(imageOrientation, targetOrientation) {
  let viewOrientation = getIdentityMat33();
  if (typeof targetOrientation !== 'undefined') {
    // i: image, v: view, t: target, O: orientation, P: point
    // [Img] -- Oi --> [Real] <-- Ot -- [Target]
    // Pi = (Oi)-1 * Ot * Pt = Ov * Pt
    // -> Ov = (Oi)-1 * Ot
    // TODO: asOneAndZeros simplifies but not nice...
    viewOrientation =
      imageOrientation.asOneAndZeros().getInverse().multiply(targetOrientation);
  }
  // TODO: why abs???
  return viewOrientation.getAbs();
}

/**
 * Get the target orientation according to an image and view orientation.
 * The target orientation is used to go from target to real space.
 *
 * @param {Matrix33} imageOrientation The image geometry.
 * @param {Matrix33} viewOrientation The view orientation.
 * @returns {Matrix33} The target orientation.
 */
export function getTargetOrientation(imageOrientation, viewOrientation) {
  // i: image, v: view, t: target, O: orientation, P: point
  // [Img] -- Oi --> [Real] <-- Ot -- [Target]
  // Pi = (Oi)-1 * Ot * Pt = Ov * Pt
  // -> Ot = Oi * Ov
  // note: asOneAndZeros as in getViewOrientation...
  let targetOrientation =
    imageOrientation.asOneAndZeros().multiply(viewOrientation);

  // TODO: why abs???
  const simpleImageOrientation = imageOrientation.asOneAndZeros().getAbs();
  if (simpleImageOrientation.equals(getCoronalMat33().getAbs())) {
    targetOrientation = targetOrientation.getAbs();
  }

  return targetOrientation;
}

/**
 * Get a scaled offset to adapt to new scale and such as the input center
 * stays at the same position.
 *
 * @param {object} offset The previous offset as {x,y}.
 * @param {object} scale The previous scale as {x,y}.
 * @param {object} newScale The new scale as {x,y}.
 * @param {object} center The scale center as {x,y}.
 * @returns {object} The scaled offset as {x,y}.
 */
export function getScaledOffset(offset, scale, newScale, center) {
  // worldPoint = indexPoint / scale + offset
  //=> indexPoint = (worldPoint - offset ) * scale

  // plane center should stay the same:
  // indexCenter / newScale + newOffset =
  //   indexCenter / oldScale + oldOffset
  //=> newOffset = indexCenter / oldScale + oldOffset -
  //     indexCenter / newScale
  //=> newOffset = worldCenter - indexCenter / newScale
  const indexCenter = {
    x: (center.x - offset.x) * scale.x,
    y: (center.y - offset.y) * scale.y
  };
  return {
    x: center.x - (indexCenter.x / newScale.x),
    y: center.y - (indexCenter.y / newScale.y)
  };
}

/**
 * Layer group.
 *
 * Display position: {x,y}
 * Plane position: Index (access: get(i))
 * (world) Position: Point3D (access: getX, getY, getZ)
 *
 * Display -> World:
 * planePos = viewLayer.displayToPlanePos(displayPos)
 * -> compensate for layer scale and offset
 * pos = viewController.getPositionFromPlanePoint(planePos)
 *
 * World -> display
 * planePos = viewController.getOffset3DFromPlaneOffset(pos)
 * no need yet for a planePos to displayPos...
 */
export class LayerGroup {

  /**
   * The container div.
   *
   * @type {HTMLElement}
   */
  #containerDiv;

  /**
   * List of layers.
   *
   * @type {Array}
   */
  #layers = [];

  /**
   * The layer scale as {x,y}.
   *
   * @type {object}
   */
  #scale = {x: 1, y: 1, z: 1};

  /**
   * The base scale as {x,y}: all posterior scale will be on top of this one.
   *
   * @type {object}
   */
  #baseScale = {x: 1, y: 1, z: 1};

  /**
   * The layer offset as {x,y}.
   *
   * @type {object}
   */
  #offset = {x: 0, y: 0, z: 0};

  /**
   * Active view layer index.
   *
   * @type {number}
   */
  #activeViewLayerIndex = null;

  /**
   * Active draw layer index.
   *
   * @type {number}
   */
  #activeDrawLayerIndex = null;

  /**
   * Listener handler.
   *
   * @type {ListenerHandler}
   */
  #listenerHandler = new ListenerHandler();

  /**
   * The target orientation matrix.
   *
   * @type {Matrix33}
   */
  #targetOrientation;

  /**
   * Flag to activate crosshair or not.
   *
   * @type {boolean}
   */
  #showCrosshair = false;

  /**
   * The current position used for the crosshair.
   *
   * @type {Point}
   */
  #currentPosition;

  /**
   * @param {HTMLElement} containerDiv The associated HTML div.
   */
  constructor(containerDiv) {
    this.#containerDiv = containerDiv;
  }

  /**
   * Get the target orientation.
   *
   * @returns {Matrix33} The orientation matrix.
   */
  getTargetOrientation() {
    return this.#targetOrientation;
  }

  /**
   * Set the target orientation.
   *
   * @param {Matrix33} orientation The orientation matrix.
   */
  setTargetOrientation(orientation) {
    this.#targetOrientation = orientation;
  }

  /**
   * Get the showCrosshair flag.
   *
   * @returns {boolean} True to display the crosshair.
   */
  getShowCrosshair() {
    return this.#showCrosshair;
  }

  /**
   * Set the showCrosshair flag.
   *
   * @param {boolean} flag True to display the crosshair.
   */
  setShowCrosshair(flag) {
    this.#showCrosshair = flag;
    if (flag) {
      // listen to offset and zoom change
      this.addEventListener('offsetchange', this.#updateCrosshairOnChange);
      this.addEventListener('zoomchange', this.#updateCrosshairOnChange);
      // show crosshair div
      this.#showCrosshairDiv();
    } else {
      // listen to offset and zoom change
      this.removeEventListener('offsetchange', this.#updateCrosshairOnChange);
      this.removeEventListener('zoomchange', this.#updateCrosshairOnChange);
      // remove crosshair div
      this.#removeCrosshairDiv();
    }
  }

  /**
   * Update crosshair on offset or zoom change.
   *
   * @param {object} _event The change event.
   */
  #updateCrosshairOnChange = (_event) => {
    this.#showCrosshairDiv();
  };

  /**
   * Get the Id of the container div.
   *
   * @returns {string} The id of the div.
   */
  getDivId() {
    return this.#containerDiv.id;
  }

  /**
   * Get the layer scale.
   *
   * @returns {object} The scale as {x,y,z}.
   */
  getScale() {
    return this.#scale;
  }

  /**
   * Get the base scale.
   *
   * @returns {object} The scale as {x,y,z}.
   */
  getBaseScale() {
    return this.#baseScale;
  }

  /**
   * Get the added scale: the scale added to the base scale
   *
   * @returns {object} The scale as {x,y,z}.
   */
  getAddedScale() {
    return {
      x: this.#scale.x / this.#baseScale.x,
      y: this.#scale.y / this.#baseScale.y,
      z: this.#scale.z / this.#baseScale.z
    };
  }

  /**
   * Get the layer offset.
   *
   * @returns {object} The offset as {x,y,z}.
   */
  getOffset() {
    return this.#offset;
  }

  /**
   * Get the number of layers handled by this class.
   *
   * @returns {number} The number of layers.
   */
  getNumberOfLayers() {
    return this.#layers.length;
  }

  /**
   * Get the active image layer.
   *
   * @returns {ViewLayer} The layer.
   */
  getActiveViewLayer() {
    return this.#layers[this.#activeViewLayerIndex];
  }

  /**
   * Get the view layers associated to a data index.
   *
   * @param {number} index The data index.
   * @returns {ViewLayer[]} The layers.
   */
  getViewLayersByDataIndex(index) {
    const res = [];
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof ViewLayer &&
        this.#layers[i].getDataIndex() === index) {
        res.push(this.#layers[i]);
      }
    }
    return res;
  }

  /**
   * Search view layers for equal imae meta data.
   *
   * @param {object} meta The meta data to find.
   * @returns {ViewLayer[]} The list of view layers that contain matched data.
   */
  searchViewLayers(meta) {
    const res = [];
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof ViewLayer) {
        if (this.#layers[i].getViewController().equalImageMeta(meta)) {
          res.push(this.#layers[i]);
        }
      }
    }
    return res;
  }

  /**
   * Get the view layers data indices.
   *
   * @returns {Array} The list of indices.
   */
  getViewDataIndices() {
    const res = [];
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof ViewLayer) {
        res.push(this.#layers[i].getDataIndex());
      }
    }
    return res;
  }

  /**
   * Get the active draw layer.
   *
   * @returns {DrawLayer} The layer.
   */
  getActiveDrawLayer() {
    return this.#layers[this.#activeDrawLayerIndex];
  }

  /**
   * Get the draw layers associated to a data index.
   *
   * @param {number} index The data index.
   * @returns {DrawLayer[]} The layers.
   */
  getDrawLayersByDataIndex(index) {
    const res = [];
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof DrawLayer &&
        this.#layers[i].getDataIndex() === index) {
        res.push(this.#layers[i]);
      }
    }
    return res;
  }

  /**
   * Set the active view layer.
   *
   * @param {number} index The index of the layer to set as active.
   */
  setActiveViewLayer(index) {
    this.#activeViewLayerIndex = index;
  }

  /**
   * Set the active view layer with a data index.
   *
   * @param {number} index The data index.
   */
  setActiveViewLayerByDataIndex(index) {
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof ViewLayer &&
        this.#layers[i].getDataIndex() === index) {
        this.setActiveViewLayer(i);
        break;
      }
    }
  }

  /**
   * Set the active draw layer.
   *
   * @param {number} index The index of the layer to set as active.
   */
  setActiveDrawLayer(index) {
    this.#activeDrawLayerIndex = index;
  }

  /**
   * Set the active draw layer with a data index.
   *
   * @param {number} index The data index.
   */
  setActiveDrawLayerByDataIndex(index) {
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof DrawLayer &&
        this.#layers[i].getDataIndex() === index) {
        this.setActiveDrawLayer(i);
        break;
      }
    }
  }

  /**
   * Add a view layer.
   *
   * @returns {ViewLayer} The created layer.
   */
  addViewLayer() {
    // layer index
    const viewLayerIndex = this.#layers.length;
    // create div
    const div = this.#getNextLayerDiv();
    // prepend to container
    this.#containerDiv.append(div);
    // view layer
    const layer = new ViewLayer(div);
    // add layer
    this.#layers.push(layer);
    // mark it as active
    this.setActiveViewLayer(viewLayerIndex);
    // bind view layer events
    this.#bindViewLayer(layer);
    // return
    return layer;
  }

  /**
   * Add a draw layer.
   *
   * @returns {DrawLayer} The created layer.
   */
  addDrawLayer() {
    // store active index
    this.#activeDrawLayerIndex = this.#layers.length;
    // create div
    const div = this.#getNextLayerDiv();
    // prepend to container
    this.#containerDiv.append(div);
    // draw layer
    const layer = new DrawLayer(div);
    // add layer
    this.#layers.push(layer);
    // bind draw layer events
    this.#bindDrawLayer(layer);
    // return
    return layer;
  }

  /**
   * Bind view layer events to this.
   *
   * @param {ViewLayer} viewLayer The view layer to bind.
   */
  #bindViewLayer(viewLayer) {
    // listen to position change to update other group layers
    viewLayer.addEventListener(
      'positionchange', this.updateLayersToPositionChange);
    // propagate view viewLayer-layer events
    for (let j = 0; j < viewEventNames.length; ++j) {
      viewLayer.addEventListener(viewEventNames[j], this.#fireEvent);
    }
    // propagate viewLayer events
    viewLayer.addEventListener('renderstart', this.#fireEvent);
    viewLayer.addEventListener('renderend', this.#fireEvent);
  }

  /**
   * Bind draw layer events to this.
   *
   * @param {DrawLayer} drawLayer The draw layer to bind.
   */
  #bindDrawLayer(drawLayer) {
    // propagate drawLayer events
    drawLayer.addEventListener('drawcreate', this.#fireEvent);
    drawLayer.addEventListener('drawdelete', this.#fireEvent);
  }

  /**
   * Get the next layer DOM div.
   *
   * @returns {HTMLDivElement} A DOM div.
   */
  #getNextLayerDiv() {
    const div = document.createElement('div');
    div.id = getLayerDivId(this.getDivId(), this.#layers.length);
    div.className = 'layer';
    div.style.pointerEvents = 'none';
    return div;
  }

  /**
   * Empty the layer list.
   */
  empty() {
    this.#layers = [];
    // reset active indices
    this.#activeViewLayerIndex = null;
    this.#activeDrawLayerIndex = null;
    // clean container div
    const previous = this.#containerDiv.getElementsByClassName('layer');
    if (previous) {
      while (previous.length > 0) {
        previous[0].remove();
      }
    }
  }

  /**
   * Show a crosshair at a given position.
   *
   * @param {Point} [position] The position where to show the crosshair,
   *   defaults to current position.
   */
  #showCrosshairDiv(position) {
    if (typeof position === 'undefined') {
      position = this.#currentPosition;
    }

    // remove previous
    this.#removeCrosshairDiv();

    // use first layer as base for calculating position and
    // line sizes
    const layer0 = this.#layers[0];
    const vc = layer0.getViewController();
    const p2D = vc.getPlanePositionFromPosition(position);
    const displayPos = layer0.planePosToDisplay(p2D.x, p2D.y);

    const lineH = document.createElement('hr');
    lineH.id = this.getDivId() + '-scroll-crosshair-horizontal';
    lineH.className = 'horizontal';
    lineH.style.width = this.#containerDiv.offsetWidth + 'px';
    lineH.style.left = '0px';
    lineH.style.top = displayPos.y + 'px';

    const lineV = document.createElement('hr');
    lineV.id = this.getDivId() + '-scroll-crosshair-vertical';
    lineV.className = 'vertical';
    lineV.style.width = this.#containerDiv.offsetHeight + 'px';
    lineV.style.left = (displayPos.x) + 'px';
    lineV.style.top = '0px';

    this.#containerDiv.appendChild(lineH);
    this.#containerDiv.appendChild(lineV);
  }

  /**
   * Remove crosshair divs.
   */
  #removeCrosshairDiv() {
    let div = document.getElementById(
      this.getDivId() + '-scroll-crosshair-horizontal');
    if (div) {
      div.remove();
    }
    div = document.getElementById(
      this.getDivId() + '-scroll-crosshair-vertical');
    if (div) {
      div.remove();
    }
  }

  /**
   * Update layers (but not the active view layer) to a position change.
   *
   * @param {object} event The position change event.
   * @function
   */
  updateLayersToPositionChange = (event) => {
    // pause positionchange listeners
    for (let j = 0; j < this.#layers.length; ++j) {
      if (this.#layers[j] instanceof ViewLayer) {
        this.#layers[j].removeEventListener(
          'positionchange', this.updateLayersToPositionChange);
        this.#layers[j].removeEventListener('positionchange', this.#fireEvent);
      }
    }

    const index = new Index(event.value[0]);
    const position = new Point(event.value[1]);

    // store current position
    this.#currentPosition = position;

    if (this.#showCrosshair) {
      this.#showCrosshairDiv(position);
    }

    // origin of the first view layer
    let baseViewLayerOrigin0 = null;
    let baseViewLayerOrigin = null;
    // update position for all layers except the source one
    for (let i = 0; i < this.#layers.length; ++i) {

      // update base offset (does not trigger redraw)
      // TODO check draw layers update
      let hasSetOffset = false;
      if (this.#layers[i] instanceof ViewLayer) {
        const vc = this.#layers[i].getViewController();
        // origin0 should always be there
        const origin0 = vc.getOrigin();
        // depending on position, origin could be undefined
        const origin = vc.getOrigin(position);

        if (!baseViewLayerOrigin) {
          baseViewLayerOrigin0 = origin0;
          baseViewLayerOrigin = origin;
        } else {
          if (vc.canSetPosition(position) &&
            typeof origin !== 'undefined') {
            // TODO: compensate for possible different orientation between views

            const scrollDiff = baseViewLayerOrigin0.minus(origin0);
            const scrollOffset = new Vector3D(
              scrollDiff.getX(), scrollDiff.getY(), scrollDiff.getZ());

            const planeDiff = baseViewLayerOrigin.minus(origin);
            const planeOffset = new Vector3D(
              planeDiff.getX(), planeDiff.getY(), planeDiff.getZ());

            hasSetOffset =
              this.#layers[i].setBaseOffset(scrollOffset, planeOffset);
          }
        }
      }

      // update position (triggers redraw)
      let hasSetPos = false;
      if (this.#layers[i].getId() !== event.srclayerid) {
        hasSetPos = this.#layers[i].setCurrentPosition(position, index);
      }

      // force redraw if needed
      if (!hasSetPos && hasSetOffset) {
        this.#layers[i].draw();
      }
    }

    // re-start positionchange listeners
    for (let k = 0; k < this.#layers.length; ++k) {
      if (this.#layers[k] instanceof ViewLayer) {
        this.#layers[k].addEventListener(
          'positionchange', this.updateLayersToPositionChange);
        this.#layers[k].addEventListener('positionchange', this.#fireEvent);
      }
    }
  };

  /**
   * Calculate the fit scale: the scale that fits the largest data.
   *
   * @returns {number|undefined} The fit scale.
   */
  calculateFitScale() {
    // check container
    if (this.#containerDiv.offsetWidth === 0 &&
      this.#containerDiv.offsetHeight === 0) {
      throw new Error('Cannot fit to zero sized container.');
    }
    // get max size
    const maxSize = this.getMaxSize();
    if (typeof maxSize === 'undefined') {
      return undefined;
    }
    // return best fit
    return Math.min(
      this.#containerDiv.offsetWidth / maxSize.x,
      this.#containerDiv.offsetHeight / maxSize.y
    );
  }

  /**
   * Set the layer group fit scale.
   *
   * @param {number} scaleIn The fit scale.
   */
  setFitScale(scaleIn) {
    // get maximum size
    const maxSize = this.getMaxSize();
    // exit if none
    if (typeof maxSize === 'undefined') {
      return;
    }

    const containerSize = {
      x: this.#containerDiv.offsetWidth,
      y: this.#containerDiv.offsetHeight
    };
    // offset to keep data centered
    const fitOffset = {
      x: -0.5 * (containerSize.x - Math.floor(maxSize.x * scaleIn)),
      y: -0.5 * (containerSize.y - Math.floor(maxSize.y * scaleIn))
    };

    // apply to layers
    for (let j = 0; j < this.#layers.length; ++j) {
      this.#layers[j].fitToContainer(scaleIn, containerSize, fitOffset);
    }

    // update crosshair
    if (this.#showCrosshair) {
      this.#showCrosshairDiv();
    }
  }

  /**
   * Get the largest data size.
   *
   * @returns {object|undefined} The largest size as {x,y}.
   */
  getMaxSize() {
    let maxSize = {x: 0, y: 0};
    for (let j = 0; j < this.#layers.length; ++j) {
      if (this.#layers[j] instanceof ViewLayer) {
        const size = this.#layers[j].getImageWorldSize();
        if (size.x > maxSize.x) {
          maxSize.x = size.x;
        }
        if (size.y > maxSize.y) {
          maxSize.y = size.y;
        }
      }
    }
    if (maxSize.x === 0 && maxSize.y === 0) {
      maxSize = undefined;
    }
    return maxSize;
  }

  /**
   * Flip all layers along the Z axis without offset compensation.
   */
  flipScaleZ() {
    this.#baseScale.z *= -1;
    this.setScale(this.#baseScale);
  }

  /**
   * Add scale to the layers. Scale cannot go lower than 0.1.
   *
   * @param {number} scaleStep The scale to add.
   * @param {Point3D} center The scale center Point3D.
   */
  addScale(scaleStep, center) {
    const newScale = {
      x: this.#scale.x * (1 + scaleStep),
      y: this.#scale.y * (1 + scaleStep),
      z: this.#scale.z * (1 + scaleStep)
    };
    this.setScale(newScale, center);
  }

  /**
   * Set the layers' scale.
   *
   * @param {object} newScale The scale to apply as {x,y,z}.
   * @param {Point3D} [center] The scale center Point3D.
   * @fires LayerGroup#zoomchange
   */
  setScale(newScale, center) {
    this.#scale = newScale;
    // apply to layers
    for (let i = 0; i < this.#layers.length; ++i) {
      this.#layers[i].setScale(this.#scale, center);
    }

    // event value
    const value = [
      newScale.x,
      newScale.y,
      newScale.z
    ];
    if (typeof center !== 'undefined') {
      value.push(center.getX());
      value.push(center.getY());
      value.push(center.getZ());
    }

    /**
     * Zoom change event.
     *
     * @event LayerGroup#zoomchange
     * @type {object}
     * @property {Array} value The changed value.
     */
    this.#fireEvent({
      type: 'zoomchange',
      value: value
    });
  }

  /**
   * Add translation to the layers.
   *
   * @param {object} translation The translation as {x,y,z}.
   */
  addTranslation(translation) {
    this.setOffset({
      x: this.#offset.x - translation.x,
      y: this.#offset.y - translation.y,
      z: this.#offset.z - translation.z
    });
  }

  /**
   * Set the layers' offset.
   *
   * @param {object} newOffset The offset as {x,y,z}.
   * @fires LayerGroup#offsetchange
   */
  setOffset(newOffset) {
    // store
    this.#offset = newOffset;
    // apply to layers
    for (let i = 0; i < this.#layers.length; ++i) {
      this.#layers[i].setOffset(this.#offset);
    }

    /**
     * Offset change event.
     *
     * @event LayerGroup#offsetchange
     * @type {object}
     * @property {Array} value The changed value.
     */
    this.#fireEvent({
      type: 'offsetchange',
      value: [
        this.#offset.x,
        this.#offset.y,
        this.#offset.z
      ]
    });
  }

  /**
   * Reset the stage to its initial scale and no offset.
   */
  reset() {
    this.setScale(this.#baseScale);
    this.setOffset({x: 0, y: 0, z: 0});
  }

  /**
   * Draw the layer.
   */
  draw() {
    for (let i = 0; i < this.#layers.length; ++i) {
      this.#layers[i].draw();
    }
  }

  /**
   * Display the layer.
   *
   * @param {boolean} flag Whether to display the layer or not.
   */
  display(flag) {
    for (let i = 0; i < this.#layers.length; ++i) {
      this.#layers[i].display(flag);
    }
  }

  /**
   * Add an event listener to this class.
   *
   * @param {string} type The event type.
   * @param {Function} callback The function associated with the provided
   *   event type, will be called with the fired event.
   */
  addEventListener(type, callback) {
    this.#listenerHandler.add(type, callback);
  }

  /**
   * Remove an event listener from this class.
   *
   * @param {string} type The event type.
   * @param {Function} callback The function associated with the provided
   *   event type.
   */
  removeEventListener(type, callback) {
    this.#listenerHandler.remove(type, callback);
  }

  /**
   * Fire an event: call all associated listeners with the input event object.
   *
   * @param {object} event The event to fire.
   */
  #fireEvent = (event) => {
    this.#listenerHandler.fireEvent(event);
  };

} // LayerGroup class