PMA.UI Documentation by Pathomation

view/viewport.js

import { checkBrowserCompatibility, ajax } from "./helpers";
import {
    Events,
    ButtonLocations,
    Themes,
    ObjectiveResolutions,
    DefaultFillColor,
    AnnotationState,
    Controls as ControlTypes,
    Annotation,
} from "./definitions";
import {
    initialize,
    createProjection,
    createMainView,
    annotationTransform,
    getControlVisibility,
    createOverviewControl,
    setControlVisibility,
    setMapSizeClass,
    login,
    applyImageAdjustments,
    printObjectivesInZoomBar,
    setControlsConfiguration,
    getControlsConfiguration,
    parseJson,
    getFingerprint,
    updateProgress,
    findClosestObjectiveValue,
    drawScalebar,
    drawTitle,
    drawBarcode,
    drawOverview,
    getAnnotationsServer,
    getRenderingOptions,
    saveRenderingOptions,
} from "./viewportHelpers";
import { startMeasuring, createMeasureTooltip, stopMeasuring } from "./measure.js";
import { Overview } from "./controls/overview";
import { saveAs } from "file-saver";
import { ol } from "./definitionsOl";
import { fromCircle } from "ol/geom/Polygon";
import { default as olText } from "ol/style/Text";
import { Fill, Stroke } from "ol/style";
import { RotationControl } from "./controls/rotationControl";
import { unByKey } from "ol/Observable";
import { PmaMouseWheelZoom, MouseWheelZoomMode } from "./interactions/customMouseWheelZoom";
import { changeDpiBlob } from "./changeDpi";
import { Feature } from "ol";

/**
 * Receives pixel data and applies an image transformation to it
 * @callback PMA.UI.View.tileTransformer
 * @param {ImageData} pixels - Represents the underlying pixel data of an area of a canvas element
 */

/**
 * Viewport position
 * @typedef {Object} Viewport~position
 * @property {Array} center - The x and y coordinates of the center point
 * @property {number} zoom - The zoom level. Can be omitted if resolution is specified
 * @property {number} [resolution] - Can be omitted if zoom is specified
 * @property {number} [rotation=0]
 */

/**
 * Viewport Field of view
 * @typedef {Object} Viewport~fov
 * @property {Array} extent - The extent of the viewport [minx, miny, maxx, maxy]
 * @property {number} rotation - The rotation of the viewport
 * @property {bool} [constrainResolution] - Whether to contain the resolution to allowed values when fitting to extent
 * @property {Array} [channels] - The selected channels
 * @property {number} [layer=0] - The selected layer
 * @property {number} [timeframe=0] - The selected timeframe
 */

/**
 * Annotation display options
 * @typedef {Object} Viewport~annotationOptions
 * @property {boolean} [visible=true] - Whether or not to display the loaded annotations
 * @property {boolean} [labels=false] - Whether or not to render the text label of each annotation in the viewer
 * @property {boolean} [imageBaseUrl=""] - The base URL from which to load images
 * @property {number} [imageScale=NaN] - Scale factor for images
 * @property {boolean} [alwaysDisplayInMicrons=false] - Whether or not to automatically select the appropriate units for annotations (μm(^2) or mm(^2)) depending on the value
 * @property {boolean} [showMeasurements=true] - Whether to show the length and area of an annotation
 * @property {boolean} [loadAnnotationsByFingerprint=false] - Whether to load annotations based on image's fingerprint
 * @property {boolean} [currentUserOnly=false] - Whether to load annotations for the current user only or for everyone
 * @property {Array<string>} [contexts=[]] - Optional context filters
 * @property {Viewport~annotationsFilterCallback} [filter] - A function that takes a PMA.core annotation and returns true if the annotation should be loaded, otherwise false
 */

/**
 * Attribution display options
 * @typedef {Object} Viewport~attributionOptions
 * @property {string} html - The HTML contents to add inside the attribution container element
 * @property {string} [className="ol-attr"] - The CSS class to assign to the attribution container element
 */

/**
 * Image flip options
 * @typedef {Object} Viewport~flipOptions
 * @property {boolean} [horizontally=false] - Whether or not to flip the image horizontally
 * @property {boolean} [vertically=false] - Whether or not to flip the image vertically
 */

/**
 * Function that returns a file name to display in the viewer
 * @callback Viewport~filenameCallback
 * @param {Object} options
 * @param {string} options.serverUrl - The URL of the current PMA.core server
 * @param {string} options.fileName - The full path of the currently loaded image
 * @returns {string}
 */

/**
 * Function that takes a PMA.core annotation and returns true if the annotation should be loaded, otherwise false
 * @callback Viewport~annotationsFilterCallback
 * @param {Object} annotation - The annotation object to filter
 * @returns {bool}
 */

/**
 * A custom button to be added to the viewer
 * @typedef {Object} Viewport~customButton
 * @property {string} title - The title of the button
 * @property {string} content - The inner html of the button
 * @property {string} class - The class of the button
 * @property {ButtonLocations} [location=ButtonLocations.S] - The location in the viewport of the custom button
 * @property {function} callback - The callback to call when the button is clicked with this referring to the viewer
 */

/**
 * An annotation as returned by pma.core
 * @typedef Viewport~annotation
 * @property {Number} AnnotationID - The annotation id
 * @property {Number} LayerID - The layer id
 * @property {string} Geometry - The annotation geometry in wkt format
 * @property {string} [Notes] - Optional notes for the annotation
 * @property {string} [Classification] - Optional classification string (Necrosis, tumor etc)
 * @property {string} [Color] - Optional color
 * @property {string} [CreatedBy] - Optional created by string
 * @property {string} [UpdateInfo] - Optional update info
 * @property {string} [Updatedby] - Optional updated by info
 * @property {string} [FillColor] - Optional fill color
 * @property {Number} [Dimensions] - Optional dimensionality of the annotation
 * @property {string} [Context] - Optional context value of the annotation
 */

/**
 * An object for configuring the visible and collapse state of a control
 * @typedef Viewport~ControlConfiguration
 * @property {Controls} control - The control to configure
 * @property {boolean} visible - The visibility of the control
 * @property {boolean} collapsed - Whether the control is collapsed or not
 */

/**
 * An object for configuring the visible and collapse state of a control
 * @typedef Viewport~SnapshotCoordinates
 * @property {Integer} x - The x coordinate
 * @property {Integer} y - The y coordinate
 * @property {Integer} w - The width
 * @property {Integer} h - The height
 * @property {Number} scale - The scale of the current view in radians
 * @property {Number} rotation - The rotation of the current view in radians
 * @property {Viewport~flipOptions} flip - Whether the image is flipped
 */

/**
 * Annotation display options
 * @typedef {Object} Viewport~referenceImage
 * @property {string} src="pathomation.png" - Source of reference image to load
 * @property {Number} width=383 - Width of reference image
 * @property {Number} height=183 - Height of reference image
 * @property {string} [backgroundColor="#ffffff"] - Background color of viewport in css format
*/

/**
 * Channel rendering options
 * @typedef {Object} Viewport~ChannelRenderingOptions
 * @property {Number} index - Index of the channel
 * @property {string} name - Name of the channel
 * @property {string} color - Color of the channel
 * @property {string} defaultColor - Default color of the channel
 * @property {Array} clipping - Clipping of the channel
 * @property {Number} gamma - Gamma of the channel
*/

