src_gui_layerGroup.js

import {getIdentityMat33} from '../math/matrix';
import {getCoronalMat33} from '../math/orientation';
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 {precisionRound} from '../utils/string';
import {ViewLayer} from './viewLayer';
import {DrawLayer} from './drawLayer';

// doc imports
/* eslint-disable no-unused-vars */
import {Matrix33} from '../math/matrix';
import {Point2D, Point3D} from '../math/point';
import {Scalar2D, Scalar3D} from '../math/scalar';
/* 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 {Scalar2D} offset The previous offset as {x,y}.
 * @param {Scalar2D} scale The previous scale as {x,y}.
 * @param {Scalar2D} newScale The new scale as {x,y}.
 * @param {Scalar2D} center The scale center as {x,y}.
 * @returns {Scalar2D} 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;

  // jsdoc does not like
  // @type {(ViewLayer|DrawLayer)[]}

  /**
   * List of layers.
   *
   * @type {Array<ViewLayer|DrawLayer>}
   */
  #layers = [];

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

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

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

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

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

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

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

  /**
   * Crosshair HTML elements.
   *
   * @type {HTMLElement[]}
   */
  #crosshairHtmlElements = [];

  /**
   * Tooltip HTML element.
   *
   * @type {HTMLElement}
   */
  #tooltipHtmlElement;

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

  /**
   * Image smoothing flag.
   *
   * @type {boolean}
   */
  #imageSmoothing = false;

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

  /**
   * 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();
    }
  }

  /**
   * Set the imageSmoothing flag value.
   *
   * @param {boolean} flag True to enable smoothing.
   */
  setImageSmoothing(flag) {
    this.#imageSmoothing = flag;
    // set for existing layers
    for (const layer of this.#layers) {
      if (layer instanceof ViewLayer) {
        layer.setImageSmoothing(flag);
      }
    }
  }

  /**
   * 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 {Scalar3D} The scale as {x,y,z}.
   */
  getScale() {
    return this.#scale;
  }

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

  /**
   * Get the added scale: the scale added to the base scale.
   *
   * @returns {Scalar3D} 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 {Scalar3D} 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() {
    let count = 0;
    this.#layers.forEach(item => {
      if (typeof item !== 'undefined') {
        count++;
      }
    });
    return count;
  }

  /**
   * Check if this layerGroup contains a layer with the input id.
   *
   * @param {string} id The layer id to look for.
   * @returns {boolean} True if this group contains
   *   a layer with the input id.
   */
  includes(id) {
    if (typeof id === 'undefined') {
      return false;
    }
    for (const layer of this.#layers) {
      if (typeof layer !== 'undefined' &&
        layer.getId() === id) {
        return true;
      }
    }
    return false;
  }

  /**
   * Get the number of view layers handled by this class.
   *
   * @returns {number} The number of layers.
   */
  getNumberOfViewLayers() {
    let count = 0;
    this.#layers.forEach(item => {
      if (typeof item !== 'undefined' &&
        item instanceof ViewLayer) {
        count++;
      }
    });
    return count;
  }

  /**
   * Get the active image layer.
   *
   * @returns {ViewLayer|undefined} The layer.
   */
  getActiveViewLayer() {
    let layer;
    if (typeof this.#activeViewLayerIndex !== 'undefined') {
      const tmpLayer = this.#layers[this.#activeViewLayerIndex];
      if (tmpLayer instanceof ViewLayer) {
        layer = tmpLayer;
      }
    } else {
      logger.info('No active view layer to return');
    }
    return layer;
  }

  /**
   * Get the base view layer.
   *
   * @returns {ViewLayer|undefined} The layer.
   */
  getBaseViewLayer() {
    let layer;
    if (this.#layers[0] instanceof ViewLayer) {
      layer = this.#layers[0];
    }
    return layer;
  }

  /**
   * Get the view layers associated to a data id.
   *
   * @param {string} dataId The data id.
   * @returns {ViewLayer[]} The layers.
   */
  getViewLayersByDataId(dataId) {
    const res = [];
    for (const layer of this.#layers) {
      if (layer instanceof ViewLayer &&
        layer.getDataId() === dataId) {
        res.push(layer);
      }
    }
    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 (const layer of this.#layers) {
      if (layer instanceof ViewLayer) {
        if (layer.getViewController().equalImageMeta(meta)) {
          res.push(layer);
        }
      }
    }
    return res;
  }

  /**
   * Get the view layers data indices.
   *
   * @returns {string[]} The list of indices.
   */
  getViewDataIndices() {
    const res = [];
    for (const layer of this.#layers) {
      if (layer instanceof ViewLayer) {
        res.push(layer.getDataId());
      }
    }
    return res;
  }

  /**
   * Get the active draw layer.
   *
   * @returns {DrawLayer|undefined} The layer.
   */
  getActiveDrawLayer() {
    let layer;
    if (typeof this.#activeDrawLayerIndex !== 'undefined') {
      const tmpLayer = this.#layers[this.#activeDrawLayerIndex];
      if (tmpLayer instanceof DrawLayer) {
        layer = tmpLayer;
      }
    } else {
      logger.info('No active draw layer to return');
    }
    return layer;
  }

  /**
   * Get the draw layers associated to a data id.
   *
   * @param {string} dataId The data id.
   * @returns {DrawLayer[]} The layers.
   */
  getDrawLayersByDataId(dataId) {
    const res = [];
    for (const layer of this.#layers) {
      if (layer instanceof DrawLayer &&
        layer.getDataId() === dataId) {
        res.push(layer);
      }
    }
    return res;
  }

  /**
   * Set the active view layer.
   *
   * @param {number} index The index of the layer to set as active.
   */
  setActiveViewLayer(index) {
    if (this.#layers[index] instanceof ViewLayer) {
      this.#activeViewLayerIndex = index;
      /**
       * Active view layer change event.
       *
       * @event LayerGroup#activeviewlayerchange
       * @type {object}
       * @property {Array} value The changed value.
       */
      this.#fireEvent({
        type: 'activelayerchange',
        value: [this.#layers[index]]
      });
    } else {
      logger.warn('No view layer to set as active with index: ' +
        index);
    }
  }

  /**
   * Set the active view layer with a data id.
   *
   * @param {string} dataId The data id.
   */
  setActiveViewLayerByDataId(dataId) {
    let index;
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof ViewLayer &&
        this.#layers[i].getDataId() === dataId) {
        // stop at first one
        index = i;
        break;
      }
    }
    if (typeof index !== 'undefined') {
      this.setActiveViewLayer(index);
    } else {
      logger.warn('No view layer to set as active with dataId: ' +
        dataId);
    }
  }

  /**
   * Set the active draw layer.
   *
   * @param {number} index The index of the layer to set as active.
   */
  setActiveDrawLayer(index) {
    if (this.#layers[index] instanceof DrawLayer) {
      this.#activeDrawLayerIndex = index;
      this.#fireEvent({
        type: 'activelayerchange',
        value: [this.#layers[index]]
      });
    } else {
      logger.warn('No draw layer to set as active with index: ' +
        index);
    }
  }

  /**
   * Set the active draw layer with a data id.
   *
   * @param {string} dataId The data id.
   */
  setActiveDrawLayerByDataId(dataId) {
    let index;
    for (let i = 0; i < this.#layers.length; ++i) {
      if (this.#layers[i] instanceof DrawLayer &&
        this.#layers[i].getDataId() === dataId) {
        // stop at first one
        index = i;
        break;
      }
    }
    if (typeof index !== 'undefined') {
      this.setActiveDrawLayer(index);
    } else {
      logger.warn('No draw layer to set as active with dataId: ' +
        dataId);
    }
  }

  /**
   * Add a view layer.
   *
   * The new layer will be marked as the active 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);
    layer.setImageSmoothing(this.#imageSmoothing);
    // 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.
   *
   * The new layer will be marked as the active 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 (const eventName of viewEventNames) {
      viewLayer.addEventListener(eventName, this.#fireEvent);
    }
    // propagate viewLayer events
    viewLayer.addEventListener('renderstart', this.#fireEvent);
    viewLayer.addEventListener('renderend', this.#fireEvent);
  }

  /**
   * Un-bind a view layer events to this.
   *
   * @param {ViewLayer} viewLayer The view layer to unbind.
   */
  #unbindViewLayer(viewLayer) {
    // stop listening to position change to update other group layers
    viewLayer.removeEventListener(
      'positionchange', this.updateLayersToPositionChange);
    // stop propagating view viewLayer-layer events
    for (const eventName of viewEventNames) {
      viewLayer.removeEventListener(eventName, this.#fireEvent);
    }
    // stop propagating viewLayer events
    viewLayer.removeEventListener('renderstart', this.#fireEvent);
    viewLayer.removeEventListener('renderend', this.#fireEvent);

    // stop view layer - image binding
    // (binding is done in layer.setView)
    viewLayer.unbindImage();
  }

  /**
   * 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);
  }

  /**
   * Un-bind a draw layer events to this.
   *
   * @param {DrawLayer} drawLayer The draw layer to unbind.
   */
  #unbindDrawLayer(drawLayer) {
    // propagate drawLayer events
    drawLayer.removeEventListener('drawcreate', this.#fireEvent);
    drawLayer.removeEventListener('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 = undefined;
    this.#activeDrawLayerIndex = undefined;
    // remove possible crosshair
    this.#removeCrosshairDiv();
    // clean container div
    const previous = this.#containerDiv.getElementsByClassName('layer');
    if (previous) {
      while (previous.length > 0) {
        previous[0].remove();
      }
    }
  }

  /**
   * Remove all layers for a specific data.
   *
   * @param {string} dataId The data to remove its layers.
   */
  removeLayersByDataId(dataId) {
    for (const layer of this.#layers) {
      if (typeof layer !== 'undefined' &&
        layer.getDataId() === dataId) {
        this.removeLayer(layer);
      }
    }
  }

  /**
   * Remove a layer from this layer group.
   * Warning: if current active layer, the index will
   *   be set to `undefined`. Call one of the setActive
   *   methods to define the active index.
   *
   * @param {ViewLayer | DrawLayer} layer The layer to remove.
   */
  removeLayer(layer) {
    // find layer
    const index = this.#layers.findIndex((item) => item === layer);
    if (index === -1) {
      throw new Error('Cannot find layer to remove');
    }
    // unbind and update active index
    if (layer instanceof ViewLayer) {
      this.#unbindViewLayer(layer);
      if (this.#activeViewLayerIndex === index) {
        if (index - 2 >= 0) {
          this.setActiveViewLayer(index - 2);
        } else {
          this.#activeViewLayerIndex = undefined;
        }
      }
    } else {
      // delete layer draws
      const numberOfDraws = layer.getNumberOfDraws();
      if (typeof numberOfDraws !== 'undefined') {
        let count = 0;
        layer.addEventListener('drawdelete', (_event) => {
          ++count;
          // unbind when all draw are deleted
          if (count === numberOfDraws) {
            this.#unbindDrawLayer(layer);
          }
        });
      }
      layer.deleteDraws();
      if (typeof numberOfDraws === 'undefined') {
        this.#unbindDrawLayer(layer);
      }
      // reset active index
      if (this.#activeDrawLayerIndex === index) {
        if (index - 2 >= 0) {
          this.setActiveDrawLayer(index - 2);
        } else {
          this.#activeDrawLayerIndex = undefined;
        }
      }
    }
    // reset in storage
    this.#layers[index] = undefined;
    // update html
    layer.removeFromDOM();
  }

  /**
   * 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
    let baseLayer;
    for (const layer of this.#layers) {
      if (layer instanceof ViewLayer) {
        baseLayer = layer;
        break;
      }
    }
    if (typeof baseLayer === 'undefined') {
      logger.warn('No layer to show crosshair');
      return;
    }

    const vc = baseLayer.getViewController();
    const planePos = vc.getPlanePositionFromPosition(position);
    const displayPos = baseLayer.planePosToDisplay(planePos);

    // horizontal line
    if (typeof displayPos.getY() !== 'undefined') {
      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.getY() + 'px';
      // add to local array
      this.#crosshairHtmlElements.push(lineH);
      // add to html
      this.#containerDiv.appendChild(lineH);
    }

    // vertical line
    if (typeof displayPos.getX() !== 'undefined') {
      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.getX()) + 'px';
      lineV.style.top = '0px';
      // add to local array
      this.#crosshairHtmlElements.push(lineV);
      // add to html
      this.#containerDiv.appendChild(lineV);
    }
  }

  /**
   * Remove crosshair divs.
   */
  #removeCrosshairDiv() {
    for (const element of this.#crosshairHtmlElements) {
      element.remove();
    }
    this.#crosshairHtmlElements = [];
  }

  /**
   * Displays a tooltip in a temporary `span`.
   * Works with css to hide/show the span only on mouse hover.
   *
   * @param {Point2D} point The update point.
   */
  showTooltip(point) {
    // remove previous div
    this.removeTooltipDiv();

    const viewLayer = this.getActiveViewLayer();
    const viewController = viewLayer.getViewController();
    const planePos = viewLayer.displayToPlanePos(point);
    const position = viewController.getPositionFromPlanePoint(planePos);
    const value = viewController.getRescaledImageValue(position);

    // create
    if (typeof value !== 'undefined') {
      const span = document.createElement('span');
      span.id = 'scroll-tooltip';
      // tooltip position
      span.style.left = (point.getX() + 10) + 'px';
      span.style.top = (point.getY() + 10) + 'px';
      let text = precisionRound(value, 3).toString();
      if (typeof viewController.getPixelUnit() !== 'undefined') {
        text += ' ' + viewController.getPixelUnit();
      }
      span.appendChild(document.createTextNode(text));
      // add to local var
      this.#tooltipHtmlElement = span;
      // add to html
      this.#containerDiv.appendChild(span);
    }
  }

  /**
   * Remove the tooltip html div.
   */
  removeTooltipDiv() {
    if (typeof this.#tooltipHtmlElement !== 'undefined') {
      this.#tooltipHtmlElement.remove();
      this.#tooltipHtmlElement = undefined;
    }
  }


  /**
   * Test if one of the view layers satisfies an input callbackFn.
   *
   * @param {Function} callbackFn A function that takes a ViewLayer as input
   *   and returns a boolean.
   * @returns {boolean} True if one of the ViewLayers satisfies the callbackFn.
   */
  someViewLayer(callbackFn) {
    let hasOne = false;
    for (const layer of this.#layers) {
      if (layer instanceof ViewLayer &&
        callbackFn(layer)) {
        hasOne = true;
        break;
      }
    }
    return hasOne;
  }

  /**
   * Can the input position be set on one of the view layers.
   *
   * @param {Point} position The input position.
   * @returns {boolean} True if one view layer accepts the input position.
   */
  isPositionInBounds(position) {
    return this.someViewLayer(function (layer) {
      return layer.getViewController().isPositionInBounds(position);
    });
  }

  /**
   * Can one of the view layers be scrolled.
   *
   * @returns {boolean} True if one view layer can be scrolled.
   */
  canScroll() {
    return this.someViewLayer(function (layer) {
      return layer.getViewController().canScroll();
    });
  }

  /**
   * Does one of the view layer have more than one slice in the
   *   given dimension.
   *
   * @param {number} dim The input dimension.
   * @returns {boolean} True if one view layer has more than one slice.
   */
  moreThanOne(dim) {
    return this.someViewLayer(function (layer) {
      return layer.getViewController().moreThanOne(dim);
    });
  }

  /**
   * 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 (const layer of this.#layers) {
      if (layer instanceof ViewLayer) {
        layer.removeEventListener(
          'positionchange', this.updateLayersToPositionChange);
        layer.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;
    let baseViewLayerOrigin;
    let scrollOffset;
    let planeOffset;
    // update position for all layers except the source one
    for (const layer of this.#layers) {
      if (typeof layer === 'undefined') {
        continue;
      }

      // update base offset (does not trigger redraw)
      let hasSetOffset = false;
      if (layer instanceof ViewLayer) {
        const vc = layer.getViewController();
        // origin0 should always be there
        const origin0 = vc.getOrigin();
        // depending on position, origin could be undefined
        const origin = vc.getOrigin(position);

        if (typeof baseViewLayerOrigin === 'undefined') {
          // first view layer, store origins
          baseViewLayerOrigin0 = origin0;
          baseViewLayerOrigin = origin;
          // no offset
          scrollOffset = new Vector3D(0, 0, 0);
          planeOffset = new Vector3D(0, 0, 0);
        } else {
          if (vc.isPositionInBounds(position) &&
            typeof origin !== 'undefined') {
            // TODO: compensate for possible different orientation between views
            const scrollDiff = baseViewLayerOrigin0.minus(origin0);
            scrollOffset = new Vector3D(
              scrollDiff.getX(), scrollDiff.getY(), scrollDiff.getZ());
            const planeDiff = baseViewLayerOrigin.minus(origin);
            planeOffset = new Vector3D(
              planeDiff.getX(), planeDiff.getY(), planeDiff.getZ());
          }
        }
      }

      // also set for draw layers
      // (should be next after a view layer)
      if (typeof scrollOffset !== 'undefined' &&
        typeof planeOffset !== 'undefined') {
        hasSetOffset =
          layer.setBaseOffset(
            scrollOffset, planeOffset,
            baseViewLayerOrigin, baseViewLayerOrigin0
          );
      }

      // reset to not propagate after draw layer
      // TODO: revise, could be unstable...
      if (layer instanceof DrawLayer) {
        scrollOffset = undefined;
        planeOffset = undefined;
      }

      // update position (triggers redraw)
      let hasSetPos = false;
      if (layer.getId() !== event.srclayerid) {
        hasSetPos = layer.setCurrentPosition(position, index);
      }

      // force redraw if needed
      if (!hasSetPos && hasSetOffset) {
        layer.draw();
      }
    }

    // re-start positionchange listeners
    for (const layer of this.#layers) {
      if (layer instanceof ViewLayer) {
        layer.addEventListener(
          'positionchange', this.updateLayersToPositionChange);
        layer.addEventListener('positionchange', this.#fireEvent);
      }
    }
  };

  /**
   * Calculate the div to world size ratio needed to fit
   *   the largest data.
   *
   * @returns {number|undefined} The ratio.
   */
  getDivToWorldSizeRatio() {
    // check container
    if (this.#containerDiv.offsetWidth === 0 &&
      this.#containerDiv.offsetHeight === 0) {
      throw new Error('Cannot fit to zero sized container.');
    }
    // get max world size
    const maxWorldSize = this.getMaxWorldSize();
    if (typeof maxWorldSize === 'undefined') {
      return undefined;
    }
    // if the container has a width but no height,
    // resize it to follow the same ratio to completely
    // fill the div with the image
    if (this.#containerDiv.offsetHeight === 0) {
      const ratioX = this.#containerDiv.offsetWidth / maxWorldSize.x;
      const height = maxWorldSize.y * ratioX;
      this.#containerDiv.style.height = height + 'px';
    }
    // return best fit
    return Math.min(
      this.#containerDiv.offsetWidth / maxWorldSize.x,
      this.#containerDiv.offsetHeight / maxWorldSize.y
    );
  }

  /**
   * Fit to container: set the layers div to world size ratio.
   *
   * @param {number} divToWorldSizeRatio The ratio.
   */
  fitToContainer(divToWorldSizeRatio) {
    // get maximum world size
    const maxWorldSize = this.getMaxWorldSize();
    // exit if none
    if (typeof maxWorldSize === '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(maxWorldSize.x * divToWorldSizeRatio)),
      y: -0.5 *
        (containerSize.y - Math.floor(maxWorldSize.y * divToWorldSizeRatio))
    };

    // apply to layers
    for (const layer of this.#layers) {
      if (typeof layer !== 'undefined') {
        layer.fitToContainer(containerSize, divToWorldSizeRatio, fitOffset);
      }
    }

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

  /**
   * Get the largest data world (mm) size.
   *
   * @returns {Scalar2D|undefined} The largest size as {x,y}.
   */
  getMaxWorldSize() {
    let maxSize = {x: 0, y: 0};
    for (const layer of this.#layers) {
      if (layer instanceof ViewLayer) {
        const size = layer.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 {Scalar3D} 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 (const layer of this.#layers) {
      if (typeof layer !== 'undefined') {
        layer.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 {Scalar3D} 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 {Scalar3D} newOffset The offset as {x,y,z}.
   * @fires LayerGroup#offsetchange
   */
  setOffset(newOffset) {
    // store
    this.#offset = newOffset;
    // apply to layers
    for (const layer of this.#layers) {
      if (typeof layer !== 'undefined') {
        layer.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 (const layer of this.#layers) {
      if (typeof layer !== 'undefined') {
        layer.draw();
      }
    }
  }

  /**
   * Display the layer.
   *
   * @param {boolean} flag Whether to display the layer or not.
   */
  display(flag) {
    for (const layer of this.#layers) {
      if (typeof layer !== 'undefined') {
        layer.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