# Source: viewer.js

    import 'ol/ol.css'
    import Collection from 'ol/Collection'
    import Draw, { createRegularPolygon } from 'ol/interaction/Draw'
    import EVENT from './events'
    import publish from './eventPublisher'
    import Feature from 'ol/Feature'
    import Fill from 'ol/style/Fill'
    import FullScreen from 'ol/control/FullScreen'
    import Icon from 'ol/style/Icon'
    import ImageLayer from 'ol/layer/Image'
    import Map from 'ol/Map'
    import Modify from 'ol/interaction/Modify'
    import MousePosition from 'ol/control/MousePosition'
    import OverviewMap from 'ol/control/OverviewMap'
    import Projection from 'ol/proj/Projection'
    import ScaleLine from 'ol/control/ScaleLine'
    import Select from 'ol/interaction/Select'
    import Snap from 'ol/interaction/Snap'
    import Translate from 'ol/interaction/Translate'
    import PointGeometry from 'ol/geom/Point'
    import Style from 'ol/style/Style'
    import Stroke from 'ol/style/Stroke'
    import Circle from 'ol/style/Circle'
    import Static from 'ol/source/ImageStatic'
    import Overlay from 'ol/Overlay'
    import PointsLayer from 'ol/layer/WebGLPoints'
    import TileLayer from 'ol/layer/WebGLTile'
    import DataTileSource from 'ol/source/DataTile'
    import TileGrid from 'ol/tilegrid/TileGrid'
    import VectorSource from 'ol/source/Vector'
    import VectorLayer from 'ol/layer/Vector'
    import View from 'ol/View'
    import DragPan from 'ol/interaction/DragPan'
    import DragZoom from 'ol/interaction/DragZoom'
    import WebGLHelper from 'ol/webgl/Helper'
    import TileDebug from 'ol/source/TileDebug'
    import { default as VectorEventType } from 'ol/source/VectorEventType'// eslint-disable-line
    import { ZoomSlider, Zoom } from 'ol/control'
    import { getCenter } from 'ol/extent'
    import { defaults as defaultInteractions } from 'ol/interaction'
    import dcmjs from 'dcmjs'
    import { quantileSeq } from 'mathjs'

    import {
      AnnotationGroup,
      _fetchGraphicData,
      _fetchGraphicIndex,
      _fetchMeasurements,
      _getCentroid,
      _getCommonZCoordinate,
      _getCoordinateDimensionality
    } from './annotation.js'
    import {
      ColormapNames,
      createColormap,
      PaletteColorLookupTable,
      buildPaletteColorLookupTable
    } from './color.js'
    import {
      groupMonochromeInstances,
      groupColorInstances,
      VLWholeSlideMicroscopyImage
    } from './metadata.js'
    import { ParameterMapping, _groupFramesPerMapping } from './mapping.js'
    import { ROI } from './roi.js'
    import { Segment } from './segment.js'
    import {
      computeRotation,
      _generateUID,
      _getUnitSuffix,
      doContentItemsMatch,
      createWindow
    } from './utils.js'
    import {
      _scoord3dCoordinates2geometryCoordinates,
      _scoord3d2Geometry,
      getPixelSpacing,
      _geometry2Scoord3d,
      _geometryCoordinates2scoord3dCoordinates,
      _getFeatureLength,
      _getFeatureArea
    } from './scoord3dUtils'
    import { OpticalPath } from './opticalPath.js'
    import {
      _areImagePyramidsEqual,
      _computeImagePyramid,
      _createTileLoadFunction,
      _fitImagePyramid,
      _getIccProfiles
    } from './pyramid.js'

    import Enums from './enums'
    import _AnnotationManager from './annotations/_AnnotationManager'
    import webWorkerManager from './webWorker/webWorkerManager.js'

    function _getInteractionBindingCondition (bindings) {
      const BUTTONS = {
        left: 1,
        middle: 4,
        right: 2
      }

      const { mouseButtons, modifierKey } = bindings

      const _mouseButtonCondition = (event) => {
        /** No mouse button condition set. */
        if (!mouseButtons || !mouseButtons.length) {
          return true
        }

        const button = event.pointerEvent
          ? event.pointerEvent.buttons
          : event.originalEvent.buttons

        return mouseButtons.some((mb) => BUTTONS[mb] === button)
      }

      const _modifierKeyCondition = (event) => {
        const pointerEvent = event.pointerEvent
          ? event.pointerEvent
          : event.originalEvent

        if (!modifierKey) {
          /**
           * No modifier key, don't pass if key pressed as other
           * tool may be using this tool.
           */
          return (
            !pointerEvent.altKey &&
            !pointerEvent.metaKey &&
            !pointerEvent.shiftKey &&
            !pointerEvent.ctrlKey
          )
        }

        switch (modifierKey) {
          case 'alt':
            return pointerEvent.altKey === true || pointerEvent.metaKey === true
          case 'shift':
            return pointerEvent.shiftKey === true
          case 'ctrl':
            return pointerEvent.ctrlKey === true
          default:
            /** Invalid modifier key set (ignore requirement as if key not pressed). */
            return (
              !pointerEvent.altKey &&
              !pointerEvent.metaKey &&
              !pointerEvent.shiftKey &&
              !pointerEvent.ctrlKey
            )
        }
      }

      return (event) => {
        return _mouseButtonCondition(event) && _modifierKeyCondition(event)
      }
    }

    /**
     * Get rotation of image relative to the slide coordinate system.
     *
     * Determines whether image needs to be rotated relative to slide
     * coordinate system based on direction cosines.
     * We want to rotate all images such that the X axis of the slide coordinate
     * system is the vertical axis (ordinate) of the viewport and the Y axis
     * of the slide coordinate system is the horizontal axis (abscissa) of the
     * viewport. Note that this is opposite to the Openlayers coordinate system.
     * There are only planar rotations, since the total pixel matrix is
     * parallel to the slide surface. Here, we further assume that rows and
     * columns of total pixel matrix are parallel to the borders of the slide,
     * i.e. the X and Y axes of the slide coordinate system.
     *
     * The row direction (left to right) of the Total Pixel Matrix
     * is defined by the first three Image Orientation Slide values.
     * The three values specify how the direction changes from the last pixel
     * to the first pixel in the row along each of the three axes of the
     * slide coordinate system (X, Y, Z), i.e. it express in which direction one
     * is moving in the slide coordinate system when the COLUMN index changes.
     * The column direction (top to bottom) of the Total Pixel Matrix
     * is defined by the second three Image Orientation Slide values.
     * The three values specify how the direction changes from the last pixel
     * to the first pixel in the column along each of the three axes of the
     * slide coordinate system (X, Y, Z), i.e. it express in which direction one
     * is moving in the slide coordinate system when the ROW index changes.
     *
     * @param {metadata.VLWholeSlideMicroscopyImage} metadata - Metadata of a DICOM
     * VL Whole Slide Microscopy Image instance
     *
     * @returns {number} Rotation in radians
     *
     * @private
     */
    function _getRotation (metadata) {
      // Angle with respect to the reference orientation
      const angle = computeRotation({
        orientation: metadata.ImageOrientationSlide
      })
      // We want the slide oriented horizontally with the label on the right side
      const correction = 90 * (Math.PI / 180)
      return angle + correction
    }

    /**
     * Map style options to OpenLayers style.
     *
     * @param {Object} styleOptions - Style options
     * @param {Object} styleOptions.stroke - Style options for the outline of the geometry
     * @param {number[]} styleOptions.stroke.color - RGBA color of the outline
     * @param {number} styleOptions.stroke.width - Width of the outline
     * @param {Object} styleOptions.fill - Style options for body the geometry
     * @param {number[]} styleOptions.fill.color - RGBA color of the body
     * @param {Object} styleOptions.image - Style options for image
     * @return {Style} OpenLayers style
     *
     * @private
     */
    function _getOpenLayersStyle (styleOptions) {
      const style = new Style()

      if ('stroke' in styleOptions) {
        const strokeOptions = {
          color: styleOptions.stroke.color,
          width: styleOptions.stroke.width
        }
        const stroke = new Stroke(strokeOptions)
        style.setStroke(stroke)
      }

      if ('fill' in styleOptions) {
        const fillOptions = {
          color: styleOptions.fill.color
        }
        const fill = new Fill(fillOptions)
        style.setFill(fill)
      }

      if ('image' in styleOptions) {
        const { image } = styleOptions

        if (image.circle) {
          const options = {
            radius: image.circle.radius,
            stroke: new Stroke(image.circle.stroke),
            fill: new Fill(image.circle.fill)
          }
          const circle = new Circle(options)
          style.setImage(circle)
        }

        if (image.icon) {
          const icon = new Icon(image.icon)
          style.setImage(icon)
        }
      }

      return style
    }

    /**
     * Add ROI properties to feature in a safe way
     *
     * @param {Object} feature - The feature instance that represents the ROI
     * @param {Object} properties -Valid ROI properties
     * @param {Object} properties.measurements - ROI measurements
     * @param {Object} properties.evaluations - ROI evaluations
     * @param {Object} properties.label - ROI label
     * @param {Object} properties.marker - ROI marker (this is used while we don't have presentation states)
     * @param {boolean} optSilent - Opt silent update
     *
     * @private
     */
    function _addROIPropertiesToFeature (feature, properties, optSilent) {
      const { Label, Measurements, Evaluations, Marker } = Enums.InternalProperties

      if (properties[Label]) {
        feature.set(Label, properties[Label], optSilent)
      }

      if (properties[Measurements]) {
        feature.set(Measurements, properties[Measurements], optSilent)
      }

      if (properties[Evaluations]) {
        feature.set(Evaluations, properties[Evaluations], optSilent)
      }

      if (properties[Marker]) {
        feature.set(Marker, properties[Marker], optSilent)
      }
    }

    /**
     * Wire measurements and qualitative evaluations to generate content items
     * based on OpenLayers feature properties and geometry.
     *
     * @param {Object} map - The map instance
     * @param {Object} feature - The feature instance
     * @param {Object} pyramid - The pyramid metadata
     * @returns {void}
     *
     * @private
     */
    function _wireMeasurementsAndQualitativeEvaluationsEvents (
      map,
      feature,
      pyramid
    ) {
      /**
       * Update feature measurement properties first and then measurements
       */
      _updateFeatureMeasurements(map, feature, pyramid)
      feature.on(Enums.FeatureEvents.CHANGE, (event) => {
        _updateFeatureMeasurements(map, event.target, pyramid)
      })

      /**
       * Update feature evaluations
       */
      _updateFeatureEvaluations(feature)
      feature.on(Enums.FeatureEvents.PROPERTY_CHANGE, (event) =>
        _updateFeatureEvaluations(event.target)
      )
    }

    /**
     * Update feature evaluations from its properties
     *
     * @param {Feature} feature
     * @returns {void}
     *
     * @private
     */
    function _updateFeatureEvaluations (feature) {
      const evaluations = feature.get(Enums.InternalProperties.Evaluations) || []
      const label = feature.get(Enums.InternalProperties.Label)

      if (!label) return

      const evaluation = new dcmjs.sr.valueTypes.TextContentItem({
        name: new dcmjs.sr.coding.CodedConcept({
          value: '112039',
          meaning: 'Tracking Identifier',
          schemeDesignator: 'DCM'
        }),
        value: label,
        relationshipType: Enums.RelationshipTypes.HAS_OBS_CONTEXT
      })

      const index = evaluations.findIndex((e) =>
        doContentItemsMatch(e, evaluation)
      )

      if (index > -1) {
        evaluations[index] = evaluation
      } else {
        evaluations.push(evaluation)
      }

      feature.set(Enums.InternalProperties.Evaluations, evaluations)
    }

    /**
     * Generate feature measurements from its measurement properties
     *
     * @param {Object} map - The map instance
     * @param {Object} feature - The feature instance
     * @param {Object} pyramid - The pyramid metadata
     * @returns {void}
     *
     * @private
     */
    function _updateFeatureMeasurements (map, feature, pyramid) {
      if (
        Enums.Markup.Measurement !== feature.get(Enums.InternalProperties.Markup)
      ) {
        return
      }

      const measurements = feature.get(Enums.InternalProperties.Measurements) || []
      const area = _getFeatureArea(feature, pyramid)
      const length = _getFeatureLength(feature, pyramid)

      if (area == null && length == null) {
        return
      }

      const unitSuffixToMeaningMap = {
        μm: 'micrometer',
        μm2: 'square micrometer',
        mm: 'millimeter',
        mm2: 'square millimeter',
        m: 'meters',
        m2: 'square meters',
        km2: 'square kilometers'
      }

      let measurement
      const view = map.getView()
      const unitSuffix = _getUnitSuffix(view)

      if (area != null) {
        const unitCodedConceptValue = `${unitSuffix}2`
        const unitCodedConceptMeaning = unitSuffixToMeaningMap[unitSuffix]
        measurement = new dcmjs.sr.valueTypes.NumContentItem({
          name: new dcmjs.sr.coding.CodedConcept({
            meaning: 'Area',
            value: '42798000',
            schemeDesignator: 'SCT'
          }),
          value: area,
          unit: [
            new dcmjs.sr.coding.CodedConcept({
              value: unitCodedConceptValue,
              meaning: unitCodedConceptMeaning,
              schemeDesignator: 'SCT'
            })
          ]
        })
      }

      if (length != null) {
        const unitCodedConceptValue = unitSuffix
        const unitCodedConceptMeaning = unitSuffixToMeaningMap[unitSuffix]
        measurement = new dcmjs.sr.valueTypes.NumContentItem({
          name: new dcmjs.sr.coding.CodedConcept({
            meaning: 'Length',
            value: '410668003',
            schemeDesignator: 'SCT'
          }),
          value: length,
          unit: [
            new dcmjs.sr.coding.CodedConcept({
              value: unitCodedConceptValue,
              meaning: unitCodedConceptMeaning,
              schemeDesignator: 'SCT'
            })
          ]
        })
      }

      if (measurement) {
        const index = measurements.findIndex((m) => (
          doContentItemsMatch(m, measurement)
        ))

        if (index > -1) {
          measurements[index] = measurement
        } else {
          measurements.push(measurement)
        }

        feature.set(Enums.InternalProperties.Measurements, measurements)
      }
    }

    /**
     * Updates the style of a feature.
     *
     * @param {Object} styleOptions - Style options
     * @param {Object} styleOptions.stroke - Style options for the outline of the geometry
     * @param {number[]} styleOptions.stroke.color - RGBA color of the outline
     * @param {number} styleOptions.stroke.width - Width of the outline
     * @param {Object} styleOptions.fill - Style options for body the geometry
     * @param {number[]} styleOptions.fill.color - RGBA color of the body
     * @param {Object} styleOptions.image - Style options for image
     *
     * @private
     */
    function _setFeatureStyle (feature, styleOptions) {
      if (styleOptions !== undefined) {
        const style = _getOpenLayersStyle(styleOptions)
        feature.setStyle(style)

        /**
         * styleOptions is used internally by internal styled components like markers.
         * This allows them to take priority over styling since OpenLayers swaps the styles
         * completely in case of a setStyle happens.
         */
        feature.set(Enums.InternalProperties.StyleOptions, styleOptions)
      }
    }

    /**
     * Build OpenLayers style expression for coloring a WebGL TileLayer.
     *
     * @param {Object} styleOptions - Style options
     * @param {number} styleOptions.windowCenter - Center of the window used for contrast stretching
     * @param {number} styleOptions.windowWidth - Width of the window used for contrast stretching
     * @param {number[][]} styleOptions.colormap - RGB color triplets
     *
     * @returns {Object} color style expression and corresponding variables
     *
     * @private
     */
    function _getColorPaletteStyleForTileLayer ({
      windowCenter,
      windowWidth,
      colormap
    }) {
      /*
       * The Palette Color Lookup Table applies to the index values in the range
       * [0, n] that are obtained by scaling stored pixel values between the lower
       * and upper value of interest (VOI) defined by the window center and width.
       */
      const minIndexValue = 0
      const maxIndexValue = colormap.length - 1
      const indexExpression = [
        'clamp',
        [
          '+',
          [
            '*',
            [
              '+',
              [
                '/',
                [
                  '-',
                  ['band', 1],
                  [
                    '-',
                    ['var', 'windowCenter'],
                    0.5
                  ]
                ],
                [
                  '-',
                  ['var', 'windowWidth'],
                  1
                ]
              ],
              0.5
            ],
            [
              '-',
              maxIndexValue,
              minIndexValue
            ]
          ],
          minIndexValue
        ],
        minIndexValue,
        maxIndexValue
      ]

      const expression = [
        'palette',
        indexExpression,
        colormap
      ]

      const variables = {
        windowCenter,
        windowWidth
      }

      return { color: expression, variables }
    }

    /**
     * Build OpenLayers style expression for coloring a WebGL TileLayer.
     *
     * @param {Object} styleOptions - Style options
     * @param {number} styleOptions.windowCenter - Center of the window used for contrast stretching
     * @param {number} styleOptions.windowWidth - Width of the window used for contrast stretching
     * @param {number[]} styleOptions.color - RGB color triplet
     *
     * @returns {Object} color style expression and corresponding variables
     *
     * @private
     */
    function _getColorInterpolationStyleForTileLayer ({
      windowCenter,
      windowWidth,
      color
    }) {
      /*
       * If no Palette Color Lookup Table is available, don't create one
       * but let WebGL interpolate colors for improved performance.
       */
      const expression = [
        'interpolate',
        ['linear'],
        [
          '+',
          [
            '/',
            [
              '-',
              ['band', 1],
              ['var', 'windowCenter']
            ],
            ['var', 'windowWidth']
          ],
          0.5
        ],
        0,
        [0, 0, 0, 1],
        1,
        ['color', ['var', 'red'], ['var', 'green'], ['var', 'blue'], 1]
      ]

      const variables = {
        red: color[0],
        green: color[1],
        blue: color[2],
        windowCenter,
        windowWidth
      }

      return { color: expression, variables }
    }

    /**
     * Build OpenLayers style expression for coloring a WebGL PointLayer.
     *
     * @param {Object} styleOptions - Style options
     * @param {string} styleOptions.name - Name of a property for which values should be colorized
     * @param {number} styleOptions.minValue - Mininum value of the output range
     * @param {number} styleOptions.maxValue - Maxinum value of the output range
     * @param {number[][]} styleOptions.colormap - RGB color triplets
     *
     * @returns {Object} color style expression
     *
     * @private
     */
    function _getColorPaletteStyleForPointLayer ({
      name,
      minValue,
      maxValue,
      colormap
    }) {
      const minIndexValue = 0
      const maxIndexValue = colormap.length - 1
      const indexExpression = [
        'clamp',
        [
          'round',
          [
            '+',
            [
              '/',
              [
                '*',
                [
                  '-',
                  ['get', name],
                  minValue
                ],
                [
                  '-',
                  maxIndexValue,
                  minIndexValue
                ]
              ],
              [
                '-',
                maxValue,
                minValue
              ]
            ],
            minIndexValue
          ]
        ],
        minIndexValue,
        maxIndexValue
      ]

      const expression = [
        'palette',
        indexExpression,
        colormap
      ]

      return { color: expression }
    }

    const _controls = Symbol('controls')
    const _drawingLayer = Symbol('drawingLayer')
    const _drawingSource = Symbol('drawingSource')
    const _features = Symbol('features')
    const _imageLayer = Symbol('imageLayer')
    const _interactions = Symbol('interactions')
    const _map = Symbol('map')
    const _mappings = Symbol('mappings')
    const _metadata = Symbol('metadata')
    const _areIccProfilesFetched = Symbol('areIccProfilesFetched')
    const _options = Symbol('options')
    const _pyramid = Symbol('pyramid')
    const _segments = Symbol('segments')
    const _opticalPaths = Symbol('opticalPaths')
    const _rotation = Symbol('rotation')
    const _projection = Symbol('projection')
    const _tileGrid = Symbol('tileGrid')
    const _annotationManager = Symbol('annotationManager')
    const _annotationGroups = Symbol('annotationGroups')
    const _overviewMap = Symbol('overviewMap')
    const _updateOverviewMapSize = Symbol('updateOverviewMapSize')

    /**
     * Interactive viewer for DICOM VL Whole Slide Microscopy Image instances
     * with Image Type VOLUME.
     *
     * @class
     * @memberof viewer
     */
    class VolumeImageViewer {
      /**
       * Create a viewer instance for displaying VOLUME images.
       *
       * @param {Object} options
       * @param {Object} options.client - A DICOMwebClient instance for interacting
       * with an origin server over HTTP
       * @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
       * Metadata of DICOM VL Whole Slide Microscopy Image instances that should be
       * diplayed.
       * @param {number} [options.preload=0] - Number of resolution levels that should
       * be preloaded
       * @param {string[]} [options.controls=[]] - Names of viewer control elements
       * that should be included in the viewport
       * @param {boolean} [options.debug=false] - Whether debug features should be
       * turned on (e.g., display of tile boundaries)
       * @param {number} [options.tilesCacheSize=1000] - Number of tiles that should
       * be cached to avoid repeated retrieval for the DICOMweb server
       */
      constructor (options) {
        this[_options] = options

        if (this[_options].debug == null) {
          this[_options].debug = false
        } else {
          this[_options].debug = true
        }

        if (this[_options].preload == null) {
          this[_options].preload = false
        } else {
          this[_options].preload = true
        }

        if (this[_options].tilesCacheSize == null) {
          this[_options].tilesCacheSize = 1000
        }

        if (this[_options].controls == null) {
          this[_options].controls = []
        }
        this[_options].controls = new Set(this[_options].controls)

        // Collection of Openlayers "TileLayer" instances
        this[_segments] = {}
        this[_mappings] = {}
        this[_annotationGroups] = {}
        this[_areIccProfilesFetched] = false

        // Collection of Openlayers "Feature" instances
        this[_features] = new Collection([], { unique: true })

        // Add unique identifier to each created "Feature" instance
        this[_features].on('add', (e) => {
          // The ID may have already been set when drawn. However, features could
          // have also been added without a draw event.
          if (e.element.getId() === undefined) {
            e.element.setId(_generateUID())
          }

          this[_annotationManager].onAdd(e.element)
        })

        this[_features].on('remove', (e) => {
          this[_annotationManager].onRemove(e.element)
        })

        if (this[_options].metadata.constructor.name !== 'Array') {
          throw new Error('Input metadata must be an array.')
        }

        if (this[_options].metadata.length === 0) {
          throw new Error('Input metadata array is empty.')
        }

        if (this[_options].metadata.some((item) => typeof item !== 'object')) {
          throw new Error('Input metadata must be an array of objects.')
        }

        // We also accept metadata in raw JSON format for backwards compatibility
        if (this[_options].metadata[0].SOPClassUID != null) {
          this[_metadata] = this[_options].metadata
        } else {
          this[_metadata] = this[_options].metadata.map(instance => {
            return new VLWholeSlideMicroscopyImage({ metadata: instance })
          })
        }

        // Group color images by opticalPathIdentifier
        const colorGroups = groupColorInstances(this[_metadata])
        const colorImageInformation = {}
        let colorOpticalPathIdentifiers = Object.keys(colorGroups)
        if (colorOpticalPathIdentifiers.length > 0) {
          const id = colorOpticalPathIdentifiers[0]
          if (colorOpticalPathIdentifiers.length > 1) {
            console.warn(
              'Volume Image Viewer detected more than one color image, ' +
              'but only one color image can be loaded and visualized at a time. ' +
              'Only the first detected color image will be loaded.'
            )
            colorOpticalPathIdentifiers = [id]
          }
          colorImageInformation[id] = {
            metadata: colorGroups[id],
            opticalPath: this[_metadata][0].OpticalPathSequence[0]
          }
        }

        const monochromeGroups = groupMonochromeInstances(this[_metadata])
        const monochromeOpticalPathIdentifiers = Object.keys(monochromeGroups)
        const monochromeImageInformation = {}
        monochromeOpticalPathIdentifiers.forEach(id => {
          const refImage = monochromeGroups[id][0]
          const opticalPath = refImage.OpticalPathSequence.find(item => {
            return item.OpticalPathIdentifier === id
          })
          monochromeImageInformation[id] = {
            metadata: monochromeGroups[id],
            opticalPath
          }
        })

        const numChannels = monochromeOpticalPathIdentifiers.length
        const numColorImages = colorOpticalPathIdentifiers.length
        if (numChannels === 0 && numColorImages === 0) {
          throw new Error('Could not find any channels or color images.')
        }
        if (numChannels > 0 && numColorImages > 0) {
          throw new Error('Found both channels and color images.')
        }
        if (numColorImages > 1) {
          throw new Error('Found more than one color image.')
        }

        /*
         * For blending we have to make some assumptions
         * 1) all channels should have the same origins, resolutions, grid sizes,
         *    tile sizes and pixel spacings (i.e. same TileGrid).
         *    These are arrays with number of element equal the number of pyramid
         *    levels. All channels shall have the same number of levels.
         * 2) given (1), we calculcate the tileGrid, projection and rotation objects
         *    using the metadata of the first channel and subsequently apply them to
         *    all the other channels.
         * 3) If the parameters in (1) are different, it means that we would have to
         *    perfom registration, which (at least for now) is out of scope.
         */
        if (numChannels > 0) {
          const opticalPathIdentifier = monochromeOpticalPathIdentifiers[0]
          const info = monochromeImageInformation[opticalPathIdentifier]
          this[_pyramid] = _computeImagePyramid({ metadata: info.metadata })
        } else {
          const opticalPathIdentifier = colorOpticalPathIdentifiers[0]
          const info = colorImageInformation[opticalPathIdentifier]
          this[_pyramid] = _computeImagePyramid({ metadata: info.metadata })
        }

        this[_rotation] = _getRotation(this[_pyramid].metadata[0])

        /*
         * Specify projection to prevent default automatic projection
         * with the default Mercator projection.
         */
        this[_projection] = new Projection({
          code: 'DICOM',
          units: 'm',
          global: true,
          extent: this[_pyramid].extent,
          getPointResolution: (pixelRes, point) => {
            /*
             * DICOM Pixel Spacing has millimeter unit while the projection has
             * meter unit.
             */
            const spacing = getPixelSpacing(
              this[_pyramid].metadata[this[_pyramid].metadata.length - 1]
            )[0]
            return pixelRes * spacing / 10 ** 3
          }
        })

        /*
         * We need to specify the tile grid, since DICOM allows tiles to
         * have different sizes at each resolution level and a different zoom
         * factor between individual levels.
         */
        this[_tileGrid] = new TileGrid({
          extent: this[_pyramid].extent,
          origins: this[_pyramid].origins,
          resolutions: this[_pyramid].resolutions,
          sizes: this[_pyramid].gridSizes,
          tileSizes: this[_pyramid].tileSizes
        })

        const view = new View({
          center: getCenter(this[_pyramid].extent),
          projection: this[_projection],
          resolutions: this[_tileGrid].getResolutions(),
          rotation: this[_rotation],
          constrainOnlyCenter: false,
          smoothResolutionConstraint: true,
          showFullExtent: true,
          extent: this[_pyramid].extent
        })

        const layers = []
        const overviewLayers = []
        this[_opticalPaths] = {}
        if (numChannels > 0) {
          const helper = new WebGLHelper()
          const overviewHelper = new WebGLHelper()
          for (const opticalPathIdentifier in monochromeImageInformation) {
            const info = monochromeImageInformation[opticalPathIdentifier]
            const pyramid = _computeImagePyramid({ metadata: info.metadata })
            console.info(`channel "${opticalPathIdentifier}"`, pyramid)
            const bitsAllocated = info.metadata[0].BitsAllocated
            const minStoredValue = 0
            const maxStoredValue = Math.pow(2, bitsAllocated) - 1

            let paletteColorLookupTableUID
            let paletteColorLookupTable
            if (info.opticalPath.PaletteColorLookupTableSequence) {
              const item = info.opticalPath.PaletteColorLookupTableSequence[0]
              paletteColorLookupTableUID = (
                item.PaletteColorLookupTableUID
                  ? item.PaletteColorLookupTableUID
                  : _generateUID()
              )
              /*
               * TODO: If the LUT Data are large, the elements may be bulkdata and
               * then have to be retrieved separately. However, for optical paths
               * they are typically communicated as Segmented LUT Data and thus
               * relatively small.
               */
              paletteColorLookupTable = new PaletteColorLookupTable({
                uid: item.PaletteColorLookupTableUID,
                redDescriptor: item.RedPaletteColorLookupTableDescriptor,
                greenDescriptor: item.GreenPaletteColorLookupTableDescriptor,
                blueDescriptor: item.BluePaletteColorLookupTableDescriptor,
                redData: item.RedPaletteColorLookupTableData,
                greenData: item.GreenPaletteColorLookupTableData,
                blueData: item.BluePaletteColorLookupTableData,
                redSegmentedData: item.SegmentedRedPaletteColorLookupTableData,
                greenSegmentedData: item.SegmentedGreenPaletteColorLookupTableData,
                blueSegmentedData: item.SegmentedBluePaletteColorLookupTableData
              })
            }
            const defaultOpticalPathStyle = {
              opacity: 1,
              limitValues: [minStoredValue, maxStoredValue]
            }
            if (paletteColorLookupTable) {
              defaultOpticalPathStyle.paletteColorLookupTable = paletteColorLookupTable
            } else {
              defaultOpticalPathStyle.color = [255, 255, 255]
            }

            const opticalPath = {
              opticalPathIdentifier,
              opticalPath: new OpticalPath({
                identifier: opticalPathIdentifier,
                description: info.opticalPath.OpticalPathDescription,
                isMonochromatic: true,
                illuminationType: info.opticalPath.IlluminationTypeCodeSequence[0],
                illuminationWaveLength: info.opticalPath.IlluminationWaveLength,
                illuminationColor: (
                  info.opticalPath.IlluminationColorCodeSequence
                    ? info.opticalPath.IlluminationColorCodeSequence[0]
                    : undefined
                ),
                studyInstanceUID: info.metadata[0].StudyInstanceUID,
                seriesInstanceUID: info.metadata[0].SeriesInstanceUID,
                sopInstanceUIDs: pyramid.metadata.map(element => {
                  return element.SOPInstanceUID
                }),
                paletteColorLookupTableUID
              }),
              pyramid: pyramid,
              style: { ...defaultOpticalPathStyle },
              defaultStyle: defaultOpticalPathStyle,
              bitsAllocated: bitsAllocated,
              minStoredValue,
              maxStoredValue,
              loaderParams: {
                pyramid: pyramid,
                client: this[_options].client,
                channel: opticalPathIdentifier
              },
              hasLoader: false
            }

            const areImagePyramidsEqual = _areImagePyramidsEqual(
              opticalPath.pyramid,
              this[_pyramid]
            )
            if (!areImagePyramidsEqual) {
              throw new Error(
                `Pyramid of optical path "${opticalPathIdentifier}" ` +
                'is different from reference pyramid.'
              )
            }

            const source = new DataTileSource({
              tileGrid: this[_tileGrid],
              projection: this[_projection],
              wrapX: false,
              transition: 0,
              bandCount: 1
            })
            source.on('tileloaderror', (event) => {
              console.error(
                `error loading tile of optical path "${opticalPathIdentifier}"`,
                event
              )
            })

            const [windowCenter, windowWidth] = createWindow(
              opticalPath.style.limitValues[0],
              opticalPath.style.limitValues[1]
            )

            let layerStyle
            if (opticalPath.style.paletteColorLookupTable) {
              layerStyle = _getColorPaletteStyleForTileLayer({
                windowCenter,
                windowWidth,
                colormap: opticalPath.style.paletteColorLookupTable.data
              })
            } else {
              layerStyle = _getColorInterpolationStyleForTileLayer({
                windowCenter,
                windowWidth,
                color: opticalPath.style.color
              })
            }

            opticalPath.layer = new TileLayer({
              source,
              extent: pyramid.extent,
              preload: this[_options].preload ? 1 : 0,
              style: layerStyle,
              visible: false,
              useInterimTilesOnError: false,
              cacheSize: this[_options].tilesCacheSize
            })
            opticalPath.layer.helper = helper
            opticalPath.layer.on('precompose', (event) => {
              const gl = event.context
              gl.enable(gl.BLEND)
              gl.blendEquation(gl.FUNC_ADD)
              gl.blendFunc(gl.SRC_COLOR, gl.ONE)
            })
            opticalPath.layer.on('error', (event) => {
              console.error(
                `error rendering optical path "${opticalPathIdentifier}"`,
                event
              )
            })

            opticalPath.overviewLayer = new TileLayer({
              source,
              extent: pyramid.extent,
              preload: 0,
              style: layerStyle,
              visible: false,
              useInterimTilesOnError: false
            })
            opticalPath.overviewLayer.helper = overviewHelper
            opticalPath.overviewLayer.on('precompose', (event) => {
              const gl = event.context
              gl.enable(gl.BLEND)
              gl.blendEquation(gl.FUNC_ADD)
              gl.blendFunc(gl.SRC_COLOR, gl.ONE)
            })

            this[_opticalPaths][opticalPathIdentifier] = opticalPath
          }
        } else {
          const opticalPathIdentifier = colorOpticalPathIdentifiers[0]
          const info = colorImageInformation[opticalPathIdentifier]
          const pyramid = _computeImagePyramid({ metadata: info.metadata })

          const defaultOpticalPathStyle = {
            opacity: 1
          }

          const opticalPath = {
            opticalPathIdentifier,
            opticalPath: new OpticalPath({
              identifier: opticalPathIdentifier,
              description: info.opticalPath.OpticalPathDescription,
              illuminationType: info.opticalPath.IlluminationTypeCodeSequence[0],
              isMonochromatic: false,
              studyInstanceUID: info.metadata[0].StudyInstanceUID,
              seriesInstanceUID: info.metadata[0].SeriesInstanceUID,
              sopInstanceUIDs: pyramid.metadata.map(element => {
                return element.SOPInstanceUID
              })
            }),
            style: { ...defaultOpticalPathStyle },
            defaultStyle: defaultOpticalPathStyle,
            pyramid: pyramid,
            bitsAllocated: 8,
            minStoredValue: 0,
            maxStoredValue: 255,
            loaderParams: {
              pyramid: pyramid,
              client: this[_options].client,
              channel: opticalPathIdentifier
            },
            hasLoader: false
          }

          const source = new DataTileSource({
            tileGrid: this[_tileGrid],
            projection: this[_projection],
            wrapX: false,
            transition: 0,
            bandCount: 3
          })
          source.on('tileloaderror', (event) => {
            console.error(
              `error loading tile of optical path "${opticalPathIdentifier}"`,
              event
            )
          })

          opticalPath.layer = new TileLayer({
            source,
            extent: this[_tileGrid].extent,
            preload: this[_options].preload ? 1 : 0,
            useInterimTilesOnError: false,
            cacheSize: this[_options].tilesCacheSize
          })
          opticalPath.layer.on('error', (event) => {
            console.error(
              `error rendering optical path "${opticalPathIdentifier}"`,
              event
            )
          })

          opticalPath.overviewLayer = new TileLayer({
            source,
            extent: pyramid.extent,
            preload: 0,
            useInterimTilesOnError: false
          })

          layers.push(opticalPath.layer)
          overviewLayers.push(opticalPath.overviewLayer)

          this[_opticalPaths][opticalPathIdentifier] = opticalPath
        }

        if (this[_options].debug) {
          const tileDebugSource = new TileDebug({
            projection: this[_projection],
            extent: this[_pyramid].extent,
            tileGrid: this[_tileGrid],
            wrapX: false,
            template: ' '
          })
          const tileDebugLayer = new TileLayer({
            source: tileDebugSource,
            extent: this[_pyramid].extent,
            projection: this[_projection]
          })
          layers.push(tileDebugLayer)
        }

        if (Math.max(...this[_pyramid].gridSizes[0]) <= 10) {
          const center = view.getCenter()
          this[_overviewMap] = new OverviewMap({
            view: new View({
              projection: this[_projection],
              rotation: this[_rotation],
              constrainOnlyCenter: true,
              minResolution: this[_tileGrid].getResolution(0),
              center: center,
              extent: center.concat(center)
            }),
            layers: overviewLayers,
            collapsed: false,
            collapsible: true,
            rotateWithView: true
          })
          this[_updateOverviewMapSize] = () => {
            const overviewElement = this[_controls].overview.element
            const overviewChildren = overviewElement.children
            const overviewmapElement = Object.values(overviewChildren).find(
              c => c.className === 'ol-overviewmap-map'
            )
            // Try to fit the overview map into the target control overlay container
            const height = this[_pyramid].metadata[0].TotalPixelMatrixRows
            const width = this[_pyramid].metadata[0].TotalPixelMatrixColumns
            const rotation = this[_rotation] / Math.PI * 180
            const isRotated = !(
              Math.abs(rotation - 180) < 0.01 || Math.abs(rotation - 0) < 0.01
            )
            const viewport = this[_map].getViewport()
            const viewportHeight = viewport.clientHeight
            const viewportWidth = viewport.clientWidth
            const viewportHeightFraction = 0.3
            const viewportWidthFraction = 0.25
            const maxTargetHeight = viewportHeight * viewportHeightFraction
            const maxTargetWidth = viewportWidth * viewportWidthFraction
            let targetHeight
            let targetWidth
            if (isRotated) {
              targetHeight = width
              targetWidth = height
            } else {
              targetHeight = height
              targetWidth = width
            }
            let resizeFactor = 1
            resizeFactor = (
              maxTargetWidth / targetWidth +
              maxTargetHeight / targetHeight
            ) / 2
            targetHeight *= resizeFactor
            targetWidth *= resizeFactor
            overviewmapElement.style.width = `${targetWidth}px`
            overviewmapElement.style.height = `${targetHeight}px`
            const map = this[_overviewMap].getOverviewMap()
            map.updateSize()
            const view = map.getView()
            const center = view.getCenter()
            view.fit(
              center.concat(center),
              { size: map.getSize() }
            )
          }
        } else {
          this[_overviewMap] = null
          this[_updateOverviewMapSize] = () => {}
        }

        this[_drawingSource] = new VectorSource({
          tileGrid: this[_tileGrid],
          projection: this[_projection],
          features: this[_features],
          wrapX: false
        })
        this[_drawingLayer] = new VectorLayer({
          extent: this[_pyramid].extent,
          source: this[_drawingSource],
          projection: this[_projection],
          updateWhileAnimating: true,
          updateWhileInteracting: true
        })
        layers.push(this[_drawingLayer])

        this[_map] = new Map({
          layers,
          view,
          controls: [],
          keyboardEventTarget: document,
          interactions: defaultInteractions({
            altShiftDragRotate: true,
            doubleClickZoom: false,
            mouseWheelZoom: true,
            keyboard: false,
            shiftDragZoom: true,
            dragPan: true,
            pinchRotate: true,
            pinchZoom: true
          })
        })

        view.fit(this[_projection].getExtent(), { size: this[_map].getSize() })

        /**
         * OpenLayer's map has default active interactions.
         * We need to reuse them here to avoid duplications.
         * Enabling or disabling interactions could cause side effects on
         * OverviewMap since it also uses the same interactions in the map
         * @private
         */
        this[_interactions] = {
          draw: undefined,
          select: undefined,
          translate: undefined,
          modify: undefined,
          snap: undefined,
          dragPan: this[_map].getInteractions().getArray().find((i) => {
            return i instanceof DragPan
          })
        }

        this[_controls] = {
          scale: new ScaleLine({
            units: 'metric',
            className: ''
          })
        }
        if (this[_options].controls.has('fullscreen')) {
          this[_controls].fullscreen = new FullScreen()
        }
        if (this[_options].controls.has('zoom')) {
          this[_controls].zoom = new Zoom()
          this[_controls].zoomslider = new ZoomSlider()
        }
        if (this[_options].controls.has('overview')) {
          if (this[_overviewMap]) {
            this[_controls].overview = this[_overviewMap]
          }
        }
        if (this[_options].controls.has('position')) {
          this[_controls].position = new MousePosition({
            projection: this[_projection],
            coordinateFormat: (imageCoordinates) => {
              const slideCoordinates = _geometryCoordinates2scoord3dCoordinates(
                imageCoordinates,
                this[_pyramid].metadata
              )
              /*
               * This assumes that the image is aligned with the X and Y axes
               * of the slide (frame of reference).
               * If one would ever change the orientation (rotation), this may
               * need to be changed accordingly. The values would not become wrong,
               * but the X and Y axes of the slide would no longer align with the
               * vertical and horizontal axes of the viewport, respectively.
               */
              const x = slideCoordinates[0].toFixed(5)
              const y = slideCoordinates[1].toFixed(5)
              return `(${x}, ${y})`
            }
          })
        }
        for (const name in this[_controls]) {
          console.info(`add control "${name}"`)
          this[_map].addControl(this[_controls][name])
        }

        this[_annotationManager] = new _AnnotationManager({
          map: this[_map],
          pyramid: this[_pyramid].metadata,
          drawingSource: this[_drawingSource]
        })
      }

      /**
       * Set the style of an optical path.
       *
       * The style determine how grayscale stored values of a MONOCHROME2 image
       * will be transformed into pseudo-color display values.
       * Grayscale stored values are first transformed into normalized grayscale
       * display values, which are subsequently transformed into pseudo-color
       * values in RGB color space.
       *
       * The input to the first transformation are grayscale stored values in the
       * range defined by parameter "limitValues", which specify a window for
       * optimizing display value intensity and contrast. The resulting normalized
       * grayscale display values are then used as input to the second
       * transformation, which maps them to pseudo-color values ranging from black
       * color (R=0, G=0, B=0) to the color defined by parameter "color" using
       * linear interpolation. Alternatively, a palette color lookup table can be
       * provided to perform more sophisticated pseudo-coloring.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @param {Object} styleOptions
       * @param {number[]} [styleOptions.color] - RGB color triplet
       * @param {number[]} [styleOptions.paletteColorLookupTable] - palette color
       * lookup table
       * @param {number} [styleOptions.opacity] - Opacity
       * @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
       * limits
       */
      setOpticalPathStyle (opticalPathIdentifier, styleOptions = {}) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath === undefined) {
          throw new Error(
            'Cannot set optical path style. Could not find optical path ' +
            `"${opticalPathIdentifier}".`
          )
        }

        if (Object.entries(styleOptions).length === 0) {
          return
        }

        console.info(
          `set style for optical path "${opticalPathIdentifier}"`,
          styleOptions
        )
        if (styleOptions.opacity != null) {
          opticalPath.style.opacity = styleOptions.opacity
          opticalPath.layer.setOpacity(styleOptions.opacity)
          opticalPath.overviewLayer.setOpacity(styleOptions.opacity)
        }

        if (opticalPath.opticalPath.isMonochromatic) {
          if (styleOptions.limitValues != null) {
            opticalPath.style.limitValues = [
              Math.max(styleOptions.limitValues[0], opticalPath.minStoredValue),
              Math.min(styleOptions.limitValues[1], opticalPath.maxStoredValue)
            ]
          }
          const [windowCenter, windowWidth] = createWindow(
            opticalPath.style.limitValues[0],
            opticalPath.style.limitValues[1]
          )

          if (styleOptions.paletteColorLookupTable != null) {
            opticalPath.style.paletteColorLookupTable = styleOptions.paletteColorLookupTable
            const style = _getColorPaletteStyleForTileLayer({
              windowCenter,
              windowWidth,
              colormap: styleOptions.paletteColorLookupTable.data
            })
            opticalPath.layer.setStyle(style)
            opticalPath.overviewLayer.setStyle(style)
          } else if (styleOptions.color != null) {
            opticalPath.style.color = styleOptions.color
            if (opticalPath.style.paletteColorLookupTable) {
              const style = _getColorInterpolationStyleForTileLayer({
                windowCenter,
                windowWidth,
                color: opticalPath.style.color
              })
              opticalPath.style.paletteColorLookupTable = undefined
              opticalPath.layer.setStyle(style)
              opticalPath.overviewLayer.setStyle(style)
            } else {
              const styleVariables = {
                windowCenter: windowCenter,
                windowWidth: windowWidth,
                red: opticalPath.style.color[0],
                green: opticalPath.style.color[1],
                blue: opticalPath.style.color[2]
              }
              opticalPath.layer.updateStyleVariables(styleVariables)
              opticalPath.overviewLayer.updateStyleVariables(styleVariables)
            }
          } else {
            const styleVariables = {
              windowCenter: windowCenter,
              windowWidth: windowWidth
            }
            opticalPath.layer.updateStyleVariables(styleVariables)
            opticalPath.overviewLayer.updateStyleVariables(styleVariables)
          }
        }
      }

      /**
       * Determine whether an optical path is colorable.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @return {boolean} yes/no answer
       */
      isOpticalPathColorable (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath == null) {
          return false
        }
        return opticalPath.opticalPath.isColorable
      }

      /**
       * Determine whether an optical path is monochromatic.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @return {boolean} yes/no answer
       */
      isOpticalPathMonochromatic (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath == null) {
          return false
        }
        return opticalPath.opticalisMonochromatic
      }

      /**
       * Get the default style of an optical path.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @return {Object} Default style of optical path
       */
      getOpticalPathDefaultStyle (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath == null) {
          throw new Error(
            'Cannot get default style of optical path. ' +
            `Could not find optical path "${opticalPathIdentifier}".`
          )
        }
        if (opticalPath.opticalPath.isMonochromatic) {
          if (opticalPath.defaultStyle.paletteColorLookupTable) {
            return {
              paletteColorLookupTable: opticalPath.defaultStyle.paletteColorLookupTable,
              opacity: opticalPath.defaultStyle.opacity,
              limitValues: opticalPath.defaultStyle.limitValues
            }
          }
          return {
            color: opticalPath.defaultStyle.color,
            opacity: opticalPath.defaultStyle.opacity,
            limitValues: opticalPath.defaultStyle.limitValues
          }
        }
        return { opacity: opticalPath.defaultStyle.opacity }
      }

      /**
       * Get the style of an optical path.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @return {Object} Style of optical path
       */
      getOpticalPathStyle (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath == null) {
          throw new Error(
            'Cannot get style of optical path. ' +
            `Could not find optical path "${opticalPathIdentifier}".`
          )
        }
        if (opticalPath.opticalPath.isMonochromatic) {
          if (opticalPath.style.paletteColorLookupTable) {
            return {
              paletteColorLookupTable: opticalPath.style.paletteColorLookupTable,
              opacity: opticalPath.style.opacity,
              limitValues: opticalPath.style.limitValues
            }
          }
          return {
            color: opticalPath.style.color,
            opacity: opticalPath.style.opacity,
            limitValues: opticalPath.style.limitValues
          }
        }
        return { opacity: opticalPath.style.opacity }
      }

      /**
       * Get image metadata for an optical path.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @returns {metadata.VLWholeSlideMicroscopyImage[]} Slide microscopy image
       * metadata
       */
      getOpticalPathMetadata (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath === undefined) {
          throw new Error(
            'Cannot get image metadata optical path. ' +
            `Could not find optical path "${opticalPathIdentifier}".`
          )
        }
        return opticalPath.pyramid.metadata
      }

      /**
       * Get all optical paths.
       *
       * @return {opticalPath.OpticalPath[]}
       */
      getAllOpticalPaths () {
        const opticalPaths = []
        for (const opticalPathIdentifier in this[_opticalPaths]) {
          opticalPaths.push(this[_opticalPaths][opticalPathIdentifier].opticalPath)
        }
        return opticalPaths.sort(item => (item.OpticalPathIdentifier))
      }

      /**
       * Activate an optical path.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       */
      activateOpticalPath (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath === undefined) {
          throw new Error(
            'Cannot activate optical path. Could not find optical path ' +
            `"${opticalPathIdentifier}".`
          )
        }
        if (!this.isOpticalPathActive(opticalPathIdentifier)) {
          /*
           * Add layer to the bottom of the layer stack to ensure that vector
           * graphics are overlayed ontop of the raster graphics.
           */
          this[_map].getLayers().insertAt(
            0,
            opticalPath.layer
          )
          if (this[_overviewMap]) {
            this[_overviewMap].getOverviewMap().getLayers().insertAt(
              0,
              opticalPath.overviewLayer
            )
          }
        }
      }

      /**
       * Deactivate an optical path.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       */
      deactivateOpticalPath (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath === undefined) {
          throw new Error(
            'Cannot deactivate optical path. Could not find optical path ' +
            `"${opticalPathIdentifier}".`
          )
        }
        if (!this.isOpticalPathActive(opticalPathIdentifier)) {
          return
        }
        this[_map].removeLayer(opticalPath.layer)
        if (this[_overviewMap]) {
          this[_overviewMap].getOverviewMap().removeLayer(opticalPath.overviewLayer)
        }
      }

      /**
       * Determine whether an optical path is active.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @return {boolean} active
       */
      isOpticalPathActive (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath == null) {
          return false
        }
        const layers = this[_map].getLayers()
        const match = layers.getArray().find(layer => {
          return layer.ol_uid === opticalPath.layer.ol_uid
        })
        if (this[_overviewMap] != null) {
          const overviewLayers = this[_overviewMap].getOverviewMap().getLayers()
          const overviewMatch = overviewLayers.getArray().find(layer => {
            return layer.ol_uid === opticalPath.overviewLayer.ol_uid
          })
          return match != null || overviewMatch != null
        } else {
          return match != null
        }
      }

      /**
       * Show an optical path.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @param {Object} [styleOptions]
       * @param {number[]} [styleOptions.color] - RGB color triplet
       * @param {number[]} [styleOptions.paletteColorLookupTable] - palette color
       * lookup table
       * @param {number} [styleOptions.opacity] - Opacity
       * @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
       * limits
       */
      showOpticalPath (opticalPathIdentifier, styleOptions = {}) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath === undefined) {
          throw new Error(
            'Cannot show optical path. Could not find optical path ' +
            `"${opticalPathIdentifier}".`
          )
        }
        console.info(`show optical path ${opticalPathIdentifier}`)
        this.activateOpticalPath(opticalPathIdentifier)

        const container = this[_map].getTargetElement()
        if (container && !opticalPath.hasLoader) {
          const metadata = opticalPath.pyramid.metadata
          _getIccProfiles(metadata, this[_options].client).then(profiles => {
            const loader = _createTileLoadFunction({
              targetElement: container,
              iccProfiles: profiles,
              ...opticalPath.loaderParams
            })
            const source = opticalPath.layer.getSource()
            source.setLoader(loader)
          })
        }

        opticalPath.layer.setVisible(true)
        opticalPath.overviewLayer.setVisible(true)
        this.setOpticalPathStyle(opticalPathIdentifier, styleOptions)
      }

      /**
       * Hide an optical path.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       */
      hideOpticalPath (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath === undefined) {
          throw new Error(
            'Cannot hide optical path. Could not find optical path ' +
            `"${opticalPathIdentifier}".`
          )
        }
        console.info(`hide optical path ${opticalPathIdentifier}`)
        opticalPath.layer.setVisible(false)
        opticalPath.overviewLayer.setVisible(false)
      }

      /**
       * Determine if an optical path is visible.
       *
       * @param {string} opticalPathIdentifier - Optical Path Identifier
       * @returns {boolean}
       */
      isOpticalPathVisible (opticalPathIdentifier) {
        const opticalPath = this[_opticalPaths][opticalPathIdentifier]
        if (opticalPath === undefined) {
          throw new Error(
            'Cannot show optical path. Could not find optical path ' +
            `"${opticalPathIdentifier}".`
          )
        }
        return opticalPath.layer.getVisible()
      }

      /**
       * Resize the viewer to fit the viewport.
       *
       * @returns {void}
       */
      resize () {
        this[_map].updateSize()
        this[_updateOverviewMapSize]()
      }

      /**
       * Get the size of the viewport.
       *
       * @return {number[]}
       */
      get size () {
        return this[_map].getSize()
      }

      /**
       * Clean up.
       *
       * Release allocated memory and clear the viewport.
       */
      cleanup () {
        console.info('cleanup')
        const itemsRequiringDisposal = [
          ...Object.values(this[_opticalPaths]),
          ...Object.values(this[_segments]),
          ...Object.values(this[_mappings]),
          ...Object.values(this[_annotationGroups])
        ]
        itemsRequiringDisposal.forEach(item => {
          item.layer.dispose()
          this[_map].removeLayer(item.layer)
          if (item.overlay) {
            item.overlay.dispose()
            this[_map].removeOverlay(item.overlay)
          }
          this[_features].clear()
        })

        webWorkerManager.terminateAllWebWorkers()
      }

      /**
       * Render the images in the specified viewport container.
       *
       * @param {Object} options - Rendering options.
       * @param {(string|HTMLElement)} options.container - HTML Element in which the viewer should be injected.
       */
      render ({ container }) {
        if (container == null) {
          console.error('container must be provided for rendering images')
          return
        }

        const itemsRequiringDecodersAndTransformers = [
          ...Object.values(this[_opticalPaths]),
          ...Object.values(this[_segments]),
          ...Object.values(this[_mappings])
        ]

        const styleControlElement = (element) => {
          element.style.backgroundColor = 'rgba(255,255,255,.75)'
          element.style.color = 'black'
          element.style.padding = '2px'
          element.style.margin = '1px'
        }

        itemsRequiringDecodersAndTransformers.forEach(item => {
          const metadata = item.pyramid.metadata
          _getIccProfiles(metadata, this[_options].client).then(profiles => {
            const source = item.layer.getSource()
            const loader = _createTileLoadFunction({
              targetElement: container,
              iccProfiles: profiles,
              ...item.loaderParams
            })
            source.setLoader(loader)
            item.hasLoader = true
            this[_map].setTarget(container)

            const view = this[_map].getView()
            const projection = view.getProjection()
            view.fit(projection.getExtent(), { size: this[_map].getSize() })

            this[_drawingSource].on(VectorEventType.ADDFEATURE, (e) => {
              publish(
                container,
                EVENT.ROI_ADDED,
                this._getROIFromFeature(e.feature, metadata)
              )
            })
            this[_drawingSource].on(VectorEventType.CHANGEFEATURE, (e) => {
              if (e.feature !== undefined || e.feature !== null) {
                const geometry = e.feature.getGeometry()
                const type = geometry.getType()
                /*
                 * The first and last point of a polygon must be identical. The
                 * last point is an implementation detail and is hidden from the
                 * user in the graphical interface. However, we must update the
                 * last point in case the first point has been modified by the
                 * user.
                 */
                if (type === 'Polygon') {
                  /*
                   * Polygon in GeoJSON format contains an array of geometries,
                   * where the first element represents the coordinates of the
                   * outer ring and the second element represents the coordinates
                   * of the inner ring (in our case the inner ring should not be
                   * present).
                   */
                  const layout = geometry.getLayout()
                  const coordinates = geometry.getCoordinates()
                  const firstPoint = coordinates[0][0]
                  const lastPoint = coordinates[0][coordinates[0].length - 1]
                  if (
                    firstPoint[0] !== lastPoint[0] ||
                    firstPoint[1] !== lastPoint[1]
                  ) {
                    coordinates[0][coordinates[0].length - 1] = firstPoint
                    geometry.setCoordinates(coordinates, layout)
                    e.feature.setGeometry(geometry)
                  }
                }
              }
              publish(
                container,
                EVENT.ROI_MODIFIED,
                this._getROIFromFeature(e.feature, metadata)
              )
            })
            this[_drawingSource].on(VectorEventType.REMOVEFEATURE, (e) => {
              publish(
                container,
                EVENT.ROI_REMOVED,
                this._getROIFromFeature(e.feature, metadata)
              )
            })

            if (this[_controls].overview && this[_overviewMap]) {
              // Style overview element (overriding Openlayers CSS "ol-overviewmap")
              const overviewElement = this[_controls].overview.element
              const overviewChildren = overviewElement.children
              const overviewmapElement = Object.values(overviewChildren).find(
                c => c.className === 'ol-overviewmap-map'
              )
              const buttonElement = Object.values(overviewChildren).find(
                c => c.type === 'button'
              )
              if (buttonElement) {
                buttonElement.title = 'Overview'
                buttonElement.style.border = '0.25px solid black'
                buttonElement.style.backgroundColor = 'white'
                buttonElement.style.cursor = 'pointer'
                const spanElement = buttonElement.children[0]
                spanElement.style.color = 'black'
                spanElement.style.backgroundColor = 'white'
              }
              styleControlElement(overviewmapElement)
              overviewmapElement.style.border = '1px solid black'
              overviewmapElement.style.color = 'black'
              this[_updateOverviewMapSize]()
            }
          })
        })

        // Style scale element (overriding Openlayers CSS "ol-scale-line")
        const scaleElement = this[_controls].scale.element
        scaleElement.style.position = 'absolute'
        scaleElement.style.right = '.5em'
        scaleElement.style.bottom = '.5em'
        scaleElement.style.left = 'auto'
        scaleElement.style.borderRadius = '4px'
        styleControlElement(scaleElement)
        const scaleInnerElement = this[_controls].scale.innerElement_
        scaleInnerElement.style.color = 'black'
        scaleInnerElement.style.fontWeight = '600'
        scaleInnerElement.style.fontSize = '10px'
        scaleInnerElement.style.textAlign = 'center'
        scaleInnerElement.style.borderWidth = '1.5px'
        scaleInnerElement.style.borderStyle = 'solid'
        scaleInnerElement.style.borderTop = 'none'
        scaleInnerElement.style.borderRightColor = 'black'
        scaleInnerElement.style.borderLeftColor = 'black'
        scaleInnerElement.style.borderBottomColor = 'black'
        scaleInnerElement.style.margin = '1px'
        scaleInnerElement.style.willChange = 'contents,width'

        // Style position element (overriding Openlayers CSS "ol-mouse-position")
        if (this[_controls].position != null) {
          const positionElement = this[_controls].position.element
          positionElement.style.position = 'absolute'
          positionElement.style.right = '.5em'
          positionElement.style.top = '.5em'
          positionElement.style.left = 'auto'
          positionElement.style.bottom = 'auto'
          positionElement.style.fontWeight = '600'
          positionElement.style.fontSize = '10px'
          positionElement.style.textAlign = 'center'
          positionElement.style.borderRadius = '4px'
          styleControlElement(positionElement)
        }
      }

      /**
       * Navigate the view to a spatial position or resolution level.
       *
       * @param {Object} options - Options.
       * @param {number} level - Zoom level.
       */
      navigate ({ level }) {
        const numLevels = this[_pyramid].resolutions.length
        if (level > numLevels) {
          throw new Error('Argument "level" exceeds number of resolution levels.')
        }
        // TODO: center position in slide/image coordinates
        const view = this[_map].getView()
        const currentZoomLevel = view.getZoom()
        if (level !== currentZoomLevel) {
          view.animate({ zoom: this[_pyramid] })
        }
      }

      /**
       * Activate the draw interaction for graphic annotation of regions of interest.
       *
       * @param {Object} options - Drawing options
       * @param {string} options.geometryType - Name of the geometry type (point, circle, box, polygon, freehandpolygon, line, freehandline)
       * @param {number} [options.maxPoints] - Maximum number of points for "line"
       * geometry
       * @param {number} [options.minPoints] - Mininum number of points for "line"
       * geometry
       * @param {Object} [options.styleOptions] - Style options
       * @param {Object} [styleOptions.stroke] - Style options for the contour of
       * the geometry
       * @param {number[]} styleOptions.stroke.color - RGBA color of the contour
       * @param {number} styleOptions.stroke.width - Width of the contour
       * @param {Object} [styleOptions.fill] - Style options for the body of the
       * geometry
       * @param {number[]} styleOptions.fill.color - RGBA color of the body
       */
      activateDrawInteraction (options) {
        this.deactivateDrawInteraction()
        console.info('activate "draw" interaction')

        const geometryOptionsMapping = {
          point: {
            type: 'Point',
            geometryName: 'Point'
          },
          circle: {
            type: 'Circle',
            geometryName: 'Circle'
          },
          box: {
            type: 'Circle',
            geometryName: 'Box',
            geometryFunction: createRegularPolygon(4)
          },
          polygon: {
            type: 'Polygon',
            geometryName: 'Polygon',
            freehand: false
          },
          freehandpolygon: {
            type: 'Polygon',
            geometryName: 'FreeHandPolygon',
            freehand: true
          },
          line: {
            type: 'LineString',
            geometryName: 'Line',
            freehand: false,
            maxPoints: options.maxPoints,
            minPoints: options.minPoints
          },
          freehandline: {
            type: 'LineString',
            geometryName: 'FreeHandLine',
            freehand: true
          }
        }

        if (!('geometryType' in options)) {
          console.error('geometry type must be specified for drawing interaction')
        }

        if (!(options.geometryType in geometryOptionsMapping)) {
          console.error(`unsupported geometry type "${options.geometryType}"`)
        }

        const internalDrawOptions = { source: this[_drawingSource] }
        const geometryDrawOptions = geometryOptionsMapping[options.geometryType]
        const builtInDrawOptions = {
          [Enums.InternalProperties.Marker]:
            options[Enums.InternalProperties.Marker],
          [Enums.InternalProperties.Markup]:
            options[Enums.InternalProperties.Markup],
          [Enums.InternalProperties.Label]: options[Enums.InternalProperties.Label]
        }
        const drawOptions = Object.assign(
          internalDrawOptions,
          geometryDrawOptions,
          builtInDrawOptions
        )

        /**
         * This used to define which mouse buttons will fire the action.
         *
         * bindings: {
         *   mouseButtons can be 'left', 'right' and/or 'middle'. if absent, the action is bound to all mouse buttons.
         *   mouseButtons: ['left', 'right'],
         *   modifierKey can be 'shift', 'ctrl' or 'alt'. If not present, the action is bound to no modifier key.
         *   modifierKey: 'ctrl' // The modifier
         * },
         */
        if (options.bindings) {
          drawOptions.condition = _getInteractionBindingCondition(options.bindings)
        }

        this[_interactions].draw = new Draw(drawOptions)
        const container = this[_map].getTargetElement()

        this[_interactions].draw.on(Enums.InteractionEvents.DRAW_START, (event) => {
          event.feature.setProperties(builtInDrawOptions, true)
          event.feature.setId(_generateUID())

          /** Set external styles before calling internal annotation hooks */
          _setFeatureStyle(
            event.feature,
            options[Enums.InternalProperties.StyleOptions]
          )

          this[_annotationManager].onDrawStart(event)

          _wireMeasurementsAndQualitativeEvaluationsEvents(
            this[_map],
            event.feature,
            this[_pyramid].metadata
          )
        })

        this[_interactions].draw.on(Enums.InteractionEvents.DRAW_ABORT, (event) => {
          this[_annotationManager].onDrawAbort(event)
        })

        this[_interactions].draw.on(Enums.InteractionEvents.DRAW_END, (event) => {
          this[_annotationManager].onDrawEnd(event)
          publish(
            container,
            EVENT.ROI_DRAWN,
            this._getROIFromFeature(event.feature, this[_pyramid].metadata)
          )
        })

        this[_map].addInteraction(this[_interactions].draw)
      }

      /**
       * Deactivate draw interaction.
       */
      deactivateDrawInteraction () {
        console.info('deactivate "draw" interaction')
        if (this[_interactions].draw !== undefined) {
          this[_map].removeInteraction(this[_interactions].draw)
          this[_interactions].draw = undefined
        }
      }

      /**
       * Whether draw interaction is active
       *
       * @type boolean
       */
      get isDrawInteractionActive () {
        return this[_interactions].draw !== undefined
      }

      /**
       * Whether drag pan interaction is active.
       *
       * @type boolean
       */
      get isDragPanInteractionActive () {
        return this[_interactions].dragPan !== undefined
      }

      /**
       * Whether drag zoom interaction is active.
       *
       * @type boolean
       */
      get isDragZoomInteractionActive () {
        return this[_interactions].dragZoom !== undefined
      }

      /**
       * Whether translate interaction is active.
       *
       * @type boolean
       */
      get isTranslateInteractionActive () {
        return this[_interactions].translate !== undefined
      }

      /**
       * Activate translate interaction.
       *
       * @param {Object} options - Translation options.
       */
      activateTranslateInteraction (options = {}) {
        this.deactivateTranslateInteraction()

        console.info('activate "translate" interaction')

        const translateOptions = { layers: [this[_drawingLayer]] }

        /**
         * Get conditional mouse bindings
         * See "options.binding" comment in activateDrawInteraction() definition.
         */
        if (options.bindings) {
          translateOptions.condition = _getInteractionBindingCondition(
            options.bindings
          )
        }

        this[_interactions].translate = new Translate(translateOptions)

        this[_map].addInteraction(this[_interactions].translate)
      }

      /**
       * Extract and transform the region of interest (ROI).
       *
       * @param {Object} feature - Openlayers Feature
       * @param {Object[]} pyramid - Metadata for resolution levels of image pyramid
       * @param {Object} context - Context
       * @returns {roi.ROI} Region of interest
       * @private
       */
      _getROIFromFeature (feature, pyramid) {
        let scoord3d
        try {
          scoord3d = _geometry2Scoord3d(feature, pyramid)
        } catch (error) {
          const uid = feature.getId()
          this.removeROI(uid)
          throw error
        }

        const featureProperties = feature.getProperties()
        const properties = {
          measurements: featureProperties.measurements,
          evaluations: featureProperties.evaluations
        }
        const uid = feature.getId()
        return new ROI({ scoord3d, properties, uid })
      }

      /**
       * Toggle overview map.
       *
       * @returns {void}
       */
      toggleOverviewMap () {
        if (this[_overviewMap]) {
          const controls = this[_map].getControls()
          const overview = controls.getArray().find((c) => c === this[_overviewMap])
          if (overview) {
            this[_map].removeControl(this[_overviewMap])
            delete this[_controls].overview
          } else {
            this[_controls].overview = this[_overviewMap]
            this[_map].addControl(this[_overviewMap])
            const map = this[_overviewMap].getOverviewMap()
            const view = map.getView()
            const projection = view.getProjection()
            view.fit(projection.getExtent(), { size: map.getSize() })
          }
        }
      }

      /**
       * Deactivate translate interaction.
       *
       * @returns {void}
       */
      deactivateTranslateInteraction () {
        console.info('deactivate "translate" interaction')
        if (this[_interactions].translate) {
          this[_map].removeInteraction(this[_interactions].translate)
          this[_interactions].translate = undefined
        }
      }

      /**
       * Activate drag zoom interaction.
       *
       * @param {Object} options - Options.
       */
      activateDragZoomInteraction (options = {}) {
        this.deactivateDragZoomInteraction()

        console.info('activate "dragZoom" interaction')

        const dragZoomOptions = { layers: [this[_drawingLayer]] }

        /**
         * Get conditional mouse bindings
         * See "options.binding" comment in activateDrawInteraction() definition.
         */
        if (options.bindings) {
          dragZoomOptions.condition = _getInteractionBindingCondition(
            options.bindings
          )
        }

        this[_interactions].dragZoom = new DragZoom(dragZoomOptions)

        this[_map].addInteraction(this[_interactions].dragZoom)
      }

      /**
       * Deactivate drag zoom interaction.
       */
      deactivateDragZoomInteraction () {
        console.info('deactivate "dragZoom" interaction')
        if (this[_interactions].dragZoom) {
          this[_map].removeInteraction(this[_interactions].dragZoom)
          this[_interactions].dragZoom = undefined
        }
      }

      /**
       * Activate select interaction.
       *
       * @param {Object} options selection options.
       */
      activateSelectInteraction (options = {}) {
        this.deactivateSelectInteraction()

        console.info('activate "select" interaction')

        const selectOptions = { layers: [this[_drawingLayer]] }

        /**
         * Get conditional mouse bindings
         * See "options.binding" comment in activateDrawInteraction() definition.
         */
        if (options.bindings) {
          selectOptions.condition = _getInteractionBindingCondition(
            options.bindings
          )
        }

        this[_interactions].select = new Select(selectOptions)

        const container = this[_map].getTargetElement()

        this[_interactions].select.on('select', (e) => {
          publish(
            container,
            EVENT.ROI_SELECTED,
            this._getROIFromFeature(e.selected[0], this[_pyramid].metadata)
          )
        })

        this[_map].addInteraction(this[_interactions].select)
      }

      /**
       * Deactivate select interaction.
       */
      deactivateSelectInteraction () {
        console.info('deactivate "select" interaction')
        if (this[_interactions].select) {
          this[_map].removeInteraction(this[_interactions].select)
          this[_interactions].select = undefined
        }
      }

      /**
       * Activate drag pan interaction.
       *
       * @param {Object} options - Options.
       */
      activateDragPanInteraction (options = {}) {
        this.deactivateDragPanInteraction()

        console.info('activate "drag pan" interaction')

        const dragPanOptions = {
          features: this[_features]
        }

        /**
         * Get conditional mouse bindings
         * See "options.binding" comment in activateDrawInteraction() definition.
         */
        if (options.bindings) {
          dragPanOptions.condition = _getInteractionBindingCondition(
            options.bindings
          )
        }

        this[_interactions].dragPan = new DragPan(dragPanOptions)

        this[_map].addInteraction(this[_interactions].dragPan)
      }

      /**
       * Deactivate drag pan interaction.
       */
      deactivateDragPanInteraction () {
        console.info('deactivate "drag pan" interaction')
        if (this[_interactions].dragPan) {
          this[_map].removeInteraction(this[_interactions].dragPan)
          this[_interactions].dragPan = undefined
        }
      }

      /**
       * Activate snap interaction.
       *
       * @param {Object} options - Snap options.
       */
      activateSnapInteraction (options = {}) {
        this.deactivateSnapInteraction()
        console.info('activate "snap" interaction')
        this[_interactions].snap = new Snap({
          source: this[_drawingSource]
        })

        this[_map].addInteraction(this[_interactions].snap)
      }

      /**
       * Deactivate snap interaction.
       */
      deactivateSnapInteraction () {
        console.info('deactivate "snap" interaction')
        if (this[_interactions].snap) {
          this[_map].removeInteraction(this[_interactions].snap)
          this[_interactions].snap = undefined
        }
      }

      /**
       * Whether select interaction is active.
       *
       * @type boolean
       */
      get isSelectInteractionActive () {
        return this[_interactions].select !== undefined
      }

      /**
       * Activate modify interaction.
       *
       * @param {Object} options - Modification options.
       */
      activateModifyInteraction (options = {}) {
        this.deactivateModifyInteraction()

        console.info('activate "modify" interaction')

        const modifyOptions = {
          features: this[_features],
          insertVertexCondition: (event) => true
        }

        /**
         * Get conditional mouse bindings
         * See "options.binding" comment in activateDrawInteraction() definition.
         */
        if (options.bindings) {
          modifyOptions.condition = _getInteractionBindingCondition(
            options.bindings
          )
        }

        this[_interactions].modify = new Modify(modifyOptions)
        const container = this[_map].getTargetElement()

        this[_interactions].modify.on(Enums.InteractionEvents.MODIFY_END, (event) => {
          const feature = event.features.item(0)
          _updateFeatureMeasurements(this[_map], feature, this[_pyramid].metadata)
          this[_annotationManager].onUpdate(feature)
          publish(
            container,
            EVENT.ROI_MODIFIED,
            this._getROIFromFeature(feature, this[_pyramid].metadata)
          )
        })

        this[_map].addInteraction(this[_interactions].modify)
      }

      /**
       * Deactivate modify interaction.
       */
      deactivateModifyInteraction () {
        console.info('deactivate "modify" interaction')
        if (this[_interactions].modify) {
          this[_map].removeInteraction(this[_interactions].modify)
          this[_interactions].modify = undefined
        }
      }

      /**
       * Whether modify interaction is active.
       *
       * @type boolean
       */
      get isModifyInteractionActive () {
        return this[_interactions].modify !== undefined
      }

      /**
       * Get all annotated regions of interest.
       *
       * @returns {roi.ROI[]} Array of regions of interest.
       */
      getAllROIs () {
        console.info('get all ROIs')
        const rois = []
        this[_features].forEach((item) => {
          rois.push(this.getROI(item.getId()))
        })
        return rois
      }

      collapseOverviewMap () {
        if (this[_overviewMap]) {
          this[_overviewMap].setCollapsed(true)
        }
      }

      expandOverviewMap () {
        if (this[_overviewMap]) {
          this[_overviewMap].setCollapsed(true)
        }
      }

      /**
       * Number of annotated regions of interest.
       *
       * @return {number}
       */
      get numberOfROIs () {
        return this[_features].getLength()
      }

      /**
       * Get an individual annotated region of interest.
       *
       * @param {string} uid - Unique identifier of the region of interest
       * @returns {roi.ROI} Region of interest.
       */
      getROI (uid) {
        console.info(`get ROI ${uid}`)
        const feature = this[_drawingSource].getFeatureById(uid)
        return this._getROIFromFeature(feature, this[_pyramid].metadata)
      }

      /**
       * Add a measurement to a region of interest.
       *
       * @param {string} uid - Unique identifier of the region of interest
       * @param {Object} item - NUM content item representing a measurement
       */
      addROIMeasurement (uid, item) {
        const meaning = item.ConceptNameCodeSequence[0].CodeMeaning
        console.info(`add measurement "${meaning}" to ROI ${uid}`)
        this[_features].forEach((feature) => {
          const id = feature.getId()
          if (id === uid) {
            const properties = feature.getProperties()
            if (!(Enums.InternalProperties.Measurements in properties)) {
              properties[Enums.InternalProperties.Measurements] = [item]
            } else {
              properties[Enums.InternalProperties.Measurements].push(item)
            }
            feature.setProperties(properties, true)
          }
        })
      }

      /**
       * Add a qualitative evaluation to a region of interest.
       *
       * @param {string} uid - Unique identifier of the region of interest
       * @param {Object} item - CODE content item representing a qualitative evaluation
       */
      addROIEvaluation (uid, item) {
        const meaning = item.ConceptNameCodeSequence[0].CodeMeaning
        console.info(`add qualitative evaluation "${meaning}" to ROI ${uid}`)
        this[_features].forEach((feature) => {
          const id = feature.getId()
          if (id === uid) {
            const properties = feature.getProperties()
            if (!(Enums.InternalProperties.Evaluations in properties)) {
              properties[Enums.InternalProperties.Evaluations] = [item]
            } else {
              properties[Enums.InternalProperties.Evaluations].push(item)
            }
            feature.setProperties(properties, true)
          }
        })
      }

      /**
       * Pop the most recently annotated regions of interest.
       *
       * @returns {roi.ROI} Regions of interest.
       */
      popROI () {
        console.info('pop ROI')
        const feature = this[_features].pop()
        return this._getROIFromFeature(feature, this[_pyramid].metadata)
      }

      /**
       * Add a regions of interest.
       *
       * @param {roi.ROI} roi - Regions of interest
       * @param {Object} [styleOptions] - Style options
       * @param {Object} [styleOptions.stroke] - Style options for the contour of
       * the geometry
       * @param {number[]} styleOptions.stroke.color - RGBA color of the contour
       * @param {number} styleOptions.stroke.width - Width of the contour
       * @param {Object} [styleOptions.fill] - Style options for the body of the
       * geometry
       * @param {number[]} styleOptions.fill.color - RGBA color of the body
       */
      addROI (roi, styleOptions = {}) {
        console.info(`add ROI ${roi.uid}`)

        const frameOfReferenceUID = this[_pyramid].metadata.FrameOfReferenceUID
        if (roi.frameOfReferenceUID !== frameOfReferenceUID) {
          throw new Error(
            `Frame of Reference UID of ROI ${roi.uid} does not match ` +
            'Frame of Reference UID of source images.'
          )
        }

        const geometry = _scoord3d2Geometry(roi.scoord3d, this[_pyramid].metadata)
        const featureOptions = { geometry }

        const feature = new Feature(featureOptions)
        _addROIPropertiesToFeature(feature, roi.properties, true)
        feature.setId(roi.uid)

        _wireMeasurementsAndQualitativeEvaluationsEvents(
          this[_map],
          feature,
          this[_pyramid].metadata
        )

        this[_features].push(feature)

        _setFeatureStyle(feature, styleOptions)
        const isVisible = Object.keys(styleOptions).length !== 0
        this[_annotationManager].setMarkupVisibility(roi.uid, isVisible)
      }

      /**
       * Update properties of a region of interest.
       *
       * @param {Object} roi - ROI to be updated
       * @param {string} roi.uid - Unique identifier of the region of interest
       * @param {Object} roi.properties - ROI properties
       * @param {Object} [roi.properties.measurements] - ROI measurements
       * @param {Object} [roi.properties.evaluations] - ROI evaluations
       * @param {Object} [roi.properties.label] - ROI label
       * @param {Object} [roi.properties.marker] - ROI marker
       */
      updateROI ({ uid, properties = {} }) {
        if (!uid) return
        console.info(`update ROI ${uid}`)

        const feature = this[_drawingSource].getFeatureById(uid)

        _addROIPropertiesToFeature(feature, properties)

        this[_annotationManager].onUpdate(feature)
      }

      /**
       * Get the style of a region of interest.
       *
       * @param {string} uid - Unique identifier of the regions of interest
       *
       * @returns {Object} - Style settings
       */
      getROIStyle (uid) {
        const feature = this[_features].getArray().find((feature) => {
          return feature.getId() === uid
        })
        if (feature == null) {
          throw new Error()
        }
        const style = feature.getStyle()
        const stroke = style.getStroke()
        const fill = style.getFill()
        return {
          stroke: {
            color: stroke.getColor(),
            width: stroke.getWidth()
          },
          fill: {
            color: fill.getColor()
          }
        }
      }

      /**
       * Set the style of a region of interest.
       *
       * @param {string} uid - Unique identifier of the regions of interest
       * @param {Object} styleOptions - Style options
       * @param {Object} [styleOptions.stroke] - Style options for the contour of
       * the geometry
       * @param {number[]} styleOptions.stroke.color - RGBA color of the contour
       * @param {number} styleOptions.stroke.width - Width of the contour
       * @param {Object} [styleOptions.fill] - Style options for the body of the
       * geometry
       * @param {number[]} styleOptions.fill.color - RGBA color of the body
       */
      setROIStyle (uid, styleOptions = {}) {
        this[_features].forEach((feature) => {
          const id = feature.getId()
          if (id === uid) {
            _setFeatureStyle(feature, styleOptions)
            const isVisible = Object.keys(styleOptions).length !== 0
            this[_annotationManager].setMarkupVisibility(id, isVisible)
          }
        })
      }

      /**
       * Add a new viewport overlay.
       *
       * @param {Object} options Overlay options
       * @param {Object} options.element The custom overlay HTML element
       * @param {string} options.className Class to style the overlay container
       * @param {number[]} options.offset Horizontal and vertical offset of the overlay container in pixels
       */
      addViewportOverlay ({ element, className, offset }) {
        const overlay = new Overlay({
          element,
          className,
          offset,
          stopEvent: false
        })
        this[_map].addOverlay(overlay)
      }

      /**
       * Remove an individual regions of interest.
       *
       * @param {string} uid - Unique identifier of the region of interest
       */
      removeROI (uid) {
        console.info(`remove ROI ${uid}`)
        const feature = this[_drawingSource].getFeatureById(uid)

        if (feature) {
          this[_features].remove(feature)
          return
        }

        /**
         * If failed to draw/cache feature in drawing source, call onFailure
         * to avoid trash of broken annotations
         */
        this[_annotationManager].onFailure(uid)
      }

      /**
       * Remove all annotated regions of interest.
       */
      removeAllROIs () {
        console.info('remove all ROIs')
        this[_features].clear()
      }

      /**
       * Hide annotated regions of interest.
       */
      hideROIs () {
        console.info('hide ROIs')
        this[_drawingLayer].setVisible(false)
        this[_annotationManager].setVisible(false)
      }

      /**
       * Show annotated regions of interest.
       */
      showROIs () {
        console.info('show ROIs')
        this[_drawingLayer].setVisible(true)
        this[_annotationManager].setVisible(true)
      }

      /**
       * Whether annotated regions of interest are currently visible.
       *
       * @return {boolean}
       */
      get areROIsVisible () {
        return this[_drawingLayer].getVisible()
      }

      /**
       * Add annotation groups.
       *
       * @param {metadata.MicroscopyBulkSimpleAnnotations} metadata - Metadata of a
       * DICOM Microscopy Simple Bulk Annotations instance
       */
      addAnnotationGroups (metadata) {
        const refImage = this[_pyramid].metadata[0]
        if (refImage.FrameOfReferenceUID !== metadata.FrameOfReferenceUID) {
          throw new Error(
            'Microscopy Bulk Simple Annotation instances must have the same ' +
            'Frame of Reference UID as the corresponding source images.'
          )
        }
        console.info(
          'add annotation groups of Microscopy Bulk Simple Annotation instances ' +
          `of series "${metadata.SeriesInstanceUID}"`
        )

        const defaultAnnotationGroupStyle = {
          opacity: 1.0,
          color: '#027ea3'
        }

        metadata.AnnotationGroupSequence.forEach((item, index) => {
          const annotationGroupUID = item.AnnotationGroupUID
          const algorithm = item.AnnotationGroupAlgorithmIdentificationSequence[0]
          const annotationGroup = {
            annotationGroup: new AnnotationGroup({
              uid: annotationGroupUID,
              number: item.AnnotationGroupNumber,
              label: item.AnnotationGroupLabel,
              algorithmType: item.AnnotationGroupGenerationType,
              algorithmName: algorithm.AlgorithmName,
              propertyCategory: item.AnnotationPropertyCategoryCodeSequence[0],
              propertyType: item.AnnotationPropertyTypeCodeSequence[0],
              studyInstanceUID: metadata.StudyInstanceUID,
              seriesInstanceUID: metadata.SeriesInstanceUID,
              sopInstanceUIDs: [metadata.SOPInstanceUID]
            }),
            style: { ...defaultAnnotationGroupStyle },
            defaultStyle: defaultAnnotationGroupStyle,
            metadata: metadata
          }

          if (item.GraphicType === 'POLYLINE') {
            /*
             * We represent graphics as centroid points, but it's unclear whether
             * the centroid of a polyline would be meaningful
             */
            console.warn(
              `skip annotation group "${annotationGroupUID}" ` +
              'with Graphic Type POLYLINE'
            )
            return
          }

          // We need to bind those variables to constants for the loader function
          const client = this[_options].client
          const pyramid = this[_pyramid].metadata

          /**
           * In the loader function "this" is bound to the vector source.
           */
          function loader (extent, resolution, projection, success, failure) {
            // TODO: figure out how to use "loader" with bbox or tile "strategy"?
            const index = annotationGroup.annotationGroup.number - 1
            const metadataItem = annotationGroup.metadata.AnnotationGroupSequence[index]
            /**
             * Bulkdata may not be available, since it's possible that all information
             * has been included into the metadata by value as InlineBinary. It must
             * only be provided if information has been included by reference as
             * BulkDataURI.
             */
            const bulkdataReferences = annotationGroup.metadata.bulkdataReferences
            let bulkdataItem
            if (bulkdataReferences.AnnotationGroupSequence != null) {
              bulkdataItem = bulkdataReferences.AnnotationGroupSequence[index]
            }

            const numberOfAnnotations = Number(metadataItem.NumberOfAnnotations)
            const graphicType = metadataItem.GraphicType
            const coordinateDimensionality = _getCoordinateDimensionality(
              metadataItem
            )
            const commonZCoordinate = _getCommonZCoordinate(metadataItem)

            const features = this.getFeatures()
            if (features.length > 0) {
              success(features)
              return
            }

            // TODO: Only fetch measurements if required.
            const promises = [
              _fetchGraphicData({ metadataItem, bulkdataItem, client }),
              _fetchGraphicIndex({ metadataItem, bulkdataItem, client }),
              _fetchMeasurements({ metadataItem, bulkdataItem, client })
            ]
            Promise.all(promises).then(retrievedBulkdata => {
              const graphicData = retrievedBulkdata[0]
              const graphicIndex = retrievedBulkdata[1]
              const measurements = retrievedBulkdata[2]

              for (let i = 0; i < numberOfAnnotations; i++) {
                const point = _getCentroid(
                  graphicType,
                  graphicData,
                  graphicIndex,
                  coordinateDimensionality,
                  commonZCoordinate,
                  i,
                  numberOfAnnotations
                )
                const coordinates = _scoord3dCoordinates2geometryCoordinates(
                  point,
                  pyramid
                )
                const feature = new Feature({
                  geometry: new PointGeometry(coordinates)
                })
                const properties = {}
                measurements.forEach(item => {
                  const name = item.name
                  const key = `${name.CodingSchemeDesignator}${name.CodeValue}`
                  const value = item.values[i]
                  properties[key] = value
                })
                feature.setProperties(properties)
                feature.setId(i + 1)
                features.push(feature)
              }

              console.info(
                `add n=${features.length} annotations ` +
                `for annotation group "${annotationGroupUID}"`
              )
              this.addFeatures(features)
              const properties = {}
              measurements.forEach(item => {
                const name = item.name
                const key = `${name.CodingSchemeDesignator}${name.CodeValue}`
                const value = quantileSeq(
                  [...item.values],
                  [0, 0.015, 0.25, 0.5, 0.75, 0.95, 1]
                )
                properties[key] = value
              })
              this.setProperties(properties)
              success(features)
            }).catch(error => {
              console.error(error)
              failure()
            })
          }

          const source = new VectorSource({
            loader,
            wrapX: false,
            rotateWithView: true,
            overlaps: false
          })
          source.on('featuresloadstart', (event) => {
            const container = this[_map].getTargetElement()
            publish(container, EVENT.LOADING_STARTED)
          })
          source.on('featuresloadend', (event) => {
            const container = this[_map].getTargetElement()
            publish(container, EVENT.LOADING_ENDED)
          })
          source.on('featuresloaderror', (event) => {
            const container = this[_map].getTargetElement()
            publish(container, EVENT.LOADING_ENDED)
          })

          /*
           * TODO: Determine optimal sizes based on number of zoom levels and
           * number of objects, and zoom factor between levels.
           * Use style variable(s) that can subsequently be updated.
           */
          const style = {
            symbol: {
              symbolType: 'circle',
              size: [
                'interpolate',
                ['linear'],
                ['zoom'],
                1,
                2,
                this[_pyramid].metadata.length,
                15
              ],
              color: annotationGroup.style.color,
              opacity: annotationGroup.style.opacity
            }
          }
          annotationGroup.layer = new PointsLayer({
            source,
            style,
            disableHitDetection: true
          })
          annotationGroup.layer.setVisible(false)

          this[_map].addLayer(annotationGroup.layer)
          this[_annotationGroups][annotationGroupUID] = annotationGroup
        })
      }

      /**
       * Remove an annotation group.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       */
      removeAnnotationGroup (annotationGroupUID) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot remove annotation group. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        console.info(`remove annotation group ${annotationGroupUID}`)
        this[_map].removeLayer(annotationGroup.layer)
        annotationGroup.layer.dispose()
        delete this[_annotationGroups][annotationGroupUID]
      }

      /**
       * Remove all annotation groups.
       */
      removeAllAnnotationGroups () {
        Object.keys(this[_annotationGroups]).forEach(annotationGroupUID => {
          this.removeAnnotationGroup(annotationGroupUID)
        })
      }

      /**
       * Show an annotation group.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       * @param {Object} styleOptions
       * @param {number} styleOptions.measurement - Selected measurement for colorizing annotations
       */
      showAnnotationGroup (annotationGroupUID, styleOptions = {}) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot show annotation group. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        console.info(`show annotation group ${annotationGroupUID}`)
        this.setAnnotationGroupStyle(annotationGroupUID, styleOptions)
        annotationGroup.layer.setVisible(true)
      }

      /**
       * Hide an annotation group.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       */
      hideAnnotationGroup (annotationGroupUID) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot hide annotation group. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        console.info(`hide annotation group ${annotationGroupUID}`)
        annotationGroup.layer.setVisible(false)
      }

      /**
       * Is annotation group visible.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       */
      isAnnotationGroupVisible (annotationGroupUID) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot determine if annotation group is visible. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        return annotationGroup.layer.getVisible()
      }

      /**
       * Set style of an annotation group.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       * @param {Object} styleOptions - Style options
       * @param {number} styleOptions.opacity - Opacity
       * @param {number} styleOptions.measurement - Selected measurement for colorizing annotations
       */
      setAnnotationGroupStyle (annotationGroupUID, styleOptions = {}) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot set style of annotation group. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        if (styleOptions.opacity != null) {
          annotationGroup.style.opacity = styleOptions.opacity
          annotationGroup.layer.setOpacity(styleOptions.opacity)
        }

        const source = annotationGroup.layer.getSource()
        const name = styleOptions.measurement
        if (name) {
          const key = `${name.CodingSchemeDesignator}${name.CodeValue}`
          const properties = source.getProperties()
          if (properties[key]) {
            const colormap = createColormap({
              name: ColormapNames.VIRIDIS,
              bins: 50
            })
            const style = {
              symbol: {
                symbolType: 'circle',
                size: [
                  'interpolate',
                  ['linear'],
                  ['zoom'],
                  1,
                  2,
                  this[_pyramid].metadata.length,
                  15
                ],
                opacity: annotationGroup.style.opacity
              }
            }
            Object.assign(
              style,
              _getColorPaletteStyleForPointLayer({
                name: key,
                minValue: properties[key][0],
                maxValue: properties[key][properties[key].length - 2],
                colormap
              })
            )
            const newLayer = new PointsLayer({
              source,
              style,
              disableHitDetection: true
            })
            this[_map].addLayer(newLayer)
            this[_map].removeLayer(annotationGroup.layer)
            annotationGroup.layer.dispose()
            annotationGroup.layer = newLayer
          }
        } else {
          const style = {
            symbol: {
              symbolType: 'circle',
              size: [
                'interpolate',
                ['linear'],
                ['zoom'],
                1,
                2,
                this[_pyramid].metadata.length,
                15
              ],
              color: annotationGroup.style.color,
              opacity: annotationGroup.style.opacity
            }
          }
          const newLayer = new PointsLayer({
            source,
            style,
            disableHitDetection: true
          })
          this[_map].addLayer(newLayer)
          this[_map].removeLayer(annotationGroup.layer)
          annotationGroup.layer.dispose()
          annotationGroup.layer = newLayer
        }
      }

      /**
       * Get default style of an annotation group.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       *
       * @returns {Object} - Default style settings
       */
      getAnnotationGroupDefaultStyle (annotationGroupUID) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot get default style of annotation group. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        return {
          opacity: annotationGroup.defaultStyle.opacity,
          color: annotationGroup.defaultStyle.color
        }
      }

      /**
       * Get style of an annotation group.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       *
       * @returns {Object} - Style settings
       */
      getAnnotationGroupStyle (annotationGroupUID) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot get style of annotation group. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        return {
          opacity: annotationGroup.style.opacity,
          color: annotationGroup.style.color
        }
      }

      /**
       * Get all annotation groups.
       *
       * @returns {annotation.AnnotationGroup[]}
       */
      getAllAnnotationGroups () {
        const groups = []
        for (const annotationGroupUID in this[_annotationGroups]) {
          groups.push(this[_annotationGroups][annotationGroupUID].annotationGroup)
        }
        return groups
      }

      /**
       * Get annotation group metadata.
       *
       * @param {string} annotationGroupUID - Unique identifier of an annotation group
       *
       * @returns {metadata.MicroscopyBulkSimpleAnnotations} - Metadata of DICOM
       * Microscopy Bulk Simple Annotations instance
       */
      getAnnotationGroupMetadata (annotationGroupUID) {
        if (!(annotationGroupUID in this[_annotationGroups])) {
          throw new Error(
            'Cannot get metadata of annotation group. ' +
            `Could not find annotation group "${annotationGroupUID}".`
          )
        }
        const annotationGroup = this[_annotationGroups][annotationGroupUID]
        return annotationGroup.metadata
      }

      /**
       * Add segments.
       *
       * @param {metadata.Segmentation[]} metadata - Metadata of one or more DICOM Segmentation instances
       */
      addSegments (metadata) {
        if (metadata.length === 0) {
          throw new Error(
            'Metadata of Segmentation instances needs to be provided to ' +
            'add segments.'
          )
        }
        const refSegmentation = metadata[0]
        const refImage = this[_pyramid].metadata[0]
        metadata.forEach(instance => {
          if (
            instance.TotalPixelMatrixColumns === undefined ||
            instance.TotalPixelMatrixRows === undefined
          ) {
            throw new Error(
              'Segmentation instances must contain attributes ' +
              '"Total Pixel Matrix Rows" and "Total Pixel Matrix Columns".'
            )
          }
          if (refImage.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
            throw new Error(
              'Segmentation instances must have the same Frame of Reference UID ' +
              'as the corresponding source images.'
            )
          }
          if (refSegmentation.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
            throw new Error(
              'Segmentation instances must all have same Frame of Reference UID.'
            )
          }
          if (refSegmentation.SeriesInstanceUID !== instance.SeriesInstanceUID) {
            throw new Error(
              'Segmentation instances must all have same Series Instance UID.'
            )
          }
          if (
            refSegmentation.SegmentSequence.length !==
            instance.SegmentSequence.length
          ) {
            throw new Error(
              'Segmentation instances must all contain the same number of items ' +
              'in the Segment Sequence.'
            )
          }
        })
        console.info(
          'add segments of Segmentation instances of series ' +
          `"${refSegmentation.SeriesInstanceUID}"`
        )

        const pyramid = _computeImagePyramid({ metadata })
        const fittedPyramid = _fitImagePyramid(pyramid, this[_pyramid])

        const tileGrid = new TileGrid({
          extent: fittedPyramid.extent,
          origins: fittedPyramid.origins,
          resolutions: fittedPyramid.resolutions,
          sizes: fittedPyramid.gridSizes,
          tileSizes: fittedPyramid.tileSizes
        })

        let minStoredValue = 0
        let maxStoredValue = 255
        if (refSegmentation.SegmentationType === 'BINARY') {
          minStoredValue = 0
          maxStoredValue = 1
        }

        refSegmentation.SegmentSequence.forEach((item, index) => {
          const segmentNumber = Number(item.SegmentNumber)
          console.info(`add segment # ${segmentNumber}`)
          let segmentUID = _generateUID({
            value: refSegmentation.SOPInstanceUID + segmentNumber.toString()
          })
          if (item.TrackingUID != null) {
            segmentUID = item.TrackingUID
          }

          const colormap = createColormap({
            name: ColormapNames.VIRIDIS,
            bins: Math.pow(2, 8)
          })

          const minZoomLevel = fittedPyramid.metadata.findIndex(item => {
            return item !== undefined
          })
          let maxZoomLevel = fittedPyramid.metadata.findIndex((item, index) => {
            return index > minZoomLevel && item === undefined
          })
          if (maxZoomLevel < 0) {
            maxZoomLevel = fittedPyramid.metadata.length - 1
          } else if (maxZoomLevel > 0) {
            maxZoomLevel = maxZoomLevel - 1
          }

          const defaultSegmentStyle = {
            opacity: 0.75,
            paletteColorLookupTable: buildPaletteColorLookupTable({
              data: colormap,
              firstValueMapped: 0
            })
          }

          const segment = {
            segment: new Segment({
              uid: segmentUID,
              number: segmentNumber,
              label: item.SegmentLabel,
              algorithmType: item.SegmentAlgorithmType,
              algorithmName: item.SegmentAlgorithmName || '',
              propertyCategory: item.SegmentedPropertyCategoryCodeSequence[0],
              propertyType: item.SegmentedPropertyTypeCodeSequence[0],
              studyInstanceUID: refSegmentation.StudyInstanceUID,
              seriesInstanceUID: refSegmentation.SeriesInstanceUID,
              sopInstanceUIDs: pyramid.metadata.map(element => {
                return element.SOPInstanceUID
              })
            }),
            pyramid,
            style: { ...defaultSegmentStyle },
            defaultStyle: defaultSegmentStyle,
            overlay: new Overlay({
              element: document.createElement('div'),
              offset: [5 + 5 * index + 2, 5]
            }),
            minStoredValue,
            maxStoredValue,
            minZoomLevel,
            maxZoomLevel,
            loaderParams: {
              pyramid: fittedPyramid,
              client: this[_options].client,
              channel: segmentNumber
            },
            hasLoader: false
          }

          const source = new DataTileSource({
            tileGrid: tileGrid,
            projection: this[_projection],
            wrapX: false,
            bandCount: 1,
            interpolate: true
          })
          source.on('tileloaderror', (event) => {
            console.error(`error loading tile of segment "${segmentUID}"`, event)
          })

          const [windowCenter, windowWidth] = createWindow(
            minStoredValue,
            maxStoredValue
          )
          segment.layer = new TileLayer({
            source,
            extent: this[_pyramid].extent,
            visible: false,
            opacity: 0.9,
            preload: this[_options].preload ? 1 : 0,
            transition: 0,
            style: _getColorPaletteStyleForTileLayer({
              windowCenter,
              windowWidth,
              colormap: segment.style.paletteColorLookupTable.data
            }),
            useInterimTilesOnError: false,
            cacheSize: this[_options].tilesCacheSize
          })
          segment.layer.on('error', (event) => {
            console.error(`error rendering segment "${segmentUID}"`, event)
          })

          this[_map].addLayer(segment.layer)

          this[_segments][segmentUID] = segment
        })
      }

      /**
       * Remove a segment.
       *
       * @param {string} segmentUID - Unique tracking identifier of a segment
       */
      removeSegment (segmentUID) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            `Cannot remove segment. Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]
        this[_map].removeLayer(segment.layer)
        segment.layer.dispose()
        this[_map].removeOverlay(segment.overlay)
        delete this[_segments][segmentUID]
      }

      /**
       * Remove all segments.
       */
      removeAllSegments () {
        Object.keys(this[_segments]).forEach(segmentUID => {
          this.removeSegment(segmentUID)
        })
      }

      /**
       * Show a segment.
       *
       * @param {string} segmentUID - Unique tracking identifier of a segment
       * @param {Object} [styleOptions]
       * @param {number} [styleOptions.opacity] - Opacity
       */
      showSegment (segmentUID, styleOptions = {}) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            `Cannot show segment. Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]
        console.info(`show segment ${segmentUID}`)

        const container = this[_map].getTargetElement()
        if (container && !segment.hasLoader) {
          const loader = _createTileLoadFunction({
            targetElement: container,
            iccProfiles: [],
            ...segment.loaderParams
          })
          const source = segment.layer.getSource()
          source.setLoader(loader)
        }

        const view = this[_map].getView()
        const currentZoomLevel = view.getZoom()
        if (
          currentZoomLevel < segment.minZoomLevel ||
          currentZoomLevel > segment.maxZoomLevel
        ) {
          view.animate({ zoom: segment.minZoomLevel })
        }

        segment.layer.setVisible(true)
        this.setSegmentStyle(segmentUID, styleOptions)
      }

      /**
       * Hide a segment.
       *
       * @param {string} segmentUID - Unique tracking identifier of a segment
       */
      hideSegment (segmentUID) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            `Cannot hide segment. Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]
        console.info(`hide segment ${segmentUID}`)
        segment.layer.setVisible(false)
        this[_map].removeOverlay(segment.overlay)
      }

      /**
       * Determine if segment is visible.
       *
       * @param {string} segmentUID - Unique tracking identifier of a segment
       * @returns {boolean}
       */
      isSegmentVisible (segmentUID) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            'Cannot determine if segment is visible. ' +
            `Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]
        return segment.layer.getVisible()
      }

      /**
       * Set the style of a segment.
       *
       * @param {string} segmentUID - Unique tracking identifier of segment
       * @param {Object} styleOptions - Style options
       * @param {number} [styleOptions.opacity] - Opacity
       */
      setSegmentStyle (segmentUID, styleOptions = {}) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            'Cannot set style of segment. ' +
            `Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]

        if (styleOptions.opacity != null) {
          segment.style.opacity = styleOptions.opacity
          segment.layer.setOpacity(styleOptions.opacity)
        }

        let title = segment.segment.propertyType.CodeMeaning
        const padding = Math.round((16 - title.length) / 2)
        title = title.padStart(title.length + padding)
        title = title.padEnd(title.length + 2 * padding)
        const overlayElement = segment.overlay.getElement()
        overlayElement.innerHTML = title
        overlayElement.style = {}
        overlayElement.style.display = 'flex'
        overlayElement.style.flexDirection = 'column'
        overlayElement.style.justifyContent = 'center'
        overlayElement.style.padding = '4px'
        overlayElement.style.backgroundColor = 'rgba(255, 255, 255, .5)'
        overlayElement.style.borderRadius = '4px'
        overlayElement.style.margin = '1px'
        overlayElement.style.color = 'black'
        overlayElement.style.fontWeight = '600'
        overlayElement.style.fontSize = '12px'
        overlayElement.style.textAlign = 'center'

        const canvas = document.createElement('canvas')
        const context = canvas.getContext('2d')
        const height = 30
        const width = 15
        context.canvas.height = height
        context.canvas.width = width

        const colors = segment.style.paletteColorLookupTable.data
        for (let j = 0; j < colors.length; j++) {
          const color = colors[colors.length - j - 1]
          const r = color[0]
          const g = color[1]
          const b = color[2]
          context.fillStyle = `rgb(${r}, ${g}, ${b})`
          context.fillRect(0, height / colors.length * j, width, 1)
        }
        overlayElement.appendChild(canvas)

        const parentElement = overlayElement.parentNode
        parentElement.style.display = 'inline'

        this[_map].addOverlay(segment.overlay)
      }

      /**
       * Get the default style of a segment.
       *
       * @param {string} segmentUID - Unique tracking identifier of segment
       *
       * @returns {Object} Default style settings
       */
      getSegmentDefaultStyle (segmentUID) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            'Cannot get default style of segment. ' +
            `Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]
        return {
          opacity: segment.defaultStyle.opacity,
          paletteColorLookupTable: segment.defaultStyle.paletteColorLookupTable
        }
      }

      /**
       * Get the style of a segment.
       *
       * @param {string} segmentUID - Unique tracking identifier of segment
       *
       * @returns {Object} Style settings
       */
      getSegmentStyle (segmentUID) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            'Cannot get style of segment. ' +
            `Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]
        return {
          opacity: segment.style.opacity,
          paletteColorLookupTable: segment.style.paletteColorLookupTable
        }
      }

      /**
       * Get image metadata for a segment.
       *
       * @param {string} segmentUID - Unique tracking identifier of segment
       *
       * @returns {metadata.Segmentation[]} Metadata of DICOM Segmentation instances
       */
      getSegmentMetadata (segmentUID) {
        if (!(segmentUID in this[_segments])) {
          throw new Error(
            'Cannot get image metadata of segment. ' +
            `Could not find segment "${segmentUID}".`
          )
        }
        const segment = this[_segments][segmentUID]
        return segment.pyramid.metadata
      }

      /**
       * Get all segments.
       *
       * @return {segment.Segment[]}
       */
      getAllSegments () {
        const segments = []
        for (const segmentUID in this[_segments]) {
          segments.push(this[_segments][segmentUID].segment)
        }
        return segments
      }

      /**
       * Add parameter mappings.
       *
       * @param {metadata.ParametricMap[]} metadata - Metadata of one or more DICOM Parametric Map instances
       */
      addParameterMappings (metadata) {
        if (metadata.length === 0) {
          throw new Error(
            'Metadata of Parametric Map instances needs to be provided to ' +
            'add mappings.'
          )
        }

        const refImage = this[_pyramid].metadata[0]
        const refParametricMap = metadata[0]
        if (refParametricMap.ContentLabel !== 'HEATMAP') {
          console.warn(
            'skip mappings because value of "Content Label" attribute of ' +
            'Parametric Map instances is not "HEATMAP"'
          )
          return
        }

        metadata.forEach(instance => {
          if (
            instance.TotalPixelMatrixColumns === undefined ||
            instance.TotalPixelMatrixRows === undefined
          ) {
            throw new Error(
              'Parametric Map instances must contain attributes ' +
              '"Total Pixel Matrix Rows" and "Total Pixel Matrix Columns".'
            )
          }
          if (refImage.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
            throw new Error(
              'Parametric Map instances must have the same Frame of Reference UID ' +
              'as the corresponding source images.'
            )
          }
          if (refParametricMap.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
            throw new Error(
              'Parametric Map instances must all have same Frame of Reference UID.'
            )
          }
          if (refParametricMap.SeriesInstanceUID !== instance.SeriesInstanceUID) {
            throw new Error(
              'Parametric Map instances must all have same Series Instance UID.'
            )
          }
        })
        console.info(
          'add mappings of Parametric Map instances of series ' +
          `"${refParametricMap.SeriesInstanceUID}"`
        )

        const pyramid = _computeImagePyramid({ metadata })
        const fittedPyramid = _fitImagePyramid(pyramid, this[_pyramid])

        const tileGrid = new TileGrid({
          extent: fittedPyramid.extent,
          origins: fittedPyramid.origins,
          resolutions: fittedPyramid.resolutions,
          sizes: fittedPyramid.gridSizes,
          tileSizes: fittedPyramid.tileSizes
        })

        const refInstance = pyramid.metadata[0]

        const sharedFuncGroup = refInstance.SharedFunctionalGroupsSequence[0]
        const frameVOILUT = sharedFuncGroup.FrameVOILUTSequence[0]
        if (frameVOILUT === undefined) {
          throw new Error(
            'The Parametric Map image does not specify a shared frame ' +
            'Value of Interest (VOI) lookup table (LUT).'
          )
        }
        const windowCenter = frameVOILUT.WindowCenter
        const windowWidth = frameVOILUT.WindowWidth

        const { mappingNumberToDescriptions } = _groupFramesPerMapping(refInstance)

        let index = 0
        for (const mappingNumber in mappingNumberToDescriptions) {
          const mappingDescriptions = mappingNumberToDescriptions[mappingNumber]
          const refItem = mappingDescriptions[0]
          const mappingLabel = refItem.LUTLabel
          const mappingExplanation = refItem.LUTExplanation
          let mappingUID = _generateUID({
            value: refInstance.SOPInstanceUID + mappingLabel
          })
          if (refItem.TrackingUID != null) {
            mappingUID = refItem.TrackingUID
          }

          const range = [NaN, NaN]
          mappingDescriptions.forEach((item, i) => {
            if (item.TrackingUID != null) {
              if (item.TrackingUID !== mappingUID) {
                throw new Error(
                  `Item #${i + 1} of Real World Value Mapping Sequence ` +
                  `of frame #${index + 1} has unexpected Tracking UID. ` +
                  'All items must have the same unique identifier value.'
                )
              }
            }
            let firstValueMapped = item.RealWorldValueFirstValueMapped
            let lastValueMapped = item.RealWorldValueLastValueMapped
            if (firstValueMapped === undefined && lastValueMapped === undefined) {
              firstValueMapped = item.DoubleFloatRealWorldValueFirstValueMapped
              lastValueMapped = item.DoubleFloatRealWorldValueLastValueMapped
            }
            const intercept = item.RealWorldValueIntercept
            const slope = item.RealWorldValueSlope
            const lowerBound = firstValueMapped * slope + intercept
            const upperBound = lastValueMapped * slope + intercept
            if (i === 0) {
              range[0] = lowerBound
              range[1] = upperBound
            } else {
              range[0] = Math.min(range[0], lowerBound)
              range[1] = Math.max(range[1], upperBound)
            }
          })

          // TODO: include real world values in legend
          if (isNaN(range[0]) || isNaN(range[1])) {
            throw new Error('Could not determine range of real world values.')
          }

          let colormap
          const isFloatPixelData = refInstance.BitsAllocated > 16
          let minStoredValue = 0
          let maxStoredValue = Math.pow(2, refInstance.BitsAllocated) - 1
          if (isFloatPixelData) {
            minStoredValue = -(Math.pow(2, refInstance.BitsAllocated) - 1) / 2
            maxStoredValue = (Math.pow(2, refInstance.BitsAllocated) - 1) / 2
          }
          if (refInstance.PixelPresentation === 'MONOCHROME') {
            colormap = createColormap({
              name: ColormapNames.MAGMA,
              bins: Math.pow(2, 8)
            })
          } else {
            if (range[0] < 0 && range[1] > 0) {
              colormap = createColormap({
                name: ColormapNames.BLUE_RED,
                bins: Math.pow(2, 8)
              })
            } else {
              colormap = createColormap({
                name: ColormapNames.HOT,
                bins: Math.pow(2, 8)
              })
            }
          }

          const minZoomLevel = fittedPyramid.metadata.findIndex(item => {
            return item !== undefined
          })
          let maxZoomLevel = fittedPyramid.metadata.findIndex((item, index) => {
            return index > minZoomLevel && item === undefined
          })
          if (maxZoomLevel < 0) {
            maxZoomLevel = fittedPyramid.metadata.length - 1
          } else if (maxZoomLevel > 0) {
            maxZoomLevel = maxZoomLevel - 1
          }

          const defaultMappingStyle = {
            opacity: 1.0,
            limitValues: [
              Math.ceil(windowCenter - windowWidth / 2),
              Math.floor(windowCenter + windowWidth / 2)
            ],
            paletteColorLookupTable: buildPaletteColorLookupTable({
              data: colormap,
              firstValueMapped: 0
            })
          }

          const mapping = {
            mapping: new ParameterMapping({
              uid: mappingUID,
              number: mappingNumber,
              label: mappingLabel,
              description: mappingExplanation,
              studyInstanceUID: refInstance.StudyInstanceUID,
              seriesInstanceUID: refInstance.SeriesInstanceUID,
              sopInstanceUIDs: pyramid.metadata.map(element => {
                return element.SOPInstanceUID
              })
            }),
            pyramid: pyramid,
            overlay: new Overlay({
              element: document.createElement('div'),
              offset: [5 + 100 * index + 2, 5]
            }),
            style: { ...defaultMappingStyle },
            defaultStyle: defaultMappingStyle,
            minStoredValue,
            maxStoredValue,
            minZoomLevel,
            maxZoomLevel,
            loaderParams: {
              pyramid: fittedPyramid,
              client: this[_options].client,
              channel: mappingNumber
            },
            hasLoader: false
          }

          const source = new DataTileSource({
            tileGrid: tileGrid,
            projection: this[_projection],
            wrapX: false,
            bandCount: 1,
            interpolate: true
          })
          source.on('tileloaderror', (event) => {
            console.error(`error loading tile of mapping "${mappingUID}"`, event)
          })

          mapping.layer = new TileLayer({
            source,
            extent: this[_pyramid].extent,
            projection: this[_projection],
            visible: false,
            opacity: 1,
            preload: this[_options].preload ? 1 : 0,
            transition: 0,
            style: _getColorPaletteStyleForTileLayer({
              windowCenter,
              windowWidth,
              colormap: mapping.style.paletteColorLookupTable.data
            })
          })
          mapping.layer.on('error', (event) => {
            console.error(`error rendering mapping "${mappingUID}"`, event)
          })
          this[_map].addLayer(mapping.layer)

          this[_mappings][mappingUID] = mapping

          index += 1
        }
      }

      /**
       * Remove a parameter mapping.
       *
       * @param {string} mappingUID - Unique tracking identifier of a mapping
       */
      removeParameterMapping (mappingUID) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            `Cannot remove mapping. Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]
        this[_map].removeLayer(mapping.layer)
        mapping.layer.dispose()
        this[_map].removeOverlay(mapping.overlay)
        delete this[_mappings][mappingUID]
      }

      /**
       * Remove all parameter mappings.
       */
      removeAllParameterMappings () {
        Object.keys(this[_mappings]).forEach(mappingUID => {
          this.removeParameterMapping(mappingUID)
        })
      }

      /**
       * Show a parameter mapping.
       *
       * @param {string} mappingUID - Unique tracking identifier of a mapping
       * @param {Object} [styleOptions]
       * @param {number} [styleOptions.opacity] - Opacity
       * @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
       */
      showParameterMapping (mappingUID, styleOptions = {}) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            `Cannot show mapping. Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]
        console.info(`show mapping ${mappingUID}`)

        const container = this[_map].getTargetElement()
        if (container && !mapping.hasLoader) {
          const loader = _createTileLoadFunction({
            targetElement: container,
            iccProfiles: [],
            ...mapping.loaderParams
          })
          const source = mapping.layer.getSource()
          source.setLoader(loader)
        }

        const view = this[_map].getView()
        const currentZoomLevel = view.getZoom()
        if (
          currentZoomLevel < mapping.minZoomLevel ||
          currentZoomLevel > mapping.maxZoomLevel
        ) {
          view.animate({ zoom: mapping.minZoomLevel })
        }

        mapping.layer.setVisible(true)
        this.setParameterMappingStyle(mappingUID, styleOptions)
      }

      /**
       * Hide a parameter mapping.
       *
       * @param {string} mappingUID - Unique tracking identifier of a mapping
       */
      hideParameterMapping (mappingUID) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            `Cannot hide mapping. Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]
        console.info(`hide mapping ${mappingUID}`)
        mapping.layer.setVisible(false)
        this[_map].removeOverlay(mapping.overlay)
      }

      /**
       * Determine if parameter mapping is visible.
       *
       * @param {string} mappingUID - Unique tracking identifier of a mapping
       * @returns {boolean}
       */
      isParameterMappingVisible (mappingUID) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            'Cannot determine if mapping is visible. ' +
            `Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]
        return mapping.layer.getVisible()
      }

      /**
       * Set the style of a parameter mapping.
       *
       * @param {string} mappingUID - Unique tracking identifier of mapping
       * @param {Object} styleOptions
       * @param {number} [styleOptions.opacity] - Opacity
       * @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
       */
      setParameterMappingStyle (mappingUID, styleOptions = {}) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            'Cannot set style of mapping. ' +
            `Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]

        if (styleOptions.opacity != null) {
          mapping.style.opacity = styleOptions.opacity
          mapping.layer.setOpacity(styleOptions.opacity)
        }

        const styleVariables = {}
        if (styleOptions.limitValues != null) {
          mapping.style.limitValues = [
            Math.max(styleOptions.limitValues[0], mapping.minStoredValue),
            Math.min(styleOptions.limitValues[1], mapping.maxStoredValue)
          ]
          const [windowCenter, windowWidth] = createWindow(
            mapping.style.limitValues[0],
            mapping.style.limitValues[1]
          )
          styleVariables.windowCenter = windowCenter
          styleVariables.windowWidth = windowWidth
          mapping.layer.updateStyleVariables(styleVariables)
        }

        let title = mapping.mapping.label
        const padding = Math.round((16 - title.length) / 2)
        title = title.padStart(title.length + padding)
        title = title.padEnd(title.length + 2 * padding)
        const overlayElement = mapping.overlay.getElement()
        overlayElement.innerHTML = title
        overlayElement.style = {}
        overlayElement.style.display = 'flex'
        overlayElement.style.flexDirection = 'column'
        overlayElement.style.justifyContent = 'center'
        overlayElement.style.padding = '4px'
        overlayElement.style.backgroundColor = 'rgba(255, 255, 255, .5)'
        overlayElement.style.borderRadius = '4px'
        overlayElement.style.margin = '1px'
        overlayElement.style.color = 'black'
        overlayElement.style.fontWeight = '600'
        overlayElement.style.fontSize = '12px'
        overlayElement.style.textAlign = 'center'

        const canvas = document.createElement('canvas')
        const context = canvas.getContext('2d')
        const height = 30
        const width = 15
        context.canvas.height = height
        context.canvas.width = width

        const colors = mapping.style.paletteColorLookupTable.data
        for (let j = 0; j < colors.length; j++) {
          const color = colors[colors.length - j - 1]
          const r = color[0]
          const g = color[1]
          const b = color[2]
          context.fillStyle = `rgb(${r}, ${g}, ${b})`
          context.fillRect(0, height / colors.length * j, width, 1)
        }
        overlayElement.appendChild(canvas)

        const parentElement = overlayElement.parentNode
        parentElement.style.display = 'inline'

        this[_map].addOverlay(mapping.overlay)
      }

      /**
       * Get the default style of a parameter mapping.
       *
       * @param {string} mappingUID - Unique tracking identifier of mapping
       * @returns {Object} Default style Options
       */
      getParameterMappingDefaultStyle (mappingUID) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            'Cannot get default style of mapping. ' +
            `Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]
        return {
          opacity: mapping.defaultStyle.opacity,
          limitValues: mapping.defaultStyle.limitValues,
          paletteColorLookupTable: mapping.defaultStyle.paletteColorLookupTable
        }
      }

      /**
       * Get the style of a parameter mapping.
       *
       * @param {string} mappingUID - Unique tracking identifier of mapping
       * @returns {Object} Style Options
       */
      getParameterMappingStyle (mappingUID) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            'Cannot get style of mapping. ' +
            `Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]
        return {
          opacity: mapping.style.opacity,
          limitValues: mapping.style.limitValues,
          paletteColorLookupTable: mapping.style.paletteColorLookupTable
        }
      }

      /**
       * Get image metadata for a parameter mapping.
       *
       * @param {string} mappingUID - Unique tracking identifier of mapping
       *
       * @returns {metadata.ParametricMap[]} Metadata of DICOM Parametric Map
       * instances
       */
      getParameterMappingMetadata (mappingUID) {
        if (!(mappingUID in this[_mappings])) {
          throw new Error(
            'Cannot get image metadata of mapping. ' +
            `Could not find mapping "${mappingUID}".`
          )
        }
        const mapping = this[_mappings][mappingUID]
        return mapping.pyramid.metadata
      }

      /**
       * Get all parameter mappings.
       *
       * @return {mapping.ParameterMapping[]}
       */
      getAllParameterMappings () {
        const mappings = []
        for (const mappingUID in this[_mappings]) {
          mappings.push(this[_mappings][mappingUID].mapping)
        }
        return mappings
      }
    }

    /**
     * Static viewer for DICOM VL Whole Slide Microscopy Image instances
     * with Image Type other than VOLUME.
     *
     * @class
     * @abstract
     * @private
     */
    class _NonVolumeImageViewer {
      /**
       * @param {Object} options
       * @param {Object} options.client - A DICOMwebClient instance for interacting
       * with an origin server over HTTP.
       * @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
       * Metadata of DICOM VL Whole Slide Microscopy Image instances
       * @param {string} options.orientation - Orientation of the slide (vertical:
       * label on top, or horizontal: label on right side).
       * @param {number} [options.resizeFactor=1] - To which extent image should be
       * reduced in size (fraction).
       * @param {boolean} [options.includeIccProfile=false] - Whether ICC Profile
       * should be included for correction of image colors.
       */
      constructor (options) {
        // We also accept metadata in raw JSON format for backwards compatibility
        if (options.metadata.SOPClassUID != null) {
          this[_metadata] = options.metadata
        } else {
          this[_metadata] = options.metadata.map(instance => {
            return new VLWholeSlideMicroscopyImage({ metadata: instance })
          })
        }

        const imageFlavor = this[_metadata].ImageType[2]
        if (imageFlavor === 'VOLUME') {
          throw new Error('Viewer cannot render images of type VOLUME.')
        }

        const resizeFactor = options.resizeFactor ? options.resizeFactor : 1
        const height = this[_metadata].TotalPixelMatrixRows * resizeFactor
        const width = this[_metadata].TotalPixelMatrixColumns * resizeFactor
        const extent = [
          0, // min X
          -(height + 1), // min Y
          width, // max X
          -1 // max Y
        ]

        const imageLoadFunction = (image, src) => {
          console.info(`load ${imageFlavor} image`)
          const mediaType = 'image/png'
          const queryParams = {}
          if (resizeFactor !== 1) {
            queryParams.viewport = [width, height].join(',')
          }
          // We make this optional because ICC Profiles can be large and
          // their inclusion can result in significant overhead.
          if (options.includeIccProfile) {
            queryParams.iccprofile = 'yes'
          }
          const retrieveOptions = {
            studyInstanceUID: this[_metadata].StudyInstanceUID,
            seriesInstanceUID: this[_metadata].SeriesInstanceUID,
            sopInstanceUID: this[_metadata].SOPInstanceUID,
            mediaTypes: [{ mediaType }],
            queryParams: queryParams
          }
          options.client.retrieveInstanceRendered(retrieveOptions).then(
            (thumbnail) => {
              const blob = new Blob([thumbnail], { type: mediaType })// eslint-disable-line
              image.getImage().src = window.URL.createObjectURL(blob)
            }
          )
        }

        const projection = new Projection({
          code: 'DICOM',
          units: 'metric',
          extent: extent,
          getPointResolution: (pixelRes, point) => {
            /*
             * DICOM Pixel Spacing has millimeter unit while the projection has
             * meter unit.
             */
            const mmSpacing = getPixelSpacing(this[_metadata])[0]
            const spacing = mmSpacing / resizeFactor / 10 ** 3
            return pixelRes * spacing
          }
        })

        const source = new Static({
          imageExtent: extent,
          projection: projection,
          imageLoadFunction: imageLoadFunction,
          url: '' // will be set by imageLoadFunction()
        })

        this[_imageLayer] = new ImageLayer({ source: source })

        // The default rotation is 'horizontal' with the slide label on the right
        let rotation = _getRotation(this[_metadata])
        if (options.orientation === 'vertical') {
          // Rotate counterclockwise by 90 degrees to have slide label at the top
          rotation -= 90 * (Math.PI / 180)
        }

        const view = new View({
          center: getCenter(extent),
          rotation: rotation,
          projection: projection,
          extent: extent,
          smoothExtentConstraint: true,
          smoothResolutionConstraint: true,
          showFullExtent: true
        })

        // Creates the map with the defined layers and view and renders it.
        this[_map] = new Map({
          layers: [this[_imageLayer]],
          view: view,
          controls: [],
          keyboardEventTarget: document
        })

        view.fit(projection.getExtent(), { size: this[_map].getSize() })
      }

      /**
       * Clean up.
       *
       * Release allocated memory and clear the viewport.
       */
      cleanup () {}

      /**
       * Render the image in the specified viewport container.
       *
       * @param {Object} options - Rendering options.
       * @param {(string|HTMLElement)} options.container - HTML Element in which the viewer should be injected.
       */
      render ({ container }) {
        if (container == null) {
          console.error('container must be provided for rendering images')
          return
        }

        this[_map].setTarget(container)
        const view = this[_map].getView()
        const projection = view.getProjection()
        view.fit(projection.getExtent(), { size: this[_map].getSize() })

        this[_map].getInteractions().forEach((interaction) => {
          this[_map].removeInteraction(interaction)
        })
      }

      /**
       * DICOM metadata for the displayed VL Whole Slide Microscopy Image instances.
       *
       * @return {VLWholeSlideMicroscopyImage}
       */
      get imageMetadata () {
        return this[_metadata]
      }

      /**
       * Resize the viewer to fit the viewport.
       *
       * @returns {void}
       */
      resize () {
        this[_map].updateSize()
        if (this[_overviewMap]) {
          this[_overviewMap].getOverviewMap().updateSize()
        }
      }

      /**
       * Get the size of the viewport.
       *
       * @return {number[]}
       */
      get size () {
        return this[_map].getSize()
      }
    }

    /**
     * Static viewer for DICOM VL Whole Slide Microscopy Image instances
     * with Image Type OVERVIEW.
     *
     * @class
     * @memberof viewer
     */
    class OverviewImageViewer extends _NonVolumeImageViewer {
      /**
       * @param {Object} options
       * @param {Object} options.client - A DICOMwebClient instance for interacting
       * with an origin server over HTTP.
       * @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
       * Metadata of DICOM VL Whole Slide Microscopy Image instances
       * @param {string} [options.orientation='horizontal'] - Orientation of the
       * slide (vertical: label on top, or horizontal: label on right side).
       * @param {number} [options.resizeFactor=1] - To which extent image should be
       * reduced in size (fraction).
       * @param {boolean} [options.includeIccProfile=false] - Whether ICC Profile
       * should be included for correction of image colors.
       */
      constructor (options) {
        if (options.orientation === undefined) {
          options.orientation = 'horizontal'
        }
        super(options)
      }
    }

    /**
     * Static viewer for DICOM VL Whole Slide Microscopy Image instances
     * with Image Type LABEL.
     *
     * @class
     * @memberof viewer
     */
    class LabelImageViewer extends _NonVolumeImageViewer {
      /**
       * @param {Object} options - Options
       * @param {Object} options.client - A DICOMwebClient instance for interacting with an origin server over HTTP
       * @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
       * Metadata of DICOM VL Whole Slide Microscopy Image instances
       * @param {string} [options.orientation='vertical'] - Orientation of the
       * slide (vertical: label on top, or horizontal: label on right side)
       * @param {number} [options.resizeFactor=1] - To which extent image should be
       * reduced in size (fraction)
       * @param {boolean} [options.includeIccProfile=false] - Whether ICC Profile
       * should be included for correction of image colors
       */
      constructor (options) {
        if (options.orientation === undefined) {
          options.orientation = 'vertical'
        }
        super(options)
      }
    }

    export { LabelImageViewer, OverviewImageViewer, VolumeImageViewer }