export
    /**
     * Creates a new viewer
     * @param {Object} options - Initialization properties
     * @param {string} options.caller - Downstream application identifier
     * @param {string|HTMLElement} options.element - The element that hosts the viewer. It can be either a valid CSS selector or an HTMLElement instance
     * @param {string} options.image - The path or UID of the image to load
     * @param {Array} options.serverUrls - An array of one or more PMA.core URLs to attempt to connect to. The viewer will loop through all the provided URLs until an accessible one is found.
     * @param {string} [options.username] - The PMA.core username to use to authenticate, in order to obtain a sessionID. If a username and password pair is provided, then a sessionID is not required.
     * @param {string} [options.password] - The PMA.core password to use to authenticate, in order to obtain a sessionID. If a username and password pair is provided, then a sessionID is not required.
     * @param {string} [options.sessionID] - The PMA.core sessionID to use to interact with PMA.core. If a sessionID is provided, then a username and password pair is not required.
     * @param {Number} [options.keyboardPanFactor=0.5] - A factor to calculate pan delta when pressing a keyboard arrow. The actual pan in pixels is calculated as keyboardPanFactor * viewportWidth.
     * @param {Themes} [options.theme="default"] - The theme to use
     * @param {boolean|Object} [options.overview=true] - Whether or not to display an overview map
     * @param {boolean} [options.overview.collapsed] - Whether or not to start the overview in collapsed state
     * @param {boolean} [options.overview.tracking] - Whether or not to start viewport movement tracking
     * @param {boolean|Object} [options.dimensions=true] - Whether or not to display the dimensions selector for images that have more than one channel, z-stack or timeframe
     * @param {boolean} [options.dimensions.collapsed] - Whether or not to start the dimensions selector in collapsed state
     * @param {Viewport~flipOptions} [options.flip] - Image flip options
     * @param {boolean|Object} [options.barcode=false] - Whether or not to display the image's barcode if it exists
     * @param {boolean} [options.barcode.collapsed=undefined] - Whether or not to start the barcode in collapsed state
     * @param {Number} [options.barcode.rotation=undefined] - Rotation in steps of 90 degrees
     * @param {boolean|Object} [options.loadingBar=true] - Whether or not to display a loading bar while the image is loading
     * @param {Viewport~position} [options.position] - The initial position of the viewport within the image
     * @param {boolean} [options.snapshot=false] - Whether or not to display a button that generates a snapshot image
     * @param {boolean} [options.rotationControl=true] - Whether or not to display rotation controls
     * @param {boolean} [options.rotationControl.collapsed] - Whether or not to start rotation controls in collapsed state
     * @param {boolean} [options.fullscreenControl=true] - Whether or not to add a full screen toggle button
     * @param {Viewport~annotationOptions} [options.annotations] - Annotation options
     * @param {Viewport~referenceImage} [options.referenceImage] - Reference image options that will load if no server url, server credentials and image path are provided
     * @param {Number} [options.digitalZoomLevels=0] - The number of digital zoom levels to add
     * @param {boolean} [options.scaleLine=true] - Whether or not to display a scale line when resolution information is available
     * @param {boolean} [options.colorAdjustments=false] - Whether or not to add a control that allows color adjustments
     * @param {boolean|Object} [options.annotationsLayers=true] - Whether or not to show server side annotation layers
     * @param {boolean} [options.annotationsLayers.collapsed=false] - Whether or not to show the annotation layers control collapsed
     * @param {boolean} [options.annotationsLayers.loadLayers=false] - Whether or not to show all the annotation layers control on slide load
     * @param {Object|boolean} [options.magnifier=false] - Whether or not to show the magnifier control
     * @param {Object|boolean} [options.magnifier.collapsed=undefined] - Whether or not to show the magnifier control in collapsed state
     * @param {Object} [options.grid] - Options for measurement grid
     * @param {Array<number>} [options.grid.size] - Grid cell width and height in micrometers
     * @param {string|Viewport~filenameCallback} [options.filename] - A string to display as the file name or a callback function. If no value is supplied, no file name is displayed.
     * @param {boolean|Viewport~attributionOptions}[options.attributions=undefined] - Whether or not to display Pathomation attribution in the viewer
     * @param {Array<Viewport~customButton>} [options.customButtons] - An array of one or more custom buttons to add to the viewer
     * @param {Viewport~fov} [options.fov] - A specified field of view to load
     * @param {boolean} [options.zoomSlider] - Whether or not to show the zoomSlider control
     * @param {function} [readyCallback] - Called when the slide has finished loading
     * @param {function} [failCallback] - Called when loading the slide failed
     * @tutorial 01-viewer-minimal
     * @tutorial 02-viewer-full
     * @tutorial 05-annotations
     * @tutorial 07-3rd-party-annotations
     */
    class Viewport {
    // options
    // readyCallback
    // failCallback
    // element
    // userInfo
    // imageInfo
    // image;
    // sessionID;
    // username;
    // password;
    // serverUrls
    // overviewControl
    // dimensionsControl
    // barcodeControl
    // colorAdjustmentsControl
    // layerSwitcher
    // zoomSliderControl
    // scaleLineControl
    // magnifierControl
    // filenameControl
    // snapShotControl
    // rotationControl
    // attributionControl
    // serviceUrl
    // imagesUrl
    // channelsString
    // imageAdjustments = { brightness: 0, contrast: 1, gamma: 1, tileTransformers: [] }
    // listeners
    // map
    // hiddenAnnotations
    // annotationsLayer
    // selectedTimeFrame
    // selectedLayer
    // mainLayer
    // measureLayer
    // gridLayer
    // measureDraw

    constructor(options, readyCallback, failCallback) {
        this.options = options;

        if (!this.options.hasOwnProperty("magnifier")) {
            this.options.magnifier = false;
        }

        if (!this.options.hasOwnProperty("colorAdjustments")) {
            this.options.colorAdjustments = false;
        }

        if (!this.options.hasOwnProperty("annotations")) {
            this.options.annotations = false;
        }

        if (!this.options.hasOwnProperty("fullscreenControl")) {
            this.options.fullscreenControl = true;
        }

        this.readyCallback = readyCallback;
        this.failCallback = failCallback;

        if (!this.options.flip) {
            this.options.flip = {
                horizontally: false,
                vertically: false,
            };
        }

        if (!checkBrowserCompatibility(options.element)) {
            return;
        }

        if (typeof options.caller !== "string" && this.image) {
            if (typeof this.failCallback === "function") {
                this.failCallback();
            }

            throw "Caller parameter not supplied";
        }

        if (typeof options.theme !== "string") {
            options.theme = "default";
        }

        if (options.element) {
            if (options.element instanceof HTMLElement) {
                this.element = options.element;
            } else if (typeof options.element == "string") {
                var el = document.querySelector(options.element);
                if (!el) {
                    throw "Invalid selector for element";
                } else {
                    this.element = el;
                }
            }
        } else {
            if (typeof this.failCallback === "function") {
                this.failCallback();
            }

            throw "No element provided";
        }

        this.userInfo = null;
        this.imageInfo = options.imageInfo;

        if (options.image) {
            this.image = options.image;
        } else if (!this.imageInfo) {
            if (!options.referenceImage || (!options.referenceImage.src || !options.referenceImage.width || !options.referenceImage.height)) {
                if (typeof this.failCallback === "function") {
                    this.failCallback();
                }

                throw "No image path, UID or reference image provided";
            }
        } else {
            this.image = this.imageInfo.Filename;
        }

        if (options.sessionID) {
            this.sessionID = options.sessionID;
        } else if (options.username || options.password) {
            this.username = options.username;
            this.password = options.password;

            if (!options.username || !options.password) {
                if (typeof this.failCallback === "function") {
                    this.failCallback();
                }

                throw "Username and/or password not povided";
            }
        } else {
            if (this.image) {
                if (typeof this.failCallback === "function") {
                    this.failCallback();
                }

                throw "SessionID or Username not provided. Cannot login.";
            }
        }

        if (
            options.serverUrls &&
            options.serverUrls instanceof Array &&
            options.serverUrls.length > 0
        ) {
            this.serverUrls = options.serverUrls;
        } else if (!this.imageInfo) {
            if (this.image) {
                if (typeof this.failCallback === "function") {
                    this.failCallback();
                }

                throw "Server URLs not provided or is not an array and no image info was given.";
            }
        }

        /**
         * The overview control
         * @type {Overview}
         * @public
         */
        this.overviewControl = null;

        /**
         * The dimension selector control
         * @type {DimensionSelector}
         * @public
         */
        this.dimensionsControl = null;

        /**
         * The barcode control
         * @type {AssociatedImage}
         * @public
         */
        this.barcodeControl = null;

        /**
         * The color adjustment control
         * @type {ColorAdjustment}
         * @public
         */
        this.colorAdjustmentsControl = null;

        /**
         * The rotation control
         * @type {RotationControl}
         * @public
         */
        this.rotationControl = null;

        /**
         * The server side annotation layers control
         * @type {LayerSwitch}
         * @public
         */
        this.layerSwitcher = null;

        /**
         * The mouse wheel interaction
         * @type {PmaMouseWheelZoom}
         * @public
         */
        this.mouseWheelInteraction = null

        /**
         * The measure draw interaction
         * @type {Draw}
         * @public
         */
        this.measureDraw = null;

        this.zoomSliderControl = null;
        this.scaleLineControl = null;
        this.magnifierControl = null;
        this.filenameControl = null;
        this.snapShotControl = null;
        this.attributionControl = null;
        this.serviceUrl = null;
        this.imagesUrl = null;
        this.channelsString = null;
        this.channelClippingString = null;
        this.channelColorString = null;
        this.channelGammaString = null;
        this.imageAdjustments = {
            brightness: 0,
            contrast: 1,
            gamma: 1,
            rgb: [1, 1, 1],
            tileTransformers: [],
        };
        if (this.options.loadingBar === undefined) {
            this.options.loadingBar = true;
        }

        if (this.options.annotationsLayers === undefined) {
            this.options.annotationsLayers = true;
        }

        // holds callbacks for events
        this.listeners = {};
        this.listeners[Events.DimensionsChanged] = [];
        this.listeners[Events.ViewChanged] = [];
        this.listeners[Events.TilesError] = [];
        this.listeners[Events.SlideLoadError] = [];
        this.listeners[Events.FilenameClick] = [];
        this.listeners[Events.AnnotationLayerChanged] = [];
        this.listeners[Events.MeasurementStop] = [];
        this.listeners[Events.ViewLoaded] = [];

        /**
         * The underlying {@link https://openlayers.org/en/latest/apidoc/ol.Map.html|OpenLayers map}
         * @public
         */
        this.map = null;

        this.debounceFn = function (func, delay) {
            return function (...args) {
                clearTimeout(this.timeoutId);
                this.timeoutId = setTimeout(() => func.apply(this, args), delay);
            }
        }

        this.debouncedSetRenderingOptions = this.debounceFn(saveRenderingOptions.bind(this), 1000);

        initialize.call(this);
    }

    /**
     * Calculates the area of an OpenLayers geometry
     * @param {ol.geom} geom - The geometry to calculate the area of
     * @returns {number} The area in squared microns or zero if resolution information is not available
     */
    calculateArea(geom) {
        if (this.imageInfo.MicrometresPerPixelX === 0) {
            return 0;
        }

        var pixelCoords = null;

        if (geom instanceof ol.geom.Polygon) {
            pixelCoords = geom.getCoordinates()[0];
        } else if (geom instanceof ol.geom.LineString) {
            ////pixelCoords = geom.getCoordinates();
            return 0;
        } else if (geom instanceof ol.geom.Circle) {
            pixelCoords = fromCircle(geom).getCoordinates()[0];
        } else if (geom instanceof ol.geom.GeometryCollection) {
            var self = this;
            var geometries = geom.getGeometries();
            var area = 0;
            for (var j = 0; j < geometries.length; j++) {
                area += self.calculateArea.call(self, geometries[j]);
            }

            return area;
        } else {
            return 0;
        }

        var transformed = [];
        for (var i = 0; i < pixelCoords.length; i++) {
            transformed.push([
                pixelCoords[i][0] * this.imageInfo.MicrometresPerPixelX,
                pixelCoords[i][1] * this.imageInfo.MicrometresPerPixelY,
            ]);
        }

        var tmpGeom = new ol.geom.Polygon([transformed]);
        var v = tmpGeom.getArea();
        tmpGeom = null;
        return v;
    }

    /**
     * Calculates the length of an OpenLayers geometry
     * @param {ol.geom} geom - The geometry to calculate the length of
     * @returns {number} The length in microns or zero if resolution information is not available
     */
    calculateLength(geom) {
        if (this.imageInfo.MicrometresPerPixelX === 0) {
            return 0;
        }

        var pixelCoords = null;

        if (geom instanceof ol.geom.Polygon) {
            pixelCoords = geom.getCoordinates()[0];
        } else if (geom instanceof ol.geom.LineString) {
            pixelCoords = geom.getCoordinates();
        } else if (geom instanceof ol.geom.Circle) {
            pixelCoords = fromCircle(geom).getCoordinates()[0];
        } else if (geom instanceof ol.geom.GeometryCollection) {
            var self = this;
            var geometries = geom.getGeometries();
            var length = 0;
            for (var j = 0; j < geometries.length; j++) {
                length += self.calculateLength.call(self, geometries[j]);
            }

            return length;
        } else {
            return 0;
        }

        var transformed = [];
        for (var i = 0; i < pixelCoords.length; i++) {
            transformed.push([
                pixelCoords[i][0] * this.imageInfo.MicrometresPerPixelX,
                pixelCoords[i][1] * this.imageInfo.MicrometresPerPixelY,
            ]);
        }

        var tmpGeom = new ol.geom.LineString(transformed);
        var v = tmpGeom.getLength();
        tmpGeom = null;
        return v;
    }

    /**
     * Formats the length of an OpenLayers geometry
     * @param {number} length - The length of the geometry to format
     * @returns {number} The string with the formatted length and unit
     * @ignore
     */
    formatLength(length) {
        if (this.imageInfo.MicrometresPerPixelX === 0) {
            return 0;
        }

        var alwaysDisplayInMicrons =
            this.options.annotations &&
            this.options.annotations.alwaysDisplayInMicrons === true;

        var output;
        if (!alwaysDisplayInMicrons && length >= 1000) {
            output = Math.round((length * 10) / 1000) / 10 + " mm";
        } else {
            output = Math.round(length * 10) / 10 + " μm";
        }

        return output;
    }

    /**
     * Formats the area of an OpenLayers geometry
     * @param {number} area - The area of the geometry to format
     * @returns {number} The string with the formatted area and unit
     */
    formatArea(area) {
        if (this.imageInfo.MicrometresPerPixelX === 0) {
            return 0;
        }

        var supscript = String.fromCharCode("2".charCodeAt(0) + 128);
        var alwaysDisplayInMicrons =
            this.options.annotations &&
            this.options.annotations.alwaysDisplayInMicrons === true;

        var output;
        if (!alwaysDisplayInMicrons && area >= 100000) {
            output = Math.round((area * 10) / 1000000) / 10 + " mm" + supscript; //<sup>2</sup>';
        } else {
            output = Math.round(area * 10) / 10 + " μm" + supscript; // <sup>2</sup>';
        }

        return output;
    }

    /**
     * Returns the text style to be used to render the text part (labels) of annotations. This method is not meant to be used by PMA.UI consumers.
     * @param {ol.Feature} [feature] - The ol.Feature associated with the annotation
     * @returns {ol.style.Text} A text style instance
     */
    getAnnotationTextStyle(feature, strokeColor) {
        var txt = this.getAnnotationFormattedLabel(feature);

        return new olText({
            text: txt,
            textAlign: "center",
            textBaseline: "bottom",
            placement: "point",
            overflow: true,
            font: "normal 16px Tahoma",
            fill: new Fill({
                color: "#000000",
            }),
            stroke: new Stroke({
                color: "#ffffff",
                width: 2,
            }),
        });
    }

    /**
     * Returns the text of an annotation. This method is not meant to be used by PMA.UI consumers.
     * @param {ol.Feature} [feature] - The ol.Feature associated with the annotation
     * @returns {String} The annotation text
     */
    getAnnotationFormattedLabel(feature) {
        var txt = "";
        if (this.options.annotations && feature && feature.metaData) {
            if (this.options.annotations.labels !== false && feature.metaData.Notes) {
                txt = feature.metaData.Notes;
            }

            if (this.options.annotations.showMeasurements !== false) {
                var geomType = feature.getGeometry().getType();
                if (feature.metaData.hasOwnProperty("Dimensions") && !isNaN(feature.metaData.Dimensions)) {
                    if (feature.metaData.Dimensions > 1) {
                        txt += "\n" + feature.metaData.FormattedArea;
                    } else if (feature.metaData.Dimensions > 0) {
                        txt += "\n" + feature.metaData.FormattedLength;
                    }
                } else {
                    if (geomType.indexOf("LineString") !== -1) {
                        txt += "\n" + feature.metaData.FormattedLength;
                    } else if (geomType.indexOf("Polygon") !== -1 || geomType.indexOf("Circle") !== -1) {
                        txt +=
                            // "\n" +
                            // feature.metaData.FormattedLength +
                            "\n" +
                            feature.metaData.FormattedArea;
                    }
                }
            }
        }

        return txt;
    }

    /**
     * Returns a style that contatins the text of an annotation. This method is not meant to be used by PMA.UI consumers.
     * @param {ol.style.Style} [style] - The ol.style.Style associated with the annotation
     * @param {ol.Feature} [feature] - The ol.Feature associated with the annotation
     * @returns {Function} The annotation style function
     */
    getAnnotationStyle(style, feature) {
        style.getText().setText(this.getAnnotationFormattedLabel(feature));
        return style;
        // var self = this;
        // return function () {
        //     style.getText().setText(self.getAnnotationFormattedLabel(feature));
        //     return style;
        // };
    }

    /**
     * Toggles the visibility of an annotation
     * @param {number} id - The id of the annotation to hide or show
     * @param {boolean} show - True to show the annotation, otherwise hide it
     */
    showAnnotation(id, show) {
        if (id === null || isNaN(id)) {
            return;
        }

        if (!this.hiddenAnnotations) {
            this.hiddenAnnotations = [];
        }

        var f = null;
        if (show) {
            for (var k = 0; k < this.hiddenAnnotations.length; k++) {
                if (this.hiddenAnnotations[k].getId() == id) {
                    f = this.hiddenAnnotations.splice(k, 1);
                    this.annotationsLayer.getSource().addFeatures(f);
                    return;
                }
            }
        } else {
            f = this.annotationsLayer.getSource().getFeatureById(id);
            if (f) {
                this.annotationsLayer.getSource().removeFeature(f);
                this.hiddenAnnotations.push(f);
            }
        }
    }

    /**
     * Toggles the visibility of annotations loaded by fingerprint and loads them if not already loaded
     * @param {boolean} show - True to show the annotation, otherwise hide it
     */
    showAnnotationsByFingerprint(show) {
        if (show) {
            if (!this.forceLoadedAnnotationsByFingerprint) {
                if (!this.options.annotations.loadAnnotationsByFingerprint) {
                    getFingerprint.call(this, (fingerprint) => {
                        this.reloadAnnotations(null, fingerprint);
                    });
                    this.forceLoadedAnnotationsByFingerprint = true;
                }
            }
        }

        if (!this.hiddenAnnotationsByFingerprint) {
            this.hiddenAnnotationsByFingerprint = [];
        }

        var f = null;
        var annotations = this.annotationsLayer.getSource().getFeatures();

        if (show) {
            for (var k = this.hiddenAnnotationsByFingerprint.length; k > 0; k--) {
                f = this.hiddenAnnotationsByFingerprint.pop();
                this.annotationsLayer.getSource().addFeature(f);
            }
        } else {
            for (var l = 0; l < annotations.length; l++) {
                f = annotations[l];
                if (f.metaData.Image !== this.imageInfo.Filename) {
                    this.annotationsLayer.getSource().removeFeature(f);
                    this.hiddenAnnotationsByFingerprint.push(f);
                }
            }
        }
    }

    /**
     * Toggles the visibility of the annotations layer in total
     * @param {boolean} show - True to show the annotations, otherwise hide them
     */
    showAnnotations(visible) {
        if (!this.annotationsLayer) {
            return;
        }

        this.annotationsLayer.setVisible(visible);
    }

    /**
     * Gets a value indicating whether or not the text of the annotations is rendered in the viewer
     */
    getAnnotationLabelsVisible() {
        if (!this.annotationsLayer) {
            return false;
        }

        return this.options.annotations.labels === true;
    }

    /**
     * Toggles the text of the annotations visibility
     * @param {boolean} visible - True to show the annotation labels, otherwise hide them
     * @param {boolean} [showMeasurements] - True to show the annotation measurements, otherwise hide them
     */
    showAnnotationsLabels(visible, showMeasurements) {
        if (!this.annotationsLayer) {
            return;
        }

        var _this = this;

        if (showMeasurements !== undefined) {
            if (this.options.annotations.labels === visible && this.options.annotations.showMeasurements === showMeasurements) {
                return;
            }
            this.options.annotations.showMeasurements = showMeasurements;
        } else {
            showMeasurements = this.options.annotations.showMeasurements;
        }

        this.options.annotations.labels = visible;
        this.annotationsLayer
            .getSource()
            .forEachFeature(function (feature, index) {
                var styles = feature.getStyle();
                var style = [];

                if (styles instanceof Array) {
                    for (var k = 0; k < styles.length; k++) {
                        if (styles[k].getText()) {
                            style.push(styles[k]);
                            break;
                        }
                    }
                } else {
                    style = [styles];
                }

                for (var k = 0; k < style.length; k++) {
                    var s = style[k];
                    if (!s) {
                        continue;
                    }
                    try {
                        if (!visible && !showMeasurements) {
                            s.getText().setText("");
                        } else {
                            s.getText().setText(_this.getAnnotationFormattedLabel(feature));
                        }
                    } catch (e) {
                        continue;
                    }
                }

                //feature.changed();
            }, this);

        this.annotationsLayer.changed();
    }

    /*        zoomToAnnotation(annotationIndex) {
                this.map.getLayers().forEach(function (layer, i) {
                    if (layer instanceof ol.layer.Vector) {
                        var selection = layer.getSource().getFeatures()[annotationIndex];
                        var extent = selection.getGeometry().getExtent();
     
                        var view2D = this.map.getView();
                        view2D.fit(extent, this.map.getSize());
                    }
                }, this);
            };
    */

    /**
     * Returns all the annotations
     * @returns {Array} An array of ol.Feature instances
     */
    getAnnotations() {
        if (!this.annotationsLayer) {
            return null;
        }

        if (this.hiddenAnnotations && this.hiddenAnnotations.length > 0) {
            return this.annotationsLayer
                .getSource()
                .getFeatures()
                .concat(this.hiddenAnnotations)
                .filter(f => !!f.metaData);
        } else {
            return this.annotationsLayer.getSource().getFeatures().filter(f => !!f.metaData);
        }
    }

    /**
     * Gets an array that contains the indices of the currently active channels
     * @returns {Array}
     */
    getActiveChannels() {
        var channels = [];
        for (
            var i = 0; i < this.imageInfo.TimeFrames[0].Layers[0].Channels.length; i++
        ) {
            if (this.imageInfo.TimeFrames[0].Layers[0].Channels[i].Active) {
                channels.push(i);
            }
        }

        return channels;
    }

    /**
     * Sets the active channels
     * @param {Array} channels - An array that contains the indices of the channels to display
     * @fires PMA.UI.View.Events#DimensionsChanged
     */
    setActiveChannels(channels) {
        for (var i = 0; i < this.imageInfo.TimeFrames[0].Layers[0].Channels.length; i++) {
            this.imageInfo.TimeFrames[0].Layers[0].Channels[i].Active = false;
        }

        for (i = 0; i < channels.length; i++) {
            this.imageInfo.TimeFrames[0].Layers[0].Channels[
                channels[i]
            ].Active = true;
        }

        this.channelsString = this.getActiveChannels().join(",");
        this.channelClippingString = this.getChannelClippingString();
        this.channelGammaString = this.getChannelGammaString();
        this.channelColorString = this.getChannelColorString();
        this.debouncedSetRenderingOptions.call(this);
        this.redraw();
        this.fireEvent(Events.DimensionsChanged, this);
    }

    /**
     * Gets the rendering options for all channels or specific channel
     * @param {object} [options] - An object that determines the channel to get rendering options. If no options exist, all channels will be returned
     * @param {number} [options.index] - The index of the channel to get rendering options
     * @returns {Viewport~ChannelRenderingOptions[]} renderingOptions - The rendering options object
     */
    getChannelRenderingOptions(options) {
        if (options && Number.isInteger(options.index)) {
            const channel = this.imageInfo.TimeFrames[0].Layers[0].Channels[options.index];
            if (!channel) {
                console.error("Channel not found!");
                return;
            }

            return [{
                index: options.index,
                name: channel.Name,
                color: (channel.UserOptions && channel.UserOptions.Color) ? channel.UserOptions.Color : channel.Color,
                defaultColor: channel.Color,
                clipping: channel.UserOptions ? channel.UserOptions.Clipping : null,
                gamma: (channel.UserOptions && channel.UserOptions.Gamma) ? channel.UserOptions.Gamma : channel.Gamma
            }];
        } else {
            const channels = this.imageInfo.TimeFrames[0].Layers[0].Channels;
            let channelRenderingOptions = [];
            for (let i = 0; i < channels.length; i++) {
                const channel = channels[i];
                channelRenderingOptions.push({
                    index: i,
                    name: channel.Name,
                    color: (channel.UserOptions && channel.UserOptions.Color) ? channel.UserOptions.Color : channel.Color,
                    defaultColor: channel.Color,
                    clipping: channel.UserOptions ? channel.UserOptions.Clipping : null,
                    gamma: (channel.UserOptions && channel.UserOptions.Gamma) ? channel.UserOptions.Gamma : channel.Gamma
                });
            }

            return channelRenderingOptions;
        }
    }

    /**
     * Sets the rendering options for a channel
     * @param {object} options - An object that determines the channel and the options to be set
     * @param {number} options.index - An object that determines the channel and the options to be set
     * @param {string} options.color - Channel's color in css format
     * @param {Array<Number>} options.clipping - A pair of values [0..100] to clip and scale pixel values
     * @param {Number} options.gamma - A value of tha gamma correction factor for this channel
     */
    setChannelRenderingOptions(options) {
        var ch = this.imageInfo.TimeFrames[0].Layers[0].Channels[options.index];
        ch.UserOptions = {
            Clipping: options.clipping,
            Color: options.color ? options.color : ch.Color,
            Gamma: options.gamma ? options.gamma : ch.Gamma
        };

        this.channelClippingString = this.getChannelClippingString();
        this.channelGammaString = this.getChannelGammaString();
        this.channelColorString = this.getChannelColorString();

        this.debouncedSetRenderingOptions.call(this);

        this.redraw();
        this.fireEvent(Events.DimensionsChanged, this);
    }

    getChannelClippingString() {
        const channels = this.imageInfo.TimeFrames[0].Layers[0].Channels;
        this.channelClippingString = "";
        for (let i = 0; i < channels.length; i++) {
            if (!channels[i].Active) {
                continue;
            }

            if (this.channelClippingString.length > 0) {
                this.channelClippingString += ",";
            }

            if ((channels[i].UserOptions && channels[i].UserOptions.Clipping) || channels[i].Clipping) {
                this.channelClippingString += (channels[i].UserOptions && channels[i].UserOptions.Clipping) ?
                    channels[i].UserOptions.Clipping[0] + "," + channels[i].UserOptions.Clipping[1] :
                    channels[i].Clipping[0] + "," + channels[i].Clipping[1];
            } else {
                this.channelClippingString += "0,100";
            }
        }

        return this.channelClippingString;
    }

    getChannelColorString() {
        const channels = this.imageInfo.TimeFrames[0].Layers[0].Channels;
        this.channelColorString = "";
        for (let i = 0; i < channels.length; i++) {
            if (!channels[i].Active) {
                continue;
            }

            if (this.channelColorString.length > 0) {
                this.channelColorString += ",";
            }

            if (channels[i].Color) {
                this.channelColorString += (channels[i].UserOptions && channels[i].UserOptions.Color) ? channels[i].UserOptions.Color : channels[i].Color;
            } else {
                this.channelColorString += "";
            }
        }

        return this.channelColorString;
    }

    getChannelGammaString() {
        const channels = this.imageInfo.TimeFrames[0].Layers[0].Channels;
        this.channelGammaString = "";
        for (let i = 0; i < channels.length; i++) {
            if (!channels[i].Active) {
                continue;
            }

            if (this.channelGammaString.length > 0) {
                this.channelGammaString += ",";
            }

            if (channels[i].Gamma) {
                this.channelGammaString += (channels[i].UserOptions && channels[i].UserOptions.Gamma) ? channels[i].UserOptions.Gamma : channels[i].Gamma;
            } else {
                this.channelGammaString += "1.0";
            }
        }

        return this.channelGammaString;
    }

    /**
     * Gets the index of the active time frame
     * @returns {number}
     */
    getActiveTimeFrame() {
        return this.selectedTimeFrame;
    }

    /**
     * Sets the active time frame
     * @param {number} timeframe - The index of the time frame to activate
     * @fires PMA.UI.View.Events#DimensionsChanged
     */
    setActiveTimeFrame(timeframe) {
        timeframe = parseInt(timeframe);
        if (timeframe >= 0 && timeframe < this.imageInfo.TimeFrames.length) {
            this.selectedTimeFrame = timeframe;
            this.redraw();
            this.fireEvent(Events.DimensionsChanged, this);
        }
    }

    /**
     * Gets the index of the active layer (z-stack)
     * @returns {number}
     */
    getActiveLayer() {
        return this.selectedLayer;
    }

    /**
     * Sets the active layer (z-stack)
     * @param {number} layer - The index of the layer to activate
     * @fires PMA.UI.View.Events#DimensionsChanged
     */
    setActiveLayer(layer) {
        layer = parseInt(layer);
        if (
            layer >= 0 &&
            layer <
            this.imageInfo.TimeFrames[this.selectedTimeFrame].Layers.length
        ) {
            this.selectedLayer = layer;
            this.redraw();
            this.fireEvent(Events.DimensionsChanged, this);
        }
    }

    /**
     * Sets the image flip options
     * @param {bool} horizontally - Whether or not to flip the image horizontally
     * @param {bool} vertically - Whether or not to flip the image vertically
     */
    setFlip(horizontally, vertically) {
        if (!this.image) {
            return;
        }

        var newH = horizontally == true;
        var newV = vertically == true;

        var hasHorizontalChange = this.options.flip.horizontally !== newH;
        var hasVerticalChange = this.options.flip.vertically !== newV;

        if (!hasHorizontalChange && !hasVerticalChange) {
            return;
        }

        // reset all annotations, before we change the transformation
        var i, geom;
        var oldExtent = this.map.getView().getProjection().getExtent();
        var oldPos = this.getPosition();
        var oldView = this.map.getView();
        var extent = oldView.getProjection().getExtent();
        var extentObj = { extent: extent, flip: this.options.flip };
        var annotations = this.getAnnotations();
        if (annotations) {
            for (i = 0; i < annotations.length; i++) {
                geom = annotations[i].getGeometry();
                geom.applyTransform(annotationTransform.bind(extentObj));
            }
        }

        this.options.flip.horizontally = newH;
        this.options.flip.vertically = newV;

        var pixelProjection = createProjection.call(this);

        this.map.setView(
            createMainView.call(
                this,
                pixelProjection,
                null,
                null, -oldView.getRotation()
            )
        );

        // now apply the new transformation
        extent = this.map.getView().getProjection().getExtent();
        extentObj = { extent: extent, flip: this.options.flip };
        if (annotations) {
            for (i = 0; i < annotations.length; i++) {
                geom = annotations[i].getGeometry();
                geom.applyTransform(annotationTransform.bind(extentObj));
            }
        }

        // if (this.options.grid) {
        //     this.toggleGrid(this.options.grid.size);
        // }

        var pos = this.getPosition();
        pos.zoom = oldPos.zoom;

        if (hasHorizontalChange) {
            pos.center[0] = extent[0] + (oldExtent[2] - oldPos.center[0]);
        } else {
            pos.center[0] = extent[0] + (oldPos.center[0] - oldExtent[0]);
        }

        if (hasVerticalChange) {
            pos.center[1] = extent[1] + (oldExtent[3] - oldPos.center[1]);
        } else {
            pos.center[1] = extent[1] + (oldPos.center[1] - oldExtent[1]);
        }

        this.setPosition(pos, null);

        this.redraw();

        if (this.overviewControl) {
            this.overviewControl.setMap(null);
            var overviewVisible = getControlVisibility.call(
                this,
                this.element.querySelector(".ol-overview")
            );

            var overviewCollapsed = this.overviewControl && this.overviewControl.getCollapsed();

            this.map.removeControl(this.overviewControl);
            this.overviewControl = createOverviewControl.call(this);
            this.map.addControl(this.overviewControl);

            if (!overviewVisible) {
                setControlVisibility.call(
                    this,
                    this.element.querySelector(".ol-overview"),
                    false
                );
            }

            this.overviewControl.setCollapsed(overviewCollapsed);
            setMapSizeClass.call(this);
        }

        this.fireEvent(Events.ViewChanged, this);
    }

    /**
     * Gets the image flip options
     * @returns {Viewport~flipOptions}
     */
    getFlip() {
        return {
            horizontally: this.options.flip.horizontally,
            vertically: this.options.flip.vertically,
        };
    }

    /**
     * Forces the viewer to refresh
     */
    redraw() {
        if (!this.image) {
            return;
        }
        var array = this.map.getLayers().getArray();
        var layer;
        for (var i = 0; i < array.length; i++) {
            if (array[i].getSource) {
                if (array[i] === this.mainLayer) {
                    var maxZoom = this.imageInfo.MaxZoomLevel;

                    var tilesPerBoxSide = Math.pow(2, maxZoom);

                    layer = new ol.layer.Tile({
                        source: new ol.source.XYZ({
                            tileUrlFunction: this.mainLayer.getSource().getTileUrlFunction(),
                            tileLoadFunction: this.mainLayer.getSource().getTileLoadFunction(),
                            projection: this.mainLayer.getSource().getProjection(),
                            wrapX: false,
                            attributions: "",
                            crossOrigin: "PMA.UI",
                            tileGrid: ol.tilegrid.createXYZ({
                                tileSize: [this.imageInfo.TileSize, this.imageInfo.TileSize],
                                extent: [0, 0, tilesPerBoxSide * this.imageInfo.TileSize, tilesPerBoxSide * this.imageInfo.TileSize],
                                maxZoom: maxZoom
                            }),
                        }),
                        className: "ol-layer main-layer",
                        extent: [0, 0, tilesPerBoxSide * this.imageInfo.TileSize, tilesPerBoxSide * this.imageInfo.TileSize],
                    });

                    layer.set("active", true, true);
                    this.mainLayer.set("active", false, true);

                    if (this.options.loadingBar) {
                        unByKey(this.listeners.tileloadstart);
                        unByKey(this.listeners.tileloadend);
                        unByKey(this.listeners.tileloaderror);

                        this.listeners.tileloadstart = [];
                        this.listeners.tileloadend = [];
                        this.listeners.tileloaderror = [];

                        this.loading = 0;
                        this.loaded = 0;
                        updateProgress.call(this);

                        var olViewer = this;

                        this.listeners.tileloadstart.push(layer.getSource().on('tileloadstart', function () {
                            if (olViewer.loading === 0) {
                                olViewer.progressEl.style.visibility = 'visible';
                            }

                            ++olViewer.loading;
                            updateProgress.call(olViewer);
                        }));

                        this.listeners.tileloadend.push(layer.getSource().on('tileloadend', function () {
                            setTimeout(function () {
                                ++olViewer.loaded;
                                updateProgress.call(olViewer);
                            }, 100);
                        }));

                        this.listeners.tileloaderror.push(layer.getSource().on('tileloaderror', function () {
                            setTimeout(function () {
                                ++olViewer.loaded;
                                updateProgress.call(olViewer);
                            }, 100);
                        }));
                    }

                    setTimeout((map, mainLayer, overviewControl) => {
                        overviewControl.setMap(null);
                        map.removeLayer(mainLayer);
                        overviewControl.setMap(map);
                    }, 10000, this.map, this.mainLayer, this.overviewControl);
                }
                // else {
                //     if (!(array[i].getSource() instanceof ol.source.XYZ)) {
                //         array[i].getSource().refresh();
                //     }
                // }
            } else if (array[i].getLayers) {
                var groupLayers = array[i].getLayers().getArray();
                for (var j = 0; j < groupLayers.length; j++) {
                    groupLayers[j].getSource().refresh();
                    groupLayers[j].setZIndex(999);
                }
            }
        }

        if (layer) {
            this.mainLayer = layer;
            this.map.addLayer(this.mainLayer);
            this.mainLayer.setZIndex(900);
            if (this.annotationsLayer) {
                this.annotationsLayer.setZIndex(1000);
            }

            if (this.measureLayer) {
                this.measureLayer.setZIndex(10000);
            }
        }
    }

    /**
     * Zooms the view to a specific objective
     * @param {string} objective - 1:1, 1X, 2X, 5X, 10X etc.
     */
    zoomToObjective(objective) {
        if (!this.imageInfo || this.imageInfo.MicrometresPerPixelX === 0) {
            return;
        }

        var mapResolution;
        if ("1:1" === objective) {
            mapResolution = 1;
        } else {
            objective = parseFloat(objective);
            if (isNaN(objective) || objective === 0.0) {
                return;
            }

            // mapResolution =
            //     (this.imageInfo.MicrometresPerPixelX - 0.75) /
            //     -0.0125 /
            //     objective;
            mapResolution = 10 / this.imageInfo.MicrometresPerPixelX / objective;
        }

        this.map.getView().setResolution(mapResolution);
    }

    /**
     * Gets the current viewport position
     * @returns {Viewport~position} position
     */
    getPosition() {
        if (!this.map) {
            return null;
        }

        var v = this.map.getView();
        if (!v) {
            return null;
        }

        return {
            center: v.getCenter(),
            zoom: v.getZoom(),
            resolution: v.getResolution(),
            rotation: v.getRotation(),
        };
    }

    /**
     * sets the current viewport position
     * @param {Viewport~position} position
     */
    setPosition(position, skipAnimation) {
        if (!this.map) {
            return;
        }

        var mainView = this.map.getView();

        mainView.animate(Object.assign(position, { duration: 0 }));
    }

    /**
     * Fits the view to supplied extent
     * @param {ol.geom.SimpleGeometry|Array.<number>} extent - An array of numbers representing an extent: `[minx, miny, maxx, maxy]` or a simple geometry
     * @param {bool} constrainResolution - Whether to fit at any resolution even outside the allowed range
     */
    fitToExtent(extent, constrainResolution) {
        if (!this.map) {
            return;
        }

        var mainView = this.map.getView();
        mainView.fit(extent, {
            size: this.map.getSize(),
            constrainResolution: constrainResolution ?
                constrainResolution : false,
        });
    }

    /**
     * Gets the currently visible extent
     * @param {bool} unrotate - Whether to unrotate the extend back to axis aligned
     * @returns {Array.<number>} An array of numbers representing an extent: `[minx, miny, maxx, maxy]`
     */
    getCurrentExtent(unrotate) {
        if (!this.map) {
            return;
        }

        var mainView = this.map.getView();
        var extent = mainView.calculateExtent(this.map.getSize());

        if (unrotate) {
            return this.rotateExtent(extent, -mainView.getRotation());
        }

        return extent;
    }

    /**
     * Overrides the current sessionID and forces the viewer to redraw
     * @param {string} sessionID
     */
    setSessionID(sessionID) {
        this.sessionID = sessionID;
        this.redraw();
    }

    /**
     * Gets the currently used sessionID
     * @returns {string}
     */
    getSessionID() {
        return this.sessionID;
    }

    /**
     * Authenticates and forces the viewer to redraw
     * @param {string} username
     * @param {string} password
     */
    login(username, password) {
        if (username) {
            this.username = username;
        }

        if (password) {
            this.password = password;
        }

        login.call(this, this.redraw);
    }

    /**
     * Generates a rectangle for the current view
     * @returns {Array.<number[]>} An array of the x, y coordinates of the 4 corners of viewing rectangle
     */
    getViewportCoordinates() {
        var mapSize = this.map.getSize();
        var coords = [
            this.map.getCoordinateFromPixel([0, 0]),
            this.map.getCoordinateFromPixel([mapSize[0], 0]),
            this.map.getCoordinateFromPixel([mapSize[0], mapSize[1]]),
            this.map.getCoordinateFromPixel([0, mapSize[1]]),
        ];

        var flip = this.getFlip();

        var maxZoom = this.imageInfo.MaxZoomLevel;
        var pow = Math.pow(2, maxZoom);

        var xPadding = 0,
            yPadding = 0;
        var boxSize = pow * this.imageInfo.TileSize;

        if (flip.horizontally === true) {
            xPadding = boxSize - this.imageInfo.Width;
        }

        if (flip.vertically !== true) {
            yPadding = boxSize - this.imageInfo.Height;
        }

        for (var i = 0; i < 4; i++) {
            coords[i][0] -= xPadding;
            coords[i][1] -= yPadding;
            if (flip.horizontally === true) {
                coords[i][0] = this.imageInfo.Width - coords[i][0];
            }

            if (flip.vertically !== true) {
                coords[i][1] = this.imageInfo.Height - coords[i][1];
            }
        }

        return coords;
    }

    /**
     * Generates the parameters required for generating a snapshot url using getSnapshotUrl
     * @returns {PMA.UI.Components~snapshotParameters}
     */
    getSnapshotParameters() {
        var coords = this.getViewportCoordinates();
        var size = this.map.getSize();
        var scale = 1 / this.map.getView().getResolution();
        var flip = this.getFlip();

        var rotation =
            ((-this.map.getView().getRotation() * 180.0) / Math.PI) % 360;
        if (flip.horizontally ^ flip.vertically) {
            rotation *= -1;
        }

        var x = Math.round(coords[0][0]);
        var y = Math.round(coords[0][1]);
        var w = Math.round(size[0] / scale);
        var h = Math.round(size[1] / scale);

        return {
            x: x,
            y: y,
            width: w,
            height: h,
            rotation: rotation,
            channels: this.getActiveChannels(),
            layer: this.selectedLayer,
            timeframe: this.selectedTimeFrame,
            flipHorizontally: flip.horizontally,
            flipVertically: flip.vertically,
        };
    }

    /**
     * An object containing the url, width and height of the final image
     * @typedef {Object} PMA.UI.View~SnapshotResult
     * @property {url} url - The snapshot url
     * @property {Number} width - The width of the snapshot
     * @property {boolean} height - The height of the snapshot
     */

    /**
     * Generates a URL where a snapshot of the current viewport is rendered
     * @param {boolean} download - True to prompt the user to save the snapshot instead of viewing it within the browser
     * @param {PMA.UI.View.ObjectiveResolutions} [objectiveResolution] - The desired objective resolution. If not passed the viewport scale will be used
     * @param {string} [format=jpg] - The snapshot image format
     * @param {boolean} [drawScaleBar=false] - Whether or not to draw a scalebar
     * @param {number} [dpi=null] - Dots per inch to set to the image
     * @param {string} [filename=snapshot] - The filename of the image
     * @param {ol.Feature} [feature=null] - Clip the snapshot to the bounding box of this annotation
     * @param {number} [scale=null] - A factor to scale the image by. Default is the current viewport scale
     * @returns {PMA.UI.View~SnapshotResult} - An object containing the url, width and height of the final image
     */
    getSnapshotUrl(download, objectiveResolution, format, drawScaleBar, dpi = null, filename = "snapshot", feature = null, scale = null) {
        if (!format) {
            format = "jpg";
        }

        if (filename === null) {
            filename = "snapshot";
        }

        drawScaleBar = !!drawScaleBar;
        scale = scale ? scale : 1 / this.map.getView().getResolution();

        var coords = this.getViewportCoordinates();
        var size = this.map.getSize().map(s => s / scale);
        var flip = this.getFlip();
        if (feature) {
            let projection = this.mainLayer.getSource().getProjection();
            var projExtent = projection.getExtent();
            var extentObj = { extent: projExtent, flip: flip };
            var geom = feature.getGeometry().clone();
            geom.applyTransform(annotationTransform.bind(extentObj));
            let vp = this.map.getView().getProjection().getExtent();
            let extent = geom.getExtent();
            let x = flip.horizontally ? extent[2] + vp[0] : extent[0];
            let y = extent[flip.vertically ? 3 : 1];
            coords = [[x, y], [extent[2], extent[3]]];
            size = [Math.abs(extent[2] - extent[0]), Math.abs(extent[3] - extent[1])];
        }

        if (objectiveResolution) {
            if (objectiveResolution == ObjectiveResolutions.Min) {
                scale = 1 / Math.pow(2, this.imageInfo.MaxZoomLevel);
            } else if (objectiveResolution == ObjectiveResolutions.Max) {
                if (this.options.digitalZoomLevels > 0) {
                    scale = 2;
                } else {
                    scale = 1;
                }
            } else if (objectiveResolution == ObjectiveResolutions["1:1"]) {
                scale = 1;
            } else {
                var obji = parseInt(objectiveResolution);
                if (obji >= 1) {
                    var leveli = -Math.log2(10) + Math.log2(obji * this.imageInfo.MicrometresPerPixelX) + this.imageInfo.MaxZoomLevel;
                    scale = 1 / Math.pow(2, this.imageInfo.MaxZoomLevel - leveli);
                }
            }
        }

        // if no flip is set, or both horizontal and vertical flipping is set
        // we need the negative rotation angle,
        // otherwise, if one fo the two flips is set but not the other, we must not negate the angle
        var rotation = ((-this.map.getView().getRotation() * 180.0) / Math.PI) % 360;
        if (flip.horizontally ^ flip.vertically) {
            rotation *= -1;
        }

        if (feature) {
            rotation = 0;
        }

        var x = Math.round(coords[0][0]);
        var y = Math.round(coords[0][1]);

        var w = Math.round(size[0]);
        var h = Math.round(size[1]);

        var url =
            this.imagesUrl +
            "region?pathOrUid=" +
            encodeURIComponent(this.image) +
            "&format=" +
            encodeURIComponent(format) +
            "&timeframe=" +
            this.selectedTimeFrame +
            "&layer=" +
            this.selectedLayer +
            "&channels=" +
            this.channelsString +
            "&channelClipping=" +
            this.channelClippingString +
            "&channelColor=" +
            this.channelColorString +
            "&gamma=" +
            this.channelGammaString +
            "&sessionID=" +
            encodeURIComponent(this.sessionID) +
            "&drawScaleBar=" +
            drawScaleBar +
            "&x=" +
            x +
            "&y=" +
            y +
            "&width=" +
            w +
            "&height=" +
            h +
            "&scale=" +
            scale +
            "&rotation=" +
            rotation +
            "&dpi=" +
            dpi +
            "&filename=" +
            filename +
            "&flipHorizontal=" +
            flip.horizontally +
            "&flipVertical=" +
            flip.vertically +
            (this.imageAdjustments.gamma && this.imageAdjustments.gamma != 1.0 ? "&postGamma=" + this.imageAdjustments.gamma.toFixed(2) : "") +
            (this.imageAdjustments.brightness && this.imageAdjustments.brightness != 0 ? "&brightness=" + this.imageAdjustments.brightness : "") +
            (this.imageAdjustments.contrast && this.imageAdjustments.contrast != 1.0 ? "&contrast=" + this.imageAdjustments.contrast.toFixed(2) : "");

        if (download) {
            url += "&downloadInsteadOfDisplay=true";
        }

        return {
            url: url,
            width: Math.round(w * scale),
            height: Math.round(h * scale),
            scale: scale,
        };
    }

    drawViewport() {
        var coords = [];
        var size = this.map.getSize();
        coords.push(this.map.getCoordinateFromPixel([0, 0]));
        coords.push(this.map.getCoordinateFromPixel([0, size[1]]));
        coords.push(this.map.getCoordinateFromPixel([size[0], size[1]]));
        coords.push(this.map.getCoordinateFromPixel([size[0], 0]));
        var poly = new ol.geom.Polygon([coords]);
        var feat = new ol.Feature(poly);
        var s = this.measureLayer.getSource();
        s.clear();
        s.addFeature(feat);
    }

    /**
     * Generates an image with a snapshot of the current viewport and saves it if format is "png" or "jpg"
     * @param {string} [format=png] - The snapshot image format, one of "png", "jpg", "blob"
     * @param {Object} [extraParams] - Extra parameters to draw the snapshot
     * @param {string} [extraParams.title] -  A title to draw on top middle of the snapshot
     * @param {boolean} [extraParams.scalebar=true] - Whether to draw a scalebar of the slide
     * @param {boolean} [extraParams.scalebarLocation="TopLeft"] - When the scalebar will be positioned, one of "TopLeft","TopRight", "BottomLeft", "BottomRight"
     * @param {boolean} [extraParams.scalebarFont="24px serif"] - A valid css font property to draw e.g. "24px serif"
     * @param {boolean} [extraParams.barcode=false] - Whether to draw the barcode of the slide
     * @param {boolean} [extraParams.overview=false] - Whether to draw an overview of the slide
     * @param {number} [extraParams.dpi=null] - Set the dpi of the output image
     * @param {string} [extraParams.filename=snapshot] - The filename of the image
     * @param {string} [extraParams.action="download"] - Action that will take place after snapshot created, one of "download","open-tab"
     * @returns {Promise<Blob>} A promise to the blob object
     */
    getSnapshot(format, extraParams) {
        if (!format) {
            format = "png";
        }
        var that = this;
        return new Promise((resolve, reject) => {
            that.map.once('rendercomplete', function (ev) {
                try {
                    var mapCanvas = document.createElement('canvas');
                    var size = ev.target.getSize();
                    mapCanvas.width = size[0];
                    mapCanvas.height = size[1];
                    var mapContext = mapCanvas.getContext('2d');
                    var scale = 1 / that.map.getView().getResolution();
                    let canvasDraw = async function () {
                        let mainCanvases = Array.from(that.map.getTargetElement().querySelectorAll('.ol-layer.main-layer canvas'));
                        let mainCanvas = mainCanvases[mainCanvases.length - 1];

                        let canvasList = Array.from(that.map.getTargetElement().querySelectorAll('.ol-layer.annotations-layer canvas'));

                        canvasList = [mainCanvas, ...canvasList];

                        let firstCanvas = canvasList[0];

                        if (canvasList.length === 0) {
                            return;
                        }

                        if (firstCanvas.width > 0) {
                            var opacity = firstCanvas.parentNode.style.opacity;
                            mapContext.globalAlpha = opacity === '' ? 1 : Number(opacity);


                            let bgColor = "#000000";
                            if (that.imageInfo && that.imageInfo.BackgroundColor) {
                                bgColor = "#" + that.imageInfo.BackgroundColor;
                            }

                            mapContext.fillStyle = bgColor;
                            mapContext.fillRect(0, 0, mapContext.canvas.width, mapContext.canvas.height);
                            for (let i = 0; i < canvasList.length; i++) {
                                if (canvasList[i].width > 0) {
                                    var transform = canvasList[i].style.transform;
                                    // Get the transform parameters from the style's transform matrix
                                    var matrix = transform
                                        .match(/^matrix\(([^\(]*)\)$/)[1]
                                        .split(',')
                                        .map(Number);
                                    // Apply the transform to the export map context
                                    CanvasRenderingContext2D.prototype.setTransform.apply(
                                        mapContext,
                                        matrix
                                    );
                                    mapContext.drawImage(canvasList[i], 0, 0);

                                    mapContext.resetTransform();
                                }
                            }

                            if (!extraParams || extraParams.scalebar !== false) {
                                drawScalebar(mapContext, that.imageInfo, scale,
                                    extraParams ? extraParams.scalebarLocation : null,
                                    extraParams ? extraParams.scalebarFont : null);
                            }

                            if (extraParams && extraParams.title) {
                                drawTitle(mapContext, extraParams.title);
                            }

                            if (extraParams && extraParams.barcode) {
                                let rotation = 0;
                                if (that.barcodeControl && that.barcodeControl.rotation) {
                                    rotation = that.barcodeControl.rotation;
                                }
                                await drawBarcode(mapContext, that, rotation);
                            }

                            if (extraParams && extraParams.overview) {
                                await drawOverview(mapContext, that);
                            }
                        }
                    }

                    canvasDraw().then(function () {
                        let filename = "snapshot";
                        if (extraParams && extraParams.filename) {
                            filename = extraParams.filename;
                        }
                        if (navigator.msSaveBlob) {
                            var blob = mapCanvas.msToBlob();
                            if (format == "jpg") {
                                console.warn("Cannot save jpg snapshot on internet explorer/edge. The resulting snapshot is in png format");
                            }

                            if (format == "blob") {
                                resolve(blob);
                                return;
                            }

                            let fn = function (blb) {
                                if (extraParams && extraParams.action === "open-tab") {
                                    var a = new FileReader();
                                    a.onload = function (e) {
                                        var tab = window.open(e.target.result, '_blank');
                                        if (tab) tab.focus();
                                    }
                                    a.readAsDataURL(blb);
                                    return;
                                }

                                navigator.msSaveBlob(blb, filename + ".png");
                            };

                            if (extraParams && extraParams.dpi) {
                                changeDpiBlob(blob, extraParams.dpi).then(function (bl) {
                                    fn(bl);
                                    resolve(bl);
                                });

                                return;
                            }

                            fn(blob);
                            resolve(blob);
                        } else {
                            mapCanvas.toBlob(
                                function (blob) {
                                    if (format == "blob") {
                                        resolve(blob);
                                        return;
                                    }

                                    let fn = function (blb) {
                                        if (extraParams && extraParams.action === "open-tab") {
                                            var fileURL = URL.createObjectURL(blb);
                                            var tab = window.open(fileURL, '_blank');
                                            if (tab) tab.focus();
                                            return;
                                        }

                                        saveAs(blb, filename + "." + (format == "jpg" ? "jpg" : "png"));
                                    };

                                    if (extraParams && extraParams.dpi) {
                                        changeDpiBlob(blob, extraParams.dpi).then(function (bl) {
                                            fn(bl);
                                            resolve(bl);
                                        });

                                        return;
                                    }

                                    fn(blob);
                                    resolve(blob);
                                },
                                format == "jpg" ? "image/jpeg" : "image/png"
                            );
                        }
                    });
                } catch (ex) {
                    reject(Error("Error creating snapshot"));
                }
            });

            that.map.renderSync();
        });
    }

    /**
     * Attaches an event listener to the viewer
     * @param {PMA.UI.View.Events} eventName - The name of the event to listen to
     * @param {function} callback - The function to call when the event occurs
     */
    listen(eventName, callback) {
        if (!this.listeners.hasOwnProperty(eventName)) {
            console.error(eventName + " is not a valid event");
        }

        this.listeners[eventName].push(callback);
    }

    /**
     * Detaches an event listener from the viewer
     * @param {PMA.UI.View.Events} eventName - The name of the event to remove the callback from
     * @param {function} callback - The function to remove from the event listener list
     * @returns {bool} Whether or not the callback was actually found and removed
     */
    unlisten(eventName, callback) {
        if (!this.listeners.hasOwnProperty(eventName)) {
            console.error(eventName + " is not a valid event");
        }

        var index = this.listeners[eventName].indexOf(callback);
        if (index >= 0 && index < this.listeners[eventName].length) {
            this.listeners[eventName].splice(index, 1);
            return true;
        }

        return false;
    }

    fireEvent(eventName, eventArgs) {
        if (!this.listeners.hasOwnProperty(eventName)) {
            console.error(eventName + " does not exist");
            return;
        }

        for (var i = 0, max = this.listeners[eventName].length; i < max; i++) {
            this.listeners[eventName][i].call(this, eventArgs);
        }
    }

    /**
     * Pans and zooms the viewer appropriately to focus around an annotation
     * @param {number} annotationId - The id of the annotation to which the viewport should focus
     */
    focusToAnnotation(annotationId) {
        var feature = this.annotationsLayer
            .getSource()
            .getFeatureById(annotationId);

        if (feature) {
            var pan = {
                duration: 500,
                center: this.map.getView().getCenter(),
            };

            var sz = this.map.getSize();
            var extent = feature.getGeometry().getExtent();
            var area = Math.abs(
                (extent[2] - extent[0]) * (extent[3] - extent[1])
            );

            if (area < 0.0001) {
                var factor = 32;
                var worldExt = this.map.getView().getProjection().getExtent();
                extent[0] -= worldExt[2] / factor;
                extent[1] -= worldExt[3] / factor;
                extent[2] += worldExt[2] / factor;
                extent[3] += worldExt[3] / factor;
            }

            this.map.getView().animate(pan);
            this.map.getView().fit(extent, sz);
        }
    }

    /**
     * Sets the brightness of the viewer
     * @param {number} brightness
     */
    setRenderingOptions() {
        saveRenderingOptions.call(this);
    }

    /**
     * Gets the current brightness value of the viewer
     * @returns {number}
     */
    getRenderingOptions() {
        return getRenderingOptions.call(this);
    }

    /**
     * Sets the brightness of the viewer
     * @param {number} brightness
     */
    setBrightness(brightness) {
        this.imageAdjustments.brightness = brightness;
        applyImageAdjustments.call(this);
    }

    /**
     * Sets the contrast of the viewer
     * @param {number} contrast
     */
    setContrast(contrast) {
        this.imageAdjustments.contrast = contrast;
        applyImageAdjustments.call(this);
    }

    /**
     * Sets the brightness and contrast of the viewer
     * @param {number} brightness
     * @param {number} contrast
     * @param {number} gamma
     */
    setBrightnessContrastGamma(brightness, contrast, gamma) {
        this.imageAdjustments.brightness = brightness;
        this.imageAdjustments.contrast = contrast;
        this.imageAdjustments.gamma = gamma;
        applyImageAdjustments.call(this);
    }

    /**
     * Sets the gamma of the viewer
     * @param {number} gamma
     */
    setGamma(gamma) {
        this.imageAdjustments.gamma = gamma;
        applyImageAdjustments.call(this);
    }

    /**
     * Sets the color balance for each rgb channel
     * @param {number} red A value between 0 and 2 indicating the red channel balance
     * @param {number} green A value between 0 and 2 indicating the green channel balance
     * @param {number} blue A value between 0 and 2 indicating the blue channel balance
     */
    setColorBalance(red, green, blue) {
        this.imageAdjustments.rgb = [red, green, blue];
        applyImageAdjustments.call(this);
    }

    /**
     * Gets the color balance values
     * @returns An array containing the color balance values for [red, green, blue]
     */
    getColorBalance() {
        return this.imageAdjustments.rgb;
    }

    /**
     * Adding tile transformer to the list of transformers
     * @param {PMA.UI.View.tileTransformer} transformer function that gets pixels as an input parameter and returns nothing, it should adjust pixels values directly in array from parameters
     */
    addTileTransformer(transformer) {
        this.imageAdjustments.tileTransformers =
            this.imageAdjustments.tileTransformers || [];
        this.imageAdjustments.tileTransformers.push(transformer);
    }

    /**
     * Setting list of tile transformers
     * @param {PMA.UI.View.tileTransformer[]} transformers list of transformer function that gets pixels as an input parameter and returns nothing, it should adjust pixels values directly in array from parameters
     */
    setTileTransformers(transformers) {
        this.imageAdjustments.tileTransformers = transformers;
    }

    /**
     * Gets the current brightness value of the viewer
     * @returns {number}
     */
    getBrightness() {
        return this.imageAdjustments.brightness;
    }

    /**
     * Gets the current gamma value of the viewer
     * @returns {number}
     */
    getGamma() {
        return this.imageAdjustments.gamma;
    }

    /**
     * Gets the current contrast value of the viewer
     * @returns {number}
     */
    getContrast() {
        return this.imageAdjustments.contrast;
    }

    /**
     * Gets the current list of tile transformers
     * @returns {Array.<PMA.UI.View.tileTransformer>}
     */
    getTileTransformers() {
        return (this.imageAdjustments.tileTransformers || []).slice();
    }

    /**
     * Gets the URL of the PMA.core server currently connected to
     * @returns {string}
     */
    getActiveServerUrl() {
        return this.imagesUrl;
    }

    /**
     * Forces the viewer to recalculate the viewport size
     */
    updateSize() {
        if (this.map) {
            this.map.updateSize();
            var controls = this.map.getControls();
            for (var i = 0; i < controls.getLength(); i++) {
                var cntrl = controls.item(i);
                if (cntrl instanceof Overview && cntrl.overviewMap) {
                    cntrl.overviewMap.updateSize();
                }
            }

            printObjectivesInZoomBar.call(this);
            setMapSizeClass.call(this);
        }
    }

    /**
     * Converts an array of pma.core annotations to open layers features
     * @param {Viewport~annotation[]} annotations - An annotation array as returned by pma.core
     * @param {Object} - The projection used for diplay
     */
    initializeFeatures(annotations, projection) {
        var format = new ol.format.WKT();
        var extent = projection.getExtent();
        var extentObj = { extent: extent, flip: this.getFlip() };
        var features = [];

        for (var i = 0; i < annotations.length; i++) {
            var annot = annotations[i];
            let f = format.readFeature(annot.Geometry);

            if (
                annot.AnnotationID === null ||
                annot.AnnotationID === undefined
            ) {
                f.setId((Math.random() * 10000) | 0);
            } else {
                f.setId(annot.AnnotationID);
            }

            var geom = f.getGeometry();
            geom.applyTransform(annotationTransform.bind(extentObj));

            var dimensions = isNaN(annot.Dimensions) ? 2 : annot.Dimensions;

            var area = this.calculateArea(geom);
            var frmtArea = this.formatArea(area);
            var length = this.calculateLength(geom);
            var frmtLength = this.formatLength(length);

            var updateDate = null;
            if (annot.AuditLastModifiedOn) {
                var updateMatches = /\/Date\(((\d*)([\+\-]?\d*))\)\//.exec(annot.AuditLastModifiedOn);
                if (updateMatches && updateMatches.length > 2) {
                    updateDate = new Date(parseInt(updateMatches[2]));
                }
            }

            var createDate = null;
            if (annot.AuditCreatedOn) {
                var createMatches = /\/Date\(((\d*)([\+\-]?\d*))\)\//.exec(annot.AuditCreatedOn);
                if (createMatches && createMatches.length > 2) {
                    createDate = new Date(parseInt(createMatches[2]));
                }
            } else {
                createDate = new Date();
            }

            var fillColor = annot.FillColor ?
                annot.FillColor :
                DefaultFillColor;
            var strokeColor = annot.Color ? annot.Color : "#3399CC";
            var imageStyle = null;

            f.name = annot.Notes;
            f.metaData = {
                AnnotationID: annot.AnnotationID,
                Classification: annot.Classification,
                Color: strokeColor,
                CreatedOn: createDate,
                CreatedBy: annot.AuditCreatedBy,
                Image: annot.Image,
                LayerID: annot.LayerID,
                Notes: annot.Notes,
                UpdateInfo: annot.UpdateInfo,
                UpdatedOn: updateDate,
                UpdatedBy: annot.AuditLastModifiedBy,
                Geometry: annot.Geometry,
                Area: dimensions > 1 ? area : 0,
                FormattedArea: dimensions > 1 ? frmtArea : "",
                Length: dimensions > 0 ? length : 0,
                FormattedLength: dimensions > 0 ? frmtLength : "",
                FillColor: fillColor,
                Dimensions: dimensions,
                State: AnnotationState.Pristine,
                LineThickness: annot.LineThickness ? annot.LineThickness : 2,
            };


            // geom.on('change', function (feature, e) {
            //     feature.metaData.State = AnnotationState.Modified;
            // }.bind(this, f));

            // f.on('change:geometry', function (e) {
            //     e.target.metaData.State = AnnotationState.Modified;
            // });

            if (geom.getType() === "MultiPoint") {
                f.metaData.PointCount = geom.getCoordinates().length;
            }

            var fill = new ol.style.Fill({
                color: fillColor,
            });

            var stroke = new ol.style.Stroke({
                color: strokeColor,
                width: f.metaData.LineThickness ? f.metaData.LineThickness : 2,
            });

            if (annot.Geometry.indexOf("POINT") !== -1) {
                if (!strokeColor.startsWith("#") && !strokeColor.startsWith("rgb")) {
                    // it's a point and it's color doesn't start with #, so it's probably an icon - a hack convention in PMA.view 1.x
                    imageStyle = new ol.style.Icon({
                        anchor: [0.5, 0.5],
                        anchorXUnits: "fraction",
                        anchorYUnits: "fraction",
                        opacity: 1,
                        src: this.options.annotations.imageBaseUrl + strokeColor,
                        scale: isNaN(this.options.annotations.imageScale) ?
                            1 : this.options.annotations.imageScale,
                    });
                }
                else {
                    imageStyle = new ol.style.Circle({
                        fill: fill,
                        stroke: stroke,
                        radius: 5,
                    });
                }
            }

            var currentAnnotationStyle = new ol.style.Style({
                image: imageStyle,
                fill: fill,
                stroke: stroke,
                text: this.getAnnotationTextStyle(f, strokeColor),
            });

            f.setStyle(this.getAnnotationStyle(currentAnnotationStyle, f));
            f.originalStyle = currentAnnotationStyle;

            features.push(f);
        }

        return features;
    }

    /**
     * Returns the supported annotation layer names for the loaded slide
     */
    getAnnotationsLayersNames() {
        if (!this.imageInfo || !this.imageInfo.AnnotationsLayers) {
            return [];
        }

        return this.imageInfo.AnnotationsLayers;
    }

    /**
     * Sets a new configuration for the controls of the viewport
     * @param {Viewport~ControlConfiguration[]} configuration - The control configuration object
     */
    setControlsConfiguration(configuration) {
        if (!Array.isArray(configuration)) {
            configuration = [configuration];
        }

        setControlsConfiguration.call(this, configuration);
    }

    /**
     * Gets the current configuration of the controls of the viewport
     * @returns {Viewport~ControlConfiguration[]} configuration - The control configuration object
     */
    getControlsConfiguration() {
        return getControlsConfiguration.call(this);
    }

    /**
     * Hides the measurement grid if visible or does nothing
     */
    hideGrid() {
        if (this.gridLayer) {
            this.gridLayer.getSource().clear();
        }
    }

    /**
     * Shows a measurement grid
     * @param {Array<number>} size - The size of the grid cells in micrometers
     */
    showGrid(size) {
        if (!size || size.length < 2 || size[0] <= 0 || size[1] <= 0) {
            throw "Invalid size specified";
        }

        this.options.grid = { size: size };

        this.gridLayer.getSource().clear();

        var sizeX = size[0] / this.imageInfo.MicrometresPerPixelX;
        var sizeY = size[1] / this.imageInfo.MicrometresPerPixelY;
        var proj = this.map.getView().getProjection();
        var extent = proj.getExtent();

        var startX = Math.min(extent[0], extent[2]);
        var endX = Math.max(extent[0], extent[2]);
        var startY = Math.min(extent[1], extent[3]);
        var endY = Math.max(extent[1], extent[3]);
        var xSteps = Math.ceil((endX - startX) / sizeX);
        var ySteps = Math.ceil((endY - startY) / sizeY);

        endX = startX + xSteps * sizeX;
        startY = endY - ySteps * sizeY;

        for (var x = 0; x <= xSteps; x++) {
            this.gridLayer.getSource().addFeature(
                new ol.Feature({
                    geometry: new ol.geom.LineString([
                        [startX + x * sizeX, startY],
                        [startX + x * sizeX, endY],
                    ]),
                })
            );
        }

        for (var y = 0; y <= ySteps; y++) {
            this.gridLayer.getSource().addFeature(
                new ol.Feature({
                    geometry: new ol.geom.LineString([
                        [startX, endY - y * sizeY],
                        [endX, endY - y * sizeY],
                    ]),
                })
            );
        }
    }

    /**
     * Reloads annotations from the server
     * @param {function} [readyCallback] - Called when the annotations have finished loading
     * @param {string} [fingerprint] - If exists, will reload annotations by fingerprint
     */
    reloadAnnotations(readyCallback, fingerprint) {
        if (!this.annotationsLayer ||
            !this.mainLayer ||
            !this.sessionID ||
            !this.image
        ) {
            console.error("Reload annotations: No image loaded in viewer");
            if (typeof readyCallback === "function") {
                readyCallback.call(this);
            }

            return;
        }

        var vectorSource = this.annotationsLayer.getSource();
        vectorSource.clear();

        if (this.hiddenAnnotations) {
            this.hiddenAnnotations = [];
        }

        if (this.hiddenAnnotationsByFingerprint) {
            this.hiddenAnnotationsByFingerprint = [];
        }

        var pixelProjection = this.mainLayer.getSource().getProjection();
        getAnnotationsServer.call(this, vectorSource, pixelProjection, fingerprint, this.options.annotations.filter, readyCallback);
    }

    rotateExtent(extent, rotation) {
        var geom = ol.geom.Polygon.fromExtent(extent);
        var center = ol.extent.getCenter(extent);
        geom.rotate(rotation, center);
        return geom.getExtent();
    }

    /**
     * Returns the current objective magnification
     * @returns {string} - A string representing the current objective magnification ie. "1X", "2X" etc
     */
    getCurrentObjective() {
        if (!this.imageInfo || this.imageInfo.MicrometresPerPixelX == 0) {
            return "";
        }

        if (!this.map) {
            console.warn("PMA.UI.Viewport map is not initialized");
            return "";
        }

        var objective = 10 / this.imageInfo.MicrometresPerPixelX / this.map.getView().getResolution();
        if (objective > 1) {
            objective |= 0;
            return objective + "X";
        }
    }

    /**
     * Returns the max objective magnification
     * @returns {string} - A string representing the max objective magnification ie. "1X", "2X" etc
     */
    getMaxObjective() {
        var objective = 10 / this.imageInfo.MicrometresPerPixelX;
        if (objective > 1) {
            objective |= 0;
            return findClosestObjectiveValue(objective) + "X";
        }
    }

    /**
     * Gets the visibility of the third party annotation layers
     * @returns {Object[]} options - An array of options
     * @returns {string} options.name - The name of the layer to change the visibility
     * @returns {boolean} options.visible - Whether to show or hide the specified layer
     */
    getAnnotationLayersVisibility() {
        if (!this.layerSwitcher) {
            return [];
        }

        return this.layerSwitcher.getLayersVisibility();
    }

    /**
     * Sets the visibility of the third party annotation layers
     * @param {Object[]} options - An array of options
     * @param {string} options.name - The name of the layer to change the visibility
     * @param {boolean} options.visible - Whether to show or hide the specified layer
     * @param {number} [options.opacity] - A number between 0 and 1 that sets the opacity of the layer
     */
    setAnnotationLayersVisibility(options) {
        if (!this.layerSwitcher) {
            return;
        }

        this.layerSwitcher.setLayersVisibility(options);
    }

    /**
    * Gets the wheel zoom mode to one of "Normal", "Objectives". The normal mode zooms through all the available resolution,
    * while the "Objectives" mode zoom only on the round objective resolutions 1X,2X,5X,10X, 20X, 40X, 80X, 160X
    * @returns {MouseWheelZoomMode} mode 
    */
    getMouseWheelZoomMode() {
        if (!this.mouseWheelInteraction) {
            return MouseWheelMode.Normal;
        }

        return this.mouseWheelInteraction.mode;
    }
    /**
     * Sets the wheel zoom mode to one of "Normal", "Objectives". The normal mode zooms through all the available resolutions,
     * while the "Objectives" mode zooms only to the rounded objective resolutions 1X, 2X, 5X, 10X, 20X, 40X, 80X and 160X
     * @param {MouseWheelZoomMode} mode 
     */
    setMouseWheelZoomMode(mode) {
        if (!this.mouseWheelInteraction) {
            return;
        }

        this.mouseWheelInteraction.setMode(mode);
    }

    /**
     * Start the realtime measuring tool
     * @param {"area" | "line"} type - Whether to start measuring length or area
     */
    startMeasuring(type) {
        startMeasuring.call(this, type);
    }

    /**
     * Stops and clears all realtime measurements
     */
    stopMeasuring() {
        stopMeasuring.call(this, true);
    }
}