//! openseadragon 6.0.2
//! Built on 2026-03-12
//! Git commit: v6.0.2-0-7842cd92
//! http://openseadragon.github.io
//! License: http://openseadragon.github.io/license/

/*
 * OpenSeadragon
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/*
 * Portions of this source file taken from jQuery:
 *
 * Copyright 2011 John Resig
 *
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files (the
 * "Software"), to deal in the Software without restriction, including
 * without limitation the rights to use, copy, modify, merge, publish,
 * distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to
 * the following conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

/*
 * Portions of this source file taken from mattsnider.com:
 *
 * Copyright (c) 2006-2013 Matt Snider
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included
 * in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
 * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
 * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
 * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
 * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
 * THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */


/**
 * @namespace OpenSeadragon
 * @version openseadragon 6.0.2
 * @classdesc The root namespace for OpenSeadragon.  All utility methods
 * and classes are defined on or below this namespace.
 *
 */


// Typedefs

 /**
  * All required and optional settings for instantiating a new instance of an OpenSeadragon image viewer.
  *
  * @typedef {Object} Options
  * @memberof OpenSeadragon
  *
  * @property {String} id
  *     Id of the element to append the viewer's container element to. If not provided, the 'element' property must be provided.
  *     If both the element and id properties are specified, the viewer is appended to the element provided in the element property.
  *
  * @property {Element} element
  *     The element to append the viewer's container element to. If not provided, the 'id' property must be provided.
  *     If both the element and id properties are specified, the viewer is appended to the element provided in the element property.
  *
  * @property {Array|String|Function|Object} [tileSources=null]
  *     Tile source(s) to open initially. This is a complex parameter; see
  *     {@link OpenSeadragon.Viewer#open} for details.
  *
  * @property {Number} [tabIndex=0]
  *     Tabbing order index to assign to the viewer element. Positive values are selected in increasing order. When tabIndex is 0
  *     source order is used. A negative value omits the viewer from the tabbing order.
  *
  * @property {Array} overlays Array of objects defining permanent overlays of
  *     the viewer. The overlays added via this option and later removed with
  *     {@link OpenSeadragon.Viewer#removeOverlay} will be added back when a new
  *     image is opened.
  *     To add overlays which can be definitively removed, one must use
  *     {@link OpenSeadragon.Viewer#addOverlay}
  *     If displaying a sequence of images, the overlays can be associated
  *     with a specific page by passing the overlays array to the page's
  *     tile source configuration.
  *     Expected properties:
  *     * x, y, (or px, py for pixel coordinates) to define the location.
  *     * width, height in point if using x,y or in pixels if using px,py. If width
  *       and height are specified, the overlay size is adjusted when zooming,
  *       otherwise the size stays the size of the content (or the size defined by CSS).
  *     * className to associate a class to the overlay
  *     * id to set the overlay element. If an element with this id already exists,
  *       it is reused, otherwise it is created. If not specified, a new element is
  *       created.
  *     * placement a string to define the relative position to the viewport.
  *       Only used if no width and height are specified. Default: 'TOP_LEFT'.
  *       See {@link OpenSeadragon.Placement} for possible values.
  *
  * @property {String} [xmlPath=null]
  *     <strong>DEPRECATED</strong>. A relative path to load a DZI file from the server.
  *     Prefer the newer Options.tileSources.
  *
  * @property {String} [prefixUrl='/images/']
  *     Prepends the prefixUrl to navImages paths, which is very useful
  *     since the default paths are rarely useful for production
  *     environments.
  *
  * @property {OpenSeadragon.NavImages} [navImages]
  *     An object with a property for each button or other built-in navigation
  *     control, eg the current 'zoomIn', 'zoomOut', 'home', and 'fullpage'.
  *     Each of those in turn provides an image path for each state of the button
  *     or navigation control, eg 'REST', 'GROUP', 'HOVER', 'PRESS'. Finally the
  *     image paths, by default assume there is a folder on the servers root path
  *     called '/images', eg '/images/zoomin_rest.png'.  If you need to adjust
  *     these paths, prefer setting the option.prefixUrl rather than overriding
  *     every image path directly through this setting.
  *
  * @property {Boolean} [debugMode=false]
  *     TODO: provide an in-screen panel providing event detail feedback.
  *
  * @property {String} [debugGridColor=['#437AB2', '#1B9E77', '#D95F02', '#7570B3', '#E7298A', '#66A61E', '#E6AB02', '#A6761D', '#666666']]
  *     The colors of grids in debug mode. Each tiled image's grid uses a consecutive color.
  *     If there are more tiled images than provided colors, the color vector is recycled.
  *
  * @property {Boolean} [silenceMultiImageWarnings=false]
  *     Silences warnings when calling viewport coordinate functions with multi-image.
  *     Useful when you're overlaying multiple images on top of one another.
  *
  * @property {Number} [blendTime=0]
  *     Specifies the duration of animation as higher or lower level tiles are
  *     replacing the existing tile.
  *
  * @property {Boolean} [alwaysBlend=false]
  *     Forces the tile to always blend.  By default the tiles skip blending
  *     when the blendTime is surpassed and the current animation frame would
  *     not complete the blend.
  *
  * @property {Boolean} [autoHideControls=true]
  *     If the user stops interacting with the viewport, fade the navigation
  *     controls.  Useful for presentation since the controls are by default
  *     floated on top of the image the user is viewing.
  *
  * @property {Boolean} [immediateRender=false]
  *     Render the best closest level first, ignoring the lowering levels which
  *     provide the effect of very blurry to sharp. It is recommended to change
  *     setting to true for mobile devices.
  *
  * @property {Number} [defaultZoomLevel=0]
  *     Zoom level to use when image is first opened or the home button is clicked.
  *     If 0, adjusts to fit viewer.
  *
  * @property {String|DrawerImplementation|Array} [drawer = ['auto', 'webgl', 'canvas', 'html']]
  *     Which drawer to use. Valid strings are 'auto', 'webgl', 'canvas', and 'html'.
  *     The string 'auto' is converted to one or more drawer type strings depending
  *     on the platform. On iOS-like devices it becomes 'canvas' due to performance
  *     limitations with the webgl drawer. On all other platforms it becomes ['webgl', 'canvas']
  *     meaning that webgl is tried first, and canvas is available as a fallback if webgl is not supported.
  *
  *     The 'webgl' drawer automatically uses WebGL2 when available, falling back to WebGL1.
  *
  *     External drawer plugins can register additional drawer types as strings.
  *     Valid drawer implementations are constructors of classes that extend OpenSeadragon.DrawerBase.
  *     An array of strings and/or constructors can be used to indicate the priority
  *     of different implementations, which will be tried in order based on browser support.
  *     The 'webgl' drawer can automatically fall back to canvas as needed, for example to draw
  *     images that do not have CORS headers set which makes them tainted and unavailable to webgl.
  *     This behavior depends on 'canvas' being included in the list of drawer candidates. If
  *     webgl is needed and canvas fallback is not desired, use 'webgl' without including 'canvas' in the list.
  *
  * @property {Object} drawerOptions
  *     Options to pass to the selected drawer implementation. For details
  *     please see {@link OpenSeadragon.DrawerOptions}.
  *
  * @property {Number} [opacity=1]
  *     Default proportional opacity of the tiled images (1=opaque, 0=hidden)
  *     Hidden images do not draw and only load when preloading is allowed.
  *
  * @property {Boolean} [preload=false]
  *     Default switch for loading hidden images (true loads, false blocks)
  *
  * @property {String} [compositeOperation=null]
  *     Valid values are 'source-over', 'source-atop', 'source-in', 'source-out',
  *     'destination-over', 'destination-atop', 'destination-in', 'destination-out',
  *     'lighter', 'difference', 'copy', 'xor', etc.
  *     For complete list of modes, please @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation/ globalCompositeOperation}
  *
  * @property {Boolean} [imageSmoothingEnabled=true]
  *     Image smoothing for rendering. Supported by the canvas and webgl drawers,
  *     and may also be supported by external drawer plugins. Note: Ignored by some
  *     (especially older) browsers which do not support this canvas property.
  *     This property can be changed in {@link Viewer.DrawerBase.setImageSmoothingEnabled}.
  *
  * @property {String|CanvasGradient|CanvasPattern|Function} [placeholderFillStyle=null]
  *     Draws a colored rectangle behind the tile if it is not loaded yet.
  *     You can pass a CSS color value like "#FF8800".
  *     When passing a function the tiledImage and canvas context are available as argument which is useful when you draw a gradient or pattern.
  *
  * @property {Object} [subPixelRoundingForTransparency=null]
  *     Determines when subpixel rounding should be applied for tiles when rendering images that support transparency.
  *     This property is a subpixel rounding enum values dictionary [{@link BROWSERS}] --> {@link SUBPIXEL_ROUNDING_OCCURRENCES}.
  *     The key is a {@link BROWSERS} value, and the value is one of {@link SUBPIXEL_ROUNDING_OCCURRENCES},
  *     indicating, for a given browser, when to apply subpixel rounding.
  *     Key '*' is the fallback value for any browser not specified in the dictionary.
  *     This property has a simple mode, and one can set it directly to
  *     {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER}, {@link SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST} or {@link SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS}
  *     in order to apply this rule for all browser. The values {@link SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS} would be equivalent to { '*', SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS }.
  *     The default is {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} for all browsers, for backward compatibility reason.
  *
  * @property {Number} [degrees=0]
  *     Initial rotation.
  *
  * @property {Boolean} [flipped=false]
  *     Initial flip state.
  *
  * @property {Boolean} [overlayPreserveContentDirection=true]
  *     When the viewport is flipped (by pressing 'f'), the overlay is flipped using ScaleX.
  *     Normally, this setting (default true) keeps the overlay's content readable by flipping it back.
  *     To make the content flip with the overlay, set overlayPreserveContentDirection to false.
  *
  * @property {Number} [minZoomLevel=null]
  *
  * @property {Number} [maxZoomLevel=null]
  *
  * @property {Boolean} [homeFillsViewer=false]
  *     Make the 'home' button fill the viewer and clip the image, instead
  *     of fitting the image to the viewer and letterboxing.
  *
  * @property {Boolean} [panHorizontal=true]
  *     Allow horizontal pan.
  *
  * @property {Boolean} [panVertical=true]
  *     Allow vertical pan.
  *
  * @property {Boolean} [constrainDuringPan=false]
  *
  * @property {Boolean} [wrapHorizontal=false]
  *     Set to true to force the image to wrap horizontally within the viewport.
  *     Useful for maps or images representing the surface of a sphere or cylinder.
  *
  * @property {Boolean} [wrapVertical=false]
  *     Set to true to force the image to wrap vertically within the viewport.
  *     Useful for maps or images representing the surface of a sphere or cylinder.
  *
  * @property {Number} [minZoomImageRatio=0.9]
  *     The minimum percentage ( expressed as a number between 0 and 1 ) of
  *     the viewport height or width at which the zoom out will be constrained.
  *     Setting it to 0, for example will allow you to zoom out infinity.
  *
  * @property {Number} [maxZoomPixelRatio=1.1]
  *     The maximum ratio to allow a zoom-in to affect the highest level pixel
  *     ratio. This can be set to Infinity to allow 'infinite' zooming into the
  *     image though it is less effective visually if the HTML5 Canvas is not
  *     available on the viewing device.
  *
  * @property {Number} [smoothTileEdgesMinZoom=1.1]
  *     A zoom percentage ( where 1 is 100% ) of the highest resolution level.
  *     When zoomed in beyond this value alternative compositing will be used to
  *     smooth out the edges between tiles. This will have a performance impact.
  *     Can be set to Infinity to turn it off.
  *     Note: This setting is ignored on iOS devices due to a known bug (See {@link https://github.com/openseadragon/openseadragon/issues/952})
  *
  * @property {Boolean} [iOSDevice=?]
  *     True if running on an iOS device, false otherwise.
  *     Used to disable certain features that behave differently on iOS devices.
  *
  * @property {Boolean} [autoResize=true]
  *     Set to false to prevent polling for viewer size changes. Useful for providing custom resize behavior.
  *
  * @property {Boolean} [preserveImageSizeOnResize=false]
  *     Set to true to have the image size preserved when the viewer is resized. This requires autoResize=true (default).
  *
  * @property {Number} [minScrollDeltaTime=50]
  *     Number of milliseconds between canvas-scroll events. This value helps normalize the rate of canvas-scroll
  *     events between different devices, causing the faster devices to slow down enough to make the zoom control
  *     more manageable.
  *
  * @property {Number} [rotationIncrement=90]
  *     The number of degrees to rotate right or left when the rotate buttons or keyboard shortcuts are activated.
  *
  * @property {Number} [maxTilesPerFrame=1]
  *     The number of tiles loaded per frame. As the frame rate of the client's machine is usually high (e.g., 50 fps),
  *     one tile per frame should be a good choice. However, for large screens or lower frame rates, the number of
  *     loaded tiles per frame can be adjusted here. Reasonable values might be 2 or 3 tiles per frame.
  *     (Note that the actual frame rate is given by the client's browser and machine).
  *
  * @property {Number} [pixelsPerWheelLine=40]
  *     For pixel-resolution scrolling devices, the number of pixels equal to one scroll line.
  *
  * @property {Number} [pixelsPerArrowPress=40]
  *     The number of pixels viewport moves when an arrow key is pressed.
  *
  * @property {Number} [visibilityRatio=0.5]
  *     The percentage ( as a number from 0 to 1 ) of the source image which
  *     must be kept within the viewport.  If the image is dragged beyond that
  *     limit, it will 'bounce' back until the minimum visibility ratio is
  *     achieved.  Setting this to 0 and wrapHorizontal ( or wrapVertical ) to
  *     true will provide the effect of an infinitely scrolling viewport.
  *
  * @property {Object} [viewportMargins={}]
  *     Pushes the "home" region in from the sides by the specified amounts.
  *     Possible subproperties (Numbers, in screen coordinates): left, top, right, bottom.
  *
  * @property {Number} [imageLoaderLimit=0]
  *     The maximum number of image requests to make concurrently. By default
  *     it is set to 0 allowing the browser to make the maximum number of
  *     image requests in parallel as allowed by the browsers policy.
  *
  * @property {Number} [clickTimeThreshold=300]
  *      The number of milliseconds within which a pointer down-up event combination
  *      will be treated as a click gesture.
  *
  * @property {Number} [clickDistThreshold=5]
  *      The maximum distance allowed between a pointer down event and a pointer up event
  *      to be treated as a click gesture.
  *
  * @property {Number} [dblClickTimeThreshold=300]
  *      The number of milliseconds within which two pointer down-up event combinations
  *      will be treated as a double-click gesture.
  *
  * @property {Number} [dblClickDistThreshold=20]
  *      The maximum distance allowed between two pointer click events
  *      to be treated as a double-click gesture.
  *
  * @property {Number} [springStiffness=6.5]
  *
  * @property {Number} [animationTime=1.2]
  *     Specifies the animation duration per each {@link OpenSeadragon.Spring}
  *     which occur when the image is dragged, zoomed or rotated.
  *
  * @property {Boolean} [loadDestinationTilesOnAnimation=true]
  *    If true, tiles are loaded only at the destination of an animation.
  *    If false, tiles are loaded along the animation path during the animation.
  * @property {OpenSeadragon.GestureSettings} [gestureSettingsMouse]
  *     Settings for gestures generated by a mouse pointer device. (See {@link OpenSeadragon.GestureSettings})
  * @property {Boolean} [gestureSettingsMouse.dragToPan=true] - Pan on drag gesture
  * @property {Boolean} [gestureSettingsMouse.scrollToZoom=true] - Zoom on scroll gesture
  * @property {Boolean} [gestureSettingsMouse.clickToZoom=true] - Zoom on click gesture
  * @property {Boolean} [gestureSettingsMouse.dblClickToZoom=false] - Zoom on double-click gesture. Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.
  * @property {Boolean} [gestureSettingsMouse.dblClickDragToZoom=false] - Zoom on dragging through
  * double-click gesture ( single click and next click to drag).  Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.
  * @property {Boolean} [gestureSettingsMouse.pinchToZoom=false] - Zoom on pinch gesture
  * @property {Boolean} [gestureSettingsMouse.zoomToRefPoint=true] - If zoomToRefPoint is true, the zoom is centered at the pointer position. Otherwise,
  *     the zoom is centered at the canvas center.
  * @property {Boolean} [gestureSettingsMouse.flickEnabled=false] - Enable flick gesture
  * @property {Number} [gestureSettingsMouse.flickMinSpeed=120] - If flickEnabled is true, the minimum speed to initiate a flick gesture (pixels-per-second)
  * @property {Number} [gestureSettingsMouse.flickMomentum=0.25] - If flickEnabled is true, the momentum factor for the flick gesture
  * @property {Boolean} [gestureSettingsMouse.pinchRotate=false] - If pinchRotate is true, the user will have the ability to rotate the image using their fingers.
  *
  * @property {OpenSeadragon.GestureSettings} [gestureSettingsTouch]
  *     Settings for gestures generated by a touch pointer device. (See {@link OpenSeadragon.GestureSettings})
  * @property {Boolean} [gestureSettingsTouch.dragToPan=true] - Pan on drag gesture
  * @property {Boolean} [gestureSettingsTouch.scrollToZoom=false] - Zoom on scroll gesture
  * @property {Boolean} [gestureSettingsTouch.clickToZoom=false] - Zoom on click gesture
  * @property {Boolean} [gestureSettingsTouch.dblClickToZoom=true] - Zoom on double-click gesture. Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.
    * @property {Boolean} [gestureSettingsTouch.dblClickDragToZoom=true] - Zoom on dragging through
  * double-click gesture ( single click and next click to drag).  Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.

  * @property {Boolean} [gestureSettingsTouch.pinchToZoom=true] - Zoom on pinch gesture
  * @property {Boolean} [gestureSettingsTouch.zoomToRefPoint=true] - If zoomToRefPoint is true, the zoom is centered at the pointer position. Otherwise,
  *     the zoom is centered at the canvas center.
  * @property {Boolean} [gestureSettingsTouch.flickEnabled=true] - Enable flick gesture
  * @property {Number} [gestureSettingsTouch.flickMinSpeed=120] - If flickEnabled is true, the minimum speed to initiate a flick gesture (pixels-per-second)
  * @property {Number} [gestureSettingsTouch.flickMomentum=0.25] - If flickEnabled is true, the momentum factor for the flick gesture
  * @property {Boolean} [gestureSettingsTouch.pinchRotate=false] - If pinchRotate is true, the user will have the ability to rotate the image using their fingers.
  *
  * @property {OpenSeadragon.GestureSettings} [gestureSettingsPen]
  *     Settings for gestures generated by a pen pointer device. (See {@link OpenSeadragon.GestureSettings})
  * @property {Boolean} [gestureSettingsPen.dragToPan=true] - Pan on drag gesture
  * @property {Boolean} [gestureSettingsPen.scrollToZoom=false] - Zoom on scroll gesture
  * @property {Boolean} [gestureSettingsPen.clickToZoom=true] - Zoom on click gesture
  * @property {Boolean} [gestureSettingsPen.dblClickToZoom=false] - Zoom on double-click gesture. Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.
  * @property {Boolean} [gestureSettingsPen.pinchToZoom=false] - Zoom on pinch gesture
  * @property {Boolean} [gestureSettingsPen.zoomToRefPoint=true] - If zoomToRefPoint is true, the zoom is centered at the pointer position. Otherwise,
  *     the zoom is centered at the canvas center.
  * @property {Boolean} [gestureSettingsPen.flickEnabled=false] - Enable flick gesture
  * @property {Number} [gestureSettingsPen.flickMinSpeed=120] - If flickEnabled is true, the minimum speed to initiate a flick gesture (pixels-per-second)
  * @property {Number} [gestureSettingsPen.flickMomentum=0.25] - If flickEnabled is true, the momentum factor for the flick gesture
  * @property {Boolean} [gestureSettingsPen.pinchRotate=false] - If pinchRotate is true, the user will have the ability to rotate the image using their fingers.
  *
  * @property {OpenSeadragon.GestureSettings} [gestureSettingsUnknown]
  *     Settings for gestures generated by unknown pointer devices. (See {@link OpenSeadragon.GestureSettings})
  * @property {Boolean} [gestureSettingsUnknown.dragToPan=true] - Pan on drag gesture
  * @property {Boolean} [gestureSettingsUnknown.scrollToZoom=true] - Zoom on scroll gesture
  * @property {Boolean} [gestureSettingsUnknown.clickToZoom=false] - Zoom on click gesture
  * @property {Boolean} [gestureSettingsUnknown.dblClickToZoom=true] - Zoom on double-click gesture. Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.
  * @property {Boolean} [gestureSettingsUnknown.dblClickDragToZoom=false] - Zoom on dragging through
  * double-click gesture ( single click and next click to drag).  Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.
  * @property {Boolean} [gestureSettingsUnknown.pinchToZoom=true] - Zoom on pinch gesture
  * @property {Boolean} [gestureSettingsUnknown.zoomToRefPoint=true] - If zoomToRefPoint is true, the zoom is centered at the pointer position. Otherwise,
  *     the zoom is centered at the canvas center.
  * @property {Boolean} [gestureSettingsUnknown.flickEnabled=true] - Enable flick gesture
  * @property {Number} [gestureSettingsUnknown.flickMinSpeed=120] - If flickEnabled is true, the minimum speed to initiate a flick gesture (pixels-per-second)
  * @property {Number} [gestureSettingsUnknown.flickMomentum=0.25] - If flickEnabled is true, the momentum factor for the flick gesture
  * @property {Boolean} [gestureSettingsUnknown.pinchRotate=false] - If pinchRotate is true, the user will have the ability to rotate the image using their fingers.
  *
  * @property {Number} [zoomPerClick=2.0]
  *     The "zoom distance" per mouse click or touch tap. <em><strong>Note:</strong> Setting this to 1.0 effectively disables the click-to-zoom feature (also see gestureSettings[Mouse|Touch|Pen].clickToZoom/dblClickToZoom).</em>
  *
  * @property {Number} [zoomPerScroll=1.2]
  *     The "zoom distance" per mouse scroll or touch pinch. <em><strong>Note:</strong> Setting this to 1.0 effectively disables the mouse-wheel zoom feature (also see gestureSettings[Mouse|Touch|Pen].scrollToZoom}).</em>
  *
  * @property {Number} [zoomPerDblClickDrag=1.2]
  *     The "zoom distance" per double-click mouse drag. <em><strong>Note:</strong> Setting this to 1.0 effectively disables the double-click-drag-to-Zoom feature (also see gestureSettings[Mouse|Touch|Pen].dblClickDragToZoom).</em>
  *
  * @property {Number} [zoomPerSecond=1.0]
  *     Sets the zoom amount per second when zoomIn/zoomOut buttons are pressed and held.
  *     The value is a factor of the current zoom, so 1.0 (the default) disables zooming when the zoomIn/zoomOut buttons
  *     are held. Higher values will increase the rate of zoom when the zoomIn/zoomOut buttons are held. Note that values
  *     < 1.0 will reverse the operation of the zoomIn/zoomOut buttons (zoomIn button will decrease the zoom, zoomOut will
  *     increase the zoom).
  *
  * @property {Boolean} [showNavigator=false]
  *     Set to true to make the navigator minimap appear.
  *
  * @property {Element} [navigatorElement=null]
  *     The element to hold the navigator minimap.
  *     If an element is specified, the Id option (see navigatorId) is ignored.
  *     If no element nor ID is specified, a div element will be generated accordingly.
  *
  * @property {String} [navigatorId=navigator-GENERATED DATE]
  *     The ID of a div to hold the navigator minimap.
  *     If an ID is specified, the navigatorPosition, navigatorSizeRatio, navigatorMaintainSizeRatio, navigator[Top|Left|Height|Width] and navigatorAutoFade options will be ignored.
  *     If an ID is not specified, a div element will be generated and placed on top of the main image.
  *
  * @property {String} [navigatorPosition='TOP_RIGHT']
  *     Valid values are 'TOP_LEFT', 'TOP_RIGHT', 'BOTTOM_LEFT', 'BOTTOM_RIGHT', or 'ABSOLUTE'.<br>
  *     If 'ABSOLUTE' is specified, then navigator[Top|Left|Height|Width] determines the size and position of the navigator minimap in the viewer, and navigatorSizeRatio and navigatorMaintainSizeRatio are ignored.<br>
  *     For 'TOP_LEFT', 'TOP_RIGHT', 'BOTTOM_LEFT', and 'BOTTOM_RIGHT', the navigatorSizeRatio or navigator[Height|Width] values determine the size of the navigator minimap.
  *
  * @property {Number} [navigatorSizeRatio=0.2]
  *     Ratio of navigator size to viewer size. Ignored if navigator[Height|Width] are specified.
  *
  * @property {Boolean} [navigatorMaintainSizeRatio=false]
  *     If true, the navigator minimap is resized (using navigatorSizeRatio) when the viewer size changes.
  *
  * @property {Number|String} [navigatorTop=null]
  *     Specifies the location of the navigator minimap (see navigatorPosition).
  *
  * @property {Number|String} [navigatorLeft=null]
  *     Specifies the location of the navigator minimap (see navigatorPosition).
  *
  * @property {Number|String} [navigatorHeight=null]
  *     Specifies the size of the navigator minimap (see navigatorPosition).
  *     If specified, navigatorSizeRatio and navigatorMaintainSizeRatio are ignored.
  *
  * @property {Number|String} [navigatorWidth=null]
  *     Specifies the size of the navigator minimap (see navigatorPosition).
  *     If specified, navigatorSizeRatio and navigatorMaintainSizeRatio are ignored.
  *
  * @property {Boolean} [navigatorAutoFade=true]
  *     If the user stops interacting with the viewport, fade the navigator minimap.
  *     Setting to false will make the navigator minimap always visible.
  *
  * @property {Boolean} [navigatorRotate=true]
  *     If true, the navigator will be rotated together with the viewer.
  *
  * @property {String} [navigatorBackground='#000']
  *     Specifies the background color of the navigator minimap
  *
  * @property {Number} [navigatorOpacity=0.8]
  *     Specifies the opacity of the navigator minimap.
  *
  * @property {String} [navigatorBorderColor='#555']
  *     Specifies the border color of the navigator minimap
  *
  * @property {String} [navigatorDisplayRegionColor='#900']
  *     Specifies the border color of the display region rectangle of the navigator minimap
  *
  * @property {Number} [controlsFadeDelay=2000]
  *     The number of milliseconds to wait once the user has stopped interacting
  *     with the interface before beginning to fade the controls. Assumes
  *     showNavigationControl and autoHideControls are both true.
  *
  * @property {Number} [controlsFadeLength=1500]
  *     The number of milliseconds to animate the controls fading out.
  *
  * @property {Number} [maxImageCacheCount=200]
  *     The max number of images we should keep in memory (per drawer).
  *
  * @property {Number} [timeout=30000]
  *     The max number of milliseconds that an image job may take to complete.
  *
  * @property {Number} [tileRetryMax=0]
  *     The max number of retries when a tile download fails. By default it's 0, so retries are disabled.
  *
  * @property {Number} [tileRetryDelay=2500]
  *     Milliseconds to wait after each tile retry if tileRetryMax is set.
  *
  * @property {Boolean} [useCanvas=true]
  *     Deprecated. Use the `drawer` option to specify preferred renderer.
  *
  * @property {Number} [minPixelRatio=0.5]
  *     The higher the minPixelRatio, the lower the quality of the image that
  *     is considered sufficient to stop rendering a given zoom level.  For
  *     example, if you are targeting mobile devices with less bandwidth you may
  *     try setting this to 1.5 or higher.
  *
  * @property {Boolean} [mouseNavEnabled=true]
  *     Is the user able to interact with the image via mouse or touch. Default
  *     interactions include draging the image in a plane, and zooming in toward
  *     and away from the image.
  *
  * @property {boolean} [keyboardNavEnabled=true]
  *     Is the user able to interact with the image via keyboard.
  *
  * @property {Boolean} [showNavigationControl=true]
  *     Set to false to prevent the appearance of the default navigation controls.<br>
  *     Note that if set to false, the customs buttons set by the options
  *     zoomInButton, zoomOutButton etc, are rendered inactive.
  *
  * @property {OpenSeadragon.ControlAnchor} [navigationControlAnchor=TOP_LEFT]
  *     Placement of the default navigation controls.
  *     To set the placement of the sequence controls, see the
  *     sequenceControlAnchor option.
  *
  * @property {Boolean} [showZoomControl=true]
  *     If true then + and - buttons to zoom in and out are displayed.<br>
  *     Note: {@link OpenSeadragon.Options.showNavigationControl} is overriding
  *     this setting when set to false.
  *
  * @property {Boolean} [showHomeControl=true]
  *     If true then the 'Go home' button is displayed to go back to the original
  *     zoom and pan.<br>
  *     Note: {@link OpenSeadragon.Options.showNavigationControl} is overriding
  *     this setting when set to false.
  *
  * @property {Boolean} [showFullPageControl=true]
  *     If true then the 'Toggle full page' button is displayed to switch
  *     between full page and normal mode.<br>
  *     Note: {@link OpenSeadragon.Options.showNavigationControl} is overriding
  *     this setting when set to false.
  *
  * @property {Boolean} [showRotationControl=false]
  *     If true then the rotate left/right controls will be displayed as part of the
  *     standard controls. This is also subject to the browser support for rotate
  *     (e.g. viewer.drawer.canRotate()).<br>
  *     Note: {@link OpenSeadragon.Options.showNavigationControl} is overriding
  *     this setting when set to false.
  *
  * @property {Boolean} [showFlipControl=false]
  *     If true then the flip controls will be displayed as part of the
  *     standard controls.
  *
  * @property {Boolean} [showSequenceControl=true]
  *     If sequenceMode is true, then provide buttons for navigating forward and
  *     backward through the images.
  *
  * @property {OpenSeadragon.ControlAnchor} [sequenceControlAnchor=TOP_LEFT]
  *     Placement of the default sequence controls.
  *
  * @property {Boolean} [navPrevNextWrap=false]
  *     If true then the 'previous' button will wrap to the last image when
  *     viewing the first image and the 'next' button will wrap to the first
  *     image when viewing the last image.
  *
  *@property {String|Element} zoomInButton
  *     Set the id or element of the custom 'Zoom in' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {String|Element} zoomOutButton
  *     Set the id or element of the custom 'Zoom out' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {String|Element} homeButton
  *     Set the id or element of the custom 'Go home' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {String|Element} fullPageButton
  *     Set the id or element of the custom 'Toggle full page' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {String|Element} rotateLeftButton
  *     Set the id or element of the custom 'Rotate left' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {String|Element} rotateRightButton
  *     Set the id or element of the custom 'Rotate right' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {String|Element} previousButton
  *     Set the id or element of the custom 'Previous page' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {String|Element} nextButton
  *     Set the id or element of the custom 'Next page' button to use.
  *     This is useful to have a custom button anywhere in the web page.<br>
  *     To only change the button images, consider using
  *     {@link OpenSeadragon.Options.navImages}
  *
  * @property {Boolean} [sequenceMode=false]
  *     Set to true to have the viewer treat your tilesources as a sequence of images to
  *     be opened one at a time rather than all at once.
  *
  * @property {Number} [initialPage=0]
  *     If sequenceMode is true, display this page initially.
  *
  * @property {Boolean} [preserveViewport=false]
  *     If sequenceMode is true, then normally navigating through each image resets the
  *     viewport to 'home' position.  If preserveViewport is set to true, then the viewport
  *     position is preserved when navigating between images in the sequence.
  *
  * @property {Boolean} [preserveOverlays=false]
  *     If sequenceMode is true, then normally navigating through each image
  *     resets the overlays.
  *     If preserveOverlays is set to true, then the overlays added with {@link OpenSeadragon.Viewer#addOverlay}
  *     are preserved when navigating between images in the sequence.
  *     Note: setting preserveOverlays overrides any overlays specified in the global
  *     "overlays" option for the Viewer. It's also not compatible with specifying
  *     per-tileSource overlays via the options, as those overlays will persist
  *     even after the tileSource is closed.
  *
  * @property {Boolean} [showReferenceStrip=false]
  *     If sequenceMode is true, then display a scrolling strip of image thumbnails for
  *     navigating through the images.
  *
  * @property {String} [referenceStripScroll='horizontal']
  *
  * @property {Element} [referenceStripElement=null]
  *
  * @property {Number} [referenceStripHeight=null]
  *
  * @property {Number} [referenceStripWidth=null]
  *
  * @property {String} [referenceStripPosition='BOTTOM_LEFT']
  *
  * @property {Number} [referenceStripSizeRatio=0.2]
  *
  * @property {Boolean} [collectionMode=false]
  *     Set to true to have the viewer arrange your TiledImages in a grid or line.
  *
  * @property {Number} [collectionRows=3]
  *     If collectionMode is true, specifies how many rows the grid should have. Use 1 to make a line.
  *     If collectionLayout is 'vertical', specifies how many columns instead.
  *
  * @property {Number} [collectionColumns=0]
  *     If collectionMode is true, specifies how many columns the grid should have. Use 1 to make a line.
  *     If collectionLayout is 'vertical', specifies how many rows instead. Ignored if collectionRows is not set to a falsy value.
  *
  * @property {String} [collectionLayout='horizontal']
  *     If collectionMode is true, specifies whether to arrange vertically or horizontally.
  *
  * @property {Number} [collectionTileSize=800]
  *     If collectionMode is true, specifies the size, in viewport coordinates, for each TiledImage to fit into.
  *     The TiledImage will be centered within a square of the specified size.
  *
  * @property {Number} [collectionTileMargin=80]
  *     If collectionMode is true, specifies the margin, in viewport coordinates, between each TiledImage.
  *
  * @property {String|Boolean} [crossOriginPolicy=false]
  *     Valid values are 'Anonymous', 'use-credentials', and false. If false, canvas requests will
  *     not use CORS, and the canvas will be tainted.
  *
  * @property {Boolean} [ajaxWithCredentials=false]
  *     Whether to set the withCredentials XHR flag for AJAX requests.
  *     Note that this can be overridden at the {@link OpenSeadragon.TileSource} level.
  *
  * @property {Boolean} [loadTilesWithAjax=false]
  *     Whether to load tile data using AJAX requests.
  *     Note that this can be overridden at the {@link OpenSeadragon.TileSource} level.
  *
  * @property {Object} [ajaxHeaders={}]
  *     A set of headers to include when making AJAX requests for tile sources or tiles.
  *
  * @property {Boolean} [splitHashDataForPost=false]
  *     Allows to treat _first_ hash ('#') symbol as a separator for POST data:
  *     URL to be opened by a {@link OpenSeadragon.TileSource} can thus look like: http://some.url#postdata=here.
  *     The whole URL is used to fetch image info metadata and it is then split to 'http://some.url' and
  *     'postdata=here'; post data is given to the {@link OpenSeadragon.TileSource} of the choice and can be further
  *     used within tile requests (see TileSource methods).
  *     NOTE: {@link OpenSeadragon.TileSource.prototype.configure} return value should contain the post data
  *     if you want to use it later - so that it is given to your constructor later.
  *     NOTE: usually, post data is expected to be ampersand-separated (just like GET parameters), and is NOT USED
  *     to fetch tile image data unless explicitly programmed, or if loadTilesWithAjax=false 4
  *     (but it is still used for the initial image info request).
  *     NOTE: passing POST data from URL by this feature only supports string values, however,
  *     TileSource can send any data using POST as long as the header is correct
  *     (@see OpenSeadragon.TileSource.prototype.getTilePostData)
  *
  * @property {Boolean} [callTileLoadedWithCachedData=false]
  *     tile-loaded event is called only for tiles that downloaded new data or
  *     their data is stored in the original form in a suplementary cache object.
  *     Caches that render directly from re-used cache does not trigger this event again,
  *     as possible modifications would be applied twice.
  */

 /**
  * Settings for gestures generated by a pointer device.
  *
  * @typedef {Object} GestureSettings
  * @memberof OpenSeadragon
  *
  * @property {Boolean} dragToPan
  *     Set to false to disable panning on drag gestures.
  *
  * @property {Boolean} scrollToZoom
  *     Set to false to disable zooming on scroll gestures.
  *
  * @property {Boolean} clickToZoom
  *     Set to false to disable zooming on click gestures.
  *
  * @property {Boolean} dblClickToZoom
  *     Set to false to disable zooming on double-click gestures. Note: If set to true
  *     then clickToZoom should be set to false to prevent multiple zooms.
  *
  * @property {Boolean} pinchToZoom
  *     Set to false to disable zooming on pinch gestures.
  *
  * @property {Boolean} flickEnabled
  *     Set to false to disable the kinetic panning effect (flick) at the end of a drag gesture.
  *
  * @property {Number} flickMinSpeed
  *     If flickEnabled is true, the minimum speed (in pixels-per-second) required to cause the kinetic panning effect (flick) at the end of a drag gesture.
  *
  * @property {Number} flickMomentum
  *     If flickEnabled is true, a constant multiplied by the velocity to determine the distance of the kinetic panning effect (flick) at the end of a drag gesture.
  *     A larger value will make the flick feel "lighter", while a smaller value will make the flick feel "heavier".
  *     Note: springStiffness and animationTime also affect the "spring" used to stop the flick animation.
  *
  */

/**
 * @typedef {OpenSeadragon.BaseDrawerOptions} OpenSeadragon.WebGLDrawerOptions
 * @memberof OpenSeadragon
 * @property {Boolean} [unpackWithPremultipliedAlpha=false]
 *  Whether to enable gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL when uploading textures.
 */

/**
 * @typedef {Object.<string, OpenSeadragon.BaseDrawerOptions>} DrawerOptions
 * Can support any drawer key as long as a drawer is registered with the drawer id = map key.
 * Therefore, one can register a new drawer that extends a drawer base and submit a custom key in the options.
 * @memberof OpenSeadragon
 * @property {OpenSeadragon.WebGLDrawerOptions} webgl - options if the WebGLDrawer is used.
 * @property {OpenSeadragon.BaseDrawerOptions} canvas - options if the CanvasDrawer is used.
 * @property {OpenSeadragon.BaseDrawerOptions} html - options if the HTMLDrawer is used.
 * @property {OpenSeadragon.BaseDrawerOptions} custom - options if a custom drawer is used.
 */


/**
  * The names for the image resources used for the image navigation buttons.
  *
  * @typedef {Object} NavImages
  * @memberof OpenSeadragon
  *
  * @property {Object} zoomIn - Images for the zoom-in button.
  * @property {String} zoomIn.REST
  * @property {String} zoomIn.GROUP
  * @property {String} zoomIn.HOVER
  * @property {String} zoomIn.DOWN
  *
  * @property {Object} zoomOut - Images for the zoom-out button.
  * @property {String} zoomOut.REST
  * @property {String} zoomOut.GROUP
  * @property {String} zoomOut.HOVER
  * @property {String} zoomOut.DOWN
  *
  * @property {Object} home - Images for the home button.
  * @property {String} home.REST
  * @property {String} home.GROUP
  * @property {String} home.HOVER
  * @property {String} home.DOWN
  *
  * @property {Object} fullpage - Images for the full-page button.
  * @property {String} fullpage.REST
  * @property {String} fullpage.GROUP
  * @property {String} fullpage.HOVER
  * @property {String} fullpage.DOWN
  *
  * @property {Object} rotateleft - Images for the rotate left button.
  * @property {String} rotateleft.REST
  * @property {String} rotateleft.GROUP
  * @property {String} rotateleft.HOVER
  * @property {String} rotateleft.DOWN
  *
  * @property {Object} rotateright - Images for the rotate right button.
  * @property {String} rotateright.REST
  * @property {String} rotateright.GROUP
  * @property {String} rotateright.HOVER
  * @property {String} rotateright.DOWN
  *
  * @property {Object} flip - Images for the flip button.
  * @property {String} flip.REST
  * @property {String} flip.GROUP
  * @property {String} flip.HOVER
  * @property {String} flip.DOWN
  *
  * @property {Object} previous - Images for the previous button.
  * @property {String} previous.REST
  * @property {String} previous.GROUP
  * @property {String} previous.HOVER
  * @property {String} previous.DOWN
  *
  * @property {Object} next - Images for the next button.
  * @property {String} next.REST
  * @property {String} next.GROUP
  * @property {String} next.HOVER
  * @property {String} next.DOWN
  *
  */

/* eslint-disable no-redeclare */
function OpenSeadragon( options ){
    return new OpenSeadragon.Viewer( options );
}

(function( $ ){


    /**
     * The OpenSeadragon version.
     *
     * @member {Object} OpenSeadragon.version
     * @property {String} versionStr - The version number as a string ('major.minor.revision').
     * @property {Number} major - The major version number.
     * @property {Number} minor - The minor version number.
     * @property {Number} revision - The revision number.
     * @since 1.0.0
     */
    $.version = {
        versionStr: '6.0.2',
        major: parseInt('6', 10),
        minor: parseInt('0', 10),
        revision: parseInt('2', 10)
    };


    /**
     * Taken from jquery 1.6.1
     * [[Class]] -> type pairs
     * @private
     */
    const class2type = {
            '[object Boolean]':                  'boolean',
            '[object Number]':                   'number',
            '[object String]':                   'string',
            '[object Function]':                 'function',
            '[object AsyncFunction]':            'function',
            '[object Promise]':                  'promise',
            '[object Array]':                    'array',
            '[object Date]':                     'date',
            '[object RegExp]':                   'regexp',
            '[object Object]':                   'object',
            '[object HTMLUnknownElement]':       'dom-node',
            '[object HTMLImageElement]':         'image',
            '[object HTMLCanvasElement]':        'canvas',
            '[object CanvasRenderingContext2D]': 'context2d'
        };
        // Save a reference to some core methods
    const toString    = Object.prototype.toString;
    const hasOwn      = Object.prototype.hasOwnProperty;

    /**
     * Taken from jQuery 1.6.1
     * @function isFunction
     * @memberof OpenSeadragon
     * @see {@link http://www.jquery.com/ jQuery}
     */
    $.isFunction = function( obj ) {
        return $.type(obj) === "function";
    };

    /**
     * Taken from jQuery 1.6.1
     * @function isArray
     * @memberof OpenSeadragon
     * @see {@link http://www.jquery.com/ jQuery}
     */
    $.isArray = Array.isArray || function( obj ) {
        return $.type(obj) === "array";
    };


    /**
     * A crude way of determining if an object is a window.
     * Taken from jQuery 1.6.1
     * @function isWindow
     * @memberof OpenSeadragon
     * @see {@link http://www.jquery.com/ jQuery}
     */
    $.isWindow = function( obj ) {
        return obj && typeof obj === "object" && "setInterval" in obj;
    };


    /**
     * Taken from jQuery 1.6.1
     * @function type
     * @memberof OpenSeadragon
     * @see {@link http://www.jquery.com/ jQuery}
     */
    $.type = function( obj ) {
        return ( obj === null ) || ( obj === undefined ) ?
            String( obj ) :
            class2type[ toString.call(obj) ] || "object";
    };


    /**
     * Taken from jQuery 1.6.1
     * @function isPlainObject
     * @memberof OpenSeadragon
     * @see {@link http://www.jquery.com/ jQuery}
     */
    $.isPlainObject = function( obj ) {
        // Must be an Object.
        // Because of IE, we also have to check the presence of the constructor property.
        // Make sure that DOM nodes and window objects don't pass through, as well
        if ( !obj || OpenSeadragon.type(obj) !== "object" || obj.nodeType || $.isWindow( obj ) ) {
            return false;
        }

        // Not own constructor property must be Object
        if ( obj.constructor &&
            !hasOwn.call(obj, "constructor") &&
            !hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) {
            return false;
        }

        // Own properties are enumerated firstly, so to speed up,
        // if last one is own, then all properties are own.

        let lastKey;
        for (const key in obj ) {
            lastKey = key;
        }

        return lastKey === undefined || hasOwn.call( obj, lastKey );
    };


    /**
     * Taken from jQuery 1.6.1
     * @function isEmptyObject
     * @memberof OpenSeadragon
     * @see {@link http://www.jquery.com/ jQuery}
     */
    $.isEmptyObject = function( obj ) {
        for ( const name in obj ) {
            return false;
        }
        return true;
    };

    /**
     * Shim around Object.freeze. Does nothing if Object.freeze is not supported.
     * @param {Object} obj The object to freeze.
     * @returns {Object} obj The frozen object.
     */
    $.freezeObject = function(obj) {
        if (Object.freeze) {
            $.freezeObject = Object.freeze;
        } else {
            $.freezeObject = function(obj) {
                return obj;
            };
        }
        return $.freezeObject(obj);
    };

    /**
     * True if the browser supports the HTML5 canvas element
     * @member {Boolean} supportsCanvas
     * @memberof OpenSeadragon
     */
    $.supportsCanvas = (function () {
        const canvasElement = document.createElement( 'canvas' );
        return !!( $.isFunction( canvasElement.getContext ) &&
                    canvasElement.getContext( '2d' ) );
    }());

    /**
     * Test whether the submitted canvas is tainted or not.
     * @argument {Canvas} canvas The canvas to test.
     * @returns {Boolean} True if the canvas is tainted.
     */
    $.isCanvasTainted = function(canvas) {
        let isTainted = false;
        try {
            // We test if the canvas is tainted by retrieving data from it.
            // An exception will be raised if the canvas is tainted.
            canvas.getContext('2d').getImageData(0, 0, 1, 1);
        } catch (e) {
            isTainted = true;
        }
        return isTainted;
    };

    /**
     * True if the browser supports the EventTarget.addEventListener() method
     * @member {Boolean} supportsAddEventListener
     * @memberof OpenSeadragon
     */
    $.supportsAddEventListener = (function () {
        return !!(document.documentElement.addEventListener && document.addEventListener);
    }());

    /**
     * True if the browser supports the EventTarget.removeEventListener() method
     * @member {Boolean} supportsRemoveEventListener
     * @memberof OpenSeadragon
     */
    $.supportsRemoveEventListener = (function () {
        return !!(document.documentElement.removeEventListener && document.removeEventListener);
    }());

    /**
     * True if the browser supports the newer EventTarget.addEventListener options argument
     * @member {Boolean} supportsEventListenerOptions
     * @memberof OpenSeadragon
     */
    $.supportsEventListenerOptions = (function () {
        let supported = 0;

        if ( $.supportsAddEventListener ) {
            try {
                const options = {
                    get capture() {
                        supported++;
                        return false;
                    },
                    get once() {
                        supported++;
                        return false;
                    },
                    get passive() {
                        supported++;
                        return false;
                    }
                };
                window.addEventListener("test", null, options);
                window.removeEventListener("test", null, options);
            } catch ( e ) {
                supported = 0;
            }
        }

        return supported >= 3;
    }());

    /**
     * If true, OpenSeadragon uses async execution, else it uses synchronous execution.
     * Note that disabling async means no plugins that use Promises / async will work with OSD.
     * @member {boolean}
     * @memberof OpenSeadragon
     */
    $.supportsAsync = true;

    /**
     * A ratio comparing the device screen's pixel density to the canvas's backing store pixel density,
     * clamped to a minimum of 1. Defaults to 1 if canvas isn't supported by the browser.
     * @function getCurrentPixelDensityRatio
     * @memberof OpenSeadragon
     * @returns {Number}
     */
    $.getCurrentPixelDensityRatio = function() {
        if ( $.supportsCanvas ) {
            const context = document.createElement('canvas').getContext('2d');
            const devicePixelRatio = window.devicePixelRatio || 1;
            const backingStoreRatio = context.webkitBackingStorePixelRatio ||
                                    context.mozBackingStorePixelRatio ||
                                    context.msBackingStorePixelRatio ||
                                    context.oBackingStorePixelRatio ||
                                    context.backingStorePixelRatio || 1;
            return Math.max(devicePixelRatio, 1) / backingStoreRatio;
        } else {
            return 1;
        }
    };

    /**
     * A ratio comparing the device screen's pixel density to the canvas's backing store pixel density,
     * clamped to a minimum of 1. Defaults to 1 if canvas isn't supported by the browser.
     * @member {Number} pixelDensityRatio
     * @memberof OpenSeadragon
     */
    $.pixelDensityRatio = $.getCurrentPixelDensityRatio();

}( OpenSeadragon ));

/**
 *  This closure defines all static methods available to the OpenSeadragon
 *  namespace.  Many, if not most, are taken directly from jQuery for use
 *  to simplify and reduce common programming patterns.  More static methods
 *  from jQuery may eventually make their way into this though we are
 *  attempting to avoid an explicit dependency on jQuery only because
 *  OpenSeadragon is a broadly useful code base and would be made less broad
 *  by requiring jQuery fully.
 *
 *  Some static methods have also been refactored from the original OpenSeadragon
 *  project.
 */
(function( $ ){

    /**
     * Taken from jQuery 1.6.1
     * @function extend
     * @memberof OpenSeadragon
     * @see {@link http://www.jquery.com/ jQuery}
     */
    $.extend = function() {
        let options;
        let name;
        let src;
        let copy;
        let copyIsArray;
        let clone;
        let target = arguments[ 0 ] || {};
        const length = arguments.length;
        let deep = false;
        let i = 1;

        // Handle a deep copy situation
        if ( typeof target === "boolean" ) {
            deep    = target;
            target  = arguments[ 1 ] || {};
            // skip the boolean and the target
            i = 2;
        }

        // Handle case when target is a string or something (possible in deep copy)
        if ( typeof target !== "object" && !OpenSeadragon.isFunction( target ) ) {
            target = {};
        }

        // extend jQuery itself if only one argument is passed
        if ( length === i ) {
            target = this;
            --i;
        }

        for ( ; i < length; i++ ) {
            // Only deal with non-null/undefined values
            options = arguments[ i ];
            if ( options !== null || options !== undefined ) {
                // Extend the base object
                for ( name in options ) {
                    const descriptor = Object.getOwnPropertyDescriptor(options, name);

                    if (descriptor !== undefined) {
                        if (descriptor.get || descriptor.set) {
                            Object.defineProperty(target, name, descriptor);
                            continue;
                        }

                        copy = descriptor.value;
                    } else {
                        $.console.warn('Could not copy inherited property "' + name + '".');
                        continue;
                    }

                    // Prevent never-ending loop
                    if ( target === copy ) {
                        continue;
                    }

                    // Recurse if we're merging plain objects or arrays
                    if ( deep && copy && ( OpenSeadragon.isPlainObject( copy ) || ( copyIsArray = OpenSeadragon.isArray( copy ) ) ) ) {
                        src = target[ name ];

                        if ( copyIsArray ) {
                            copyIsArray = false;
                            clone = src && OpenSeadragon.isArray( src ) ? src : [];

                        } else {
                            clone = src && OpenSeadragon.isPlainObject( src ) ? src : {};
                        }

                        // Never move original objects, clone them
                        target[ name ] = OpenSeadragon.extend( deep, clone, copy );

                    // Don't bring in undefined values
                    } else if ( copy !== undefined ) {
                        target[ name ] = copy;
                    }
                }
            }
        }

        // Return the modified object
        return target;
    };

    const isIOSDevice = function () {
        if (typeof navigator !== 'object') {
            return false;
        }
        const userAgent = navigator.userAgent;
        if (typeof userAgent !== 'string') {
            return false;
        }
        return userAgent.indexOf('iPhone') !== -1 ||
               userAgent.indexOf('iPad') !== -1 ||
               userAgent.indexOf('iPod') !== -1;
    };

    $.extend( $, /** @lends OpenSeadragon */{
        /**
         * The default values for the optional settings documented at {@link OpenSeadragon.Options}.
         * @static
         * @type {Object}
         */
        DEFAULT_SETTINGS: {
            //DATA SOURCE DETAILS
            xmlPath:                null,
            tileSources:            null,
            tileHost:               null,
            initialPage:            0,
            crossOriginPolicy:      false,
            ajaxWithCredentials:    false,
            loadTilesWithAjax:      false,
            ajaxHeaders:            {},
            splitHashDataForPost:   false,
            callTileLoadedWithCachedData: false,

            //PAN AND ZOOM SETTINGS AND CONSTRAINTS
            panHorizontal:          true,
            panVertical:            true,
            constrainDuringPan:     false,
            wrapHorizontal:         false,
            wrapVertical:           false,
            visibilityRatio:        0.5, //-> how much of the viewer can be negative space
            minPixelRatio:          0.5, //->closer to 0 draws tiles meant for a higher zoom at this zoom
            defaultZoomLevel:       0,
            minZoomLevel:           null,
            maxZoomLevel:           null,
            homeFillsViewer:        false,

            //UI RESPONSIVENESS AND FEEL
            clickTimeThreshold:     300,
            clickDistThreshold:     5,
            dblClickTimeThreshold:  300,
            dblClickDistThreshold:  20,
            springStiffness:        6.5,
            animationTime:          1.2,
            loadDestinationTilesOnAnimation: true,
            gestureSettingsMouse:   {
                dragToPan: true,
                scrollToZoom: true,
                clickToZoom: true,
                dblClickToZoom: false,
                dblClickDragToZoom: false,
                pinchToZoom: false,
                zoomToRefPoint: true,
                flickEnabled: false,
                flickMinSpeed: 120,
                flickMomentum: 0.25,
                pinchRotate: false
            },
            gestureSettingsTouch:   {
                dragToPan: true,
                scrollToZoom: false,
                clickToZoom: false,
                dblClickToZoom: true,
                dblClickDragToZoom: true,
                pinchToZoom: true,
                zoomToRefPoint: true,
                flickEnabled: true,
                flickMinSpeed: 120,
                flickMomentum: 0.25,
                pinchRotate: false
            },
            gestureSettingsPen:     {
                dragToPan: true,
                scrollToZoom: false,
                clickToZoom: true,
                dblClickToZoom: false,
                dblClickDragToZoom: false,
                pinchToZoom: false,
                zoomToRefPoint: true,
                flickEnabled: false,
                flickMinSpeed: 120,
                flickMomentum: 0.25,
                pinchRotate: false
            },
            gestureSettingsUnknown: {
                dragToPan: true,
                scrollToZoom: false,
                clickToZoom: false,
                dblClickToZoom: true,
                dblClickDragToZoom: false,
                pinchToZoom: true,
                zoomToRefPoint: true,
                flickEnabled: true,
                flickMinSpeed: 120,
                flickMomentum: 0.25,
                pinchRotate: false
            },
            zoomPerClick:           2,
            zoomPerScroll:          1.2,
            zoomPerDblClickDrag:    1.2,
            zoomPerSecond:          1.0,
            blendTime:              0,
            alwaysBlend:            false,
            autoHideControls:       true,
            immediateRender:        false,
            minZoomImageRatio:      0.9, //-> closer to 0 allows zoom out to infinity
            maxZoomPixelRatio:      1.1, //-> higher allows 'over zoom' into pixels
            smoothTileEdgesMinZoom: 1.1, //-> higher than maxZoomPixelRatio disables it
            iOSDevice:              isIOSDevice(),
            pixelsPerWheelLine:     40,
            pixelsPerArrowPress:    40,
            autoResize:             true,
            preserveImageSizeOnResize: false, // requires autoResize=true
            minScrollDeltaTime:     50,
            rotationIncrement:      90,
            maxTilesPerFrame:       1,

            //DEFAULT CONTROL SETTINGS
            showSequenceControl:     true,  //SEQUENCE
            sequenceControlAnchor:   null,  //SEQUENCE
            preserveViewport:        false, //SEQUENCE
            preserveOverlays:        false, //SEQUENCE
            navPrevNextWrap:         false, //SEQUENCE
            showNavigationControl:   true,  //ZOOM/HOME/FULL/ROTATION
            navigationControlAnchor: null,  //ZOOM/HOME/FULL/ROTATION
            showZoomControl:         true,  //ZOOM
            showHomeControl:         true,  //HOME
            showFullPageControl:     true,  //FULL
            showRotationControl:     false, //ROTATION
            showFlipControl:         false,  //FLIP
            controlsFadeDelay:       2000,  //ZOOM/HOME/FULL/SEQUENCE
            controlsFadeLength:      1500,  //ZOOM/HOME/FULL/SEQUENCE
            mouseNavEnabled:         true,  //GENERAL MOUSE INTERACTIVITY
            keyboardNavEnabled:      true,  //GENERAL KEYBOARD INTERACTIVITY

            //VIEWPORT NAVIGATOR SETTINGS
            showNavigator:              false,
            navigatorElement:           null,
            navigatorId:                null,
            navigatorPosition:          null,
            navigatorSizeRatio:         0.2,
            navigatorMaintainSizeRatio: false,
            navigatorTop:               null,
            navigatorLeft:              null,
            navigatorHeight:            null,
            navigatorWidth:             null,
            navigatorAutoFade:          true,
            navigatorRotate:            true,
            navigatorBackground:        '#000',
            navigatorOpacity:           0.8,
            navigatorBorderColor:       '#555',
            navigatorDisplayRegionColor: '#900',

            // INITIAL ROTATION
            degrees:                    0,

            // INITIAL FLIP STATE
            flipped:                          false,
            overlayPreserveContentDirection:  true,

            // APPEARANCE
            opacity:                           1, // to be passed into each TiledImage
            compositeOperation:                null, // to be passed into each TiledImage

            // DRAWER SETTINGS
            drawer:                            ['auto', 'webgl', 'canvas', 'html'], // prefer using auto, then webgl (with WebGL2 if available), then canvas (i.e. context2d), then fallback to html
            // DRAWER CONFIGURATIONS
            drawerOptions: {
                // [drawer-id]: {options} map
            },

            // TILED IMAGE SETTINGS
            preload:                           false, // to be passed into each TiledImage
            imageSmoothingEnabled:             true,  // to be passed into each TiledImage
            placeholderFillStyle:              null,  // to be passed into each TiledImage
            subPixelRoundingForTransparency:   null,  // to be passed into each TiledImage

            //REFERENCE STRIP SETTINGS
            showReferenceStrip:          false,
            referenceStripScroll:       'horizontal',
            referenceStripElement:       null,
            referenceStripHeight:        null,
            referenceStripWidth:         null,
            referenceStripPosition:      'BOTTOM_LEFT',
            referenceStripSizeRatio:     0.2,

            //COLLECTION VISUALIZATION SETTINGS
            collectionRows:         3, //or columns depending on layout
            collectionColumns:      0, //columns in horizontal layout, rows in vertical layout
            collectionLayout:       'horizontal', //vertical
            collectionMode:         false,
            collectionTileSize:     800,
            collectionTileMargin:   80,

            //PERFORMANCE SETTINGS
            imageLoaderLimit:       0,
            maxImageCacheCount:     200,
            timeout:                30000,
            tileRetryMax:           0,
            tileRetryDelay:         2500,

            //INTERFACE RESOURCE SETTINGS
            prefixUrl:              "/images/",
            navImages: {
                zoomIn: {
                    REST:   'zoomin_rest.png',
                    GROUP:  'zoomin_grouphover.png',
                    HOVER:  'zoomin_hover.png',
                    DOWN:   'zoomin_pressed.png'
                },
                zoomOut: {
                    REST:   'zoomout_rest.png',
                    GROUP:  'zoomout_grouphover.png',
                    HOVER:  'zoomout_hover.png',
                    DOWN:   'zoomout_pressed.png'
                },
                home: {
                    REST:   'home_rest.png',
                    GROUP:  'home_grouphover.png',
                    HOVER:  'home_hover.png',
                    DOWN:   'home_pressed.png'
                },
                fullpage: {
                    REST:   'fullpage_rest.png',
                    GROUP:  'fullpage_grouphover.png',
                    HOVER:  'fullpage_hover.png',
                    DOWN:   'fullpage_pressed.png'
                },
                rotateleft: {
                    REST:   'rotateleft_rest.png',
                    GROUP:  'rotateleft_grouphover.png',
                    HOVER:  'rotateleft_hover.png',
                    DOWN:   'rotateleft_pressed.png'
                },
                rotateright: {
                    REST:   'rotateright_rest.png',
                    GROUP:  'rotateright_grouphover.png',
                    HOVER:  'rotateright_hover.png',
                    DOWN:   'rotateright_pressed.png'
                },
                flip: { // Flip icon designed by Yaroslav Samoylov from the Noun Project and modified by Nelson Campos ncampos@criteriamarathon.com, https://thenounproject.com/term/flip/136289/
                    REST:   'flip_rest.png',
                    GROUP:  'flip_grouphover.png',
                    HOVER:  'flip_hover.png',
                    DOWN:   'flip_pressed.png'
                },
                previous: {
                    REST:   'previous_rest.png',
                    GROUP:  'previous_grouphover.png',
                    HOVER:  'previous_hover.png',
                    DOWN:   'previous_pressed.png'
                },
                next: {
                    REST:   'next_rest.png',
                    GROUP:  'next_grouphover.png',
                    HOVER:  'next_hover.png',
                    DOWN:   'next_pressed.png'
                }
            },

            //DEVELOPER SETTINGS
            debugMode:              false,
            debugGridColor:         ['#437AB2', '#1B9E77', '#D95F02', '#7570B3', '#E7298A', '#66A61E', '#E6AB02', '#A6761D', '#666666'],
            silenceMultiImageWarnings: false

        },

        /**
         * Returns a function which invokes the method as if it were a method belonging to the object.
         * @function
         * @param {Object} object
         * @param {Function} method
         * @returns {Function}
         */
        delegate: function( object, method ) {
            return function(){
                let args = arguments;
                if ( args === undefined ){
                    args = [];
                }
                return method.apply( object, args );
            };
        },


        /**
         * An enumeration of Browser vendors.
         * @static
         * @type {Object}
         * @property {Number} UNKNOWN
         * @property {Number} IE
         * @property {Number} FIREFOX
         * @property {Number} SAFARI
         * @property {Number} CHROME
         * @property {Number} OPERA
         * @property {Number} EDGE
         * @property {Number} CHROMEEDGE
         */
        BROWSERS: {
            UNKNOWN:    0,
            IE:         1,
            FIREFOX:    2,
            SAFARI:     3,
            CHROME:     4,
            OPERA:      5,
            EDGE:       6,
            CHROMEEDGE: 7
        },

        /**
         * An enumeration of when subpixel rounding should occur.
         * @static
         * @type {Object}
         * @property {Number} NEVER Never apply subpixel rounding for transparency.
         * @property {Number} ONLY_AT_REST Do not apply subpixel rounding for transparency during animation (panning, zoom, rotation) and apply it once animation is over.
         * @property {Number} ALWAYS Apply subpixel rounding for transparency during animation and when animation is over.
         */
        SUBPIXEL_ROUNDING_OCCURRENCES: {
            NEVER:        0,
            ONLY_AT_REST: 1,
            ALWAYS:       2
        },

        /**
         * Keep track of which {@link Viewer}s have been created.
         * - Key: {@link Element} to which a Viewer is attached.
         * - Value: {@link Viewer} of the element defined by the key.
         * @private
         * @static
         * @type {Object}
         */
        _viewers: new Map(),

       /**
         * Returns the {@link Viewer} attached to a given DOM element. If there is
         * no viewer attached to the provided element, undefined is returned.
         * @function
         * @param {String|Element} element Accepts an id or element.
         * @returns {Viewer} The viewer attached to the given element, or undefined.
         */
        getViewer: function(element) {
            return $._viewers.get(this.getElement(element));
        },

        /**
         * Returns a DOM Element for the given id or element.
         * @function
         * @param {String|Element} element Accepts an id or element.
         * @returns {Element} The element with the given id, null, or the element itself.
         */
        getElement: function( element ) {
            if ( typeof ( element ) === "string" ) {
                element = document.getElementById( element );
            }
            return element;
        },


        /**
         * Determines the position of the upper-left corner of the element.
         * @function
         * @param {Element|String} element - the element we want the position for.
         * @returns {OpenSeadragon.Point} - the position of the upper left corner of the element.
         */
        getElementPosition: function( element ) {
            let result = new $.Point();
            let isFixed;
            let offsetParent;

            element      = $.getElement( element );
            isFixed      = $.getElementStyle( element ).position === "fixed";
            offsetParent = getOffsetParent( element, isFixed );

            while ( offsetParent ) {

                result.x += element.offsetLeft;
                result.y += element.offsetTop;

                if ( isFixed ) {
                    result = result.plus( $.getPageScroll() );
                }

                element = offsetParent;
                isFixed = $.getElementStyle( element ).position === "fixed";
                offsetParent = getOffsetParent( element, isFixed );
            }

            return result;
        },


        /**
         * Determines the position of the upper-left corner of the element adjusted for current page and/or element scroll.
         * @function
         * @param {Element|String} element - the element we want the position for.
         * @returns {OpenSeadragon.Point} - the position of the upper left corner of the element adjusted for current page and/or element scroll.
         */
        getElementOffset: function( element ) {
            element = $.getElement( element );

            const doc = element && element.ownerDocument;
            let boundingRect = { top: 0, left: 0 };

            if ( !doc ) {
                return new $.Point();
            }

            const docElement = doc.documentElement;

            if ( typeof element.getBoundingClientRect !== typeof undefined ) {
                boundingRect = element.getBoundingClientRect();
            }

            const win = ( doc === doc.window ) ?
                doc :
                ( doc.nodeType === 9 ) ?
                    doc.defaultView || doc.parentWindow :
                    false;

            return new $.Point(
                boundingRect.left + ( win.pageXOffset || docElement.scrollLeft ) - ( docElement.clientLeft || 0 ),
                boundingRect.top + ( win.pageYOffset || docElement.scrollTop ) - ( docElement.clientTop || 0 )
            );
        },


        /**
         * Determines the height and width of the given element.
         * @function
         * @param {Element|String} element
         * @returns {OpenSeadragon.Point}
         */
        getElementSize: function( element ) {
            element = $.getElement( element );

            return new $.Point(
                element.clientWidth,
                element.clientHeight
            );
        },


        /**
         * Returns the CSSStyle object for the given element.
         * @function
         * @param {Element|String} element
         * @returns {CSSStyle}
         */
        getElementStyle:
            document.documentElement.currentStyle ?
            function( element ) {
                element = $.getElement( element );
                return element.currentStyle;
            } :
            function( element ) {
                element = $.getElement( element );
                return window.getComputedStyle( element, "" );
            },

        /**
         * Returns the property with the correct vendor prefix appended.
         * @param {String} property the property name
         * @returns {String} the property with the correct prefix or null if not
         * supported.
         */
        getCssPropertyWithVendorPrefix: function(property) {
            const memo = {};

            $.getCssPropertyWithVendorPrefix = function(property) {
                if (memo[property] !== undefined) {
                    return memo[property];
                }
                const style = document.createElement('div').style;
                let result = null;
                if (style[property] !== undefined) {
                    result = property;
                } else {
                    const prefixes = ['Webkit', 'Moz', 'MS', 'O',
                        'webkit', 'moz', 'ms', 'o'];
                    const suffix = $.capitalizeFirstLetter(property);
                    for (let i = 0; i < prefixes.length; i++) {
                        const prop = prefixes[i] + suffix;
                        if (style[prop] !== undefined) {
                            result = prop;
                            break;
                        }
                    }
                }
                memo[property] = result;
                return result;
            };
            return $.getCssPropertyWithVendorPrefix(property);
        },

        /**
         * Capitalizes the first letter of a string
         * @param {String} string
         * @returns {String} The string with the first letter capitalized
         */
        capitalizeFirstLetter: function(string) {
            return string.charAt(0).toUpperCase() + string.slice(1);
        },

        /**
         * Compute the modulo of a number but makes sure to always return
         * a positive value (also known as Euclidean modulo).
         * @param {Number} number the number to compute the modulo of
         * @param {Number} modulo the modulo
         * @returns {Number} the result of the modulo of number
         */
        positiveModulo: function(number, modulo) {
            let result = number % modulo;
            if (result < 0) {
                result += modulo;
            }
            return result;
        },


        /**
         * Determines if a point is within the bounding rectangle of the given element (hit-test).
         * @function
         * @param {Element|String} element
         * @param {OpenSeadragon.Point} point
         * @returns {Boolean}
         */
        pointInElement: function( element, point ) {
            element = $.getElement( element );
            const offset = $.getElementOffset( element );
            const size = $.getElementSize( element );
            return point.x >= offset.x && point.x < offset.x + size.x && point.y < offset.y + size.y && point.y >= offset.y;
        },


        /**
         * Gets the position of the mouse on the screen for a given event.
         * @function
         * @param {Event} [event]
         * @returns {OpenSeadragon.Point}
         */
        getMousePosition: function( event ) {

            if ( typeof ( event.pageX ) === "number" ) {
                $.getMousePosition = function( event ){
                    const result = new $.Point();

                    result.x = event.pageX;
                    result.y = event.pageY;

                    return result;
                };
            } else if ( typeof ( event.clientX ) === "number" ) {
                $.getMousePosition = function( event ){
                    const result = new $.Point();

                    result.x =
                        event.clientX +
                        document.body.scrollLeft +
                        document.documentElement.scrollLeft;
                    result.y =
                        event.clientY +
                        document.body.scrollTop +
                        document.documentElement.scrollTop;

                    return result;
                };
            } else {
                throw new Error(
                    "Unknown event mouse position, no known technique."
                );
            }

            return $.getMousePosition( event );
        },


        /**
         * Determines the page's current scroll position.
         * @function
         * @returns {OpenSeadragon.Point}
         */
        getPageScroll: function() {
            const docElement = document.documentElement || {};
            const body = document.body || {};

            if ( typeof ( window.pageXOffset ) === "number" ) {
                $.getPageScroll = function(){
                    return new $.Point(
                        window.pageXOffset,
                        window.pageYOffset
                    );
                };
            } else if ( body.scrollLeft || body.scrollTop ) {
                $.getPageScroll = function(){
                    return new $.Point(
                        document.body.scrollLeft,
                        document.body.scrollTop
                    );
                };
            } else if ( docElement.scrollLeft || docElement.scrollTop ) {
                $.getPageScroll = function(){
                    return new $.Point(
                        document.documentElement.scrollLeft,
                        document.documentElement.scrollTop
                    );
                };
            } else {
                // We can't reassign the function yet, as there was no scroll.
                return new $.Point(0, 0);
            }

            return $.getPageScroll();
        },

        /**
         * Set the page scroll position.
         * @function
         * @returns {OpenSeadragon.Point}
         */
        setPageScroll: function( scroll ) {
            if ( typeof ( window.scrollTo ) !== "undefined" ) {
                $.setPageScroll = function( scroll ) {
                    window.scrollTo( scroll.x, scroll.y );
                };
            } else {
                const originalScroll = $.getPageScroll();
                if ( originalScroll.x === scroll.x &&
                    originalScroll.y === scroll.y ) {
                    // We are already correctly positioned and there
                    // is no way to detect the correct method.
                    return;
                }

                document.body.scrollLeft = scroll.x;
                document.body.scrollTop = scroll.y;
                let currentScroll = $.getPageScroll();
                if ( currentScroll.x !== originalScroll.x &&
                    currentScroll.y !== originalScroll.y ) {
                    $.setPageScroll = function( scroll ) {
                        document.body.scrollLeft = scroll.x;
                        document.body.scrollTop = scroll.y;
                    };
                    return;
                }

                document.documentElement.scrollLeft = scroll.x;
                document.documentElement.scrollTop = scroll.y;
                currentScroll = $.getPageScroll();
                if ( currentScroll.x !== originalScroll.x &&
                    currentScroll.y !== originalScroll.y ) {
                    $.setPageScroll = function( scroll ) {
                        document.documentElement.scrollLeft = scroll.x;
                        document.documentElement.scrollTop = scroll.y;
                    };
                    return;
                }

                // We can't find anything working, so we do nothing.
                $.setPageScroll = function( scroll ) {
                };
            }

            $.setPageScroll( scroll );
        },

        /**
         * Determines the size of the browsers window.
         * @function
         * @returns {OpenSeadragon.Point}
         */
        getWindowSize: function() {
            const docElement = document.documentElement || {};
            const body = document.body || {};

            if ( typeof ( window.innerWidth ) === 'number' ) {
                $.getWindowSize = function(){
                    return new $.Point(
                        window.innerWidth,
                        window.innerHeight
                    );
                };
            } else if ( docElement.clientWidth || docElement.clientHeight ) {
                $.getWindowSize = function(){
                    return new $.Point(
                        document.documentElement.clientWidth,
                        document.documentElement.clientHeight
                    );
                };
            } else if ( body.clientWidth || body.clientHeight ) {
                $.getWindowSize = function(){
                    return new $.Point(
                        document.body.clientWidth,
                        document.body.clientHeight
                    );
                };
            } else {
                throw new Error("Unknown window size, no known technique.");
            }

            return $.getWindowSize();
        },


        /**
         * Wraps the given element in a nest of divs so that the element can
         * be easily centered using CSS tables
         * @function
         * @param {Element|String} element
         * @returns {Element} outermost wrapper element
         */
        makeCenteredNode: function( element ) {
            // Convert a possible ID to an actual HTMLElement
            element = $.getElement( element );

            /*
                CSS tables require you to have a display:table/row/cell hierarchy so we need to create
                three nested wrapper divs:
             */

            const wrappers = [
                $.makeNeutralElement( 'div' ),
                $.makeNeutralElement( 'div' ),
                $.makeNeutralElement( 'div' )
            ];

            // It feels like we should be able to pass style dicts to makeNeutralElement:
            $.extend(wrappers[0].style, {
                display: "table",
                height: "100%",
                width: "100%"
            });

            $.extend(wrappers[1].style, {
                display: "table-row"
            });

            $.extend(wrappers[2].style, {
                display: "table-cell",
                verticalAlign: "middle",
                textAlign: "center"
            });

            wrappers[0].appendChild(wrappers[1]);
            wrappers[1].appendChild(wrappers[2]);
            wrappers[2].appendChild(element);

            return wrappers[0];
        },

        /**
         * Log trace information from the system. Useful for logging and debugging
         * async events. Calls to this function SHOULD NOT BE present in the release.
         * (or at least used only in debug mode).
         * @param {OpenSeadragon.Tile|OpenSeadragon.CacheRecord|string} tile message to log or tile to inspect
         * @param {boolean} stacktrace if true log the stacktrace
         */
        trace: function(tile, stacktrace = false) {
            this.__traceLogs = [];
            setInterval(() => {
                if (!this.__traceLogs.length) {
                    return;
                }
                console.log(this.__traceLogs.join('\n'));
                this.__traceLogs = [];
            }, 2000);
            this.trace = function (tile, stacktrace = false) {
                if (typeof tile === 'string') {
                    this.__traceLogs.push(tile);
                    if (stacktrace) {
                        this.__traceLogs.push(...new Error().stack.split('\n').slice(1));
                    }
                    return;
                }
                if (tile instanceof OpenSeadragon.Tile) {
                    tile = tile.getCache(tile.originalCacheKey);
                }
                const cacheTile = tile._tiles[0];
                this.__traceLogs.push(`Cache ${cacheTile.toString()} loaded ${cacheTile.loaded} loading ${cacheTile.loading} cacheCount ${Object.keys(cacheTile._caches).length} - CACHE ${tile.__invStamp}`);
                if (stacktrace) {
                    this.__traceLogs.push(...new Error().stack.split('\n').slice(1));
                }
            };
            this.trace(tile, stacktrace);
        },


        /**
         * Creates an easily positionable element of the given type that therefor
         * serves as an excellent container element.
         * @function
         * @param {String} tagName
         * @returns {Element}
         */
        makeNeutralElement: function( tagName ) {
            const element = document.createElement( tagName );
            const style   = element.style;

            style.background = "transparent none";
            style.border     = "none";
            style.margin     = "0px";
            style.padding    = "0px";
            style.position   = "static";

            return element;
        },


        /**
         * Returns the current milliseconds, using Date.now() if available
         * @function
         */
        now: function( ) {
            if (Date.now) {
                $.now = Date.now;
            } else {
                $.now = function() {
                    return new Date().getTime();
                };
            }

            return $.now();
        },


        /**
         * Ensures an image is loaded correctly to support alpha transparency.
         * @function
         * @param {String} src
         * @returns {Element}
         */
        makeTransparentImage: function( src ) {
            const img = $.makeNeutralElement( "img" );

            img.src = src;

            return img;
        },


        /**
         * Sets the opacity of the specified element.
         * @function
         * @param {Element|String} element
         * @param {Number} opacity
         * @param {Boolean} [usesAlpha]
         */
        setElementOpacity: function( element, opacity, usesAlpha ) {

            let ieOpacity;
            let ieFilter;

            element = $.getElement( element );

            if ( usesAlpha && !$.Browser.alpha ) {
                opacity = Math.round( opacity );
            }

            if ( $.Browser.opacity ) {
                element.style.opacity = opacity < 1 ? opacity : "";
            } else {
                if ( opacity < 1 ) {
                    ieOpacity = Math.round( 100 * opacity );
                    ieFilter  = "alpha(opacity=" + ieOpacity + ")";
                    element.style.filter = ieFilter;
                } else {
                    element.style.filter = "";
                }
            }
        },


        /**
         * Sets the specified element's touch-action style attribute to 'none'.
         * @function
         * @param {Element|String} element
         */
        setElementTouchActionNone: function( element ) {
            element = $.getElement( element );
            if ( typeof element.style.touchAction !== 'undefined' ) {
                element.style.touchAction = 'none';
            } else if ( typeof element.style.msTouchAction !== 'undefined' ) {
                element.style.msTouchAction = 'none';
            }
        },


        /**
         * Sets the specified element's pointer-events style attribute to the passed value.
         * @function
         * @param {Element|String} element
         * @param {String} value
         */
        setElementPointerEvents: function( element, value ) {
            element = $.getElement( element );
            if (typeof element.style !== 'undefined' && typeof element.style.pointerEvents !== 'undefined' ) {
                element.style.pointerEvents = value;
            }
        },


        /**
         * Sets the specified element's pointer-events style attribute to 'none'.
         * @function
         * @param {Element|String} element
         */
        setElementPointerEventsNone: function( element ) {
            $.setElementPointerEvents( element, 'none' );
        },


        /**
         * Add the specified CSS class to the element if not present.
         * @function
         * @param {Element|String} element
         * @param {String} className
         */
        addClass: function( element, className ) {
            element = $.getElement( element );

            if (!element.className) {
                element.className = className;
            } else if ( ( ' ' + element.className + ' ' ).
                indexOf( ' ' + className + ' ' ) === -1 ) {
                element.className += ' ' + className;
            }
        },

        /**
         * Find the first index at which an element is found in an array or -1
         * if not present.
         *
         * Code taken and adapted from
         * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf#Compatibility
         *
         * @function
         * @param {Array} array The array from which to find the element
         * @param {Object} searchElement The element to find
         * @param {Number} [fromIndex=0] Index to start research.
         * @returns {Number} The index of the element in the array.
         */
        indexOf: function( array, searchElement, fromIndex ) {
            if ( Array.prototype.indexOf ) {
                this.indexOf = function( array, searchElement, fromIndex ) {
                    return array.indexOf( searchElement, fromIndex );
                };
            } else {
                this.indexOf = function( array, searchElement, fromIndex ) {
                    let pivot = ( fromIndex ) ? fromIndex : 0;
                    if ( !array ) {
                        throw new TypeError( );
                    }

                    const length = array.length;
                    if ( length === 0 || pivot >= length ) {
                        return -1;
                    }

                    if ( pivot < 0 ) {
                        pivot = length - Math.abs( pivot );
                    }

                    for ( let i = pivot; i < length; i++ ) {
                        if ( array[i] === searchElement ) {
                            return i;
                        }
                    }
                    return -1;
                };
            }
            return this.indexOf( array, searchElement, fromIndex );
        },

        /**
         * Remove the specified CSS class from the element.
         * @function
         * @param {Element|String} element
         * @param {String} className
         */
        removeClass: function( element, className ) {
            const newClasses = [];

            element = $.getElement( element );
            const oldClasses = element.className.split( /\s+/ );
            for ( let i = 0; i < oldClasses.length; i++ ) {
                if ( oldClasses[ i ] && oldClasses[ i ] !== className ) {
                    newClasses.push( oldClasses[ i ] );
                }
            }
            element.className = newClasses.join(' ');
        },

        /**
         * Convert passed addEventListener() options to boolean or options object,
         * depending on browser support.
         * @function
         * @param {Boolean|Object} [options] Boolean useCapture, or if [supportsEventListenerOptions]{@link OpenSeadragon.supportsEventListenerOptions}, can be an object
         * @param {Boolean} [options.capture]
         * @param {Boolean} [options.passive]
         * @param {Boolean} [options.once]
         * @returns {String} The protocol (http:, https:, file:, ftp: ...)
         */
        normalizeEventListenerOptions: function (options) {
            let opts;
            if ( typeof options !== 'undefined' ) {
                if ( typeof options === 'boolean' ) {
                    // Legacy Boolean useCapture
                    opts = $.supportsEventListenerOptions ? { capture: options } : options;
                } else {
                    // Options object
                    opts = $.supportsEventListenerOptions ? options :
                        ( ( typeof options.capture !== 'undefined' ) ? options.capture : false );
                }
            } else {
                // No options specified - Legacy optional useCapture argument
                //   (for IE, first supported on version 9, so we'll pass a Boolean)
                opts = $.supportsEventListenerOptions ? { capture: false } : false;
            }
            return opts;
        },

        /**
         * Adds an event listener for the given element, eventName and handler.
         * @function
         * @param {Element|String} element
         * @param {String} eventName
         * @param {Function} handler
         * @param {Boolean|Object} [options] Boolean useCapture, or if [supportsEventListenerOptions]{@link OpenSeadragon.supportsEventListenerOptions}, can be an object
         * @param {Boolean} [options.capture]
         * @param {Boolean} [options.passive]
         * @param {Boolean} [options.once]
         */
        addEvent: (function () {
            if ( $.supportsAddEventListener ) {
                return function ( element, eventName, handler, options ) {
                    options = $.normalizeEventListenerOptions(options);
                    element = $.getElement( element );
                    element.addEventListener( eventName, handler, options );
                };
            } else if ( document.documentElement.attachEvent && document.attachEvent ) {
                return function ( element, eventName, handler ) {
                    element = $.getElement( element );
                    element.attachEvent( 'on' + eventName, handler );
                };
            } else {
                throw new Error( "No known event model." );
            }
        }()),


        /**
         * Remove a given event listener for the given element, event type and
         * handler.
         * @function
         * @param {Element|String} element
         * @param {String} eventName
         * @param {Function} handler
         * @param {Boolean|Object} [options] Boolean useCapture, or if [supportsEventListenerOptions]{@link OpenSeadragon.supportsEventListenerOptions}, can be an object
         * @param {Boolean} [options.capture]
         */
        removeEvent: (function () {
            if ( $.supportsRemoveEventListener ) {
                return function ( element, eventName, handler, options ) {
                    options = $.normalizeEventListenerOptions(options);
                    element = $.getElement( element );
                    element.removeEventListener( eventName, handler, options );
                };
            } else if ( document.documentElement.detachEvent && document.detachEvent ) {
                return function( element, eventName, handler ) {
                    element = $.getElement( element );
                    element.detachEvent( 'on' + eventName, handler );
                };
            } else {
                throw new Error( "No known event model." );
            }
        }()),


        /**
         * Cancels the default browser behavior had the event propagated all
         * the way up the DOM to the window object.
         * @function
         * @param {Event} [event]
         */
        cancelEvent: function( event ) {
            event.preventDefault();
        },


        /**
         * Returns true if {@link OpenSeadragon.cancelEvent|cancelEvent} has been called on
         * the event, otherwise returns false.
         * @function
         * @param {Event} [event]
         */
        eventIsCanceled: function( event ) {
            return event.defaultPrevented;
        },


        /**
         * Stops the propagation of the event through the DOM in the capturing and bubbling phases.
         * @function
         * @param {Event} [event]
         */
        stopEvent: function( event ) {
            event.stopPropagation();
        },


        /**
         * Retrieves the value of a url parameter from the window.location string.
         * @function
         * @param {String} key
         * @returns {String} The value of the url parameter or null if no param matches.
         */
        getUrlParameter: function( key ) {
            // eslint-disable-next-line no-use-before-define
            const value = URLPARAMS[ key ];
            return value ? value : null;
        },

        /**
         * Retrieves the protocol used by the url. The url can either be absolute
         * or relative.
         * @function
         * @private
         * @param {String} url The url to retrieve the protocol from.
         * @returns {String} The protocol (http:, https:, file:, ftp: ...)
         */
        getUrlProtocol: function( url ) {
            const match = url.match(/^([a-z]+:)\/\//i);
            if ( match === null ) {
                // Relative URL, retrive the protocol from window.location
                return window.location.protocol;
            }
            return match[1].toLowerCase();
        },

        /**
         * Create an XHR object
         * @private
         * @param {type} [local] Deprecated. Ignored (IE/ActiveXObject file protocol no longer supported).
         * @returns {XMLHttpRequest}
         */
        createAjaxRequest: function() {
            if ( window.XMLHttpRequest ) {
                $.createAjaxRequest = function() {
                    return new XMLHttpRequest();
                };
                return new XMLHttpRequest();
            } else {
                throw new Error( "Browser doesn't support XMLHttpRequest." );
            }
        },

        /**
         * Makes an AJAX request.
         * @param {String} url - the url to request
         * @param {Function} onSuccess
         * @param {Function} onError
         * @throws {Error}
         * @returns {XMLHttpRequest}
         * @deprecated deprecated way of calling this function
         *//**
         * Makes an AJAX request.
         * @param {Object} options
         * @param {String} options.url - the url to request
         * @param {Function} options.success - a function to call on a successful response
         * @param {Function} options.error - a function to call on when an error occurs
         * @param {Object} options.headers - headers to add to the AJAX request
         * @param {String} options.responseType - the response type of the AJAX request
         * @param {String} options.postData - HTTP POST data (usually but not necessarily in k=v&k2=v2... form,
         *      see TileSource::getTilePostData), GET method used if null
         * @param {Boolean} [options.withCredentials=false] - whether to set the XHR's withCredentials
         * @throws {Error}
         * @returns {XMLHttpRequest}
         */
        makeAjaxRequest: function( url, onSuccess, onError ) {
            let withCredentials;
            let headers;
            let responseType;
            let postData;

            // Note that our preferred API is that you pass in a single object; the named
            // arguments are for legacy support.
            if( $.isPlainObject( url ) ){
                onSuccess = url.success;
                onError = url.error;
                withCredentials = url.withCredentials;
                headers = url.headers;
                responseType = url.responseType || null;
                postData = url.postData || null;
                url = url.url;
            } else {
                $.console.warn("OpenSeadragon.makeAjaxRequest() deprecated usage!");
            }

            const protocol = $.getUrlProtocol( url );
            const request = $.createAjaxRequest();

            if ( !$.isFunction( onSuccess ) ) {
                throw new Error( "makeAjaxRequest requires a success callback" );
            }

            request.onreadystatechange = function() {
                // 4 = DONE (https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest#Properties)
                if ( request.readyState === 4 ) {
                    request.onreadystatechange = function(){};

                    // With protocols other than http/https, a successful request status is in
                    // the 200's on Firefox and 0 on other browsers
                    if ( (request.status >= 200 && request.status < 300) ||
                        ( request.status === 0 &&
                          protocol !== "http:" &&
                          protocol !== "https:" )) {
                        onSuccess( request );
                    } else {
                        if ( $.isFunction( onError ) ) {
                            onError( request );
                        } else {
                            $.console.error( "AJAX request returned %d: %s", request.status, url );
                        }
                    }
                }
            };

            const method = postData ? "POST" : "GET";
            try {
                request.open( method, url, true );

                if (responseType) {
                    request.responseType = responseType;
                }

                if (headers) {
                    for (const headerName in headers) {
                        if (Object.prototype.hasOwnProperty.call(headers, headerName) && headers[headerName]) {
                            request.setRequestHeader(headerName, headers[headerName]);
                        }
                    }
                }

                if (withCredentials) {
                    request.withCredentials = true;
                }

                request.send(postData);
            } catch (e) {
                $.console.error( "%s while making AJAX request: %s", e.name, e.message );

                request.onreadystatechange = function(){};

                if ( $.isFunction( onError ) ) {
                    onError( request, e );
                }
            }

            return request;
        },

        /**
         * Taken from jQuery 1.6.1
         * @function
         * @param {Object} options
         * @param {String} options.url
         * @param {Function} options.callback
         * @param {String} [options.param='callback'] The name of the url parameter
         *      to request the jsonp provider with.
         * @param {String} [options.callbackName=] The name of the callback to
         *      request the jsonp provider with.
         */
        jsonp: function( options ){
            let script;
            let url     = options.url;
            const head    = document.head ||
                    document.getElementsByTagName( "head" )[ 0 ] ||
                    document.documentElement;
            const jsonpCallback = options.callbackName || 'openseadragon' + $.now();
            const previous      = window[ jsonpCallback ];
            const replace       = "$1" + jsonpCallback + "$2";
            const callbackParam = options.param || 'callback';
            const callback      = options.callback;

            url = url.replace( /(=)\?(&|$)|\?\?/i, replace );
            // Add callback manually
            url += (/\?/.test( url ) ? "&" : "?") + callbackParam + "=" + jsonpCallback;

            // Install callback
            window[ jsonpCallback ] = function( response ) {
                if ( !previous ){
                    try{
                        delete window[ jsonpCallback ];
                    }catch(e){
                        //swallow
                    }
                } else {
                    window[ jsonpCallback ] = previous;
                }
                if( callback && $.isFunction( callback ) ){
                    callback( response );
                }
            };

            script = document.createElement( "script" );

            //TODO: having an issue with async info requests
            if( undefined !== options.async || false !== options.async ){
                script.async = "async";
            }

            if ( options.scriptCharset ) {
                script.charset = options.scriptCharset;
            }

            script.src = url;

            // Attach handlers for all browsers
            script.onload = script.onreadystatechange = function( _, isAbort ) {

                if ( isAbort || !script.readyState || /loaded|complete/.test( script.readyState ) ) {

                    // Handle memory leak in IE
                    script.onload = script.onreadystatechange = null;

                    // Remove the script
                    if ( head && script.parentNode ) {
                        head.removeChild( script );
                    }

                    // Dereference the script
                    script = undefined;
                }
            };
            // Use insertBefore instead of appendChild  to circumvent an IE6 bug.
            // This arises when a base node is used (#2709 and #4378).
            head.insertBefore( script, head.firstChild );

        },


        /**
         * Fully deprecated. Will throw an error.
         * @function
         * @deprecated use {@link OpenSeadragon.Viewer#open}
         */
        createFromDZI: function() {
            throw "OpenSeadragon.createFromDZI is deprecated, use Viewer.open.";
        },

        /**
         * Parses an XML string into a DOM Document.
         * @function
         * @param {String} string
         * @returns {Document}
         */
        parseXml: function( string ) {
            if ( window.DOMParser ) {

                $.parseXml = function( string ) {
                    let xmlDoc = null;

                    const parser = new DOMParser();
                    xmlDoc = parser.parseFromString( string, "text/xml" );
                    return xmlDoc;
                };

            } else {
                throw new Error( "Browser doesn't support XML DOM." );
            }

            return $.parseXml( string );
        },

        /**
         * Parses a JSON string into a Javascript object.
         * @function
         * @param {String} string
         * @returns {Object}
         */
        parseJSON: function(string) {
            $.parseJSON = window.JSON.parse;
            return $.parseJSON(string);
        },

        /**
         * Reports whether the image format is supported for tiling in this
         * version.
         * @function
         * @param {String} [extension]
         * @returns {Boolean}
         */
        imageFormatSupported: function( extension ) {
            extension = extension ? extension : "";
            // eslint-disable-next-line no-use-before-define
            return !!FILEFORMATS[ extension.toLowerCase() ];
        },

        /**
         * Updates supported image formats with user-specified values.
         * Preexisting formats that are not being updated are left unchanged.
         * By default, the defined formats are
         * <pre><code>{
         *      avif: true,
         *      bmp:  false,
         *      jpeg: true,
         *      jpg:  true,
         *      png:  true,
         *      tif:  false,
         *      wdp:  false,
         *      webp: true
         * }
         * </code></pre>
         * @function
         * @example
         * // sets bmp as supported and png as unsupported
         * setImageFormatsSupported({bmp: true, png: false});
         * @param {Object} formats An object containing format extensions as
         * keys and booleans as values.
         */
        setImageFormatsSupported: function(formats) {
            //TODO: how to deal with this within the data pipeline?
            // $.console.warn("setImageFormatsSupported method is deprecated. You should check that" +
            //     " the system supports your TileSources by implementing corresponding data type converters.");

            // eslint-disable-next-line no-use-before-define
            $.extend(FILEFORMATS, formats);
        },
    });


    //TODO: $.console is often used inside a try/catch block which generally
    //      prevents allowings errors to occur with detection until a debugger
    //      is attached.  Although I've been guilty of the same anti-pattern
    //      I eventually was convinced that errors should naturally propagate in
    //      all but the most special cases.
    /**
     * A convenient alias for console when available, and a simple null
     * function when console is unavailable.
     * @static
     * @private
     */
    const nullfunction = function( msg ){
        //document.location.hash = msg;
    };

    $.console = window.console || {
        log:    nullfunction,
        debug:  nullfunction,
        info:   nullfunction,
        warn:   nullfunction,
        error:  nullfunction,
        assert: nullfunction
    };


    /**
     * The current browser vendor, version, and related information regarding detected features.
     * @member {Object} Browser
     * @memberof OpenSeadragon
     * @static
     * @type {Object}
     * @property {OpenSeadragon.BROWSERS} vendor - One of the {@link OpenSeadragon.BROWSERS} enumeration values.
     * @property {Number} version
     * @property {Boolean} alpha - Does the browser support image alpha transparency.
     */
    $.Browser = {
        vendor:     $.BROWSERS.UNKNOWN,
        version:    0,
        alpha:      true
    };


    const FILEFORMATS = {
            avif: true,
            bmp:  false,
            jpeg: true,
            jpg:  true,
            png:  true,
            tif:  false,
            wdp:  false,
            webp: true
        };
    const URLPARAMS = {};

    (function() {
        //A small auto-executing routine to determine the browser vendor,
        //version and supporting feature sets.
        const ver = navigator.appVersion;
        const ua  = navigator.userAgent;
        let regex;

        //console.error( 'appName: ' + navigator.appName );
        //console.error( 'appVersion: ' + navigator.appVersion );
        //console.error( 'userAgent: ' + navigator.userAgent );

        //TODO navigator.appName is deprecated. Should be 'Netscape' for all browsers
        //  but could be dropped at any time
        //  See https://developer.mozilla.org/en-US/docs/Web/API/Navigator/appName
        //      https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
        switch( navigator.appName ){
            case "Microsoft Internet Explorer":
                if( !!window.attachEvent &&
                    !!window.ActiveXObject ) {

                    $.Browser.vendor = $.BROWSERS.IE;
                    $.Browser.version = parseFloat(
                        ua.substring(
                            ua.indexOf( "MSIE" ) + 5,
                            ua.indexOf( ";", ua.indexOf( "MSIE" ) ) )
                        );
                }
                break;
            case "Netscape":
                if (window.addEventListener) {
                    if ( ua.indexOf( "Edge" ) >= 0 ) {
                        $.Browser.vendor = $.BROWSERS.EDGE;
                        $.Browser.version = parseFloat(
                            ua.substring( ua.indexOf( "Edge" ) + 5 )
                        );
                    } else if ( ua.indexOf( "Edg" ) >= 0 ) {
                        $.Browser.vendor = $.BROWSERS.CHROMEEDGE;
                        $.Browser.version = parseFloat(
                            ua.substring( ua.indexOf( "Edg" ) + 4 )
                        );
                    } else if ( ua.indexOf( "Firefox" ) >= 0 ) {
                        $.Browser.vendor = $.BROWSERS.FIREFOX;
                        $.Browser.version = parseFloat(
                            ua.substring( ua.indexOf( "Firefox" ) + 8 )
                        );
                    } else if ( ua.indexOf( "Safari" ) >= 0 ) {
                        $.Browser.vendor = ua.indexOf( "Chrome" ) >= 0 ?
                            $.BROWSERS.CHROME :
                            $.BROWSERS.SAFARI;
                        $.Browser.version = parseFloat(
                            ua.substring(
                                ua.substring( 0, ua.indexOf( "Safari" ) ).lastIndexOf( "/" ) + 1,
                                ua.indexOf( "Safari" )
                            )
                        );
                    } else {
                        regex = new RegExp( "Trident/.*rv:([0-9]{1,}[.0-9]{0,})");
                        if ( regex.exec( ua ) !== null ) {
                            $.Browser.vendor = $.BROWSERS.IE;
                            $.Browser.version = parseFloat( RegExp.$1 );
                        }
                    }
                }
                break;
            case "Opera":
                $.Browser.vendor = $.BROWSERS.OPERA;
                $.Browser.version = parseFloat( ver );
                break;
        }

            // ignore '?' portion of query string
        const query = window.location.search.substring( 1 );
        const parts = query.split('&');

        for ( let i = 0; i < parts.length; i++ ) {
            const part = parts[ i ];
            const sep  = part.indexOf( '=' );

            if ( sep > 0 ) {
                const key = part.substring( 0, sep );
                const value = part.substring( sep + 1 );
                try {
                    URLPARAMS[ key ] = decodeURIComponent( value );
                } catch (e) {
                    $.console.error( "Ignoring malformed URL parameter: %s=%s", key, value );
                }
            }
        }

        //determine if this browser supports image alpha transparency
        $.Browser.alpha = !(
            $.Browser.vendor === $.BROWSERS.CHROME && $.Browser.version < 2
        );

        //determine if this browser supports element.style.opacity
        $.Browser.opacity = true;

        if ( $.Browser.vendor === $.BROWSERS.IE ) {
            $.console.error('Internet Explorer is not supported by OpenSeadragon');
        }
    })();


    // Adding support for HTML5's requestAnimationFrame as suggested by acdha.
    // Implementation taken from matt synder's post here:
    // http://mattsnider.com/cross-browser-and-legacy-supported-requestframeanimation/
    (function( w ) {

        // most browsers have an implementation
        const requestAnimationFrame = w.requestAnimationFrame ||
            w.mozRequestAnimationFrame ||
            w.webkitRequestAnimationFrame ||
            w.msRequestAnimationFrame;

        const cancelAnimationFrame = w.cancelAnimationFrame ||
            w.mozCancelAnimationFrame ||
            w.webkitCancelAnimationFrame ||
            w.msCancelAnimationFrame;

        // polyfill, when necessary
        if ( requestAnimationFrame && cancelAnimationFrame ) {
            // We can't assign these window methods directly to $ because they
            // expect their "this" to be "window", so we call them in wrappers.
            $.requestAnimationFrame = function(){
                return requestAnimationFrame.apply( w, arguments );
            };
            $.cancelAnimationFrame = function(){
                return cancelAnimationFrame.apply( w, arguments );
            };
        } else {
            let aAnimQueue = [];
            let processing = [];
            let iIntervalId;
            let iRequestId = 0;

            // create a mock requestAnimationFrame function
            $.requestAnimationFrame = function( callback ) {
                aAnimQueue.push( [ ++iRequestId, callback ] );

                if ( !iIntervalId ) {
                     iIntervalId = setInterval( function() {
                        if ( aAnimQueue.length ) {
                            const time = $.now();
                            // Process all of the currently outstanding frame
                            // requests, but none that get added during the
                            // processing.
                            // Swap the arrays so we don't have to create a new
                            // array every frame.
                            const temp = processing;
                            processing = aAnimQueue;
                            aAnimQueue = temp;
                            while ( processing.length ) {
                                processing.shift()[ 1 ]( time );
                            }
                        } else {
                            // don't continue the interval, if unnecessary
                            clearInterval( iIntervalId );
                            iIntervalId = undefined;
                        }
                    }, 1000 / 50);  // estimating support for 50 frames per second
                }

                return iRequestId;
            };

            // create a mock cancelAnimationFrame function
            $.cancelAnimationFrame = function( requestId ) {
                // find the request ID and remove it
                let i, j;
                for ( i = 0, j = aAnimQueue.length; i < j; i += 1 ) {
                    if ( aAnimQueue[ i ][ 0 ] === requestId ) {
                        aAnimQueue.splice( i, 1 );
                        return;
                    }
                }

                // If it's not in the queue, it may be in the set we're currently
                // processing (if cancelAnimationFrame is called from within a
                // requestAnimationFrame callback).
                for ( i = 0, j = processing.length; i < j; i += 1 ) {
                    if ( processing[ i ][ 0 ] === requestId ) {
                        processing.splice( i, 1 );
                        return;
                    }
                }
            };
        }
    })( window );

    /**
     * @private
     * @inner
     * @function
     * @param {Element} element
     * @param {Boolean} [isFixed]
     * @returns {Element}
     */
    function getOffsetParent( element, isFixed ) {
        if ( isFixed && element !== document.body ) {
            return document.body;
        } else {
            return element.offsetParent;
        }
    }

    /**
     * @template T
     * @typedef {function(): OpenSeadragon.Promise<T>} AsyncNullaryFunction
     * Represents an asynchronous function that takes no arguments and returns a promise of type T.
     */

    /**
     * @template T, A
     * @typedef {function(A): OpenSeadragon.Promise<T>} AsyncUnaryFunction
     * Represents an asynchronous function that:
     * @param {A} arg - The single argument of type A.
     * @returns {OpenSeadragon.Promise<T>} A promise that resolves to a value of type T.
     */

    /**
     * @template T, A, B
     * @typedef {function(A, B): OpenSeadragon.Promise<T>} AsyncBinaryFunction
     * Represents an asynchronous function that:
     * @param {A} arg1 - The first argument of type A.
     * @param {B} arg2 - The second argument of type B.
     * @returns {OpenSeadragon.Promise<T>} A promise that resolves to a value of type T.
     */

    /**
     * Promise proxy in OpenSeadragon, enables $.supportsAsync feature.
     * This proxy is also necessary because OperaMini does not implement Promises (checks fail).
     * @type {PromiseConstructor}
     */
    $.Promise = window["Promise"] && $.supportsAsync ? window["Promise"] : class {
        constructor(handler) {
            this._error = false;
            this.__value = undefined;

            try {
                // Make sure to unwrap all nested promises!
                handler(
                    (value) => {
                        while (value instanceof $.Promise) {
                            value = value._value;
                        }
                        this._value = value;
                    },
                    (error) => {
                        while (error instanceof $.Promise) {
                            error = error._value;
                        }
                        this._value = error;
                        this._error = true;
                    }
                );
            } catch (e) {
                this._value = e;
                this._error = true;
            }
        }

        then(handler) {
            if (!this._error) {
                try {
                    this._value = handler(this._value);
                } catch (e) {
                    this._value = e;
                    this._error = true;
                }
            }
            return this;
        }

        catch(handler) {
            if (this._error) {
                try {
                    this._value = handler(this._value);
                    this._error = false;
                } catch (e) {
                    this._value = e;
                    this._error = true;
                }
            }
            return this;
        }

        get _value() {
            return this.__value;
        }
        set _value(val) {
            if (val && val.constructor === this.constructor) {
                val = val._value; //unwrap
            }
            this.__value = val;
        }

        static resolve(value) {
            return new this((resolve) => resolve(value));
        }

        static reject(error) {
            return new this((_, reject) => reject(error));
        }

        static all(functions) {
            return new this((resolve) => {
                // no async support, just execute them
                return resolve(functions.map(fn => fn()));
            });
        }

        static race(functions) {
            if (functions.length < 1) {
                return this.resolve();
            }
            // no async support, just execute the first
            return new this((resolve) => {
                return resolve(functions[0]());
            });
        }
    };
}(OpenSeadragon));


// Universal Module Definition, supports CommonJS, AMD and simple script tag
(function (root, $) {
    if (typeof define === 'function' && define.amd) {
        // expose as amd module
        define([], function () {
            return $;
        });
    } else if (typeof module === 'object' && module.exports) {
        // expose as commonjs module
        module.exports = $;
    } else {
        if (!root) {
            root = typeof window === 'object' && window;
            if (!root) {
                $.console.error("OpenSeadragon must run in browser environment!");
            }
        }
        // expose as window.OpenSeadragon
        root.OpenSeadragon = $;
    }
}(this, OpenSeadragon));

/* eslint-disable one-var-declaration-per-line */

/*
 * OpenSeadragon - Mat3
 *
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 */


/*
 * Portions of this source file are taken from WegGL Fundamentals:
 *
 * Copyright 2012, Gregg Tavares.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Gregg Tavares. nor the names of his
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 */




(function( $ ){

// Modified from https://webglfundamentals.org/webgl/lessons/webgl-2d-matrices.html

/**
 *
 *
 * @class Mat3
 * @classdesc A left-to-right matrix representation, useful for affine transforms for
 * positioning tiles for drawing
 *
 * @memberof OpenSeadragon
 *
 * @param {Array} [values] - Initial values for the matrix
 *
 **/
class Mat3{
    constructor(values){
        if(!values) {
            values = [
                0, 0, 0,
                0, 0, 0,
                0, 0, 0
            ];
        }
        this.values = values;
    }

    /**
     * @function makeIdentity
     * @memberof OpenSeadragon.Mat3
     * @static
     * @returns {OpenSeadragon.Mat3} an identity matrix
     */
    static makeIdentity(){
        return new Mat3([
            1, 0, 0,
            0, 1, 0,
            0, 0, 1
        ]);
    }

    /**
     * @function makeTranslation
     * @memberof OpenSeadragon.Mat3
     * @static
     * @param {Number} tx The x value of the translation
     * @param {Number} ty The y value of the translation
     * @returns {OpenSeadragon.Mat3} A translation matrix
     */
    static makeTranslation(tx, ty) {
        return new Mat3([
            1, 0, 0,
            0, 1, 0,
            tx, ty, 1,
        ]);
    }

    /**
     * @function makeRotation
     * @memberof OpenSeadragon.Mat3
     * @static
     * @param {Number} angleInRadians The desired rotation angle, in radians
     * @returns {OpenSeadragon.Mat3} A rotation matrix
     */
    static makeRotation(angleInRadians) {
        const c = Math.cos(angleInRadians);
        const s = Math.sin(angleInRadians);
        return new Mat3([
            c, -s, 0,
            s, c, 0,
            0, 0, 1,
        ]);
    }

    /**
     * @function makeScaling
     * @memberof OpenSeadragon.Mat3
     * @static
     * @param {Number} sx The x value of the scaling
     * @param {Number} sy The y value of the scaling
     * @returns {OpenSeadragon.Mat3} A scaling matrix
     */
    static makeScaling(sx, sy) {
        return new Mat3([
            sx, 0, 0,
            0, sy, 0,
            0, 0, 1,
        ]);
    }

    /**
     * @alias multiply
     * @memberof! OpenSeadragon.Mat3
     * @param {OpenSeadragon.Mat3} other the matrix to multiply with
     * @returns {OpenSeadragon.Mat3} The result of matrix multiplication
     */
    multiply(other) {
        let a = this.values;
        let b = other.values;

        const a00 = a[0 * 3 + 0], a01 = a[0 * 3 + 1], a02 = a[0 * 3 + 2];
        const a10 = a[1 * 3 + 0], a11 = a[1 * 3 + 1], a12 = a[1 * 3 + 2];
        const a20 = a[2 * 3 + 0], a21 = a[2 * 3 + 1], a22 = a[2 * 3 + 2];
        const b00 = b[0 * 3 + 0], b01 = b[0 * 3 + 1], b02 = b[0 * 3 + 2];
        const b10 = b[1 * 3 + 0], b11 = b[1 * 3 + 1], b12 = b[1 * 3 + 2];
        const b20 = b[2 * 3 + 0], b21 = b[2 * 3 + 1], b22 = b[2 * 3 + 2];

        return new Mat3([
            b00 * a00 + b01 * a10 + b02 * a20,
            b00 * a01 + b01 * a11 + b02 * a21,
            b00 * a02 + b01 * a12 + b02 * a22,
            b10 * a00 + b11 * a10 + b12 * a20,
            b10 * a01 + b11 * a11 + b12 * a21,
            b10 * a02 + b11 * a12 + b12 * a22,
            b20 * a00 + b21 * a10 + b22 * a20,
            b20 * a01 + b21 * a11 + b22 * a21,
            b20 * a02 + b21 * a12 + b22 * a22,
        ]);
    }

    /**
     * Sets the values of the matrix.
     * @param a00 top left
     * @param a01 top middle
     * @param a02 top right
     * @param a10 middle left
     * @param a11 middle middle
     * @param a12 middle right
     * @param a20 bottom left
     * @param a21 bottom middle
     * @param a22 bottom right
     */
    setValues(a00, a01, a02,
              a10, a11, a12,
              a20, a21, a22) {
        this.values[0] = a00;
        this.values[1] = a01;
        this.values[2] = a02;
        this.values[3] = a10;
        this.values[4] = a11;
        this.values[5] = a12;
        this.values[6] = a20;
        this.values[7] = a21;
        this.values[8] = a22;
    }

    /**
     * Scaling & translation only changes certain values, no need to compute full matrix multiplication.
     * @memberof OpenSeadragon.Mat3
     * @returns {OpenSeadragon.Mat3} The result of matrix multiplication
     */
    scaleAndTranslate(sx, sy, tx, ty) {
        const a = this.values;
        const a00 = a[0];
        const a01 = a[1];
        const a02 = a[2];
        const a10 = a[3];
        const a11 = a[4];
        const a12 = a[5];
        return new Mat3([
            sx * a00,
            sx * a01,
            sx * a02,
            sy * a10,
            sy * a11,
            sy * a12,
            tx * a00 + ty * a10,
            tx * a01 + ty * a11,
            tx * a02 + ty * a12,
        ]);
    }

    /**
     * Scaling & translation only changes certain values, no need to compute full matrix multiplication.
     * Optimization: in case the original matrix can be thrown away, optimize instead by computing in-place.
     * @memberof OpenSeadragon.Mat3
     */
    scaleAndTranslateSelf(sx, sy, tx, ty) {
        const a = this.values;

        const m00 = a[0], m01 = a[1], m02 = a[2];
        const m10 = a[3], m11 = a[4], m12 = a[5];

        a[0] = sx * m00;
        a[1] = sx * m01;
        a[2] = sx * m02;

        a[3] = sy * m10;
        a[4] = sy * m11;
        a[5] = sy * m12;

        a[6] = tx * m00 + ty * m10 + a[6];
        a[7] = tx * m01 + ty * m11 + a[7];
        a[8] = tx * m02 + ty * m12 + a[8];
    }

    /**
     * Move and translate another matrix by self. 'this' matrix must be scale & translate matrix.
     * Optimization: in case the original matrix can be thrown away, optimize instead by computing in-place.
     * Used for optimization: we have
     * A) THIS matrix, carrying scale and translation,
     * B) OTHER general matrix to scale and translate.
     * Since THIS matrix is unique per tile, we can optimize the operation by:
     *  - move & scale OTHER by THIS, and
     *  - store the result to THIS, since we don't need to keep the scaling and translation, but
     *    we need to keep the original OTHER matrix (for each tile within tiled image).
     * @param {OpenSeadragon.Mat3} other the matrix to scale and translate by this matrix and accept values from
     * @memberof OpenSeadragon.Mat3
     */
    scaleAndTranslateOtherSetSelf(other) {
        const a = other.values;
        const out = this.values;

        // Read scale and translation values from 'this'
        const sx = out[0]; // scale X (this[0])
        const sy = out[4]; // scale Y (this[4])
        const tx = out[6]; // translate X
        const ty = out[7]; // translate Y

        // Compute result = this * other, store into this.values (in-place)
        out[0] = sx * a[0];
        out[1] = sx * a[1];
        out[2] = sx * a[2];

        out[3] = sy * a[3];
        out[4] = sy * a[4];
        out[5] = sy * a[5];

        out[6] = tx * a[0] + ty * a[3] + a[6];
        out[7] = tx * a[1] + ty * a[4] + a[7];
        out[8] = tx * a[2] + ty * a[5] + a[8];
    }
}


$.Mat3 = Mat3;

}( OpenSeadragon ));

/*
 * OpenSeadragon - full-screen support functions
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ) {
    /**
     * Determine native full screen support we can get from the browser.
     * @member fullScreenApi
     * @memberof OpenSeadragon
     * @type {object}
     * @property {Boolean} supportsFullScreen Return true if full screen API is supported.
     * @property {Function} isFullScreen Return true if currently in full screen mode.
     * @property {Function} getFullScreenElement Return the element currently in full screen mode.
     * @property {Function} requestFullScreen Make a request to go in full screen mode.
     * @property {Function} exitFullScreen Make a request to exit full screen mode.
     * @property {Function} cancelFullScreen Deprecated, use exitFullScreen instead.
     * @property {String} fullScreenEventName Event fired when the full screen mode change.
     * @property {String} fullScreenErrorEventName Event fired when a request to go
     * in full screen mode failed.
     */
    const fullScreenApi = {
        supportsFullScreen: false,
        isFullScreen: function() { return false; },
        getFullScreenElement: function() { return null; },
        requestFullScreen: function() {},
        exitFullScreen: function() {},
        cancelFullScreen: function() {},
        fullScreenEventName: '',
        fullScreenErrorEventName: ''
    };

    // check for native support
    if ( document.exitFullscreen ) {
        // W3C standard
        fullScreenApi.supportsFullScreen = true;
        fullScreenApi.getFullScreenElement = function() {
            return document.fullscreenElement;
        };
        fullScreenApi.requestFullScreen = function( element ) {
            return element.requestFullscreen().catch(function (msg) {
                $.console.error('Fullscreen request failed: ', msg);
            });
        };
        fullScreenApi.exitFullScreen = function() {
            document.exitFullscreen().catch(function (msg) {
                $.console.error('Error while exiting fullscreen: ', msg);
            });
        };
        fullScreenApi.fullScreenEventName = "fullscreenchange";
        fullScreenApi.fullScreenErrorEventName = "fullscreenerror";
    } else if ( document.msExitFullscreen ) {
        // IE 11
        fullScreenApi.supportsFullScreen = true;
        fullScreenApi.getFullScreenElement = function() {
            return document.msFullscreenElement;
        };
        fullScreenApi.requestFullScreen = function( element ) {
            return element.msRequestFullscreen();
        };
        fullScreenApi.exitFullScreen = function() {
            document.msExitFullscreen();
        };
        fullScreenApi.fullScreenEventName = "MSFullscreenChange";
        fullScreenApi.fullScreenErrorEventName = "MSFullscreenError";
    } else if ( document.webkitExitFullscreen ) {
        // Recent webkit
        fullScreenApi.supportsFullScreen = true;
        fullScreenApi.getFullScreenElement = function() {
            return document.webkitFullscreenElement;
        };
        fullScreenApi.requestFullScreen = function( element ) {
            return element.webkitRequestFullscreen();
        };
        fullScreenApi.exitFullScreen = function() {
            document.webkitExitFullscreen();
        };
        fullScreenApi.fullScreenEventName = "webkitfullscreenchange";
        fullScreenApi.fullScreenErrorEventName = "webkitfullscreenerror";
    } else if ( document.webkitCancelFullScreen ) {
        // Old webkit
        fullScreenApi.supportsFullScreen = true;
        fullScreenApi.getFullScreenElement = function() {
            return document.webkitCurrentFullScreenElement;
        };
        fullScreenApi.requestFullScreen = function( element ) {
            return element.webkitRequestFullScreen();
        };
        fullScreenApi.exitFullScreen = function() {
            document.webkitCancelFullScreen();
        };
        fullScreenApi.fullScreenEventName = "webkitfullscreenchange";
        fullScreenApi.fullScreenErrorEventName = "webkitfullscreenerror";
    } else if ( document.mozCancelFullScreen ) {
        // Firefox
        fullScreenApi.supportsFullScreen = true;
        fullScreenApi.getFullScreenElement = function() {
            return document.mozFullScreenElement;
        };
        fullScreenApi.requestFullScreen = function( element ) {
            return element.mozRequestFullScreen();
        };
        fullScreenApi.exitFullScreen = function() {
            document.mozCancelFullScreen();
        };
        fullScreenApi.fullScreenEventName = "mozfullscreenchange";
        fullScreenApi.fullScreenErrorEventName = "mozfullscreenerror";
    }
    fullScreenApi.isFullScreen = function() {
        return fullScreenApi.getFullScreenElement() !== null;
    };
    fullScreenApi.cancelFullScreen = function() {
        $.console.error("cancelFullScreen is deprecated. Use exitFullScreen instead.");
        fullScreenApi.exitFullScreen();
    };

    // export api
    $.extend( $, fullScreenApi );

})( OpenSeadragon );

/*
 * OpenSeadragon - EventSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function($){

/**
 * @typedef {Object} OpenSeadragon.Event
 * @memberof OpenSeadragon
 * @property {boolean|function} [stopPropagation=undefined] - If set to true or the functional predicate returns true,
 *   the event exits after handling the current call.
 */

/**
 * Event handler method signature used by all OpenSeadragon events.
 *
 * @typedef {function(OpenSeadragon.Event): void} OpenSeadragon.EventHandler
 * @memberof OpenSeadragon
 * @param {OpenSeadragon.Event} event - The event object containing event-specific properties.
 * @returns {void} This handler does not return a value.
 */

/**
 * Event handler method signature used by all OpenSeadragon events.
 *
 * @typedef {function(OpenSeadragon.Event): Promise<void>} OpenSeadragon.AsyncEventHandler
 * @memberof OpenSeadragon
 * @param {OpenSeadragon.Event} event - The event object containing event-specific properties.
 * @returns {Promise<void>} This handler does not return a value.
 */


/**
 * @class EventSource
 * @classdesc For use by classes which want to support custom, non-browser events.
 *
 * @memberof OpenSeadragon
 */
$.EventSource = function() {
    this.events = {};
    this._rejectedEventList = {};
};

/** @lends OpenSeadragon.EventSource.prototype */
$.EventSource.prototype = {

    /**
     * Add an event handler to be triggered only once (or a given number of times)
     * for a given event. It is not removable with removeHandler().
     * @function
     * @param {String} eventName - Name of event to register.
     * @param {OpenSeadragon.EventHandler|OpenSeadragon.AsyncEventHandler} handler - Function to call when event
     * is triggered.
     * @param {Object} [userData=null] - Arbitrary object to be passed unchanged
     * to the handler.
     * @param {Number} [times=1] - The number of times to handle the event
     * before removing it.
     * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority.
     * @returns {Boolean} - True if the handler was added, false if it was rejected
     */
    addOnceHandler: function(eventName, handler, userData, times, priority) {
        const self = this;
        times = times || 1;
        let count = 0;
        const onceHandler = function(event) {
            count++;
            if (count === times) {
                self.removeHandler(eventName, onceHandler);
            }
            return handler(event);
        };
        return this.addHandler(eventName, onceHandler, userData, priority);
    },

    /**
     * Add an event handler for a given event.
     * @function
     * @param {String} eventName - Name of event to register.
     * @param {OpenSeadragon.EventHandler|OpenSeadragon.AsyncEventHandler} handler - Function to call when event is triggered.
     * @param {Object} [userData=null] - Arbitrary object to be passed unchanged to the handler.
     * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority.
     * @returns {Boolean} - True if the handler was added, false if it was rejected
     */
    addHandler: function ( eventName, handler, userData, priority ) {

        if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){
            $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`);
            return false;
        }

        let events = this.events[ eventName ];
        if ( !events ) {
            this.events[ eventName ] = events = [];
        }
        if ( handler && $.isFunction( handler ) ) {
            let index = events.length,
                event = { handler: handler, userData: userData || null, priority: priority || 0 };
            events[ index ] = event;
            while ( index > 0 && events[ index - 1 ].priority < events[ index ].priority ) {
                events[ index ] = events[ index - 1 ];
                events[ index - 1 ] = event;
                index--;
            }
        }
        return true;
    },

    /**
     * Remove a specific event handler for a given event.
     * @function
     * @param {String} eventName - Name of event for which the handler is to be removed.
     * @param {OpenSeadragon.EventHandler|OpenSeadragon.AsyncEventHandler} handler - Function to be removed.
     */
    removeHandler: function ( eventName, handler ) {
        const events = this.events[ eventName ];
        const handlers = [];
        if ( !events ) {
            return;
        }
        if ( $.isArray( events ) ) {
            for ( let i = 0; i < events.length; i++ ) {
                if ( events[i].handler !== handler ) {
                    handlers.push( events[ i ] );
                }
            }
            this.events[ eventName ] = handlers;
        }
    },

    /**
     * Get the amount of handlers registered for a given event.
     * @param {String} eventName - Name of event to inspect.
     * @returns {number} amount of events
     */
    numberOfHandlers: function (eventName) {
        const events = this.events[ eventName ];
        if ( !events ) {
            return 0;
        }
        return events.length;
    },

    /**
     * Remove all event handlers for a given event type. If no type is given all
     * event handlers for every event type are removed.
     * @function
     * @param {String} [eventName] - Name of event for which all handlers are to be removed.
     */
    removeAllHandlers: function( eventName ) {
        if ( eventName ){
            this.events[ eventName ] = [];
        } else{
            for ( let eventType in this.events ) {
                this.events[ eventType ] = [];
            }
        }
    },

    /**
     * Get a function which iterates the list of all handlers registered for a given event, calling the handler for each.
     * @function
     * @param {String} eventName - Name of event to get handlers for.
     */
    getHandler: function ( eventName) {
        let events = this.events[ eventName ];
        if ( !events || !events.length ) {
            return null;
        }
        events = events.length === 1 ?
            [ events[ 0 ] ] :
            Array.apply( null, events );
        return function ( source, args ) {
            let length = events.length;
            for ( let i = 0; i < length; i++ ) {
                if ( events[ i ] ) {
                    args.eventSource = source;
                    args.userData = events[ i ].userData;
                    events[ i ].handler( args );

                    if (args.stopPropagation && (typeof args.stopPropagation !== "function" || args.stopPropagation() === true)) {
                        break;
                    }
                }
            }
        };
    },

    /**
     * Get a function which iterates the list of all handlers registered for a given event,
     * calling the handler for each and awaiting async ones.
     * @function
     * @param {String} eventName - Name of event to get handlers for.
     * @param {any} bindTarget - Bound target to return with the promise on finish
     */
    getAwaitingHandler: function ( eventName, bindTarget ) {
        let events = this.events[ eventName ];
        if ( !events || !events.length ) {
            return null;
        }
        events = events.length === 1 ?
            [ events[ 0 ] ] :
            Array.apply( null, events );

        return function ( source, args ) {
            // We return a promise that gets resolved after all the events finish.
            // Returning loop result is not correct, loop promises chain dynamically
            // and outer code could process finishing logics in the middle of event loop.
            return new $.Promise((resolve, reject) => {
                const length = events.length;
                function loop(index) {
                    if ( index >= length || !events[ index ] ) {
                        resolve(bindTarget);
                        return null;
                    }
                    args.eventSource = source;
                    args.userData = events[ index ].userData;
                    let result;
                    try {
                        result = events[ index ].handler( args );
                    } catch (e) {
                        return reject(e);
                    }
                    result = (!result || $.type(result) !== "promise") ? $.Promise.resolve() : result;
                    return result.then(() => {
                        if (!args.stopPropagation || (typeof args.stopPropagation === "function" && args.stopPropagation() === false)) {
                            return loop(index + 1);
                        }
                        return loop(length);
                    });
                }
                loop(0).catch(reject);
            });
        };
    },

    /**
     * Trigger an event, optionally passing additional information. Does not await async handlers, i.e.
     * OpenSeadragon.AsyncEventHandler.
     * @function
     * @param {String} eventName - Name of event to register.
     * @param {Object|undefined} eventArgs - Event-specific data.
     * @returns {Boolean} True if the event was fired, false if it was rejected because of rejectEventHandler(eventName)
     */
    raiseEvent: function( eventName, eventArgs ) {
        //uncomment if you want to get a log of all events
        //$.console.log( "Event fired:", eventName );

        if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){
            $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`);
            return false;
        }

        const handler = this.getHandler( eventName );
        if ( handler ) {
            handler( this, eventArgs || {} );
        }
        return true;
    },

    /**
     * Trigger an event, optionally passing additional information.
     * This events awaits every asynchronous or promise-returning function, i.e.
     * OpenSeadragon.AsyncEventHandler.
     * @param {String} eventName - Name of event to register.
     * @param {Object|undefined} eventArgs - Event-specific data.
     * @param {?} [bindTarget = null] - Promise-resolved value on the event finish
     * @return {OpenSeadragon.Promise|undefined} - Promise resolved upon the event completion.
     */
    raiseEventAwaiting: function ( eventName, eventArgs, bindTarget = null ) {
        //uncomment if you want to get a log of all events
        //$.console.log( "Awaiting event fired:", eventName );

        const awaitingHandler = this.getAwaitingHandler(eventName, bindTarget);
        if (awaitingHandler) {
            return awaitingHandler(this, eventArgs || {});
        }
        return $.Promise.resolve(bindTarget);
    },

    /**
     * Set an event name as being disabled, and provide an optional error message
     * to be printed to the console
     * @param {String} eventName - Name of the event
     * @param {String} [errorMessage] - Optional string to print to the console
     * @private
     */
    rejectEventHandler(eventName, errorMessage = ''){
        this._rejectedEventList[eventName] = errorMessage;
    },

    /**
     * Explicitly allow an event handler to be added for this event type, undoing
     * the effects of rejectEventHandler
     * @param {String} eventName - Name of the event
     * @private
     */
    allowEventHandler(eventName){
        delete this._rejectedEventList[eventName];
    }
};

}( OpenSeadragon ));

/*
 * OpenSeadragon - MouseTracker
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function ( $ ) {

    // All MouseTracker instances
    const MOUSETRACKERS  = [];

    // dictionary from hash to private properties
    const THIS           = {};


    /**
     * @class MouseTracker
     * @classdesc Provides simplified handling of common pointer device (mouse, touch, pen, etc.) gestures
     *            and keyboard events on a specified element.
     * @memberof OpenSeadragon
     * @param {Object} options
     *      Allows configurable properties to be entirely specified by passing
     *      an options object to the constructor.  The constructor also supports
     *      the original positional arguments 'element', 'clickTimeThreshold',
     *      and 'clickDistThreshold' in that order.
     * @param {Element|String} options.element
     *      A reference to an element or an element id for which the pointer/key
     *      events will be monitored.
     * @param {Boolean} [options.startDisabled=false]
     *      If true, event tracking on the element will not start until
     *      {@link OpenSeadragon.MouseTracker.setTracking|setTracking} is called.
     * @param {Number} [options.clickTimeThreshold=300]
     *      The number of milliseconds within which a pointer down-up event combination
     *      will be treated as a click gesture.
     * @param {Number} [options.clickDistThreshold=5]
     *      The maximum distance allowed between a pointer down event and a pointer up event
     *      to be treated as a click gesture.
     * @param {Number} [options.dblClickTimeThreshold=300]
     *      The number of milliseconds within which two pointer down-up event combinations
     *      will be treated as a double-click gesture.
     * @param {Number} [options.dblClickDistThreshold=20]
     *      The maximum distance allowed between two pointer click events
     *      to be treated as a click gesture.
     * @param {Number} [options.stopDelay=50]
     *      The number of milliseconds without pointer move before the stop
     *      event is fired.
     * @param {OpenSeadragon.EventHandler} [options.preProcessEventHandler=null]
     *      An optional handler for controlling DOM event propagation and processing.
     * @param {OpenSeadragon.EventHandler} [options.contextMenuHandler=null]
     *      An optional handler for contextmenu.
     * @param {OpenSeadragon.EventHandler} [options.enterHandler=null]
     *      An optional handler for pointer enter.
     * @param {OpenSeadragon.EventHandler} [options.leaveHandler=null]
     *      An optional handler for pointer leave.
     * @param {OpenSeadragon.EventHandler} [options.exitHandler=null]
     *      An optional handler for pointer leave. <span style="color:red;">Deprecated. Use leaveHandler instead.</span>
     * @param {OpenSeadragon.EventHandler} [options.overHandler=null]
     *      An optional handler for pointer over.
     * @param {OpenSeadragon.EventHandler} [options.outHandler=null]
     *      An optional handler for pointer out.
     * @param {OpenSeadragon.EventHandler} [options.pressHandler=null]
     *      An optional handler for pointer press.
     * @param {OpenSeadragon.EventHandler} [options.nonPrimaryPressHandler=null]
     *      An optional handler for pointer non-primary button press.
     * @param {OpenSeadragon.EventHandler} [options.releaseHandler=null]
     *      An optional handler for pointer release.
     * @param {OpenSeadragon.EventHandler} [options.nonPrimaryReleaseHandler=null]
     *      An optional handler for pointer non-primary button release.
     * @param {OpenSeadragon.EventHandler} [options.moveHandler=null]
     *      An optional handler for pointer move.
     * @param {OpenSeadragon.EventHandler} [options.scrollHandler=null]
     *      An optional handler for mouse wheel scroll.
     * @param {OpenSeadragon.EventHandler} [options.clickHandler=null]
     *      An optional handler for pointer click.
     * @param {OpenSeadragon.EventHandler} [options.dblClickHandler=null]
     *      An optional handler for pointer double-click.
     * @param {OpenSeadragon.EventHandler} [options.dragHandler=null]
     *      An optional handler for the drag gesture.
     * @param {OpenSeadragon.EventHandler} [options.dragEndHandler=null]
     *      An optional handler for after a drag gesture.
     * @param {OpenSeadragon.EventHandler} [options.pinchHandler=null]
     *      An optional handler for the pinch gesture.
     * @param {OpenSeadragon.EventHandler} [options.keyDownHandler=null]
     *      An optional handler for keydown.
     * @param {OpenSeadragon.EventHandler} [options.keyUpHandler=null]
     *      An optional handler for keyup.
     * @param {OpenSeadragon.EventHandler} [options.keyHandler=null]
     *      An optional handler for keypress.
     * @param {OpenSeadragon.EventHandler} [options.focusHandler=null]
     *      An optional handler for focus.
     * @param {OpenSeadragon.EventHandler} [options.blurHandler=null]
     *      An optional handler for blur.
     * @param {Object} [options.userData=null]
     *      Arbitrary object to be passed unchanged to any attached handler methods.
     */
    $.MouseTracker = function ( options ) {

        MOUSETRACKERS.push( this );

        const args = arguments;

        if ( !$.isPlainObject( options ) ) {
            options = {
                element:            args[ 0 ],
                clickTimeThreshold: args[ 1 ],
                clickDistThreshold: args[ 2 ]
            };
        }

        this.hash               = uniqueHash(); // An unique hash for this tracker.
        /**
         * The element for which pointer events are being monitored.
         * @member {Element} element
         * @memberof OpenSeadragon.MouseTracker#
         */
        this.element            = $.getElement( options.element );
        /**
         * The number of milliseconds within which a pointer down-up event combination
         * will be treated as a click gesture.
         * @member {Number} clickTimeThreshold
         * @memberof OpenSeadragon.MouseTracker#
         */
        this.clickTimeThreshold = options.clickTimeThreshold || $.DEFAULT_SETTINGS.clickTimeThreshold;
        /**
         * The maximum distance allowed between a pointer down event and a pointer up event
         * to be treated as a click gesture.
         * @member {Number} clickDistThreshold
         * @memberof OpenSeadragon.MouseTracker#
         */
        this.clickDistThreshold = options.clickDistThreshold || $.DEFAULT_SETTINGS.clickDistThreshold;
        /**
         * The number of milliseconds within which two pointer down-up event combinations
         * will be treated as a double-click gesture.
         * @member {Number} dblClickTimeThreshold
         * @memberof OpenSeadragon.MouseTracker#
         */
        this.dblClickTimeThreshold = options.dblClickTimeThreshold || $.DEFAULT_SETTINGS.dblClickTimeThreshold;
        /**
         * The maximum distance allowed between two pointer click events
         * to be treated as a double-click gesture.
         * @member {Number} dblClickDistThreshold
         * @memberof OpenSeadragon.MouseTracker#
         */
        this.dblClickDistThreshold = options.dblClickDistThreshold || $.DEFAULT_SETTINGS.dblClickDistThreshold;
        /*eslint-disable no-multi-spaces*/
        this.userData              = options.userData          || null;
        this.stopDelay             = options.stopDelay         || 50;

        this.preProcessEventHandler   = options.preProcessEventHandler   || null;
        this.contextMenuHandler       = options.contextMenuHandler       || null;
        this.enterHandler             = options.enterHandler             || null;
        this.leaveHandler             = options.leaveHandler             || null;
        this.exitHandler              = options.exitHandler              || null; // Deprecated v2.5.0
        this.overHandler              = options.overHandler              || null;
        this.outHandler               = options.outHandler               || null;
        this.pressHandler             = options.pressHandler             || null;
        this.nonPrimaryPressHandler   = options.nonPrimaryPressHandler   || null;
        this.releaseHandler           = options.releaseHandler           || null;
        this.nonPrimaryReleaseHandler = options.nonPrimaryReleaseHandler || null;
        this.moveHandler              = options.moveHandler              || null;
        this.scrollHandler            = options.scrollHandler            || null;
        this.clickHandler             = options.clickHandler             || null;
        this.dblClickHandler          = options.dblClickHandler          || null;
        this.dragHandler              = options.dragHandler              || null;
        this.dragEndHandler           = options.dragEndHandler           || null;
        this.pinchHandler             = options.pinchHandler             || null;
        this.stopHandler              = options.stopHandler              || null;
        this.keyDownHandler           = options.keyDownHandler           || null;
        this.keyUpHandler             = options.keyUpHandler             || null;
        this.keyHandler               = options.keyHandler               || null;
        this.focusHandler             = options.focusHandler             || null;
        this.blurHandler              = options.blurHandler              || null;
        /*eslint-enable no-multi-spaces*/

        //Store private properties in a scope sealed hash map
        const _this = this;

        /**
         * @private
         * @property {Boolean} tracking
         *      Are we currently tracking pointer events for this element.
         */
        THIS[ this.hash ] = {
            click:                 function ( event ) { onClick( _this, event ); },
            dblclick:              function ( event ) { onDblClick( _this, event ); },
            keydown:               function ( event ) { onKeyDown( _this, event ); },
            keyup:                 function ( event ) { onKeyUp( _this, event ); },
            keypress:              function ( event ) { onKeyPress( _this, event ); },
            focus:                 function ( event ) { onFocus( _this, event ); },
            blur:                  function ( event ) { onBlur( _this, event ); },
            contextmenu:           function ( event ) { onContextMenu( _this, event ); },

            wheel:                 function ( event ) { onWheel( _this, event ); },
            mousewheel:            function ( event ) { onMouseWheel( _this, event ); },
            DOMMouseScroll:        function ( event ) { onMouseWheel( _this, event ); },
            MozMousePixelScroll:   function ( event ) { onMouseWheel( _this, event ); },

            losecapture:           function ( event ) { onLoseCapture( _this, event ); },

            mouseenter:            function ( event ) { onPointerEnter( _this, event ); },
            mouseleave:            function ( event ) { onPointerLeave( _this, event ); },
            mouseover:             function ( event ) { onPointerOver( _this, event ); },
            mouseout:              function ( event ) { onPointerOut( _this, event ); },
            mousedown:             function ( event ) { onPointerDown( _this, event ); },
            mouseup:               function ( event ) { onPointerUp( _this, event ); },
            mousemove:             function ( event ) { onPointerMove( _this, event ); },

            touchstart:            function ( event ) { onTouchStart( _this, event ); },
            touchend:              function ( event ) { onTouchEnd( _this, event ); },
            touchmove:             function ( event ) { onTouchMove( _this, event ); },
            touchcancel:           function ( event ) { onTouchCancel( _this, event ); },

            gesturestart:          function ( event ) { onGestureStart( _this, event ); }, // Safari/Safari iOS
            gesturechange:         function ( event ) { onGestureChange( _this, event ); }, // Safari/Safari iOS

            gotpointercapture:     function ( event ) { onGotPointerCapture( _this, event ); },
            lostpointercapture:    function ( event ) { onLostPointerCapture( _this, event ); },
            pointerenter:          function ( event ) { onPointerEnter( _this, event ); },
            pointerleave:          function ( event ) { onPointerLeave( _this, event ); },
            pointerover:           function ( event ) { onPointerOver( _this, event ); },
            pointerout:            function ( event ) { onPointerOut( _this, event ); },
            pointerdown:           function ( event ) { onPointerDown( _this, event ); },
            pointerup:             function ( event ) { onPointerUp( _this, event ); },
            pointermove:           function ( event ) { onPointerMove( _this, event ); },
            pointercancel:         function ( event ) { onPointerCancel( _this, event ); },
            pointerupcaptured:     function ( event ) { onPointerUpCaptured( _this, event ); },
            pointermovecaptured:   function ( event ) { onPointerMoveCaptured( _this, event ); },

            tracking:              false,

            // Active pointers lists. Array of GesturePointList objects, one for each pointer device type.
            // GesturePointList objects are added each time a pointer is tracked by a new pointer device type (see getActivePointersListByType()).
            // Active pointers are any pointer being tracked for this element which are in the hit-test area
            //     of the element (for hover-capable devices) and/or have contact or a button press initiated in the element.
            activePointersLists:   [],

            // Tracking for double-click gesture
            lastClickPos:          null,
            dblClickTimeOut:       null,

            // Tracking for pinch gesture
            pinchGPoints:          [],
            lastPinchDist:         0,
            currentPinchDist:      0,
            lastPinchCenter:       null,
            currentPinchCenter:    null,

            // Tracking for drag
            sentDragEvent:         false
        };

        if ( $.MouseTracker.havePointerEvents ) {
            $.setElementPointerEvents( this.element, 'auto' );
        }

        if (this.exitHandler) {
            $.console.error("MouseTracker.exitHandler is deprecated. Use MouseTracker.leaveHandler instead.");
        }

        if ( !options.startDisabled ) {
            this.setTracking( true );
        }
    };

    /** @lends OpenSeadragon.MouseTracker.prototype */
    $.MouseTracker.prototype = {

        /**
         * Clean up any events or objects created by the tracker.
         * @function
         */
        destroy: function () {
            stopTracking( this );
            this.element = null;

            for ( let i = 0; i < MOUSETRACKERS.length; i++ ) {
                if ( MOUSETRACKERS[ i ] === this ) {
                    MOUSETRACKERS.splice( i, 1 );
                    break;
                }
            }

            THIS[ this.hash ] = null;
            delete THIS[ this.hash ];
        },

        /**
         * Are we currently tracking events on this element.
         * @deprecated Just use this.tracking
         * @function
         * @returns {Boolean} Are we currently tracking events on this element.
         */
        isTracking: function () {
            return THIS[ this.hash ].tracking;
        },

        /**
         * Enable or disable whether or not we are tracking events on this element.
         * @function
         * @param {Boolean} track True to start tracking, false to stop tracking.
         * @returns {OpenSeadragon.MouseTracker} Chainable.
         */
        setTracking: function ( track ) {
            if ( track ) {
                startTracking( this );
            } else {
                stopTracking( this );
            }
            //chain
            return this;
        },

        /**
         * Returns the {@link OpenSeadragon.MouseTracker.GesturePointList|GesturePointList} for the given pointer device type,
         * creating and caching a new {@link OpenSeadragon.MouseTracker.GesturePointList|GesturePointList} if one doesn't already exist for the type.
         * @function
         * @param {String} type - The pointer device type: "mouse", "touch", "pen", etc.
         * @returns {OpenSeadragon.MouseTracker.GesturePointList}
         */
        getActivePointersListByType: function ( type ) {
            const delegate = THIS[ this.hash ];
            const len = delegate ? delegate.activePointersLists.length : 0;
            let list;

            for ( let i = 0; i < len; i++ ) {
                if ( delegate.activePointersLists[ i ].type === type ) {
                    return delegate.activePointersLists[ i ];
                }
            }

            list = new $.MouseTracker.GesturePointList( type );
            if(delegate){
                delegate.activePointersLists.push( list );
            }
            return list;
        },

        /**
         * Returns the total number of pointers currently active on the tracked element.
         * @function
         * @returns {Number}
         */
        getActivePointerCount: function () {
            const delegate = THIS[ this.hash ];
            const len = delegate.activePointersLists.length;
            let count = 0;

            for ( let i = 0; i < len; i++ ) {
                count += delegate.activePointersLists[ i ].getLength();
            }

            return count;
        },

        /**
         * Do we currently have any assigned gesture handlers.
         * @returns {Boolean} Do we currently have any assigned gesture handlers.
         */
        get hasGestureHandlers() {
            return !!(this.pressHandler ||
                      this.nonPrimaryPressHandler ||
                      this.releaseHandler ||
                      this.nonPrimaryReleaseHandler ||
                      this.clickHandler ||
                      this.dblClickHandler ||
                      this.dragHandler ||
                      this.dragEndHandler ||
                      this.pinchHandler);
        },

        /**
         * Do we currently have a scroll handler.
         * @returns {Boolean} Do we currently have a scroll handler.
         */
        get hasScrollHandler() {
            return !!this.scrollHandler;
        },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
         */
        preProcessEventHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Boolean} event.preventDefault
         *      Set to true to prevent the default user-agent's handling of the contextmenu event.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        contextMenuHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Number} event.pointers
         *      Number of pointers (all types) active in the tracked element.
         * @param {Boolean} event.insideElementPressed
         *      True if the left mouse button is currently being pressed and was
         *      initiated inside the tracked element, otherwise false.
         * @param {Boolean} event.buttonDownAny
         *      Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        enterHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @since v2.5.0
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Number} event.pointers
         *      Number of pointers (all types) active in the tracked element.
         * @param {Boolean} event.insideElementPressed
         *      True if the left mouse button is currently being pressed and was
         *      initiated inside the tracked element, otherwise false.
         * @param {Boolean} event.buttonDownAny
         *      Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        leaveHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @deprecated v2.5.0 Use leaveHandler instead
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Number} event.pointers
         *      Number of pointers (all types) active in the tracked element.
         * @param {Boolean} event.insideElementPressed
         *      True if the left mouse button is currently being pressed and was
         *      initiated inside the tracked element, otherwise false.
         * @param {Boolean} event.buttonDownAny
         *      Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        exitHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @since v2.5.0
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Number} event.pointers
         *      Number of pointers (all types) active in the tracked element.
         * @param {Boolean} event.insideElementPressed
         *      True if the left mouse button is currently being pressed and was
         *      initiated inside the tracked element, otherwise false.
         * @param {Boolean} event.buttonDownAny
         *      Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        overHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @since v2.5.0
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Number} event.pointers
         *      Number of pointers (all types) active in the tracked element.
         * @param {Boolean} event.insideElementPressed
         *      True if the left mouse button is currently being pressed and was
         *      initiated inside the tracked element, otherwise false.
         * @param {Boolean} event.buttonDownAny
         *      Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        outHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        pressHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.button
         *      Button which caused the event.
         *      -1: none, 0: primary/left, 1: aux/middle, 2: secondary/right, 3: X1/back, 4: X2/forward, 5: pen eraser.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        nonPrimaryPressHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Boolean} event.insideElementPressed
         *      True if the left mouse button is currently being pressed and was
         *      initiated inside the tracked element, otherwise false.
         * @param {Boolean} event.insideElementReleased
         *      True if the cursor inside the tracked element when the button was released.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        releaseHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.button
         *      Button which caused the event.
         *      -1: none, 0: primary/left, 1: aux/middle, 2: secondary/right, 3: X1/back, 4: X2/forward, 5: pen eraser.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        nonPrimaryReleaseHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        moveHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.scroll
         *      The scroll delta for the event.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead. Touch devices no longer generate scroll event.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Boolean} event.preventDefault
         *      Set to true to prevent the default user-agent's handling of the wheel event.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        scrollHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Boolean} event.quick
         *      True only if the clickDistThreshold and clickTimeThreshold are both passed. Useful for ignoring drag events.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Element} event.originalTarget
         *      The DOM element clicked on.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        clickHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        dblClickHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {OpenSeadragon.Point} event.delta
         *      The x,y components of the difference between the current position and the last drag event position.  Useful for ignoring or weighting the events.
         * @param {Number} event.speed
         *     Current computed speed, in pixels per second.
         * @param {Number} event.direction
         *     Current computed direction, expressed as an angle counterclockwise relative to the positive X axis (-pi to pi, in radians). Only valid if speed > 0.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        dragHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.speed
         *     Speed at the end of a drag gesture, in pixels per second.
         * @param {Number} event.direction
         *     Direction at the end of a drag gesture, expressed as an angle counterclockwise relative to the positive X axis (-pi to pi, in radians). Only valid if speed > 0.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        dragEndHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {Array.<OpenSeadragon.MouseTracker.GesturePoint>} event.gesturePoints
         *      Gesture points associated with the gesture. Velocity data can be found here.
         * @param {OpenSeadragon.Point} event.lastCenter
         *      The previous center point of the two pinch contact points relative to the tracked element.
         * @param {OpenSeadragon.Point} event.center
         *      The center point of the two pinch contact points relative to the tracked element.
         * @param {Number} event.lastDistance
         *      The previous distance between the two pinch contact points in CSS pixels.
         * @param {Number} event.distance
         *      The distance between the two pinch contact points in CSS pixels.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        pinchHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {String} event.pointerType
         *     "mouse", "touch", "pen", etc.
         * @param {OpenSeadragon.Point} event.position
         *      The position of the event relative to the tracked element.
         * @param {Number} event.buttons
         *      Current buttons pressed.
         *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @param {Boolean} event.isTouchEvent
         *      True if the original event is a touch event, otherwise false. <span style="color:red;">Deprecated. Use pointerType and/or originalEvent instead.</span>
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        stopHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {Number} event.keyCode
         *      The key code that was pressed.
         * @param {Boolean} event.ctrl
         *      True if the ctrl key was pressed during this event.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.alt
         *      True if the alt key was pressed during this event.
         * @param {Boolean} event.meta
         *      True if the meta key was pressed during this event.
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Boolean} event.preventDefault
         *      Set to true to prevent the default user-agent's handling of the keydown event.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        keyDownHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {Number} event.keyCode
         *      The key code that was pressed.
         * @param {Boolean} event.ctrl
         *      True if the ctrl key was pressed during this event.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.alt
         *      True if the alt key was pressed during this event.
         * @param {Boolean} event.meta
         *      True if the meta key was pressed during this event.
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Boolean} event.preventDefault
         *      Set to true to prevent the default user-agent's handling of the keyup event.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        keyUpHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {Number} event.keyCode
         *      The key code that was pressed.
         * @param {Boolean} event.ctrl
         *      True if the ctrl key was pressed during this event.
         * @param {Boolean} event.shift
         *      True if the shift key was pressed during this event.
         * @param {Boolean} event.alt
         *      True if the alt key was pressed during this event.
         * @param {Boolean} event.meta
         *      True if the meta key was pressed during this event.
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Boolean} event.preventDefault
         *      Set to true to prevent the default user-agent's handling of the keypress event.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        keyHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        focusHandler: function () { },

        /**
         * Implement or assign implementation to these handlers during or after
         * calling the constructor.
         * @function
         * @param {Object} event
         * @param {OpenSeadragon.MouseTracker} event.eventSource
         *      A reference to the tracker instance.
         * @param {Object} event.originalEvent
         *      The original event object.
         * @param {Object} event.userData
         *      Arbitrary user-defined object.
         */
        blurHandler: function () { }
    };

    // https://github.com/openseadragon/openseadragon/pull/790
    /**
     * True if inside an iframe, otherwise false.
     * @member {Boolean} isInIframe
     * @private
     * @inner
     */
    const isInIframe = (function() {
        try {
            return window.self !== window.top;
        } catch (e) {
            return true;
        }
    })();

    // https://github.com/openseadragon/openseadragon/pull/790
    /**
     * @function
     * @private
     * @inner
     * @returns {Boolean} True if the target supports DOM Level 2 event subscription methods, otherwise false.
     */
    function canAccessEvents (target) {
        try {
            return target.addEventListener && target.removeEventListener;
        } catch (e) {
            return false;
        }
    }

    /**
     * Provides continuous computation of velocity (speed and direction) of active pointers.
     * This is a singleton, used by all MouseTracker instances, as it is unlikely there will ever be more than
     * two active gesture pointers at a time.
     *
     * @private
     * @member gesturePointVelocityTracker
     * @memberof OpenSeadragon.MouseTracker
     */
    $.MouseTracker.gesturePointVelocityTracker = (function () {
        const trackerPoints = [];
        let intervalId = 0;
        let lastTime = 0;

        // Generates a unique identifier for a tracked gesture point
        const _generateGuid = function ( tracker, gPoint ) {
            return tracker.hash.toString() + gPoint.type + gPoint.id.toString();
        };

        // Interval timer callback. Computes velocity for all tracked gesture points.
        const _doTracking = function () {
            const len = trackerPoints.length;
            const now = $.now();
            let distance;
            let speed;

            const elapsedTime = now - lastTime;
            lastTime = now;

            for ( let i = 0; i < len; i++ ) {
                const trackPoint = trackerPoints[ i ];
                const gPoint = trackPoint.gPoint;
                // Math.atan2 gives us just what we need for a velocity vector, as we can simply
                //   use cos()/sin() to extract the x/y velocity components.
                gPoint.direction = Math.atan2( gPoint.currentPos.y - trackPoint.lastPos.y, gPoint.currentPos.x - trackPoint.lastPos.x );
                // speed = distance / elapsed time
                distance = trackPoint.lastPos.distanceTo( gPoint.currentPos );
                trackPoint.lastPos = gPoint.currentPos;
                speed = 1000 * distance / ( elapsedTime + 1 );
                // Simple biased average, favors the most recent speed computation. Smooths out erratic gestures a bit.
                gPoint.speed = 0.75 * speed + 0.25 * gPoint.speed;
            }
        };

        // Public. Add a gesture point to be tracked
        const addPoint = function ( tracker, gPoint ) {
            const guid = _generateGuid( tracker, gPoint );

            trackerPoints.push(
                {
                    guid: guid,
                    gPoint: gPoint,
                    lastPos: gPoint.currentPos
                } );

            // Only fire up the interval timer when there's gesture pointers to track
            if ( trackerPoints.length === 1 ) {
                lastTime = $.now();
                intervalId = window.setInterval( _doTracking, 50 );
            }
        };

        // Public. Stop tracking a gesture point
        const removePoint = function ( tracker, gPoint ) {
            const guid = _generateGuid( tracker, gPoint );
            let len = trackerPoints.length;

            for ( let i = 0; i < len; i++ ) {
                if ( trackerPoints[ i ].guid === guid ) {
                    trackerPoints.splice( i, 1 );
                    // Only run the interval timer if theres gesture pointers to track
                    len--;
                    if ( len === 0 ) {
                        window.clearInterval( intervalId );
                    }
                    break;
                }
            }
        };

        return {
            addPoint:    addPoint,
            removePoint: removePoint
        };
    } )();


///////////////////////////////////////////////////////////////////////////////
// Pointer event model and feature detection
///////////////////////////////////////////////////////////////////////////////

    $.MouseTracker.captureElement = document;

    /**
     * Detect available mouse wheel event name.
     */
    $.MouseTracker.wheelEventName = ( 'onwheel' in document.createElement( 'div' ) ) ? 'wheel' : // Modern browsers support 'wheel'
                                    document.onmousewheel !== undefined ? 'mousewheel' :         // Webkit (and unsupported IE) support at least 'mousewheel'
                                    'DOMMouseScroll';                                            // Assume old Firefox (deprecated)

    /**
     * Detect browser pointer device event model(s) and build appropriate list of events to subscribe to.
     */
    $.MouseTracker.subscribeEvents = [ "click", "dblclick", "keydown", "keyup", "keypress", "focus", "blur", "contextmenu", $.MouseTracker.wheelEventName ];

    if( $.MouseTracker.wheelEventName === "DOMMouseScroll" ) {
        // Older Firefox
        $.MouseTracker.subscribeEvents.push( "MozMousePixelScroll" );
    }

    if ( window.PointerEvent ) {
        // W3C Pointer Event implementations (see http://www.w3.org/TR/pointerevents)
        $.MouseTracker.havePointerEvents = true;
        $.MouseTracker.subscribeEvents.push( "pointerenter", "pointerleave", "pointerover", "pointerout", "pointerdown", "pointerup", "pointermove", "pointercancel" );
        // Pointer events capture support
        $.MouseTracker.havePointerCapture = (function () {
            const divElement = document.createElement( 'div' );
            return $.isFunction( divElement.setPointerCapture ) && $.isFunction( divElement.releasePointerCapture );
        }());
        if ( $.MouseTracker.havePointerCapture ) {
            $.MouseTracker.subscribeEvents.push( "gotpointercapture", "lostpointercapture" );
        }
    } else {
        // Legacy W3C mouse events
        $.MouseTracker.havePointerEvents = false;
        $.MouseTracker.subscribeEvents.push( "mouseenter", "mouseleave", "mouseover", "mouseout", "mousedown", "mouseup", "mousemove" );
        $.MouseTracker.mousePointerId = "legacy-mouse";
        // Legacy mouse events capture support (IE/Firefox only?)
        $.MouseTracker.havePointerCapture = (function () {
            const divElement = document.createElement( 'div' );
            return $.isFunction( divElement.setCapture ) && $.isFunction( divElement.releaseCapture );
        }());
        if ( $.MouseTracker.havePointerCapture ) {
            $.MouseTracker.subscribeEvents.push( "losecapture" );
        }
        // Legacy touch events
        if ( 'ontouchstart' in window ) {
            // iOS, Android, and other W3c Touch Event implementations
            //    (see http://www.w3.org/TR/touch-events/)
            //    (see https://developer.apple.com/library/ios/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html)
            //    (see https://developer.apple.com/library/safari/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html)
            $.MouseTracker.subscribeEvents.push( "touchstart", "touchend", "touchmove", "touchcancel" );
        }
        if ( 'ongesturestart' in window ) {
            // iOS (see https://developer.apple.com/library/ios/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html)
            //   Subscribe to these to prevent default gesture handling
            $.MouseTracker.subscribeEvents.push( "gesturestart", "gesturechange" );
        }
    }


///////////////////////////////////////////////////////////////////////////////
// Classes and typedefs
///////////////////////////////////////////////////////////////////////////////

    /**
     * Used for the processing/disposition of DOM events (propagation, default handling, capture, etc.)
     *
     * @typedef {Object} EventProcessInfo
     * @memberof OpenSeadragon.MouseTracker
     * @since v2.5.0
     *
     * @property {OpenSeadragon.MouseTracker} eventSource
     *      A reference to the tracker instance.
     * @property {Object} originalEvent
     *      The original DOM event object.
     * @property {Number} eventPhase
     *      0 == NONE, 1 == CAPTURING_PHASE, 2 == AT_TARGET, 3 == BUBBLING_PHASE.
     * @property {String} eventType
     *     "keydown", "keyup", "keypress", "focus", "blur", "contextmenu", "gotpointercapture", "lostpointercapture", "pointerenter", "pointerleave", "pointerover", "pointerout", "pointerdown", "pointerup", "pointermove", "pointercancel", "wheel", "click", "dblclick".
     * @property {String} pointerType
     *     "mouse", "touch", "pen", etc.
     * @property {Boolean} isEmulated
     *      True if this is an emulated event. If true, originalEvent is either the event that caused
     *      the emulated event, a synthetic event object created with values from the actual DOM event,
     *      or null if no DOM event applies. Emulated events can occur on eventType "wheel" on legacy mouse-scroll
     *      event emitting user agents.
     * @property {Boolean} isStoppable
     *      True if propagation of the event (e.g. bubbling) can be stopped with stopPropagation/stopImmediatePropagation.
     * @property {Boolean} isCancelable
     *      True if the event's default handling by the browser can be prevented with preventDefault.
     * @property {Boolean} defaultPrevented
     *      True if the event's default handling has already been prevented by a descendent element.
     * @property {Boolean} preventDefault
     *      Set to true to prevent the event's default handling by the browser.
     * @property {Boolean} preventGesture
     *      Set to true to prevent this MouseTracker from generating a gesture from the event.
     *      Valid on eventType "pointerdown".
     * @property {Boolean} stopPropagation
     *      Set to true prevent the event from propagating to ancestor/descendent elements on capture/bubble phase.
     * @property {Boolean} shouldCapture
     *      (Internal Use) Set to true if the pointer should be captured (events (re)targeted to tracker element).
     * @property {Boolean} shouldReleaseCapture
     *      (Internal Use) Set to true if the captured pointer should be released.
     * @property {Object} userData
     *      Arbitrary user-defined object.
     */


    /**
     * Represents a point of contact on the screen made by a mouse cursor, pen, touch, or other pointer device.
     *
     * @typedef {Object} GesturePoint
     * @memberof OpenSeadragon.MouseTracker
     *
     * @property {Number} id
     *     Identifier unique from all other active GesturePoints for a given pointer device.
     * @property {String} type
     *     The pointer device type: "mouse", "touch", "pen", etc.
     * @property {Boolean} captured
     *     True if events for the gesture point are captured to the tracked element.
     * @property {Boolean} isPrimary
     *     True if the gesture point is a master pointer amongst the set of active pointers for each pointer type. True for mouse and primary (first) touch/pen pointers.
     * @property {Boolean} insideElementPressed
     *     True if button pressed or contact point initiated inside the screen area of the tracked element.
     * @property {Boolean} insideElement
     *     True if pointer or contact point is currently inside the bounds of the tracked element.
     * @property {Number} speed
     *     Current computed speed, in pixels per second.
     * @property {Number} direction
     *     Current computed direction, expressed as an angle counterclockwise relative to the positive X axis (-pi to pi, in radians). Only valid if speed > 0.
     * @property {OpenSeadragon.Point} contactPos
     *     The initial pointer contact position, relative to the page including any scrolling. Only valid if the pointer has contact (pressed, touch contact, pen contact).
     * @property {Number} contactTime
     *     The initial pointer contact time, in milliseconds. Only valid if the pointer has contact (pressed, touch contact, pen contact).
     * @property {OpenSeadragon.Point} lastPos
     *     The last pointer position, relative to the page including any scrolling.
     * @property {Number} lastTime
     *     The last pointer contact time, in milliseconds.
     * @property {OpenSeadragon.Point} currentPos
     *     The current pointer position, relative to the page including any scrolling.
     * @property {Number} currentTime
     *     The current pointer contact time, in milliseconds.
     */


    /**
     * @class GesturePointList
     * @classdesc Provides an abstraction for a set of active {@link OpenSeadragon.MouseTracker.GesturePoint|GesturePoint} objects for a given pointer device type.
     *            Active pointers are any pointer being tracked for this element which are in the hit-test area
     *            of the element (for hover-capable devices) and/or have contact or a button press initiated in the element.
     * @memberof OpenSeadragon.MouseTracker
     * @param {String} type - The pointer device type: "mouse", "touch", "pen", etc.
     */
    $.MouseTracker.GesturePointList = function ( type ) {
        this._gPoints = [];
        /**
         * The pointer device type: "mouse", "touch", "pen", etc.
         * @member {String} type
         * @memberof OpenSeadragon.MouseTracker.GesturePointList#
         */
        this.type = type;
        /**
         * Current buttons pressed for the device.
         * Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
         * @member {Number} buttons
         * @memberof OpenSeadragon.MouseTracker.GesturePointList#
         */
        this.buttons = 0;
        /**
         * Current number of contact points (touch points, mouse down, etc.) for the device.
         * @member {Number} contacts
         * @memberof OpenSeadragon.MouseTracker.GesturePointList#
         */
        this.contacts = 0;
        /**
         * Current number of clicks for the device. Used for multiple click gesture tracking.
         * @member {Number} clicks
         * @memberof OpenSeadragon.MouseTracker.GesturePointList#
         */
        this.clicks = 0;
        /**
         * Current number of captured pointers for the device.
         * @member {Number} captureCount
         * @memberof OpenSeadragon.MouseTracker.GesturePointList#
         */
        this.captureCount = 0;
    };

    /** @lends OpenSeadragon.MouseTracker.GesturePointList.prototype */
    $.MouseTracker.GesturePointList.prototype = {
        /**
         * @function
         * @returns {Number} Number of gesture points in the list.
         */
        getLength: function () {
            return this._gPoints.length;
        },
        /**
         * @function
         * @returns {Array.<OpenSeadragon.MouseTracker.GesturePoint>} The list of gesture points in the list as an array (read-only).
         */
        asArray: function () {
            return this._gPoints;
        },
        /**
         * @function
         * @param {OpenSeadragon.MouseTracker.GesturePoint} gesturePoint - A gesture point to add to the list.
         * @returns {Number} Number of gesture points in the list.
         */
        add: function ( gp ) {
            return this._gPoints.push( gp );
        },
        /**
         * @function
         * @param {Number} id - The id of the gesture point to remove from the list.
         * @returns {Number} Number of gesture points in the list.
         */
        removeById: function ( id ) {
            const len = this._gPoints.length;
            for ( let i = 0; i < len; i++ ) {
                if ( this._gPoints[ i ].id === id ) {
                    this._gPoints.splice( i, 1 );
                    break;
                }
            }
            return this._gPoints.length;
        },
        /**
         * @function
         * @param {Number} index - The index of the gesture point to retrieve from the list.
         * @returns {OpenSeadragon.MouseTracker.GesturePoint|null} The gesture point at the given index, or null if not found.
         */
        getByIndex: function ( index ) {
            if ( index < this._gPoints.length) {
                return this._gPoints[ index ];
            }

            return null;
        },
        /**
         * @function
         * @param {Number} id - The id of the gesture point to retrieve from the list.
         * @returns {OpenSeadragon.MouseTracker.GesturePoint|null} The gesture point with the given id, or null if not found.
         */
        getById: function ( id ) {
            const len = this._gPoints.length;
            for ( let i = 0; i < len; i++ ) {
                if ( this._gPoints[ i ].id === id ) {
                    return this._gPoints[ i ];
                }
            }
            return null;
        },
        /**
         * @function
         * @returns {OpenSeadragon.MouseTracker.GesturePoint|null} The primary gesture point in the list, or null if not found.
         */
        getPrimary: function ( id ) {
            const len = this._gPoints.length;
            for ( let i = 0; i < len; i++ ) {
                if ( this._gPoints[ i ].isPrimary ) {
                    return this._gPoints[ i ];
                }
            }
            return null;
        },

        /**
         * Increment this pointer list's contact count.
         * It will evaluate whether this pointer type is allowed to have multiple contacts.
         * @function
         */
        addContact: function() {
            ++this.contacts;

            if (this.contacts > 1 && (this.type === "mouse" || this.type === "pen")) {
                $.console.warn('GesturePointList.addContact() Implausible contacts value');
                this.contacts = 1;
            }
        },

        /**
         * Decrement this pointer list's contact count.
         * It will make sure the count does not go below 0.
         * @function
         */
        removeContact: function() {
            --this.contacts;

            if (this.contacts < 0) {
                this.contacts = 0;
            }
        }
    };


///////////////////////////////////////////////////////////////////////////////
// Utility functions
///////////////////////////////////////////////////////////////////////////////

    /**
     * Removes all tracked pointers.
     * @private
     * @inner
     */
    function clearTrackedPointers( tracker ) {
        const delegate = THIS[ tracker.hash ];
        const pointerListCount = delegate.activePointersLists.length;

        for ( let i = 0; i < pointerListCount; i++ ) {
            const pointsList = delegate.activePointersLists[ i ];

            if ( pointsList.getLength() > 0 ) {
                // Make an array containing references to the gPoints in the pointer list
                //   (because calls to stopTrackingPointer() are going to modify the pointer list)
                const gPointsToRemove = [];
                const gPoints = pointsList.asArray();
                for ( let j = 0; j < gPoints.length; j++ ) {
                    gPointsToRemove.push( gPoints[ j ] );
                }

                // Release and remove all gPoints from the pointer list
                for ( let j = 0; j < gPointsToRemove.length; j++ ) {
                    stopTrackingPointer( tracker, pointsList, gPointsToRemove[ j ] );
                }
            }
        }

        for ( let i = 0; i < pointerListCount; i++ ) {
            delegate.activePointersLists.pop();
        }

        delegate.sentDragEvent = false;
    }

    /**
     * Starts tracking pointer events on the tracked element.
     * @private
     * @inner
     */
    function startTracking( tracker ) {
        const delegate = THIS[ tracker.hash ];

        if ( !delegate.tracking ) {
            for ( let i = 0; i < $.MouseTracker.subscribeEvents.length; i++ ) {
                const event = $.MouseTracker.subscribeEvents[ i ];
                $.addEvent(
                    tracker.element,
                    event,
                    delegate[ event ],
                    event === $.MouseTracker.wheelEventName ? { passive: false, capture: false } : false
                );
            }

            clearTrackedPointers( tracker );

            delegate.tracking = true;
        }
    }

    /**
     * Stops tracking pointer events on the tracked element.
     * @private
     * @inner
     */
    function stopTracking( tracker ) {
        const delegate = THIS[ tracker.hash ];

        if ( delegate.tracking ) {
            for ( let i = 0; i < $.MouseTracker.subscribeEvents.length; i++ ) {
                const event = $.MouseTracker.subscribeEvents[ i ];
                $.removeEvent(
                    tracker.element,
                    event,
                    delegate[ event ],
                    false
                );
            }

            clearTrackedPointers( tracker );

            delegate.tracking = false;
        }
    }

    /**
     * @private
     * @inner
     */
    function getCaptureEventParams( tracker, pointerType ) {
        const delegate = THIS[ tracker.hash ];

        if ( pointerType === 'pointerevent' ) {
            return {
                upName: 'pointerup',
                upHandler: delegate.pointerupcaptured,
                moveName: 'pointermove',
                moveHandler: delegate.pointermovecaptured
            };
        } else if ( pointerType === 'mouse' ) {
            return {
                upName: 'pointerup',
                upHandler: delegate.pointerupcaptured,
                moveName: 'pointermove',
                moveHandler: delegate.pointermovecaptured
            };
        } else if ( pointerType === 'touch' ) {
            return {
                upName: 'touchend',
                upHandler: delegate.touchendcaptured,
                moveName: 'touchmove',
                moveHandler: delegate.touchmovecaptured
            };
        } else {
            throw new Error( "MouseTracker.getCaptureEventParams: Unknown pointer type." );
        }
    }

    /**
     * Begin capturing pointer events to the tracked element.
     * @private
     * @inner
     */
    function capturePointer( tracker, gPoint ) {
        if ( $.MouseTracker.havePointerCapture ) {
            if ( $.MouseTracker.havePointerEvents ) {
                // Can throw NotFoundError (InvalidPointerId Firefox < 82)
                //   (should never happen so we'll log a warning)
                try {
                    tracker.element.setPointerCapture( gPoint.id );
                    //$.console.log('element.setPointerCapture() called');
                } catch ( e ) {
                    $.console.warn('setPointerCapture() called on invalid pointer ID');
                    return;
                }
            } else {
                tracker.element.setCapture( true );
                //$.console.log('element.setCapture() called');
            }
        } else {
            // Emulate mouse capture by hanging listeners on the document object.
            //    (Note we listen on the capture phase so the captured handlers will get called first)
            // eslint-disable-next-line no-use-before-define
            //$.console.log('Emulated mouse capture set');
            const eventParams = getCaptureEventParams( tracker, $.MouseTracker.havePointerEvents ? 'pointerevent' : gPoint.type );
            // https://github.com/openseadragon/openseadragon/pull/790
            if (isInIframe && canAccessEvents(window.top)) {
                $.addEvent(
                    window.top,
                    eventParams.upName,
                    eventParams.upHandler,
                    true
                );
            }
            $.addEvent(
                $.MouseTracker.captureElement,
                eventParams.upName,
                eventParams.upHandler,
                true
            );
            $.addEvent(
                $.MouseTracker.captureElement,
                eventParams.moveName,
                eventParams.moveHandler,
                true
            );
        }

        updatePointerCaptured( tracker, gPoint, true );
    }


    /**
     * Stop capturing pointer events to the tracked element.
     * @private
     * @inner
     */
    function releasePointer( tracker, gPoint ) {
        if ( $.MouseTracker.havePointerCapture ) {
            if ( $.MouseTracker.havePointerEvents ) {
                const pointsList = tracker.getActivePointersListByType( gPoint.type );
                const cachedGPoint = pointsList.getById( gPoint.id );
                if ( !cachedGPoint || !cachedGPoint.captured ) {
                    return;
                }
                // Can throw NotFoundError (InvalidPointerId Firefox < 82)
                //   (should never happen, but it does on Firefox 79 touch so we won't log a warning)
                try {
                    tracker.element.releasePointerCapture( gPoint.id );
                    //$.console.log('element.releasePointerCapture() called');
                } catch ( e ) {
                    //$.console.warn('releasePointerCapture() called on invalid pointer ID');
                }
            } else {
                tracker.element.releaseCapture();
                //$.console.log('element.releaseCapture() called');
            }
        } else {
            // Emulate mouse capture by hanging listeners on the document object.
            //    (Note we listen on the capture phase so the captured handlers will get called first)
            //$.console.log('Emulated mouse capture release');
            const eventParams = getCaptureEventParams( tracker, $.MouseTracker.havePointerEvents ? 'pointerevent' : gPoint.type );
            // https://github.com/openseadragon/openseadragon/pull/790
            if (isInIframe && canAccessEvents(window.top)) {
                $.removeEvent(
                    window.top,
                    eventParams.upName,
                    eventParams.upHandler,
                    true
                );
            }
            $.removeEvent(
                $.MouseTracker.captureElement,
                eventParams.moveName,
                eventParams.moveHandler,
                true
            );
            $.removeEvent(
                $.MouseTracker.captureElement,
                eventParams.upName,
                eventParams.upHandler,
                true
            );
        }

        updatePointerCaptured( tracker, gPoint, false );
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     * @private
     * @inner
     */
    function getPointerId( event ) {
        return ( $.MouseTracker.havePointerEvents ) ? event.pointerId : $.MouseTracker.mousePointerId;
    }


    /**
     * Gets a W3C Pointer Events model compatible pointer type string from a DOM pointer event.
     *
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     * @private
     * @inner
     */
    function getPointerType( event ) {
        return $.MouseTracker.havePointerEvents && event.pointerType ? event.pointerType : 'mouse';
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     * @private
     * @inner
     */
    function getIsPrimary( event ) {
        return ( $.MouseTracker.havePointerEvents ) ? event.isPrimary : true;
    }


    /**
     * @private
     * @inner
     */
    function getMouseAbsolute( event ) {
        return $.getMousePosition( event );
    }

    /**
     * @private
     * @inner
     */
    function getMouseRelative( event, element ) {
        return getPointRelativeToAbsolute( getMouseAbsolute( event ), element );
    }

    /**
     * @private
     * @inner
     */
    function getPointRelativeToAbsolute( point, element ) {
        const offset = $.getElementOffset( element );
        return point.minus( offset );
    }

    /**
     * @private
     * @inner
     */
    function getCenterPoint( point1, point2 ) {
        return new $.Point( ( point1.x + point2.x ) / 2, ( point1.y + point2.y ) / 2 );
    }


///////////////////////////////////////////////////////////////////////////////
// Device-specific DOM event handlers
///////////////////////////////////////////////////////////////////////////////

    /**
     * @private
     * @inner
     */
    function onClick( tracker, event ) {
        //$.console.log('click ' + (tracker.userData ? tracker.userData.toString() : ''));

        const eventInfo = {
            originalEvent: event,
            eventType: 'click',
            pointerType: 'mouse',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onDblClick( tracker, event ) {
        //$.console.log('dblclick ' + (tracker.userData ? tracker.userData.toString() : ''));

        const eventInfo = {
            originalEvent: event,
            eventType: 'dblclick',
            pointerType: 'mouse',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onKeyDown( tracker, event ) {
        //$.console.log( "keydown %s %s %s %s %s", event.keyCode, event.charCode, event.ctrlKey, event.shiftKey, event.altKey );
        let eventArgs = null;

        const eventInfo = {
            originalEvent: event,
            eventType: 'keydown',
            pointerType: '',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( tracker.keyDownHandler && !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
            eventArgs = {
                eventSource:          tracker,
                keyCode:              event.keyCode ? event.keyCode : event.charCode,
                ctrl:                 event.ctrlKey,
                shift:                event.shiftKey,
                alt:                  event.altKey,
                meta:                 event.metaKey,
                originalEvent:        event,
                preventDefault:       eventInfo.preventDefault || eventInfo.defaultPrevented,
                userData:             tracker.userData
            };

            tracker.keyDownHandler( eventArgs );
        }

        if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) {
                $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onKeyUp( tracker, event ) {
        //$.console.log( "keyup %s %s %s %s %s", event.keyCode, event.charCode, event.ctrlKey, event.shiftKey, event.altKey );

        let eventArgs = null;

        const eventInfo = {
            originalEvent: event,
            eventType: 'keyup',
            pointerType: '',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( tracker.keyUpHandler && !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
            eventArgs = {
                eventSource:          tracker,
                keyCode:              event.keyCode ? event.keyCode : event.charCode,
                ctrl:                 event.ctrlKey,
                shift:                event.shiftKey,
                alt:                  event.altKey,
                meta:                 event.metaKey,
                originalEvent:        event,
                preventDefault:       eventInfo.preventDefault || eventInfo.defaultPrevented,
                userData:             tracker.userData
            };

            tracker.keyUpHandler( eventArgs );
        }

        if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onKeyPress( tracker, event ) {
        //$.console.log( "keypress %s %s %s %s %s", event.keyCode, event.charCode, event.ctrlKey, event.shiftKey, event.altKey );

        let eventArgs = null;

        const eventInfo = {
            originalEvent: event,
            eventType: 'keypress',
            pointerType: '',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( tracker.keyHandler && !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
            eventArgs = {
                eventSource:          tracker,
                keyCode:              event.keyCode ? event.keyCode : event.charCode,
                ctrl:                 event.ctrlKey,
                shift:                event.shiftKey,
                alt:                  event.altKey,
                meta:                 event.metaKey,
                originalEvent:        event,
                preventDefault:       eventInfo.preventDefault || eventInfo.defaultPrevented,
                userData:             tracker.userData
            };

            tracker.keyHandler( eventArgs );
        }

        if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onFocus( tracker, event ) {
        //$.console.log('focus  ' + (tracker.userData ? tracker.userData.toString() : ''));

        // focus doesn't bubble and is not cancelable, but we call
        //   preProcessEvent() so it's dispatched to preProcessEventHandler
        //   if necessary
        const eventInfo = {
            originalEvent: event,
            eventType: 'focus',
            pointerType: '',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( tracker.focusHandler && !eventInfo.preventGesture ) {
            tracker.focusHandler(
                {
                    eventSource:          tracker,
                    originalEvent:        event,
                    userData:             tracker.userData
                }
            );
        }
    }


    /**
     * @private
     * @inner
     */
    function onBlur( tracker, event ) {
        //$.console.log('blur  ' + (tracker.userData ? tracker.userData.toString() : ''));

        // blur doesn't bubble and is not cancelable, but we call
        //   preProcessEvent() so it's dispatched to preProcessEventHandler
        //   if necessary
        const eventInfo = {
            originalEvent: event,
            eventType: 'blur',
            pointerType: '',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( tracker.blurHandler && !eventInfo.preventGesture ) {
            tracker.blurHandler(
                {
                    eventSource:          tracker,
                    originalEvent:        event,
                    userData:             tracker.userData
                }
            );
        }
    }


    /**
     * @private
     * @inner
     */
    function onContextMenu( tracker, event ) {
        //$.console.log('contextmenu ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        let eventArgs = null;

        const eventInfo = {
            originalEvent: event,
            eventType: 'contextmenu',
            pointerType: 'mouse',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        // ContextMenu
        if ( tracker.contextMenuHandler && !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
            eventArgs = {
                eventSource:          tracker,
                position:             getPointRelativeToAbsolute( getMouseAbsolute( event ), tracker.element ),
                originalEvent:        eventInfo.originalEvent,
                preventDefault:       eventInfo.preventDefault || eventInfo.defaultPrevented,
                userData:             tracker.userData
            };

            tracker.contextMenuHandler( eventArgs );
        }

        if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * Handler for 'wheel' events
     *
     * @private
     * @inner
     */
    function onWheel( tracker, event ) {
        handleWheelEvent( tracker, event, event );
    }


    /**
     * Handler for 'mousewheel', 'DOMMouseScroll', and 'MozMousePixelScroll' events
     *
     * @private
     * @inner
     */
    function onMouseWheel( tracker, event ) {
        // Simulate a 'wheel' event
        const simulatedEvent = {
            target:     event.target || event.srcElement,
            type:       "wheel",
            shiftKey:   event.shiftKey || false,
            clientX:    event.clientX,
            clientY:    event.clientY,
            pageX:      event.pageX ? event.pageX : event.clientX,
            pageY:      event.pageY ? event.pageY : event.clientY,
            deltaMode:  event.type === "MozMousePixelScroll" ? 0 : 1, // 0=pixel, 1=line, 2=page
            deltaX:     0,
            deltaZ:     0
        };

        // Calculate deltaY
        if ( $.MouseTracker.wheelEventName === "mousewheel" ) {
            simulatedEvent.deltaY = -event.wheelDelta / $.DEFAULT_SETTINGS.pixelsPerWheelLine;
        } else {
            simulatedEvent.deltaY = event.detail;
        }

        handleWheelEvent( tracker, simulatedEvent, event );
    }


    /**
     * Handles 'wheel' events.
     * The event may be simulated by the legacy mouse wheel event handler (onMouseWheel()).
     *
     * @private
     * @inner
     */
    function handleWheelEvent( tracker, event, originalEvent ) {
        let nDelta = 0;
        let eventInfo;

        let eventArgs = null;

        // The nDelta variable is gated to provide smooth z-index scrolling
        //   since the mouse wheel allows for substantial deltas meant for rapid
        //   y-index scrolling.
        // event.deltaMode: 0=pixel, 1=line, 2=page
        // TODO: Deltas in pixel mode should be accumulated then a scroll value computed after $.DEFAULT_SETTINGS.pixelsPerWheelLine threshold reached
        nDelta = event.deltaY ? (event.deltaY < 0 ? 1 : -1) : 0;

        eventInfo = {
            originalEvent: event,
            eventType: 'wheel',
            pointerType: 'mouse',
            isEmulated: event !== originalEvent
        };
        preProcessEvent( tracker, eventInfo );

        if ( tracker.scrollHandler && !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
            eventArgs = {
                eventSource:          tracker,
                pointerType:          'mouse',
                position:             getMouseRelative( event, tracker.element ),
                scroll:               nDelta,
                shift:                event.shiftKey,
                isTouchEvent:         false,
                originalEvent:        originalEvent,
                preventDefault:       eventInfo.preventDefault || eventInfo.defaultPrevented,
                userData:             tracker.userData
            };


            tracker.scrollHandler( eventArgs );
        }

        if ( eventInfo.stopPropagation ) {
            $.stopEvent( originalEvent );
        }
        if ( ( eventArgs && eventArgs.preventDefault ) || ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) ) {
                $.cancelEvent( originalEvent );
        }
}


    /**
     * TODO Never actually seen this event fired, and documentation is tough to find
     * @private
     * @inner
     */
    function onLoseCapture( tracker, event ) {
        //$.console.log('losecapture ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const gPoint = {
            id: $.MouseTracker.mousePointerId,
            type: 'mouse'
        };

        const eventInfo = {
            originalEvent: event,
            eventType: 'lostpointercapture',
            pointerType: 'mouse',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( event.target === tracker.element ) {
            updatePointerCaptured( tracker, gPoint, false );
        }

        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onTouchStart( tracker, event ) {
        const touchCount = event.changedTouches.length;
        const pointsList = tracker.getActivePointersListByType( 'touch' );

        const time = $.now();

        //$.console.log('touchstart ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        if ( pointsList.getLength() > event.touches.length - touchCount ) {
            $.console.warn('Tracked touch contact count doesn\'t match event.touches.length');
        }

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerdown',
            pointerType: 'touch',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        for ( let i = 0; i < touchCount; i++ ) {
            const gPoint = {
                id: event.changedTouches[ i ].identifier,
                type: 'touch',
                // Simulate isPrimary
                isPrimary: pointsList.getLength() === 0,
                currentPos: getMouseAbsolute( event.changedTouches[ i ] ),
                currentTime: time
            };

            // simulate touchenter on our tracked element
            updatePointerEnter( tracker, eventInfo, gPoint );

            updatePointerDown( tracker, eventInfo, gPoint, 0 );

            updatePointerCaptured( tracker, gPoint, true );
        }

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onTouchEnd( tracker, event ) {
        const touchCount = event.changedTouches.length;
        const time = $.now();

        //$.console.log('touchend ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerup',
            pointerType: 'touch',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        for ( let i = 0; i < touchCount; i++ ) {
            const gPoint = {
                id: event.changedTouches[ i ].identifier,
                type: 'touch',
                currentPos: getMouseAbsolute( event.changedTouches[ i ] ),
                currentTime: time
            };

            updatePointerUp( tracker, eventInfo, gPoint, 0 );

            updatePointerCaptured( tracker, gPoint, false );

            // simulate touchleave on our tracked element
            updatePointerLeave( tracker, eventInfo, gPoint );
        }

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onTouchMove( tracker, event ) {
        const touchCount = event.changedTouches.length;
        const time = $.now();

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointermove',
            pointerType: 'touch',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        for ( let i = 0; i < touchCount; i++ ) {
            const gPoint = {
                id: event.changedTouches[ i ].identifier,
                type: 'touch',
                currentPos: getMouseAbsolute( event.changedTouches[ i ] ),
                currentTime: time
            };

            updatePointerMove( tracker, eventInfo, gPoint );
        }

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onTouchCancel( tracker, event ) {
        const touchCount = event.changedTouches.length;
        //$.console.log('touchcancel ' + (tracker.userData ? tracker.userData.toString() : ''));

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointercancel',
            pointerType: 'touch',
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        for ( let i = 0; i < touchCount; i++ ) {
            const gPoint = {
                id: event.changedTouches[ i ].identifier,
                type: 'touch'
            };

            //TODO need to only do this if our element is target?
            updatePointerCancel( tracker, eventInfo, gPoint );
        }

        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onGestureStart( tracker, event ) {
        if ( !$.eventIsCanceled( event ) ) {
            event.preventDefault();
        }
        return false;
    }


    /**
     * @private
     * @inner
     */
    function onGestureChange( tracker, event ) {
        if ( !$.eventIsCanceled( event ) ) {
            event.preventDefault();
        }
        return false;
    }


    /**
     * @private
     * @inner
     */
    function onGotPointerCapture( tracker, event ) {
        //$.console.log('gotpointercapture ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const eventInfo = {
            originalEvent: event,
            eventType: 'gotpointercapture',
            pointerType: getPointerType( event ),
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( event.target === tracker.element ) {
            //$.console.log('gotpointercapture ' + (tracker.userData ? tracker.userData.toString() : ''));
            updatePointerCaptured( tracker, {
                id: event.pointerId,
                type: getPointerType( event )
            }, true );
        }

        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onLostPointerCapture( tracker, event ) {
        //$.console.log('lostpointercapture ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const eventInfo = {
            originalEvent: event,
            eventType: 'lostpointercapture',
            pointerType: getPointerType( event ),
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        if ( event.target === tracker.element ) {
            //$.console.log('lostpointercapture ' + (tracker.userData ? tracker.userData.toString() : ''));
            updatePointerCaptured( tracker, {
                id: event.pointerId,
                type: getPointerType( event )
            }, false );
        }

        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function onPointerEnter( tracker, event ) {
        //$.console.log('pointerenter ' + (tracker.userData ? tracker.userData.toString() : ''));

        const gPoint = {
            id: getPointerId( event ),
            type: getPointerType( event ),
            isPrimary: getIsPrimary( event ),
            currentPos: getMouseAbsolute( event ),
            currentTime: $.now()
        };

        // pointerenter doesn't bubble and is not cancelable, but we call
        //   preProcessEvent() so it's dispatched to preProcessEventHandler
        //   if necessary
        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerenter',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        updatePointerEnter( tracker, eventInfo, gPoint );
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function onPointerLeave( tracker, event ) {
        //$.console.log('pointerleave ' + (tracker.userData ? tracker.userData.toString() : ''));

        const gPoint = {
            id: getPointerId( event ),
            type: getPointerType( event ),
            isPrimary: getIsPrimary( event ),
            currentPos: getMouseAbsolute( event ),
            currentTime: $.now()
        };

        // pointerleave doesn't bubble and is not cancelable, but we call
        //   preProcessEvent() so it's dispatched to preProcessEventHandler
        //   if necessary
        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerleave',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        updatePointerLeave( tracker, eventInfo, gPoint );
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function onPointerOver( tracker, event ) {
        //$.console.log('pointerover ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const gPoint = {
            id: getPointerId( event ),
            type: getPointerType( event ),
            isPrimary: getIsPrimary( event ),
            currentPos: getMouseAbsolute( event ),
            currentTime: $.now()
        };

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerover',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        updatePointerOver( tracker, eventInfo, gPoint );

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function onPointerOut( tracker, event ) {
        //$.console.log('pointerout ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const gPoint = {
            id: getPointerId( event ),
            type: getPointerType( event ),
            isPrimary: getIsPrimary( event ),
            currentPos: getMouseAbsolute( event ),
            currentTime: $.now()
        };

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerout',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        updatePointerOut( tracker, eventInfo, gPoint );

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function onPointerDown( tracker, event ) {
        const gPoint = {
            id: getPointerId( event ),
            type: getPointerType( event ),
            isPrimary: getIsPrimary( event ),
            currentPos: getMouseAbsolute( event ),
            currentTime: $.now()
        };

        // Most browsers implicitly capture touch pointer events
        // Note no IE versions (unsupported) have element.hasPointerCapture() so
        //    no implicit pointer capture possible
        // var implicitlyCaptured = ($.MouseTracker.havePointerEvents &&
        //                         event.target.hasPointerCapture &&
        //                         $.Browser.vendor !== $.BROWSERS.IE) ?
        //                         event.target.hasPointerCapture(event.pointerId) : false;
        const implicitlyCaptured = $.MouseTracker.havePointerEvents &&
                                gPoint.type === 'touch';

        //$.console.log('pointerdown ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerdown',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        updatePointerDown( tracker, eventInfo, gPoint, event.button );

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
        if ( eventInfo.shouldCapture ) {
            if ( implicitlyCaptured ) {
                updatePointerCaptured( tracker, gPoint, true );
            } else {
                capturePointer( tracker, gPoint );
            }
        }
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function onPointerUp( tracker, event ) {
        handlePointerUp( tracker, event );
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * This handler is attached to the window object (on the capture phase) to emulate mouse capture.
     * onPointerUp is still attached to the tracked element, so stop propagation to avoid processing twice.
     *
     * @private
     * @inner
     */
    function onPointerUpCaptured( tracker, event ) {
        const pointsList = tracker.getActivePointersListByType( getPointerType( event ) );
        if ( pointsList.getById( event.pointerId ) ) {
            handlePointerUp( tracker, event );
        }
        $.stopEvent( event );
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function handlePointerUp( tracker, event ) {
        //$.console.log('pointerup ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const gPoint = {
            id: getPointerId( event ),
            type: getPointerType( event ),
            isPrimary: getIsPrimary( event ),
            currentPos: getMouseAbsolute( event ),
            currentTime: $.now()
        };

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointerup',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        updatePointerUp( tracker, eventInfo, gPoint, event.button );

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }

        // Per spec, pointerup events are supposed to release capture. Not all browser
        //   versions have adhered to the spec, and there's no harm in releasing
        //   explicitly
        if ( eventInfo.shouldReleaseCapture ) {
            if ( event.target === tracker.element ) {
                releasePointer( tracker, gPoint );
            } else {
                updatePointerCaptured( tracker, gPoint, false );
            }
        }
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function onPointerMove( tracker, event ) {
        handlePointerMove( tracker, event );
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * This handler is attached to the window object (on the capture phase) to emulate mouse capture.
     * onPointerMove is still attached to the tracked element, so stop propagation to avoid processing twice.
     *
     * @private
     * @inner
     */
    function onPointerMoveCaptured( tracker, event ) {
        const pointsList = tracker.getActivePointersListByType( getPointerType( event ) );
        if ( pointsList.getById( event.pointerId ) ) {
            handlePointerMove( tracker, event );
        }
        $.stopEvent( event );
    }


    /**
     * Note: Called for both pointer events and legacy mouse events
     *         ($.MouseTracker.havePointerEvents determines which)
     *
     * @private
     * @inner
     */
    function handlePointerMove( tracker, event ) {
        // Pointer changed coordinates, button state, pressure, tilt, or contact geometry (e.g. width and height)

        const gPoint = {
            id: getPointerId( event ),
            type: getPointerType( event ),
            isPrimary: getIsPrimary( event ),
            currentPos: getMouseAbsolute( event ),
            currentTime: $.now()
        };

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointermove',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        updatePointerMove( tracker, eventInfo, gPoint );

        if ( eventInfo.preventDefault && !eventInfo.defaultPrevented ) {
            $.cancelEvent( event );
        }
        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


    /**
     * @private
     * @inner
     */
    function onPointerCancel( tracker, event ) {
        //$.console.log('pointercancel ' + (tracker.userData ? tracker.userData.toString() : '') + ' ' + (event.target === tracker.element ? 'tracker.element' : ''));

        const gPoint = {
            id: event.pointerId,
            type: getPointerType( event )
        };

        const eventInfo = {
            originalEvent: event,
            eventType: 'pointercancel',
            pointerType: gPoint.type,
            isEmulated: false
        };
        preProcessEvent( tracker, eventInfo );

        //TODO need to only do this if our element is target?
        updatePointerCancel( tracker, eventInfo, gPoint );

        if ( eventInfo.stopPropagation ) {
            $.stopEvent( event );
        }
    }


///////////////////////////////////////////////////////////////////////////////
// Device-agnostic DOM event handlers
///////////////////////////////////////////////////////////////////////////////

    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker.GesturePointList} pointsList
     *     The GesturePointList to track the pointer in.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture point to track.
     * @returns {Number} Number of gesture points in pointsList.
     */
    function startTrackingPointer( pointsList, gPoint ) {
        //$.console.log('startTrackingPointer *** ' + pointsList.type + ' ' + gPoint.id.toString());
        gPoint.speed = 0;
        gPoint.direction = 0;
        gPoint.contactPos = gPoint.currentPos;
        gPoint.contactTime = gPoint.currentTime;
        gPoint.lastPos = gPoint.currentPos;
        gPoint.lastTime = gPoint.currentTime;

        return pointsList.add( gPoint );
    }


    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.GesturePointList} pointsList
     *     The GesturePointList to stop tracking the pointer on.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture point to stop tracking.
     * @returns {Number} Number of gesture points in pointsList.
     */
    function stopTrackingPointer( tracker, pointsList, gPoint ) {
        //$.console.log('stopTrackingPointer *** ' + pointsList.type + ' ' + gPoint.id.toString());
        let listLength;
        const trackedGPoint = pointsList.getById( gPoint.id );

        if ( trackedGPoint ) {
            if ( trackedGPoint.captured ) {
                $.console.warn('stopTrackingPointer() called on captured pointer');
                releasePointer( tracker, trackedGPoint );
            }

            // If child element relinquishes capture to a parent we may get here
            //   from a pointerleave event while a pointerup event will never be received.
            //   In that case, we'll clean up the contact count
            pointsList.removeContact();

            listLength = pointsList.removeById( gPoint.id );
        } else {
            listLength = pointsList.getLength();
        }

        return listLength;
    }


    /**
     * @function
     * @private
     * @inner
     */
    function getEventProcessDefaults( tracker, eventInfo ) {
        switch ( eventInfo.eventType ) {
            case 'pointermove':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = true;
                eventInfo.preventDefault = false;
                eventInfo.preventGesture = !tracker.hasGestureHandlers;
                eventInfo.stopPropagation = false;
                break;
            case 'pointerover':
            case 'pointerout':
            case 'contextmenu':
            case 'keydown':
            case 'keyup':
            case 'keypress':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = true;
                eventInfo.preventDefault = false; // onContextMenu(), onKeyDown(), onKeyUp(), onKeyPress() may set true
                eventInfo.preventGesture = false;
                eventInfo.stopPropagation = false;
                break;
            case 'pointerdown':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = true;
                eventInfo.preventDefault = false; // updatePointerDown() may set true (tracker.hasGestureHandlers)
                eventInfo.preventGesture = !tracker.hasGestureHandlers;
                eventInfo.stopPropagation = false;
                break;
            case 'pointerup':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = true;
                eventInfo.preventDefault = false;
                eventInfo.preventGesture = !tracker.hasGestureHandlers;
                eventInfo.stopPropagation = false;
                break;
            case 'wheel':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = true;
                eventInfo.preventDefault = false; // handleWheelEvent() may set true
                eventInfo.preventGesture = !tracker.hasScrollHandler;
                eventInfo.stopPropagation = false;
                break;
            case 'gotpointercapture':
            case 'lostpointercapture':
            case 'pointercancel':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = false;
                eventInfo.preventDefault = false;
                eventInfo.preventGesture = false;
                eventInfo.stopPropagation = false;
                break;
            case 'click':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = true;
                eventInfo.preventDefault = !!tracker.clickHandler;
                eventInfo.preventGesture = false;
                eventInfo.stopPropagation = false;
                break;
            case 'dblclick':
                eventInfo.isStoppable = true;
                eventInfo.isCancelable = true;
                eventInfo.preventDefault = !!tracker.dblClickHandler;
                eventInfo.preventGesture = false;
                eventInfo.stopPropagation = false;
                break;
            case 'focus':
            case 'blur':
            case 'pointerenter':
            case 'pointerleave':
            default:
                eventInfo.isStoppable = false;
                eventInfo.isCancelable = false;
                eventInfo.preventDefault = false;
                eventInfo.preventGesture = false;
                eventInfo.stopPropagation = false;
                break;
        }
    }


    /**
     * Sets up for and calls preProcessEventHandler. Call with the following parameters -
     * this function will fill in the rest of the preProcessEventHandler event object
     * properties
     *
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     * @param {Object} eventInfo.originalEvent
     * @param {String} eventInfo.eventType
     * @param {String} eventInfo.pointerType
     * @param {Boolean} eventInfo.isEmulated
     */
    function preProcessEvent( tracker, eventInfo ) {
        eventInfo.eventSource = tracker;
        eventInfo.eventPhase = eventInfo.originalEvent ?
                        ((typeof eventInfo.originalEvent.eventPhase !== 'undefined') ?
                                            eventInfo.originalEvent.eventPhase : 0) : 0;
        eventInfo.defaultPrevented = $.eventIsCanceled( eventInfo.originalEvent );
        eventInfo.shouldCapture = false;
        eventInfo.shouldReleaseCapture = false;
        eventInfo.userData = tracker.userData;

        getEventProcessDefaults( tracker, eventInfo );

        if ( tracker.preProcessEventHandler ) {
            tracker.preProcessEventHandler( eventInfo );
        }
    }


    /**
     * Sets or resets the captured property on the tracked pointer matching the passed gPoint's id/type
     *
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {Object} gPoint
     *     An object with id and type properties describing the pointer to update.
     * @param {Boolean} isCaptured
     *      Value to set the captured property to.
     */
    function updatePointerCaptured( tracker, gPoint, isCaptured ) {
        const pointsList = tracker.getActivePointersListByType( gPoint.type );
        const updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            if ( isCaptured && !updateGPoint.captured ) {
                updateGPoint.captured = true;
                pointsList.captureCount++;
            } else if ( !isCaptured && updateGPoint.captured ) {
                updateGPoint.captured = false;
                pointsList.captureCount--;
                if ( pointsList.captureCount < 0 ) {
                    pointsList.captureCount = 0;
                    $.console.warn('updatePointerCaptured() - pointsList.captureCount went negative');
                }
            }
        } else {
            $.console.warn('updatePointerCaptured() called on untracked pointer');
        }
    }


    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture point associated with the event.
     */
    function updatePointerEnter( tracker, eventInfo, gPoint ) {
        const pointsList = tracker.getActivePointersListByType( gPoint.type );
        const updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            // Already tracking the pointer...update it
            updateGPoint.insideElement = true;
            updateGPoint.lastPos = updateGPoint.currentPos;
            updateGPoint.lastTime = updateGPoint.currentTime;
            updateGPoint.currentPos = gPoint.currentPos;
            updateGPoint.currentTime = gPoint.currentTime;

            gPoint = updateGPoint;
        } else {
            // Initialize for tracking and add to the tracking list
            gPoint.captured = false; // Handled by updatePointerCaptured()
            gPoint.insideElementPressed = false;
            gPoint.insideElement = true;
            startTrackingPointer( pointsList, gPoint );
        }

        // Enter (doesn't bubble and not cancelable)
        if ( tracker.enterHandler ) {
            tracker.enterHandler(
                {
                    eventSource:          tracker,
                    pointerType:          gPoint.type,
                    position:             getPointRelativeToAbsolute( gPoint.currentPos, tracker.element ),
                    buttons:              pointsList.buttons,
                    pointers:             tracker.getActivePointerCount(),
                    insideElementPressed: gPoint.insideElementPressed,
                    buttonDownAny:        pointsList.buttons !== 0,
                    isTouchEvent:         gPoint.type === 'touch',
                    originalEvent:        eventInfo.originalEvent,
                    userData:             tracker.userData
                }
            );
        }
    }


    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture point associated with the event.
     */
    function updatePointerLeave( tracker, eventInfo, gPoint ) {
        const pointsList = tracker.getActivePointersListByType(gPoint.type);
        const updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            // Already tracking the pointer. If captured then update it, else stop tracking it
            if ( updateGPoint.captured ) {
                updateGPoint.insideElement = false;
                updateGPoint.lastPos = updateGPoint.currentPos;
                updateGPoint.lastTime = updateGPoint.currentTime;
                updateGPoint.currentPos = gPoint.currentPos;
                updateGPoint.currentTime = gPoint.currentTime;
            } else {
                stopTrackingPointer( tracker, pointsList, updateGPoint );
            }

            gPoint = updateGPoint;
        } else {
            gPoint.captured = false; // Handled by updatePointerCaptured()
            gPoint.insideElementPressed = false;
        }

        // Leave (doesn't bubble and not cancelable)
        //   Note: exitHandler is deprecated (v2.5.0), replaced by leaveHandler
        if ( tracker.leaveHandler || tracker.exitHandler ) {
            const dispatchEventObj = {
                eventSource:          tracker,
                pointerType:          gPoint.type,
                // GitHub PR: https://github.com/openseadragon/openseadragon/pull/1754 (gPoint.currentPos && )
                position:             gPoint.currentPos && getPointRelativeToAbsolute( gPoint.currentPos, tracker.element ),
                buttons:              pointsList.buttons,
                pointers:             tracker.getActivePointerCount(),
                insideElementPressed: gPoint.insideElementPressed,
                buttonDownAny:        pointsList.buttons !== 0,
                isTouchEvent:         gPoint.type === 'touch',
                originalEvent:        eventInfo.originalEvent,
                userData:             tracker.userData
            };

            if ( tracker.leaveHandler ) {
                tracker.leaveHandler( dispatchEventObj );
            }
            // Deprecated
            if ( tracker.exitHandler ) {
                tracker.exitHandler( dispatchEventObj );
            }
        }
    }


    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture point associated with the event.
     */
    function updatePointerOver( tracker, eventInfo, gPoint ) {
        const pointsList = tracker.getActivePointersListByType( gPoint.type );
        const updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            gPoint = updateGPoint;
        } else {
            gPoint.captured = false;
            gPoint.insideElementPressed = false;
            //gPoint.insideElement = true; // Tracked by updatePointerEnter
        }

        if ( tracker.overHandler ) {
            // Over
            tracker.overHandler(
                {
                    eventSource:          tracker,
                    pointerType:          gPoint.type,
                    position:             getPointRelativeToAbsolute( gPoint.currentPos, tracker.element ),
                    buttons:              pointsList.buttons,
                    pointers:             tracker.getActivePointerCount(),
                    insideElementPressed: gPoint.insideElementPressed,
                    buttonDownAny:        pointsList.buttons !== 0,
                    isTouchEvent:         gPoint.type === 'touch',
                    originalEvent:        eventInfo.originalEvent,
                    userData:             tracker.userData
                }
            );
        }
    }

    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture point associated with the event.
     */
    function updatePointerOut( tracker, eventInfo, gPoint ) {
        const pointsList = tracker.getActivePointersListByType(gPoint.type);
        const updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            gPoint = updateGPoint;
        } else {
            gPoint.captured = false;
            gPoint.insideElementPressed = false;
            //gPoint.insideElement = true; // Tracked by updatePointerEnter
        }

        if ( tracker.outHandler ) {
            // Out
            tracker.outHandler( {
                eventSource:          tracker,
                pointerType:          gPoint.type,
                position:             gPoint.currentPos && getPointRelativeToAbsolute( gPoint.currentPos, tracker.element ),
                buttons:              pointsList.buttons,
                pointers:             tracker.getActivePointerCount(),
                insideElementPressed: gPoint.insideElementPressed,
                buttonDownAny:        pointsList.buttons !== 0,
                isTouchEvent:         gPoint.type === 'touch',
                originalEvent:        eventInfo.originalEvent,
                userData:             tracker.userData
            } );
        }
    }


    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture point associated with the event.
     * @param {Number} buttonChanged
     *      The button involved in the event: -1: none, 0: primary/left, 1: aux/middle, 2: secondary/right, 3: X1/back, 4: X2/forward, 5: pen eraser.
     *      Note on chorded button presses (a button pressed when another button is already pressed): In the W3C Pointer Events model,
     *      only one pointerdown/pointerup event combo is fired. Chorded button state changes instead fire pointermove events.
     */
    function updatePointerDown( tracker, eventInfo, gPoint, buttonChanged ) {
        const delegate = THIS[ tracker.hash ];
        const pointsList = tracker.getActivePointersListByType( gPoint.type );

        if ( typeof eventInfo.originalEvent.buttons !== 'undefined' ) {
            pointsList.buttons = eventInfo.originalEvent.buttons;
        } else {
            if ( buttonChanged === 0 ) {
                // Primary
                pointsList.buttons |= 1;
            } else if ( buttonChanged === 1 ) {
                // Aux
                pointsList.buttons |= 4;
            } else if ( buttonChanged === 2 ) {
                // Secondary
                pointsList.buttons |= 2;
            } else if ( buttonChanged === 3 ) {
                // X1 (Back)
                pointsList.buttons |= 8;
            } else if ( buttonChanged === 4 ) {
                // X2 (Forward)
                pointsList.buttons |= 16;
            } else if ( buttonChanged === 5 ) {
                // Pen Eraser
                pointsList.buttons |= 32;
            }
        }

        // Only capture and track primary button, pen, and touch contacts
        if ( buttonChanged !== 0 ) {
            eventInfo.shouldCapture = false;
            eventInfo.shouldReleaseCapture = false;

            // Aux Press
            if ( tracker.nonPrimaryPressHandler &&
                                !eventInfo.preventGesture &&
                                !eventInfo.defaultPrevented ) {
                eventInfo.preventDefault = true;

                tracker.nonPrimaryPressHandler(
                    {
                        eventSource:          tracker,
                        pointerType:          gPoint.type,
                        position:             getPointRelativeToAbsolute( gPoint.currentPos, tracker.element ),
                        button:               buttonChanged,
                        buttons:              pointsList.buttons,
                        isTouchEvent:         gPoint.type === 'touch',
                        originalEvent:        eventInfo.originalEvent,
                        userData:             tracker.userData
                    }
                );
            }

            return;
        }

        const updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            // Already tracking the pointer...update it
            //updateGPoint.captured = true; // Handled by updatePointerCaptured()
            updateGPoint.insideElementPressed = true;
            updateGPoint.insideElement = true;
            updateGPoint.originalTarget = eventInfo.originalEvent.target;
            updateGPoint.contactPos = gPoint.currentPos;
            updateGPoint.contactTime = gPoint.currentTime;
            updateGPoint.lastPos = updateGPoint.currentPos;
            updateGPoint.lastTime = updateGPoint.currentTime;
            updateGPoint.currentPos = gPoint.currentPos;
            updateGPoint.currentTime = gPoint.currentTime;

            gPoint = updateGPoint;
        } else {
            // Initialize for tracking and add to the tracking list (no pointerenter event occurred before this)
            // NOTE: pointerdown event on untracked pointer
            gPoint.captured = false; // Handled by updatePointerCaptured()
            gPoint.insideElementPressed = true;
            gPoint.insideElement = true;
            gPoint.originalTarget = eventInfo.originalEvent.target;
            startTrackingPointer( pointsList, gPoint );
        }

        pointsList.addContact();
        //$.console.log('contacts++ ', pointsList.contacts);

        if ( !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
            eventInfo.shouldCapture = true;
            eventInfo.shouldReleaseCapture = false;
            eventInfo.preventDefault = true;

            if ( tracker.dragHandler || tracker.dragEndHandler || tracker.pinchHandler ) {
                $.MouseTracker.gesturePointVelocityTracker.addPoint( tracker, gPoint );
            }

            if ( pointsList.contacts === 1 ) {
                // Press
                if ( tracker.pressHandler && !eventInfo.preventGesture ) {
                    tracker.pressHandler(
                        {
                            eventSource:          tracker,
                            pointerType:          gPoint.type,
                            position:             getPointRelativeToAbsolute( gPoint.contactPos, tracker.element ),
                            buttons:              pointsList.buttons,
                            isTouchEvent:         gPoint.type === 'touch',
                            originalEvent:        eventInfo.originalEvent,
                            userData:             tracker.userData
                        }
                    );
                }
            } else if ( pointsList.contacts === 2 ) {
                if ( tracker.pinchHandler && gPoint.type === 'touch' ) {
                    // Initialize for pinch
                    delegate.pinchGPoints = pointsList.asArray();
                    delegate.lastPinchDist = delegate.currentPinchDist = delegate.pinchGPoints[ 0 ].currentPos.distanceTo( delegate.pinchGPoints[ 1 ].currentPos );
                    delegate.lastPinchCenter = delegate.currentPinchCenter = getCenterPoint( delegate.pinchGPoints[ 0 ].currentPos, delegate.pinchGPoints[ 1 ].currentPos );
                }
            }
        } else {
            eventInfo.shouldCapture = false;
            eventInfo.shouldReleaseCapture = false;
        }
    }


    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture points associated with the event.
     * @param {Number} buttonChanged
     *      The button involved in the event: -1: none, 0: primary/left, 1: aux/middle, 2: secondary/right, 3: X1/back, 4: X2/forward, 5: pen eraser.
     *      Note on chorded button presses (a button pressed when another button is already pressed): In the W3C Pointer Events model,
     *      only one pointerdown/pointerup event combo is fired. Chorded button state changes instead fire pointermove events.
     */
    function updatePointerUp( tracker, eventInfo, gPoint, buttonChanged ) {
        const delegate = THIS[ tracker.hash ];
        const pointsList = tracker.getActivePointersListByType( gPoint.type );
        let releasePoint;
        let releaseTime;
        let wasCaptured = false;
        let quick;

        if ( typeof eventInfo.originalEvent.buttons !== 'undefined' ) {
            pointsList.buttons = eventInfo.originalEvent.buttons;
        } else {
            if ( buttonChanged === 0 ) {
                // Primary
                pointsList.buttons ^= ~1;
            } else if ( buttonChanged === 1 ) {
                // Aux
                pointsList.buttons ^= ~4;
            } else if ( buttonChanged === 2 ) {
                // Secondary
                pointsList.buttons ^= ~2;
            } else if ( buttonChanged === 3 ) {
                // X1 (Back)
                pointsList.buttons ^= ~8;
            } else if ( buttonChanged === 4 ) {
                // X2 (Forward)
                pointsList.buttons ^= ~16;
            } else if ( buttonChanged === 5 ) {
                // Pen Eraser
                pointsList.buttons ^= ~32;
            }
        }

        eventInfo.shouldCapture = false;

        // Only capture and track primary button, pen, and touch contacts
        if ( buttonChanged !== 0 ) {
            eventInfo.shouldReleaseCapture = false;

            // Aux Release
            if ( tracker.nonPrimaryReleaseHandler &&
                                !eventInfo.preventGesture &&
                                !eventInfo.defaultPrevented ) {
                eventInfo.preventDefault = true;

                tracker.nonPrimaryReleaseHandler(
                    {
                        eventSource:           tracker,
                        pointerType:           gPoint.type,
                        position:              getPointRelativeToAbsolute(gPoint.currentPos, tracker.element),
                        button:                buttonChanged,
                        buttons:               pointsList.buttons,
                        isTouchEvent:          gPoint.type === 'touch',
                        originalEvent:         eventInfo.originalEvent,
                        userData:              tracker.userData
                    }
                );
            }

            return;
        }

        let updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            pointsList.removeContact();
            //$.console.log('contacts-- ', pointsList.contacts);

            // Update the pointer, stop tracking it if not still in this element
            if ( updateGPoint.captured ) {
                //updateGPoint.captured = false; // Handled by updatePointerCaptured()
                wasCaptured = true;
            }
            updateGPoint.lastPos = updateGPoint.currentPos;
            updateGPoint.lastTime = updateGPoint.currentTime;
            updateGPoint.currentPos = gPoint.currentPos;
            updateGPoint.currentTime = gPoint.currentTime;
            if ( !updateGPoint.insideElement ) {
                stopTrackingPointer( tracker, pointsList, updateGPoint );
            }

            releasePoint = updateGPoint.currentPos;
            releaseTime = updateGPoint.currentTime;
        } else {
            // NOTE: updatePointerUp(): pointerup on untracked gPoint
            // ...we'll start to track pointer again
            gPoint.captured = false; // Handled by updatePointerCaptured()
            gPoint.insideElementPressed = false;
            gPoint.insideElement = true;
            startTrackingPointer( pointsList, gPoint );

            updateGPoint = gPoint;
        }

        if ( !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
            if ( wasCaptured ) {
                // Pointer was activated in our element but could have been removed in any element since events are captured to our element

                eventInfo.shouldReleaseCapture = true;
                eventInfo.preventDefault = true;

                if ( tracker.dragHandler || tracker.dragEndHandler || tracker.pinchHandler ) {
                    $.MouseTracker.gesturePointVelocityTracker.removePoint( tracker, updateGPoint );
                }

                if ( pointsList.contacts === 0 ) {

                    // Release (pressed in our element)
                    if ( tracker.releaseHandler && releasePoint ) {
                        tracker.releaseHandler(
                            {
                                eventSource:           tracker,
                                pointerType:           updateGPoint.type,
                                position:              getPointRelativeToAbsolute( releasePoint, tracker.element ),
                                buttons:               pointsList.buttons,
                                insideElementPressed:  updateGPoint.insideElementPressed,
                                insideElementReleased: updateGPoint.insideElement,
                                isTouchEvent:          updateGPoint.type === 'touch',
                                originalEvent:         eventInfo.originalEvent,
                                userData:              tracker.userData
                            }
                        );
                    }

                    // Drag End
                    if ( tracker.dragEndHandler && delegate.sentDragEvent ) {
                        tracker.dragEndHandler(
                            {
                                eventSource:          tracker,
                                pointerType:          updateGPoint.type,
                                position:             getPointRelativeToAbsolute( updateGPoint.currentPos, tracker.element ),
                                speed:                updateGPoint.speed,
                                direction:            updateGPoint.direction,
                                shift:                eventInfo.originalEvent.shiftKey,
                                isTouchEvent:         updateGPoint.type === 'touch',
                                originalEvent:        eventInfo.originalEvent,
                                userData:             tracker.userData
                            }
                        );
                    }

                    // We want to clear this flag regardless of whether we fired the dragEndHandler
                    delegate.sentDragEvent = false;

                    // Click / Double-Click
                    if ( ( tracker.clickHandler || tracker.dblClickHandler ) && updateGPoint.insideElement ) {
                        quick = releaseTime - updateGPoint.contactTime <= tracker.clickTimeThreshold &&
                                        updateGPoint.contactPos.distanceTo( releasePoint ) <= tracker.clickDistThreshold;

                        // Click
                        if ( tracker.clickHandler ) {
                            tracker.clickHandler(
                                {
                                    eventSource:          tracker,
                                    pointerType:          updateGPoint.type,
                                    position:             getPointRelativeToAbsolute( updateGPoint.currentPos, tracker.element ),
                                    quick:                quick,
                                    shift:                eventInfo.originalEvent.shiftKey,
                                    isTouchEvent:         updateGPoint.type === 'touch',
                                    originalEvent:        eventInfo.originalEvent,
                                    originalTarget:       updateGPoint.originalTarget,
                                    userData:             tracker.userData
                                }
                            );
                        }

                        // Double-Click
                        if ( tracker.dblClickHandler && quick ) {
                            pointsList.clicks++;
                            if ( pointsList.clicks === 1 ) {
                                delegate.lastClickPos = releasePoint;
                                /*jshint loopfunc:true*/
                                delegate.dblClickTimeOut = setTimeout( function() {
                                    pointsList.clicks = 0;
                                }, tracker.dblClickTimeThreshold );
                                /*jshint loopfunc:false*/
                            } else if ( pointsList.clicks === 2 ) {
                                clearTimeout( delegate.dblClickTimeOut );
                                pointsList.clicks = 0;
                                if ( delegate.lastClickPos.distanceTo( releasePoint ) <= tracker.dblClickDistThreshold ) {
                                    tracker.dblClickHandler(
                                        {
                                            eventSource:          tracker,
                                            pointerType:          updateGPoint.type,
                                            position:             getPointRelativeToAbsolute( updateGPoint.currentPos, tracker.element ),
                                            shift:                eventInfo.originalEvent.shiftKey,
                                            isTouchEvent:         updateGPoint.type === 'touch',
                                            originalEvent:        eventInfo.originalEvent,
                                            userData:             tracker.userData
                                        }
                                    );
                                }
                                delegate.lastClickPos = null;
                            }
                        }
                    }
                } else if ( pointsList.contacts === 2 ) {
                    if ( tracker.pinchHandler && updateGPoint.type === 'touch' ) {
                        // Reset for pinch
                        delegate.pinchGPoints = pointsList.asArray();
                        delegate.lastPinchDist = delegate.currentPinchDist = delegate.pinchGPoints[ 0 ].currentPos.distanceTo( delegate.pinchGPoints[ 1 ].currentPos );
                        delegate.lastPinchCenter = delegate.currentPinchCenter = getCenterPoint( delegate.pinchGPoints[ 0 ].currentPos, delegate.pinchGPoints[ 1 ].currentPos );
                    }
                }
            } else {
                // Pointer was activated in another element but removed in our element

                eventInfo.shouldReleaseCapture = false;

                // Release (pressed in another element)
                if ( tracker.releaseHandler && releasePoint ) {
                    tracker.releaseHandler(
                        {
                            eventSource:           tracker,
                            pointerType:           updateGPoint.type,
                            position:              getPointRelativeToAbsolute( releasePoint, tracker.element ),
                            buttons:               pointsList.buttons,
                            insideElementPressed:  updateGPoint.insideElementPressed,
                            insideElementReleased: updateGPoint.insideElement,
                            isTouchEvent:          updateGPoint.type === 'touch',
                            originalEvent:         eventInfo.originalEvent,
                            userData:              tracker.userData
                        }
                    );
                    eventInfo.preventDefault = true;
                }
            }
        }
    }


    /**
     * Call when pointer(s) change coordinates, button state, pressure, tilt, or contact geometry (e.g. width and height)
     *
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture points associated with the event.
     */
    function updatePointerMove( tracker, eventInfo, gPoint ) {
        const delegate = THIS[ tracker.hash ];
        const pointsList = tracker.getActivePointersListByType( gPoint.type );
        let delta;

        if ( typeof eventInfo.originalEvent.buttons !== 'undefined' ) {
            pointsList.buttons = eventInfo.originalEvent.buttons;
        }

        let updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            // Already tracking the pointer...update it
            updateGPoint.lastPos = updateGPoint.currentPos;
            updateGPoint.lastTime = updateGPoint.currentTime;
            updateGPoint.currentPos = gPoint.currentPos;
            updateGPoint.currentTime = gPoint.currentTime;
        } else {
            // Should never get here, but due to user agent bugs (e.g. legacy touch) it sometimes happens
            return;
        }

        eventInfo.shouldCapture = false;
        eventInfo.shouldReleaseCapture = false;

        // Stop (mouse only)
        if ( tracker.stopHandler && gPoint.type === 'mouse' ) {
            clearTimeout( tracker.stopTimeOut );
            tracker.stopTimeOut = setTimeout( function() {
                handlePointerStop( tracker, eventInfo.originalEvent, gPoint.type );
            }, tracker.stopDelay );
        }

        if ( pointsList.contacts === 0 ) {
            // Move (no contacts: hovering mouse or other hover-capable device)
            if ( tracker.moveHandler ) {
                tracker.moveHandler(
                    {
                        eventSource:          tracker,
                        pointerType:          gPoint.type,
                        position:             getPointRelativeToAbsolute( gPoint.currentPos, tracker.element ),
                        buttons:              pointsList.buttons,
                        isTouchEvent:         gPoint.type === 'touch',
                        originalEvent:        eventInfo.originalEvent,
                        userData:             tracker.userData
                    }
                );
            }
        } else if ( pointsList.contacts === 1 ) {
            // Move (1 contact)
            if ( tracker.moveHandler ) {
                updateGPoint = pointsList.asArray()[ 0 ];
                tracker.moveHandler(
                    {
                        eventSource:          tracker,
                        pointerType:          updateGPoint.type,
                        position:             getPointRelativeToAbsolute( updateGPoint.currentPos, tracker.element ),
                        buttons:              pointsList.buttons,
                        isTouchEvent:         updateGPoint.type === 'touch',
                        originalEvent:        eventInfo.originalEvent,
                        userData:             tracker.userData
                    }
                );
            }

            // Drag
            if ( tracker.dragHandler && !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
                updateGPoint = pointsList.asArray()[ 0 ];
                delta = updateGPoint.currentPos.minus( updateGPoint.lastPos );
                tracker.dragHandler(
                    {
                        eventSource:          tracker,
                        pointerType:          updateGPoint.type,
                        position:             getPointRelativeToAbsolute( updateGPoint.currentPos, tracker.element ),
                        buttons:              pointsList.buttons,
                        delta:                delta,
                        speed:                updateGPoint.speed,
                        direction:            updateGPoint.direction,
                        shift:                eventInfo.originalEvent.shiftKey,
                        isTouchEvent:         updateGPoint.type === 'touch',
                        originalEvent:        eventInfo.originalEvent,
                        userData:             tracker.userData
                    }
                );
                eventInfo.preventDefault = true;
                delegate.sentDragEvent = true;
            }
        } else if ( pointsList.contacts === 2 ) {
            // Move (2 contacts, use center)
            if ( tracker.moveHandler ) {
                const gPointArray = pointsList.asArray();
                tracker.moveHandler(
                    {
                        eventSource:          tracker,
                        pointerType:          gPointArray[ 0 ].type,
                        position:             getPointRelativeToAbsolute( getCenterPoint( gPointArray[ 0 ].currentPos, gPointArray[ 1 ].currentPos ), tracker.element ),
                        buttons:              pointsList.buttons,
                        isTouchEvent:         gPointArray[ 0 ].type === 'touch',
                        originalEvent:        eventInfo.originalEvent,
                        userData:             tracker.userData
                    }
                );
            }

            // Pinch
            if ( tracker.pinchHandler && gPoint.type === 'touch' &&
                                !eventInfo.preventGesture && !eventInfo.defaultPrevented ) {
                delta = delegate.pinchGPoints[ 0 ].currentPos.distanceTo( delegate.pinchGPoints[ 1 ].currentPos );
                if ( delta !== delegate.currentPinchDist ) {
                    delegate.lastPinchDist = delegate.currentPinchDist;
                    delegate.currentPinchDist = delta;
                    delegate.lastPinchCenter = delegate.currentPinchCenter;
                    delegate.currentPinchCenter = getCenterPoint( delegate.pinchGPoints[ 0 ].currentPos, delegate.pinchGPoints[ 1 ].currentPos );
                    tracker.pinchHandler(
                        {
                            eventSource:          tracker,
                            pointerType:          'touch',
                            gesturePoints:        delegate.pinchGPoints,
                            lastCenter:           getPointRelativeToAbsolute( delegate.lastPinchCenter, tracker.element ),
                            center:               getPointRelativeToAbsolute( delegate.currentPinchCenter, tracker.element ),
                            lastDistance:         delegate.lastPinchDist,
                            distance:             delegate.currentPinchDist,
                            shift:                eventInfo.originalEvent.shiftKey,
                            originalEvent:        eventInfo.originalEvent,
                            userData:             tracker.userData
                        }
                    );
                    eventInfo.preventDefault = true;
                }
            }
        }
    }


    /**
     * @function
     * @private
     * @inner
     * @param {OpenSeadragon.MouseTracker} tracker
     *     A reference to the MouseTracker instance.
     * @param {OpenSeadragon.MouseTracker.EventProcessInfo} eventInfo
     *     Processing info for originating DOM event.
     * @param {OpenSeadragon.MouseTracker.GesturePoint} gPoint
     *      Gesture points associated with the event.
     */
    function updatePointerCancel( tracker, eventInfo, gPoint ) {
        const pointsList = tracker.getActivePointersListByType( gPoint.type );
        const updateGPoint = pointsList.getById( gPoint.id );

        if ( updateGPoint ) {
            stopTrackingPointer( tracker, pointsList, updateGPoint );
        }
    }


    /**
     * @private
     * @inner
     */
    function handlePointerStop( tracker, originalMoveEvent, pointerType ) {
        if ( tracker.stopHandler ) {
            tracker.stopHandler( {
                eventSource:          tracker,
                pointerType:          pointerType,
                position:             getMouseRelative( originalMoveEvent, tracker.element ),
                buttons:              tracker.getActivePointersListByType( pointerType ).buttons,
                isTouchEvent:         pointerType === 'touch',
                originalEvent:        originalMoveEvent,
                userData:             tracker.userData
            } );
        }
    }


    /**
     * @function
     * @private
     * @inner
     */
    function uniqueHash( ) {
        let uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2);
        while (uniqueId in THIS) {
            // rehash when not unique
            uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2);
        }
        return uniqueId;
    }

}(OpenSeadragon));

/*
 * OpenSeadragon - Control
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * An enumeration of supported locations where controls can be anchored.
 * The anchoring is always relative to the container.
 * @member ControlAnchor
 * @memberof OpenSeadragon
 * @static
 * @type {Object}
 * @property {Number} NONE
 * @property {Number} TOP_LEFT
 * @property {Number} TOP_RIGHT
 * @property {Number} BOTTOM_LEFT
 * @property {Number} BOTTOM_RIGHT
 * @property {Number} ABSOLUTE
 */
$.ControlAnchor = {
    NONE: 0,
    TOP_LEFT: 1,
    TOP_RIGHT: 2,
    BOTTOM_RIGHT: 3,
    BOTTOM_LEFT: 4,
    ABSOLUTE: 5
};

/**
 * @class Control
 * @classdesc A Control represents any interface element which is meant to allow the user
 * to interact with the zoomable interface. Any control can be anchored to any
 * element.
 *
 * @memberof OpenSeadragon
 * @param {Element} element - the control element to be anchored in the container.
 * @param {Object } options - All required and optional settings for configuring a control element.
 * @param {OpenSeadragon.ControlAnchor} [options.anchor=OpenSeadragon.ControlAnchor.NONE] - the position of the control
 *  relative to the container.
 * @param {Boolean} [options.attachToViewer=true] - Whether the control should be added directly to the viewer, or
 *  directly to the container
 * @param {Boolean} [options.autoFade=true] - Whether the control should have the autofade behavior
 * @param {Element} container - the element to control will be anchored too.
 */
$.Control = function ( element, options, container ) {

    const parent = element.parentNode;
    if (typeof options === 'number')
    {
        $.console.error("Passing an anchor directly into the OpenSeadragon.Control constructor is deprecated; " +
                        "please use an options object instead.  " +
                        "Support for this deprecated variant is scheduled for removal in December 2013");
         options = {anchor: options};
    }
    options.attachToViewer = (typeof options.attachToViewer === 'undefined') ? true : options.attachToViewer;
    /**
     * True if the control should have autofade behavior.
     * @member {Boolean} autoFade
     * @memberof OpenSeadragon.Control#
     */
    this.autoFade = (typeof options.autoFade === 'undefined') ? true : options.autoFade;
    /**
     * The element providing the user interface with some type of control (e.g. a zoom-in button).
     * @member {Element} element
     * @memberof OpenSeadragon.Control#
     */
    this.element    = element;
    /**
     * The position of the Control relative to its container.
     * @member {OpenSeadragon.ControlAnchor} anchor
     * @memberof OpenSeadragon.Control#
     */
    this.anchor     = options.anchor;
    /**
     * The Control's containing element.
     * @member {Element} container
     * @memberof OpenSeadragon.Control#
     */
    this.container  = container;
    /**
     * A neutral element surrounding the control element.
     * @member {Element} wrapper
     * @memberof OpenSeadragon.Control#
     */
    if ( this.anchor === $.ControlAnchor.ABSOLUTE ) {
        this.wrapper    = $.makeNeutralElement( "div" );
        this.wrapper.style.position = "absolute";
        this.wrapper.style.top = typeof (options.top) === "number" ? (options.top + 'px') : options.top;
        this.wrapper.style.left  = typeof (options.left) === "number" ? (options.left + 'px') : options.left;
        this.wrapper.style.height = typeof (options.height) === "number" ? (options.height + 'px') : options.height;
        this.wrapper.style.width  = typeof (options.width) === "number" ? (options.width + 'px') : options.width;
        this.wrapper.style.margin = "0px";
        this.wrapper.style.padding = "0px";

        this.element.style.position = "relative";
        this.element.style.top = "0px";
        this.element.style.left = "0px";
        this.element.style.height = "100%";
        this.element.style.width = "100%";
    } else {
        this.wrapper    = $.makeNeutralElement( "div" );
        this.wrapper.style.display = "inline-block";
        if ( this.anchor === $.ControlAnchor.NONE ) {
            // IE6 fix
            this.wrapper.style.width = this.wrapper.style.height = "100%";
        }
    }
    this.wrapper.appendChild( this.element );

    if (options.attachToViewer ) {
        if ( this.anchor === $.ControlAnchor.TOP_RIGHT ||
             this.anchor === $.ControlAnchor.BOTTOM_RIGHT ) {
            this.container.insertBefore(
                this.wrapper,
                this.container.firstChild
            );
        } else {
            this.container.appendChild( this.wrapper );
        }
    } else {
        parent.appendChild( this.wrapper );
    }

};

/** @lends OpenSeadragon.Control.prototype */
$.Control.prototype = {

    /**
     * Removes the control from the container.
     * @function
     */
    destroy: function() {
        this.wrapper.removeChild( this.element );
        if (this.anchor !== $.ControlAnchor.NONE) {
            this.container.removeChild(this.wrapper);
        }
    },

    /**
     * Determines if the control is currently visible.
     * @function
     * @returns {Boolean} true if currently visible, false otherwise.
     */
    isVisible: function() {
        return this.wrapper.style.display !== "none";
    },

    /**
     * Toggles the visibility of the control.
     * @function
     * @param {Boolean} visible - true to make visible, false to hide.
     */
    setVisible: function( visible ) {
        this.wrapper.style.display = visible ?
            ( this.anchor === $.ControlAnchor.ABSOLUTE ? 'block' : 'inline-block' ) :
            "none";
    },

    /**
     * Sets the opacity level for the control.
     * @function
     * @param {Number} opactiy - a value between 1 and 0 inclusively.
     */
    setOpacity: function( opacity ) {
        $.setElementOpacity( this.wrapper, opacity, true );
    }
};

}( OpenSeadragon ));

/*
 * OpenSeadragon - ControlDock
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){
    /**
     * @class ControlDock
     * @classdesc Provides a container element (a &lt;form&gt; element) with support for the layout of control elements.
     *
     * @memberof OpenSeadragon
     */
    $.ControlDock = function( options ){
        const layouts = [ 'topleft', 'topright', 'bottomright', 'bottomleft'];

        $.extend( true, this, {
            id: 'controldock-' + $.now() + '-' + Math.floor(Math.random() * 1000000),
            container: $.makeNeutralElement( 'div' ),
            controls: []
        }, options );

        // Disable the form's submit; otherwise button clicks and return keys
        // can trigger it.
        this.container.onsubmit = function() {
            return false;
        };

        if( this.element ){
            this.element = $.getElement( this.element );
            this.element.appendChild( this.container );
            if( $.getElementStyle(this.element).position === 'static' ){
                this.element.style.position = 'relative';
            }
            this.container.style.width = '100%';
            this.container.style.height = '100%';
        }

        for( let i = 0; i < layouts.length; i++ ){
            let layout = layouts[ i ];
            this.controls[ layout ] = $.makeNeutralElement( "div" );
            this.controls[ layout ].style.position = 'absolute';
            if ( layout.match( 'left' ) ){
                this.controls[ layout ].style.left = '0px';
            }
            if ( layout.match( 'right' ) ){
                this.controls[ layout ].style.right = '0px';
            }
            if ( layout.match( 'top' ) ){
                this.controls[ layout ].style.top = '0px';
            }
            if ( layout.match( 'bottom' ) ){
                this.controls[ layout ].style.bottom = '0px';
            }
        }

        this.container.appendChild( this.controls.topleft );
        this.container.appendChild( this.controls.topright );
        this.container.appendChild( this.controls.bottomright );
        this.container.appendChild( this.controls.bottomleft );
    };

    /** @lends OpenSeadragon.ControlDock.prototype */
    $.ControlDock.prototype = {

        /**
         * @function
         */
        addControl: function ( element, controlOptions ) {
            element = $.getElement( element );
            let div = null;

            if ( getControlIndex( this, element ) >= 0 ) {
                return;     // they're trying to add a duplicate control
            }

            switch ( controlOptions.anchor ) {
                case $.ControlAnchor.TOP_RIGHT:
                    div = this.controls.topright;
                    element.style.position = "relative";
                    element.style.paddingRight = "0px";
                    element.style.paddingTop = "0px";
                    break;
                case $.ControlAnchor.BOTTOM_RIGHT:
                    div = this.controls.bottomright;
                    element.style.position = "relative";
                    element.style.paddingRight = "0px";
                    element.style.paddingBottom = "0px";
                    break;
                case $.ControlAnchor.BOTTOM_LEFT:
                    div = this.controls.bottomleft;
                    element.style.position = "relative";
                    element.style.paddingLeft = "0px";
                    element.style.paddingBottom = "0px";
                    break;
                case $.ControlAnchor.TOP_LEFT:
                    div = this.controls.topleft;
                    element.style.position = "relative";
                    element.style.paddingLeft = "0px";
                    element.style.paddingTop = "0px";
                    break;
                case $.ControlAnchor.ABSOLUTE:
                    div = this.container;
                    element.style.margin = "0px";
                    element.style.padding = "0px";
                    break;
                default:
                case $.ControlAnchor.NONE:
                    div = this.container;
                    element.style.margin = "0px";
                    element.style.padding = "0px";
                    break;
            }

            this.controls.push(
                new $.Control( element, controlOptions, div )
            );
            element.style.display = "inline-block";
        },


        /**
         * @function
         * @returns {OpenSeadragon.ControlDock} Chainable.
         */
        removeControl: function ( element ) {
            element = $.getElement( element );
            const i = getControlIndex( this, element );

            if ( i >= 0 ) {
                this.controls[ i ].destroy();
                this.controls.splice( i, 1 );
            }

            return this;
        },

        /**
         * @function
         * @returns {OpenSeadragon.ControlDock} Chainable.
         */
        clearControls: function () {
            while ( this.controls.length > 0 ) {
                this.controls.pop().destroy();
            }

            return this;
        },


        /**
         * @function
         * @returns {Boolean}
         */
        areControlsEnabled: function () {
            for ( let i = this.controls.length - 1; i >= 0; i-- ) {
                if ( this.controls[ i ].isVisible() ) {
                    return true;
                }
            }

            return false;
        },


        /**
         * @function
         * @returns {OpenSeadragon.ControlDock} Chainable.
         */
        setControlsEnabled: function( enabled ) {
            for (let i = this.controls.length - 1; i >= 0; i-- ) {
                this.controls[ i ].setVisible( enabled );
            }

            return this;
        }

    };


    ///////////////////////////////////////////////////////////////////////////////
    // Utility methods
    ///////////////////////////////////////////////////////////////////////////////
    function getControlIndex( dock, element ) {
        const controls = dock.controls;

        for (let i = controls.length - 1; i >= 0; i-- ) {
            if ( controls[ i ].element === element ) {
                return i;
            }
        }

        return -1;
    }

}( OpenSeadragon ));

/*
 * OpenSeadragon - Placement
 *
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function($) {

    /**
     * An enumeration of positions to anchor an element.
     * @member Placement
     * @memberOf OpenSeadragon
     * @static
     * @readonly
     * @property {OpenSeadragon.Placement} CENTER
     * @property {OpenSeadragon.Placement} TOP_LEFT
     * @property {OpenSeadragon.Placement} TOP
     * @property {OpenSeadragon.Placement} TOP_RIGHT
     * @property {OpenSeadragon.Placement} RIGHT
     * @property {OpenSeadragon.Placement} BOTTOM_RIGHT
     * @property {OpenSeadragon.Placement} BOTTOM
     * @property {OpenSeadragon.Placement} BOTTOM_LEFT
     * @property {OpenSeadragon.Placement} LEFT
     */
    $.Placement = $.freezeObject({
        CENTER:       0,
        TOP_LEFT:     1,
        TOP:          2,
        TOP_RIGHT:    3,
        RIGHT:        4,
        BOTTOM_RIGHT: 5,
        BOTTOM:       6,
        BOTTOM_LEFT:  7,
        LEFT:         8,
        properties: {
            0: {
                isLeft: false,
                isHorizontallyCentered: true,
                isRight: false,
                isTop: false,
                isVerticallyCentered: true,
                isBottom: false
            },
            1: {
                isLeft: true,
                isHorizontallyCentered: false,
                isRight: false,
                isTop: true,
                isVerticallyCentered: false,
                isBottom: false
            },
            2: {
                isLeft: false,
                isHorizontallyCentered: true,
                isRight: false,
                isTop: true,
                isVerticallyCentered: false,
                isBottom: false
            },
            3: {
                isLeft: false,
                isHorizontallyCentered: false,
                isRight: true,
                isTop: true,
                isVerticallyCentered: false,
                isBottom: false
            },
            4: {
                isLeft: false,
                isHorizontallyCentered: false,
                isRight: true,
                isTop: false,
                isVerticallyCentered: true,
                isBottom: false
            },
            5: {
                isLeft: false,
                isHorizontallyCentered: false,
                isRight: true,
                isTop: false,
                isVerticallyCentered: false,
                isBottom: true
            },
            6: {
                isLeft: false,
                isHorizontallyCentered: true,
                isRight: false,
                isTop: false,
                isVerticallyCentered: false,
                isBottom: true
            },
            7: {
                isLeft: true,
                isHorizontallyCentered: false,
                isRight: false,
                isTop: false,
                isVerticallyCentered: false,
                isBottom: true
            },
            8: {
                isLeft: true,
                isHorizontallyCentered: false,
                isRight: false,
                isTop: false,
                isVerticallyCentered: true,
                isBottom: false
            }
        }
    });

}(OpenSeadragon));

/*
 * OpenSeadragon - Viewer
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

// dictionary from hash to private properties
const THIS = {};
let nextHash = 1;

/**
 *
 * The main point of entry into creating a zoomable image on the page.<br>
 * <br>
 * We have provided an idiomatic javascript constructor which takes
 * a single object, but still support the legacy positional arguments.<br>
 * <br>
 * The options below are given in order that they appeared in the constructor
 * as arguments and we translate a positional call into an idiomatic call.<br>
 * <br>
 * To create a viewer, you can use either of this methods:<br>
 * <ul>
 * <li><code>var viewer = new OpenSeadragon.Viewer(options);</code></li>
 * <li><code>var viewer = OpenSeadragon(options);</code></li>
 * </ul>
 * @class Viewer
 * @classdesc The main OpenSeadragon viewer class.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.EventSource
 * @extends OpenSeadragon.ControlDock
 * @param {OpenSeadragon.Options} options - Viewer options.
 *
 **/
$.Viewer = function( options ) {

    const args  = arguments;
    const _this = this;
    let i;


    //backward compatibility for positional args while preferring more
    //idiomatic javascript options object as the only argument
    if( !$.isPlainObject( options ) ){
        options = {
            id:                 args[ 0 ],
            xmlPath:            args.length > 1 ? args[ 1 ] : undefined,
            prefixUrl:          args.length > 2 ? args[ 2 ] : undefined,
            controls:           args.length > 3 ? args[ 3 ] : undefined,
            overlays:           args.length > 4 ? args[ 4 ] : undefined
        };
    }

    //options.config and the general config argument are deprecated
    //in favor of the more direct specification of optional settings
    //being pass directly on the options object
    if ( options.config ){
        $.extend( true, options, options.config );
        delete options.config;
    }

    // Move deprecated drawer options from the base options object into a sub-object
    // This is an array to make it easy to add additional properties to convert to
    // drawer options later if it makes sense to set at the drawer level rather than
    // per tiled image (for example, subPixelRoundingForTransparency).
    const drawerOptionList = [
            'useCanvas', // deprecated
        ];
    options.drawerOptions = Object.assign({},
        drawerOptionList.reduce((drawerOptions, option) => {
            drawerOptions[option] = options[option];
            delete options[option];
            return drawerOptions;
        }, {}),
        options.drawerOptions);

    //Public properties
    //Allow the options object to override global defaults
    $.extend( true, this, {

        //internal state and dom identifiers
        id:             options.id,
        hash:           options.hash || nextHash++,
        /**
         * Parent viewer reference. Base Viewer has null reference, child viewers (such as navigator
         * or reference strip) must reference the parent viewer they were spawned from.
         * @member {OpenSeadragon.Viewer} viewer
         * @memberof OpenSeadragon.Viewer#
         */
        viewer:         null,
        /**
         * Index for page to be shown first next time open() is called (only used in sequenceMode).
         * @member {Number} initialPage
         * @memberof OpenSeadragon.Viewer#
         */
        initialPage:    0,

        //dom nodes
        /**
         * The parent element of this Viewer instance, passed in when the Viewer was created.
         * @member {Element} element
         * @memberof OpenSeadragon.Viewer#
         */
        element:        null,
        /**
         * A &lt;div&gt; element (provided by {@link OpenSeadragon.ControlDock}), the base element of this Viewer instance.<br><br>
         * Child element of {@link OpenSeadragon.Viewer#element}.
         * @member {Element} container
         * @memberof OpenSeadragon.Viewer#
         */
        container:      null,
        /**
         * A &lt;div&gt; element, the element where user-input events are handled for panning and zooming.<br><br>
         * Child element of {@link OpenSeadragon.Viewer#container},
         * positioned on top of {@link OpenSeadragon.Viewer#keyboardCommandArea}.<br><br>
         * The parent of {@link OpenSeadragon.Drawer#canvas} instances.
         * @member {Element} canvas
         * @memberof OpenSeadragon.Viewer#
         */
        canvas:         null,

        // Overlays list. An overlay allows to add html on top of the viewer.
        overlays:           [],
        // Container inside the canvas where overlays are drawn.
        overlaysContainer:  null,

        //private state properties

        // When we go full-screen we insert ourselves into the body and make
        // everything else hidden. This is basically the same as
        // `requestFullScreen` but works in all browsers: iPhone is known to not
        // allow full-screen with the requestFullScreen API.  This holds the
        // children of the body and their display values, so we can undo our
        // changes when we go out of full-screen
        previousDisplayValuesOfBodyChildren:   [],

        //This was originally initialized in the constructor and so could never
        //have anything in it.  now it can because we allow it to be specified
        //in the options and is only empty by default if not specified. Also
        //this array was returned from get_controls which I find confusing
        //since this object has a controls property which is treated in other
        //functions like clearControls.  I'm removing the accessors.
        customControls: [],

        //These are originally not part options but declared as members
        //in initialize.  It's still considered idiomatic to put them here
        //source is here for backwards compatibility. It is not an official
        //part of the API and should not be relied upon.
        source:         null,
        /**
         * Handles rendering of tiles in the viewer. Created for each TileSource opened.
         * @member {OpenSeadragon.Drawer} drawer
         * @memberof OpenSeadragon.Viewer#
         */
        drawer:             null,
        /**
         * Resolved list of drawer type strings (after expanding 'auto', de-duplicating, and
         * normalizing: constructors are replaced by their getType() result). Used to decide
         * allowed fallbacks: WebGL drawer only falls back to canvas when the string 'canvas' is
         * in this list (see per-tile and context-loss fallback). Normalized so includes('canvas')
         * is reliable even when custom drawer constructors were passed in options.
         * @member {string[]} drawerCandidates
         * @memberof OpenSeadragon.Viewer#
         */
        drawerCandidates:   null,
        /**
         * Keeps track of all of the tiled images in the scene.
         * @member {OpenSeadragon.World} world
         * @memberof OpenSeadragon.Viewer#
         */
        world:              null,
        /**
         * Handles coordinate-related functionality - zoom, pan, rotation, etc. Created for each TileSource opened.
         * @member {OpenSeadragon.Viewport} viewport
         * @memberof OpenSeadragon.Viewer#
         */
        viewport:       null,
        /**
         * @member {OpenSeadragon.Navigator} navigator
         * @memberof OpenSeadragon.Viewer#
         */
        navigator:      null,

        //A collection viewport is a separate viewport used to provide
        //simultaneous rendering of sets of tiles
        collectionViewport:     null,
        collectionDrawer:       null,

        //UI image resources
        //TODO: rename navImages to uiImages
        navImages:      null,

        //interface button controls
        buttonGroup:        null,

        //TODO: this is defunct so safely remove it
        profiler:       null

    }, $.DEFAULT_SETTINGS, options );

    if ( typeof ( this.hash) === "undefined" ) {
        throw new Error("A hash must be defined, either by specifying options.id or options.hash.");
    }
    if ( typeof ( THIS[ this.hash ] ) !== "undefined" ) {
        // We don't want to throw an error here, as the user might have discarded
        // the previous viewer with the same hash and now want to recreate it.
        $.console.warn("Hash " + this.hash + " has already been used.");
    }

    //Private state properties
    THIS[ this.hash ] = {
        fsBoundsDelta:     new $.Point( 1, 1 ),
        prevContainerSize: null,
        animating:         false,
        forceRedraw:       false,
        needsResize:       false,
        forceResize:       false,
        mouseInside:       false,
        group:             null,
        // whether we should be continuously zooming
        zooming:           false,
        // how much we should be continuously zooming by
        zoomFactor:        null,
        lastZoomTime:      null,
        fullPage:          false,
        onfullscreenchange: null,
        lastClickTime: null,
        draggingToZoom: false,
    };

    this._sequenceIndex = 0;
    this._firstOpen = true;
    this._updateRequestId = null;
    this._loadQueue = [];
    this.currentOverlays = [];
    this._updatePixelDensityRatioBind = null;

    this._lastScrollTime = $.now(); // variable used to help normalize the scroll event speed of different devices

    this._fullyLoaded = false; // variable used to track the viewer's aggregate loading state.

    this._navActionFrames = {};     // tracks cumulative pan distance per key press
    this._navActionVirtuallyHeld = {};   // marks keys virtually held after early release
    this._minNavActionFrames = 10;      // minimum pan distance per tap or key press

    this._activeActions = { // variable to keep track of currently pressed action
        // Basic arrow key panning (no modifiers)
        panUp: false,
        panDown: false,
        panLeft: false,
        panRight: false,

        // Modifier-based actions
        zoomIn: false,    // Shift + Up
        zoomOut: false    // Shift + Down
    };


    //Inherit some behaviors and properties
    $.EventSource.call( this );

    this.addHandler( 'open-failed', function ( event ) {
        const msg = $.getString( "Errors.OpenFailed", event.eventSource, event.message);
        _this._showMessage( msg );
    });

    $.ControlDock.call( this, options );

    //Deal with tile sources
    if (this.xmlPath) {
        //Deprecated option.  Now it is preferred to use the tileSources option
        this.tileSources = [ this.xmlPath ];
    }

    this.element              = this.element || document.getElementById( this.id );
    this.canvas               = $.makeNeutralElement( "div" );
    this.canvas.className = "openseadragon-canvas";

    // Injecting mobile-only CSS to remove focus outline
    if (!document.querySelector('style[data-openseadragon-mobile-css]')) {
        const style = document.createElement('style');
        style.setAttribute('data-openseadragon-mobile-css', 'true');
        style.textContent =
            '@media (hover: none) {' +
            '    .openseadragon-canvas:focus {' +
            '        outline: none !important;' +
            '    }' +
            '}';
        document.head.appendChild(style);
    }

    (function( style ){
        style.width    = "100%";
        style.height   = "100%";
        style.overflow = "hidden";
        style.position = "absolute";
        style.top      = "0px";
        style.left     = "0px";
    }(this.canvas.style));
    $.setElementTouchActionNone( this.canvas );
    if (options.tabIndex !== "") {
        this.canvas.tabIndex = (options.tabIndex === undefined ? 0 : options.tabIndex);
    }

    //the container is created through applying the ControlDock constructor above
    this.container.className = "openseadragon-container";
    (function( style ){
        style.width     = "100%";
        style.height    = "100%";
        style.position  = "relative";
        style.overflow  = "hidden";
        style.left      = "0px";
        style.top       = "0px";
        style.textAlign = "left";  // needed to protect against
    }( this.container.style ));
    $.setElementTouchActionNone( this.container );

    this.container.insertBefore( this.canvas, this.container.firstChild );
    this.element.appendChild( this.container );
    //Used for toggling between fullscreen and default container size
    //TODO: these can be closure private and shared across Viewer
    //      instances.
    this.bodyWidth      = document.body.style.width;
    this.bodyHeight     = document.body.style.height;
    this.bodyOverflow   = document.body.style.overflow;
    this.docOverflow    = document.documentElement.style.overflow;

    this.innerTracker = new $.MouseTracker({
        userData:                 'Viewer.innerTracker',
        element:                  this.canvas,
        startDisabled:            !this.mouseNavEnabled,
        clickTimeThreshold:       this.clickTimeThreshold,
        clickDistThreshold:       this.clickDistThreshold,
        dblClickTimeThreshold:    this.dblClickTimeThreshold,
        dblClickDistThreshold:    this.dblClickDistThreshold,
        contextMenuHandler:       $.delegate( this, onCanvasContextMenu ),
        keyDownHandler:           $.delegate( this, onCanvasKeyDown ),
        keyUpHandler:             $.delegate(this, onCanvasKeyUp),
        keyHandler:               $.delegate( this, onCanvasKeyPress ),
        clickHandler:             $.delegate( this, onCanvasClick ),
        dblClickHandler:          $.delegate( this, onCanvasDblClick ),
        dragHandler:              $.delegate( this, onCanvasDrag ),
        dragEndHandler:           $.delegate( this, onCanvasDragEnd ),
        enterHandler:             $.delegate( this, onCanvasEnter ),
        leaveHandler:             $.delegate( this, onCanvasLeave ),
        pressHandler:             $.delegate( this, onCanvasPress ),
        releaseHandler:           $.delegate( this, onCanvasRelease ),
        nonPrimaryPressHandler:   $.delegate( this, onCanvasNonPrimaryPress ),
        nonPrimaryReleaseHandler: $.delegate( this, onCanvasNonPrimaryRelease ),
        scrollHandler:            $.delegate( this, onCanvasScroll ),
        pinchHandler:             $.delegate( this, onCanvasPinch ),
        focusHandler:             $.delegate( this, onCanvasFocus ),
        blurHandler:              $.delegate( this, onCanvasBlur ),
    });

    this.outerTracker = new $.MouseTracker({
        userData:              'Viewer.outerTracker',
        element:               this.container,
        startDisabled:         !this.mouseNavEnabled,
        clickTimeThreshold:    this.clickTimeThreshold,
        clickDistThreshold:    this.clickDistThreshold,
        dblClickTimeThreshold: this.dblClickTimeThreshold,
        dblClickDistThreshold: this.dblClickDistThreshold,
        enterHandler:          $.delegate( this, onContainerEnter ),
        leaveHandler:          $.delegate( this, onContainerLeave )
    });

    if( this.toolbar ){
        this.toolbar = new $.ControlDock({ element: this.toolbar });
    }

    this.bindStandardControls();

    THIS[ this.hash ].prevContainerSize = _getSafeElemSize( this.container );

    if(window.ResizeObserver){
        this._autoResizePolling = false;
        this._resizeObserver = new ResizeObserver(function(){
            THIS[_this.hash].needsResize = true;
        });

        this._resizeObserver.observe(this.container, {});
    } else {
        this._autoResizePolling = true;
    }

    // Create the world
    this.world = new $.World({
        viewer: this
    });

    this.world.addHandler('add-item', function(event) {
        // For backwards compatibility, we maintain the source property
        _this.source = _this.world.getItemAt(0).source;

        THIS[ _this.hash ].forceRedraw = true;

        if (!_this._updateRequestId) {
            _this._updateRequestId = scheduleUpdate( _this, updateMulti );
        }

        const tiledImage = event.item;
        const fullyLoadedHandler =  function() {
            const newFullyLoaded = _this._areAllFullyLoaded();
            if (newFullyLoaded !== _this._fullyLoaded) {
                _this._fullyLoaded = newFullyLoaded;

                /**
                 * Fired when the viewer's aggregate "fully loaded" state changes (when all
                 * TiledImages in the world have loaded tiles for the current view resolution).
                 *
                 * @event fully-loaded-change
                 * @memberof OpenSeadragon.Viewer
                 * @type {object}
                 * @property {Boolean} fullyLoaded - The new aggregate "fully loaded" value
                 * @property {OpenSeadragon.Viewer} eventSource - Reference to the Viewer instance
                 * @property {?Object} userData - Arbitrary subscriber-defined object
                 */
                _this.raiseEvent('fully-loaded-change', {
                    fullyLoaded: newFullyLoaded
                });
            }
        };
        tiledImage._fullyLoadedHandlerForViewer = fullyLoadedHandler;
        tiledImage.addHandler('fully-loaded-change', fullyLoadedHandler);
    });

    this.world.addHandler('remove-item', function(event) {
        const tiledImage = event.item;

        // SAFE cleanup with existence check
        if (tiledImage._fullyLoadedHandlerForViewer) {
            tiledImage.removeHandler('fully-loaded-change', tiledImage._fullyLoadedHandlerForViewer);
            delete tiledImage._fullyLoadedHandlerForViewer; // Remove the reference
        }

        // For backwards compatibility, we maintain the source property
        if (_this.world.getItemCount()) {
            _this.source = _this.world.getItemAt(0).source;
        } else {
            _this.source = null;
        }

        THIS[ _this.hash ].forceRedraw = true;
    });

    this.world.addHandler('metrics-change', function(event) {
        if (_this.viewport) {
            _this.viewport._setContentBounds(_this.world.getHomeBounds(), _this.world.getContentFactor());
        }
    });

    this.world.addHandler('item-index-change', function(event) {
        // For backwards compatibility, we maintain the source property
        _this.source = _this.world.getItemAt(0).source;
    });

    // Create the viewport
    this.viewport = new $.Viewport({
        containerSize:                      THIS[ this.hash ].prevContainerSize,
        springStiffness:                    this.springStiffness,
        animationTime:                      this.animationTime,
        minZoomImageRatio:                  this.minZoomImageRatio,
        maxZoomPixelRatio:                  this.maxZoomPixelRatio,
        visibilityRatio:                    this.visibilityRatio,
        wrapHorizontal:                     this.wrapHorizontal,
        wrapVertical:                       this.wrapVertical,
        defaultZoomLevel:                   this.defaultZoomLevel,
        minZoomLevel:                       this.minZoomLevel,
        maxZoomLevel:                       this.maxZoomLevel,
        viewer:                             this,
        degrees:                            this.degrees,
        flipped:                            this.flipped,
        overlayPreserveContentDirection:    this.overlayPreserveContentDirection,
        navigatorRotate:                    this.navigatorRotate,
        homeFillsViewer:                    this.homeFillsViewer,
        margins:                            this.viewportMargins,
        silenceMultiImageWarnings:          this.silenceMultiImageWarnings
    });

    this.viewport._setContentBounds(this.world.getHomeBounds(), this.world.getContentFactor());

    // Create the image loader
    this.imageLoader = new $.ImageLoader({
        jobLimit: this.imageLoaderLimit,
        timeout: options.timeout,
        tileRetryMax: this.tileRetryMax,
        tileRetryDelay: this.tileRetryDelay
    });

    // Create the tile cache
    this.tileCache = new $.TileCache({
        viewer: this,
        maxImageCacheCount: this.maxImageCacheCount
    });

    //Create the drawer based on selected options
    if (Object.prototype.hasOwnProperty.call(this.drawerOptions, 'useCanvas') ){
        $.console.error('useCanvas is deprecated, use the "drawer" option to indicate preferred drawer(s)');

        // for backwards compatibility, use HTMLDrawer if useCanvas is defined and is falsey
        if (!this.drawerOptions.useCanvas){
            this.drawer = $.HTMLDrawer;
        }

        delete this.drawerOptions.useCanvas;
    }
    let drawerCandidates = Array.isArray(this.drawer) ? this.drawer : [this.drawer];
    if (drawerCandidates.length === 0){
        // if an empty array was passed in, throw a warning and use the defaults
        // note: if the drawer option is not specified, the defaults will already be set so this won't apply
        drawerCandidates = [$.DEFAULT_SETTINGS.drawer].flat(); // ensure it is a list
        $.console.warn('No valid drawers were selected. Using the default value.');
    }

    // 'auto' is expanded in the candidate list in a platform-dependent way: on iOS-like devices
    // to ['canvas'] only, on other platforms to ['webgl', 'canvas'] so that if WebGL fails at
    // creation, canvas is tried next. Same detection as getAutoDrawerCandidates() / determineDrawer('auto').
    drawerCandidates = drawerCandidates.flatMap(
        function(c) {
            return c === 'auto' ? getAutoDrawerCandidates() : [c];
        }
    );
    drawerCandidates = drawerCandidates.filter(
        function(c, i, arr) {
            return arr.indexOf(c) === i;
        }
    );
    this.drawerCandidates = drawerCandidates.map(getDrawerTypeString).filter(Boolean);

    this.drawer = null;
    for (const drawerCandidate of drawerCandidates){
        const success = this.requestDrawer(drawerCandidate, {mainDrawer: true, redrawImmediately: false});
        if(success){
            break;
        }
    }

    if (!this.drawer){
        $.console.error('No drawer could be created!');
        throw('Error with creating the selected drawer(s)');
    }

    // Pass the imageSmoothingEnabled option along to the drawer
    this.drawer.setImageSmoothingEnabled(this.imageSmoothingEnabled);

    // Overlay container
    this.overlaysContainer    = $.makeNeutralElement( "div" );
    this.canvas.appendChild( this.overlaysContainer );

    // Now that we have a drawer, see if it supports rotate. If not we need to remove the rotate buttons
    if (!this.drawer.canRotate()) {
        // Disable/remove the rotate left/right buttons since they aren't supported
        if (this.rotateLeft) {
            i = this.buttonGroup.buttons.indexOf(this.rotateLeft);
            this.buttonGroup.buttons.splice(i, 1);
            this.buttonGroup.element.removeChild(this.rotateLeft.element);
        }
        if (this.rotateRight) {
            i = this.buttonGroup.buttons.indexOf(this.rotateRight);
            this.buttonGroup.buttons.splice(i, 1);
            this.buttonGroup.element.removeChild(this.rotateRight.element);
        }
    }

    this._addUpdatePixelDensityRatioEvent();

    if ('navigatorAutoResize' in this) {
        $.console.warn('navigatorAutoResize is deprecated, this value will be ignored.');
    }

    //Instantiate a navigator if configured
    if ( this.showNavigator){
        this.navigator = new $.Navigator({
            element:           this.navigatorElement,
            id:                this.navigatorId,
            position:          this.navigatorPosition,
            sizeRatio:         this.navigatorSizeRatio,
            maintainSizeRatio: this.navigatorMaintainSizeRatio,
            top:               this.navigatorTop,
            left:              this.navigatorLeft,
            width:             this.navigatorWidth,
            height:            this.navigatorHeight,
            autoFade:          this.navigatorAutoFade,
            prefixUrl:         this.prefixUrl,
            viewer:            this,
            navigatorRotate:   this.navigatorRotate,
            background:        this.navigatorBackground,
            opacity:           this.navigatorOpacity,
            borderColor:       this.navigatorBorderColor,
            displayRegionColor: this.navigatorDisplayRegionColor,
            crossOriginPolicy: this.crossOriginPolicy,
            animationTime:     this.animationTime,
            drawer:            this.drawer.getType(),
            drawerOptions:     this.drawerOptions,
            loadTilesWithAjax: this.loadTilesWithAjax,
            ajaxHeaders:       this.ajaxHeaders,
            ajaxWithCredentials: this.ajaxWithCredentials,
        });
    }

    // Sequence mode
    if (this.sequenceMode) {
        this.bindSequenceControls();
    }

    // Open initial tilesources
    if (this.tileSources) {
        this.open( this.tileSources );
    }

    // Add custom controls
    for ( i = 0; i < this.customControls.length; i++ ) {
        this.addControl(
            this.customControls[ i ].id,
            {anchor: this.customControls[ i ].anchor}
        );
    }

    // Initial fade out
    $.requestAnimationFrame( function(){
        beginControlsAutoHide( _this );
    } );

    // Register the viewer
    $._viewers.set(this.element, this);
};

$.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, /** @lends OpenSeadragon.Viewer.prototype */{


    /**
     * @function
     * @returns {Boolean}
     */
    isOpen: function () {
        return !!this.world.getItemCount();
    },

    /**
     * Checks whether all TiledImage instances in the viewer's world are fully loaded.
     * This determines if the entire viewer content is ready for optimal display without partial tile loading.
     * @private
     * @returns {Boolean} True if all TiledImages report being fully loaded,
     *                    false if any image still has pending tiles
     */
    _areAllFullyLoaded: function() {
        const count = this.world.getItemCount();

        // Iterate through all TiledImages in the viewer's world
        for (let i = 0; i < count; i++) {
            let tiledImage = this.world.getItemAt(i);

            // Return immediately if any image isn't fully loaded
            if (!tiledImage.getFullyLoaded()) {
                return false;
            }
        }
        // All images passed the check
        return true;
    },

    /**
     * @function
     * @returns {Boolean} True if all required tiles are loaded, false otherwise
     */
    getFullyLoaded: function() {
        return this._fullyLoaded;
    },

    /**
     * Executes the provided callback when the TiledImage is fully loaded. If already loaded,
     * schedules the callback asynchronously. Otherwise, attaches a one-time event listener
     * for the 'fully-loaded-change' event.
     * @param {Function} callback - Function to execute when loading completes
     * @memberof OpenSeadragon.Viewer.prototype
     */
    whenFullyLoaded: function(callback) {
        if (this.getFullyLoaded()) {
            setTimeout(callback, 1); // Asynchronous execution
        } else {
            this.addOnceHandler('fully-loaded-change', function() {
                callback(); // Maintain context
            });
        }
    },

    // deprecated
    openDzi: function ( dzi ) {
        $.console.error( "[Viewer.openDzi] this function is deprecated; use Viewer.open() instead." );
        return this.open( dzi );
    },

    // deprecated
    openTileSource: function ( tileSource ) {
        $.console.error( "[Viewer.openTileSource] this function is deprecated; use Viewer.open() instead." );
        return this.open( tileSource );
    },

    //deprecated
    get buttons () {
        $.console.warn('Viewer.buttons is deprecated; Please use Viewer.buttonGroup');
        return this.buttonGroup;
    },

    /**
     * Open tiled images into the viewer, closing any others.
     * To get the TiledImage instance created by open, add an event listener for
     * {@link OpenSeadragon.Viewer.html#.event:open}, which when fired can be used to get access
     * to the instance, i.e., viewer.world.getItemAt(0).
     * @function
     * @param {OpenSeadragon.TileSourceSpecifier|OpenSeadragon.TileSourceSpecifier[]} tileSources - This can be a TiledImage
     * specifier, a TileSource specifier, or an array of either. A TiledImage specifier
     * is the same as the options parameter for {@link OpenSeadragon.Viewer#addTiledImage},
     * except for the index property; images are added in sequence.
     * A TileSource specifier is anything you could pass as the tileSource property
     * of the options parameter for {@link OpenSeadragon.Viewer#addTiledImage}.
     * @param {Number} [initialPage = undefined] - If sequenceMode is true, display this page initially
     * for the given tileSources. If specified, will overwrite the Viewer's existing initialPage property.
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:open
     * @fires OpenSeadragon.Viewer.event:open-failed
     */
    open: function (tileSources, initialPage = undefined) {
        const _this = this;

        this.close();

        if (!tileSources) {
            return this;
        }

        if (this.sequenceMode && $.isArray(tileSources)) {
            if (this.referenceStrip) {
                this.referenceStrip.destroy();
                this.referenceStrip = null;
            }

            if (typeof initialPage !== 'undefined' && !isNaN(initialPage)) {
              this.initialPage = initialPage;
            }

            this.tileSources = tileSources;
            this._sequenceIndex = Math.max(0, Math.min(this.tileSources.length - 1, this.initialPage));
            if (this.tileSources.length) {
                this.open(this.tileSources[this._sequenceIndex]);

                if ( this.showReferenceStrip ){
                    this.addReferenceStrip();
                }
            }

            this._updateSequenceButtons( this._sequenceIndex );
            return this;
        }

        if (!$.isArray(tileSources)) {
            tileSources = [tileSources];
        }

        if (!tileSources.length) {
            return this;
        }

        this._opening = true;

        const expected = tileSources.length;
        let successes = 0;
        let failures = 0;
        let failEvent;

        const checkCompletion = function() {
            if (successes + failures === expected) {
                if (successes) {
                    if (_this._firstOpen || !_this.preserveViewport) {
                        _this.viewport.goHome( true );
                        _this.viewport.update();
                    }

                    _this._firstOpen = false;

                    let source = tileSources[0];
                    if (source.tileSource) {
                        source = source.tileSource;
                    }

                    // Global overlays
                    if( _this.overlays && !_this.preserveOverlays ){
                        for ( let i = 0; i < _this.overlays.length; i++ ) {
                            _this.currentOverlays[ i ] = getOverlayObject( _this, _this.overlays[ i ] );
                        }
                    }

                    _this._drawOverlays();
                    _this._opening = false;

                    /**
                     * Raised when the viewer has opened and loaded one or more TileSources.
                     *
                     * @event open
                     * @memberof OpenSeadragon.Viewer
                     * @type {object}
                     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
                     * @property {OpenSeadragon.TileSource} source - The tile source that was opened.
                     * @property {?Object} userData - Arbitrary subscriber-defined object.
                     */
                    // TODO: what if there are multiple sources?
                    _this.raiseEvent( 'open', { source: source } );
                } else {
                    _this._opening = false;

                    /**
                     * Raised when an error occurs loading a TileSource.
                     *
                     * @event open-failed
                     * @memberof OpenSeadragon.Viewer
                     * @type {object}
                     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
                     * @property {String} message - Information about what failed.
                     * @property {String} source - The tile source that failed.
                     * @property {?Object} userData - Arbitrary subscriber-defined object.
                     */
                    _this.raiseEvent( 'open-failed', failEvent );
                }
            }
        };

        const doOne = function(index, options) {
            if (!$.isPlainObject(options) || !options.tileSource) {
                options = {
                    tileSource: options
                };
            }

            if (options.index !== undefined) {
                $.console.warn('[Viewer.open] Ignoring user-supplied index; preserving order by setting index to ' + index + '. If you need to set indexes, use addTiledImage instead.');
                delete options.index;
                // ensure we keep the order we received
                options.index = index;
            }

            if (options.collectionImmediately === undefined) {
                options.collectionImmediately = true;
            }

            const originalSuccess = options.success;
            options.success = function(event) {
                successes++;

                // TODO: now that options has other things besides tileSource, the overlays
                // should probably be at the options level, not the tileSource level.
                if (options.tileSource.overlays) {
                    for (let i = 0; i < options.tileSource.overlays.length; i++) {
                        _this.addOverlay(options.tileSource.overlays[i]);
                    }
                }

                if (originalSuccess) {
                    originalSuccess(event);
                }

                checkCompletion();
            };

            const originalError = options.error;
            options.error = function(event) {
                failures++;

                if (!failEvent) {
                    failEvent = event;
                }

                if (originalError) {
                    originalError(event);
                }

                checkCompletion();
            };

            _this.addTiledImage(options);
        };

        // TileSources
        for (let i = 0; i < tileSources.length; i++) {
            doOne(i, tileSources[i]);
        }

        return this;
    },

    /**
     * Updates data within every tile in the viewer. Should be called
     * when tiles are outdated and should be re-processed. Useful mainly
     * for plugins that change tile data.
     * @function
     * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
     * @fires OpenSeadragon.Viewer.event:tile-invalidated
     * @return {OpenSeadragon.Promise<?>}
     */
    requestInvalidate: function (restoreTiles = true) {
        if ( !THIS[ this.hash ] || !this._drawerList ) {
            //this viewer has already been destroyed or is a child in connected mode: returning immediately
            return $.Promise.resolve();
        }

        const tStamp = $.now();
        // if drawer option broadCastTileInvalidation is enabled, this is NOOP for any but the base drawer, that runs update on all
        return $.Promise.all(this._drawerList.map(drawer => drawer.viewer.world.requestInvalidate(restoreTiles, tStamp)));
    },


    /**
     * @function
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:close
     */
    close: function ( ) {
        if ( !THIS[ this.hash ] ) {
            //this viewer has already been destroyed: returning immediately
            return this;
        }

        this._opening = false;

        if ( this.navigator ) {
            this.navigator.close();
        }

        if (!this.preserveOverlays) {
            this.clearOverlays();
            this.overlaysContainer.innerHTML = "";
        }

        THIS[ this.hash ].animating = false;

        this.world.removeAll();
        this.tileCache.clear();
        this.imageLoader.clear();
        /**
         * Raised when the viewer is closed (see {@link OpenSeadragon.Viewer#close}).
         *
         * @event close
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'close' );

        return this;
    },


    /**
     * Function to destroy the viewer and clean up everything created by OpenSeadragon.
     *
     * Example:
     * var viewer = OpenSeadragon({
     *   [...]
     * });
     *
     * //when you are done with the viewer:
     * viewer.destroy();
     * viewer = null; //important
     *
     * @function
     * @fires OpenSeadragon.Viewer.event:before-destroy
     * @fires OpenSeadragon.Viewer.event:destroy
     */
    destroy: function( ) {
        if ( !THIS[ this.hash ] ) {
            //this viewer has already been destroyed: returning immediately
            return;
        }

        /**
         * Raised when the viewer is about to be destroyed (see {@link OpenSeadragon.Viewer#before-destroy}).
         *
         * @event before-destroy
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'before-destroy' );

        this._removeUpdatePixelDensityRatioEvent();

        this.close();

        this.clearOverlays();
        this.overlaysContainer.innerHTML = "";

        //TODO: implement this...
        //this.unbindSequenceControls()
        //this.unbindStandardControls()
        if (this._resizeObserver){
            this._resizeObserver.disconnect();
        }

        if (this.referenceStrip) {
            this.referenceStrip.destroy();
            this.referenceStrip = null;
        }

        if ( this._updateRequestId !== null ) {
            $.cancelAnimationFrame( this._updateRequestId );
            this._updateRequestId = null;
        }

        if ( this.drawer ) {
            this.drawer.destroy();
        }

        if ( this.navigator ) {
            this.navigator.destroy();
            THIS[ this.navigator.hash ] = null;
            delete THIS[ this.navigator.hash ];
            this.navigator = null;
        }


        if (this.buttonGroup) {
            this.buttonGroup.destroy();
        } else if (this.customButtons) {
            while (this.customButtons.length) {
                this.customButtons.pop().destroy();
            }
        }

        if (this.paging) {
            this.paging.destroy();
        }

        // Remove both the canvas and container elements added by OpenSeadragon
        // This will also remove its children (like the canvas)
        if (this.container && this.container.parentNode === this.element) {
            this.element.removeChild(this.container);
        }
        this.container.onsubmit = null;
        this.clearControls();

        // destroy the mouse trackers
        if (this.innerTracker){
            this.innerTracker.destroy();
        }
        if (this.outerTracker){
            this.outerTracker.destroy();
        }

        THIS[ this.hash ] = null;
        delete THIS[ this.hash ];

        // clear all our references to dom objects
        this.canvas = null;
        this.container = null;

        // Unregister the viewer
        $._viewers.delete(this.element);

        // clear our reference to the main element - they will need to pass it in again, creating a new viewer
        this.element = null;



        /**
         * Raised when the viewer is destroyed (see {@link OpenSeadragon.Viewer#destroy}).
         *
         * @event destroy
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'destroy' );

        this.removeAllHandlers();
    },

    /**
     * Check if the viewer has been destroyed or not yet initialized.
     * @return {boolean}
     */
    isDestroyed() {
        return !THIS[ this.hash ];
    },

    /**
     * Request a drawer for this viewer, as a supported string or drawer constructor.
     * @param {String | OpenSeadragon.DrawerBase} drawerCandidate The type of drawer to try to construct.
     * @param { Object } options
     * @param { Boolean } [options.mainDrawer] Whether to use this as the viewer's main drawer. Default = true.
     * @param { Boolean } [options.redrawImmediately] Whether to immediately draw a new frame. Only used if options.mainDrawer = true. Default = true.
     * @param { Object } [options.drawerOptions] Options for this drawer. Defaults to viewer.drawerOptions.
     * for this viewer type. See {@link OpenSeadragon.Options}.
     * @returns {Object | Boolean} The drawer that was created, or false if the requested drawer is not supported
     */
    requestDrawer(drawerCandidate, options){
        const defaultOpts = {
            mainDrawer: true,
            redrawImmediately: true,
            drawerOptions: null
        };
        options = $.extend(true, defaultOpts, options);
        const mainDrawer = options.mainDrawer;
        const redrawImmediately = options.redrawImmediately;
        const drawerOptions = options.drawerOptions;

        const oldDrawer = this.drawer;

        let Drawer = null;

        //if the candidate inherits from a drawer base, use it
        if (drawerCandidate && drawerCandidate.prototype instanceof $.DrawerBase) {
            Drawer = drawerCandidate;
            drawerCandidate = 'custom';
        } else if (typeof drawerCandidate === "string") {
            Drawer = $.determineDrawer(drawerCandidate);
        }

        if (!Drawer) {
            $.console.warn('Unsupported drawer %s! Drawer must be an existing string type, or a class that extends OpenSeadragon.DrawerBase.', drawerCandidate);
        }

        // Guard isSupported() in try/catch so a buggy or throwing plugin drawer cannot crash the whole viewer
        let supported = false;
        if (Drawer) {
            try {
                supported = Drawer.isSupported();
            } catch (e) {
                $.console.warn('Error in %s isSupported(); treating this drawer as unsupported:', drawerCandidate, e && e.message ? e.message : e);
            }
        }
        if (supported) {
            // if the drawer is supported, create it and return it.
            // first destroy the previous drawer
            if(oldDrawer && mainDrawer){
                oldDrawer.destroy();
            }

            // create the new drawer
            const newDrawer = new Drawer({
                viewer:             this,
                viewport:           this.viewport,
                element:            this.canvas,
                debugGridColor:     this.debugGridColor,
                options:            drawerOptions || this.drawerOptions[drawerCandidate],
            });

            if(mainDrawer){
                this.drawer = newDrawer;
                if(redrawImmediately){
                    this.forceRedraw();
                }
            }

            return newDrawer;
        }

        return false;
    },

    /**
     * @function
     * @returns {Boolean}
     */
    isMouseNavEnabled: function () {
        return this.innerTracker.tracking;
    },

    /**
     * @function
     * @param {Boolean} enabled - true to enable, false to disable
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:mouse-enabled
     */
    setMouseNavEnabled: function( enabled ){
        this.innerTracker.setTracking( enabled );
        this.outerTracker.setTracking( enabled );
        /**
         * Raised when mouse/touch navigation is enabled or disabled (see {@link OpenSeadragon.Viewer#setMouseNavEnabled}).
         *
         * @event mouse-enabled
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Boolean} enabled
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'mouse-enabled', { enabled: enabled } );
        return this;
    },


    /**
     * @function
     * @returns {Boolean}
     */
    isKeyboardNavEnabled: function () {
        return this.keyboardNavEnabled;
    },

    /**
     * @function
     * @param {Boolean} enabled - true to enable, false to disable
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:keyboard-enabled
     */
    setKeyboardNavEnabled: function( enabled ){
        this.keyboardNavEnabled = enabled;

        /**
         * Raised when keyboard navigation is enabled or disabled (see {@link OpenSeadragon.Viewer#setKeyboardNavEnabled}).
         *
         * @event keyboard-enabled
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Boolean} enabled
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'keyboard-enabled', { enabled: enabled } );
        return this;
    },


    /**
     * @function
     * @returns {Boolean}
     */
    areControlsEnabled: function () {
        let enabled = this.controls.length;
        for( let i = 0; i < this.controls.length; i++ ){
            enabled = enabled && this.controls[ i ].isVisible();
        }
        return enabled;
    },


    /**
     * Shows or hides the controls (e.g. the default navigation buttons).
     *
     * @function
     * @param {Boolean} true to show, false to hide.
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:controls-enabled
     */
    setControlsEnabled: function( enabled ) {
        if( enabled ){
            abortControlsAutoHide( this );
        } else {
            beginControlsAutoHide( this );
        }
        /**
         * Raised when the navigation controls are shown or hidden (see {@link OpenSeadragon.Viewer#setControlsEnabled}).
         *
         * @event controls-enabled
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Boolean} enabled
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'controls-enabled', { enabled: enabled } );
        return this;
    },

    /**
     * Turns debugging mode on or off for this viewer.
     *
     * @function
     * @param {Boolean} debugMode true to turn debug on, false to turn debug off.
     */
    setDebugMode: function(debugMode){

        for (let i = 0; i < this.world.getItemCount(); i++) {
            this.world.getItemAt(i).debugMode = debugMode;
        }

        this.debugMode = debugMode;
        this.forceRedraw();
    },

    /**
     * Update headers to include when making AJAX requests.
     *
     * Unless `propagate` is set to false (which is likely only useful in rare circumstances),
     * the updated headers are propagated to all tiled images, each of which will subsequently
     * propagate the changed headers to all their tiles.
     * If applicable, the headers of the viewer's navigator and reference strip will also be updated.
     *
     * Note that the rules for merging headers still apply, i.e. headers returned by
     * {@link OpenSeadragon.TileSource#getTileAjaxHeaders} take precedence over
     * `TiledImage.ajaxHeaders`, which take precedence over the headers here in the viewer.
     *
     * @function
     * @param {Object} ajaxHeaders Updated AJAX headers.
     * @param {Boolean} [propagate=true] Whether to propagate updated headers to tiled images, etc.
     */
    setAjaxHeaders: function(ajaxHeaders, propagate) {
        if (ajaxHeaders === null) {
            ajaxHeaders = {};
        }
        if (!$.isPlainObject(ajaxHeaders)) {
            $.console.error('[Viewer.setAjaxHeaders] Ignoring invalid headers, must be a plain object');
            return;
        }
        if (propagate === undefined) {
            propagate = true;
        }

        this.ajaxHeaders = ajaxHeaders;

        if (propagate) {
            for (let i = 0; i < this.world.getItemCount(); i++) {
                this.world.getItemAt(i)._updateAjaxHeaders(true);
            }

            if (this.navigator) {
                this.navigator.setAjaxHeaders(this.ajaxHeaders, true);
            }

            if (this.referenceStrip && this.referenceStrip.miniViewers) {
                for (const key in this.referenceStrip.miniViewers) {
                    this.referenceStrip.miniViewers[key].setAjaxHeaders(this.ajaxHeaders, true);
                }
            }
        }
    },

    /**
     * Adds the given button to this viewer.
     *
     * @function
     * @param {OpenSeadragon.Button} button
     */
    addButton: function( button ){
        this.buttonGroup.addButton(button);
    },

    /**
     * @function
     * @returns {Boolean}
     */
    isFullPage: function () {
        return THIS[this.hash] && THIS[ this.hash ].fullPage;
    },


    /**
     * Toggle full page mode.
     * @function
     * @param {Boolean} fullPage
     *      If true, enter full page mode.  If false, exit full page mode.
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:pre-full-page
     * @fires OpenSeadragon.Viewer.event:full-page
     */
    setFullPage: function( fullPage ) {

        const body = document.body;
        const bodyStyle = body.style;
        const docStyle = document.documentElement.style;
        const _this = this;
        let nodes;

        //don't bother modifying the DOM if we are already in full page mode.
        if ( fullPage === this.isFullPage() ) {
            return this;
        }

        const fullPageEventArgs = {
            fullPage: fullPage,
            preventDefaultAction: false
        };
        /**
         * Raised when the viewer is about to change to/from full-page mode (see {@link OpenSeadragon.Viewer#setFullPage}).
         *
         * @event pre-full-page
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Boolean} fullPage - True if entering full-page mode, false if exiting full-page mode.
         * @property {Boolean} preventDefaultAction - Set to true to prevent full-page mode change. Default: false.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'pre-full-page', fullPageEventArgs );
        if ( fullPageEventArgs.preventDefaultAction ) {
            return this;
        }

        if ( fullPage && this.element ) {

            this.elementSize = $.getElementSize( this.element );
            this.pageScroll = $.getPageScroll();

            this.elementMargin = this.element.style.margin;
            this.element.style.margin = "0";
            this.elementPadding = this.element.style.padding;
            this.element.style.padding = "0";

            this.bodyMargin = bodyStyle.margin;
            this.docMargin = docStyle.margin;
            bodyStyle.margin = "0";
            docStyle.margin = "0";

            this.bodyPadding = bodyStyle.padding;
            this.docPadding = docStyle.padding;
            bodyStyle.padding = "0";
            docStyle.padding = "0";

            this.bodyWidth = bodyStyle.width;
            this.docWidth = docStyle.width;
            bodyStyle.width = "100%";
            docStyle.width = "100%";

            this.bodyHeight = bodyStyle.height;
            this.docHeight = docStyle.height;
            bodyStyle.height = "100%";
            docStyle.height = "100%";

            this.bodyDisplay = bodyStyle.display;
            bodyStyle.display = "block";

            //when entering full screen on the ipad it wasn't sufficient to
            //leave the body intact as only only the top half of the screen
            //would respond to touch events on the canvas, while the bottom half
            //treated them as touch events on the document body.  Thus we make
            //them invisible (display: none) and apply the older values when we
            //go out of full screen.
            this.previousDisplayValuesOfBodyChildren = [];
            THIS[ this.hash ].prevElementParent = this.element.parentNode;
            THIS[ this.hash ].prevNextSibling = this.element.nextSibling;
            THIS[ this.hash ].prevElementWidth = this.element.style.width;
            THIS[ this.hash ].prevElementHeight = this.element.style.height;
            nodes = body.children.length;
            for ( let i = 0; i < nodes; i++ ) {
                const element = body.children[i];
                if (element === this.element) {
                    // Do not hide ourselves...
                    continue;
                }
                this.previousDisplayValuesOfBodyChildren.push({
                    element,
                    display: element.style.display
                });
                element.style.display = 'none';
            }

            //If we've got a toolbar, we need to enable the user to use css to
            //preserve it in fullpage mode
            if ( this.toolbar && this.toolbar.element ) {
                //save a reference to the parent so we can put it back
                //in the long run we need a better strategy
                this.toolbar.parentNode = this.toolbar.element.parentNode;
                this.toolbar.nextSibling = this.toolbar.element.nextSibling;
                body.appendChild( this.toolbar.element );

                //Make sure the user has some ability to style the toolbar based
                //on the mode
                $.addClass( this.toolbar.element, 'fullpage' );
            }

            $.addClass( this.element, 'fullpage' );
            body.appendChild( this.element );

            this.element.style.height = '100vh';
            this.element.style.width = '100vw';

            if ( this.toolbar && this.toolbar.element ) {
                this.element.style.height = (
                    $.getElementSize( this.element ).y - $.getElementSize( this.toolbar.element ).y
                ) + 'px';
            }

            THIS[ this.hash ].fullPage = true;

            // mouse will be inside container now
            $.delegate( this, onContainerEnter )( {} );

        } else {

            this.element.style.margin = this.elementMargin;
            this.element.style.padding = this.elementPadding;

            bodyStyle.margin = this.bodyMargin;
            docStyle.margin = this.docMargin;

            bodyStyle.padding = this.bodyPadding;
            docStyle.padding = this.docPadding;

            bodyStyle.width = this.bodyWidth;
            docStyle.width = this.docWidth;

            bodyStyle.height = this.bodyHeight;
            docStyle.height = this.docHeight;

            bodyStyle.display = this.bodyDisplay;

            body.removeChild( this.element );
            nodes = this.previousDisplayValuesOfBodyChildren.length;
            for ( let i = 0; i < nodes; i++ ) {
                const { element, display } = this.previousDisplayValuesOfBodyChildren[i];
                element.style.display = display;
            }

            $.removeClass( this.element, 'fullpage' );
            THIS[ this.hash ].prevElementParent.insertBefore(
                this.element,
                THIS[ this.hash ].prevNextSibling
            );

            //If we've got a toolbar, we need to enable the user to use css to
            //reset it to its original state
            if ( this.toolbar && this.toolbar.element ) {
                body.removeChild( this.toolbar.element );

                //Make sure the user has some ability to style the toolbar based
                //on the mode
                $.removeClass( this.toolbar.element, 'fullpage' );

                this.toolbar.parentNode.insertBefore(
                    this.toolbar.element,
                    this.toolbar.nextSibling
                );
                delete this.toolbar.parentNode;
                delete this.toolbar.nextSibling;
            }

            this.element.style.width = THIS[ this.hash ].prevElementWidth;
            this.element.style.height = THIS[ this.hash ].prevElementHeight;

            // After exiting fullPage or fullScreen, it can take some time
            // before the browser can actually set the scroll.
            let restoreScrollCounter = 0;
            const restoreScroll = function() {
                $.setPageScroll( _this.pageScroll );
                const pageScroll = $.getPageScroll();
                restoreScrollCounter++;
                if (restoreScrollCounter < 10 &&
                    (pageScroll.x !== _this.pageScroll.x ||
                    pageScroll.y !== _this.pageScroll.y)) {
                    $.requestAnimationFrame( restoreScroll );
                }
            };
            $.requestAnimationFrame( restoreScroll );

            THIS[ this.hash ].fullPage = false;

            // mouse will likely be outside now
            $.delegate( this, onContainerLeave )( { } );

        }

        if ( this.navigator && this.viewport ) {
            this.navigator.update( this.viewport );
        }

        /**
         * Raised when the viewer has changed to/from full-page mode (see {@link OpenSeadragon.Viewer#setFullPage}).
         *
         * @event full-page
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Boolean} fullPage - True if changed to full-page mode, false if exited full-page mode.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'full-page', { fullPage: fullPage } );

        return this;
    },

    /**
     * Toggle full screen mode if supported. Toggle full page mode otherwise.
     * @function
     * @param {Boolean} fullScreen
     *      If true, enter full screen mode.  If false, exit full screen mode.
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:pre-full-screen
     * @fires OpenSeadragon.Viewer.event:full-screen
     */
    setFullScreen: function( fullScreen ) {
        const _this = this;

        if ( !$.supportsFullScreen ) {
            return this.setFullPage( fullScreen );
        }

        if ( $.isFullScreen() === fullScreen ) {
            return this;
        }

        const fullScreenEventArgs = {
            fullScreen: fullScreen,
            preventDefaultAction: false
        };
        /**
         * Raised when the viewer is about to change to/from full-screen mode (see {@link OpenSeadragon.Viewer#setFullScreen}).
         * Note: the pre-full-screen event is not raised when the user is exiting
         * full-screen mode by pressing the Esc key. In that case, consider using
         * the full-screen, pre-full-page or full-page events.
         *
         * @event pre-full-screen
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Boolean} fullScreen - True if entering full-screen mode, false if exiting full-screen mode.
         * @property {Boolean} preventDefaultAction - Set to true to prevent full-screen mode change. Default: false.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'pre-full-screen', fullScreenEventArgs );
        if ( fullScreenEventArgs.preventDefaultAction ) {
            return this;
        }

        if ( fullScreen ) {

            this.setFullPage( true );
            // If the full page mode is not actually entered, we need to prevent
            // the full screen mode.
            if ( !this.isFullPage() ) {
                return this;
            }

            this.fullPageStyleWidth = this.element.style.width;
            this.fullPageStyleHeight = this.element.style.height;
            this.element.style.width = '100%';
            this.element.style.height = '100%';

            const onFullScreenChange = function() {
                if (!THIS[ _this.hash ]) {
                    $.removeEvent( document, $.fullScreenEventName, onFullScreenChange );
                    $.removeEvent( document, $.fullScreenErrorEventName, onFullScreenChange );
                    return;
                }

                const isFullScreen = $.isFullScreen();
                if ( !isFullScreen ) {
                    $.removeEvent( document, $.fullScreenEventName, onFullScreenChange );
                    $.removeEvent( document, $.fullScreenErrorEventName, onFullScreenChange );

                    _this.setFullPage( false );
                    if ( _this.isFullPage() ) {
                        _this.element.style.width = _this.fullPageStyleWidth;
                        _this.element.style.height = _this.fullPageStyleHeight;
                    }
                }
                if ( _this.navigator && _this.viewport ) {
                    //09/08/2018 - Fabroh : Fix issue #1504 : Ensure to get the navigator updated on fullscreen out with custom location with a timeout
                    setTimeout(function(){
                        _this.navigator.update( _this.viewport );
                    });
                }
                /**
                 * Raised when the viewer has changed to/from full-screen mode (see {@link OpenSeadragon.Viewer#setFullScreen}).
                 *
                 * @event full-screen
                 * @memberof OpenSeadragon.Viewer
                 * @type {object}
                 * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
                 * @property {Boolean} fullScreen - True if changed to full-screen mode, false if exited full-screen mode.
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent( 'full-screen', { fullScreen: isFullScreen } );
            };
            $.addEvent( document, $.fullScreenEventName, onFullScreenChange );
            $.addEvent( document, $.fullScreenErrorEventName, onFullScreenChange );

            $.requestFullScreen( document.body );

        } else {
            $.exitFullScreen();
        }
        return this;
    },

    /**
     * @function
     * @returns {Boolean}
     */
    isVisible: function () {
        return this.container.style.visibility !== "hidden";
    },


    //
    /**
     * @function
     * @returns {Boolean} returns true if the viewer is in fullscreen
     */
     isFullScreen: function () {
        return $.isFullScreen() && this.isFullPage();
    },

    /**
     * @function
     * @param {Boolean} visible
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:visible
     */
    setVisible: function( visible ){
        this.container.style.visibility = visible ? "" : "hidden";
        /**
         * Raised when the viewer is shown or hidden (see {@link OpenSeadragon.Viewer#setVisible}).
         *
         * @event visible
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Boolean} visible
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'visible', { visible: visible } );
        return this;
    },

    /**
     * @typedef OpenSeadragon.TileSourceSpecifier
     * @property {Object} options
     * @property {OpenSeadragon.TileSource|String|Object|Function} options.tileSource - The TileSource specifier.
     * A String implies a url used to determine the tileSource implementation
     *      based on the file extension of url. JSONP is implied by *.js,
     *      otherwise the url is retrieved as text and the resulting text is
     *      introspected to determine if its json, xml, or text and parsed.
     * An Object implies an inline configuration which has a single
     *      property sufficient for being able to determine tileSource
     *      implementation. If the object has a property which is a function
     *      named 'getTileUrl', it is treated as a custom TileSource.
     * @property {Number} [options.index] The index of the item. Added on top of
     * all other items if not specified.
     * @property {Boolean} [options.replace=false] If true, the item at options.index will be
     * removed and the new item is added in its place. options.tileSource will be
     * interpreted and fetched if necessary before the old item is removed to avoid leaving
     * a gap in the world.
     * @property {Number} [options.x=0] The X position for the image in viewport coordinates.
     * @property {Number} [options.y=0] The Y position for the image in viewport coordinates.
     * @property {Number} [options.width=1] The width for the image in viewport coordinates.
     * @property {Number} [options.height] The height for the image in viewport coordinates.
     * @property {OpenSeadragon.Rect} [options.fitBounds] The bounds in viewport coordinates
     * to fit the image into. If specified, x, y, width and height get ignored.
     * @property {OpenSeadragon.Placement} [options.fitBoundsPlacement=OpenSeadragon.Placement.CENTER]
     * How to anchor the image in the bounds if options.fitBounds is set.
     * @property {OpenSeadragon.Rect} [options.clip] - An area, in image pixels, to clip to
     * (portions of the image outside of this area will not be visible). Only works on
     * browsers that support the HTML5 canvas.
     * @property {Number} [options.opacity=1] Proportional opacity of the tiled images (1=opaque, 0=hidden)
     * @property {Boolean} [options.preload=false] Default switch for loading hidden images (true loads, false blocks)
     * @property {Boolean} [options.zombieCache] In the case that this method removes any TiledImage instance,
     *      allow the item-referenced cache to remain in memory even without active tiles. Default false.
     * @property {Number} [options.degrees=0] Initial rotation of the tiled image around
     * its top left corner in degrees.
     * @property {Boolean} [options.flipped=false] Whether to horizontally flip the image.
     * @property {String} [options.compositeOperation] How the image is composited onto other images.
     * @property {String} [options.crossOriginPolicy] The crossOriginPolicy for this specific image,
     * overriding viewer.crossOriginPolicy.
     * @property {Boolean} [options.ajaxWithCredentials] Whether to set withCredentials on tile AJAX
     * @property {Boolean} [options.loadTilesWithAjax]
     *      Whether to load tile data using AJAX requests.
     *      Defaults to the setting in {@link OpenSeadragon.Options}.
     * @property {Object} [options.ajaxHeaders]
     *      A set of headers to include when making tile AJAX requests.
     *      Note that these headers will be merged over any headers specified in {@link OpenSeadragon.Options}.
     *      Specifying a falsy value for a header will clear its existing value set at the Viewer level (if any).
     * @property {Function} [options.success] A function that gets called when the image is
     * successfully added. It's passed the event object which contains a single property:
     * "item", which is the resulting instance of TiledImage.
     * @property {Function} [options.error] A function that gets called if the image is
     * unable to be added. It's passed the error event object, which contains "message"
     * and "source" properties.
     * @property {Boolean} [options.collectionImmediately=false] If collectionMode is on,
     * specifies whether to snap to the new arrangement immediately or to animate to it.
     * @property {String|CanvasGradient|CanvasPattern|Function} [options.placeholderFillStyle] - See {@link OpenSeadragon.Options}.
     * @param {string|string[]} [options.originalDataType=undefined]
     *      A default format to convert tiles to at the beginning. The format is the base tile format,
     *      and this can optimize rendering or processing logics in case for example a plugin always requires a certain
     *      format to convert to.
     */

    /**
     * Add a tiled image to the viewer.
     * options.tileSource can be anything that {@link OpenSeadragon.Viewer#open}
     *  supports except arrays of images.
     * Note that you can specify options.width or options.height, but not both.
     * The other dimension will be calculated according to the item's aspect ratio.
     * If collectionMode is on (see {@link OpenSeadragon.Options}), the new image is
     * automatically arranged with the others.
     * @function
     * @param {OpenSeadragon.TileSourceSpecifier} options
     * @fires OpenSeadragon.World.event:add-item
     * @fires OpenSeadragon.Viewer.event:add-item-failed
     */
    addTiledImage: function( options ) {
        $.console.assert(options, "[Viewer.addTiledImage] options is required");
        $.console.assert(options.tileSource, "[Viewer.addTiledImage] options.tileSource is required");
        $.console.assert(!options.replace || (options.index > -1 && options.index < this.world.getItemCount()),
            "[Viewer.addTiledImage] if options.replace is used, options.index must be a valid index in Viewer.world");

        this._hideMessage();

        const originalSuccess = options.success;
        const originalError = options.error;
        if (options.replace) {
            options.replaceItem = this.world.getItemAt(options.index);
        }

        const myQueueItem = {
            options: options
        };

        this._loadQueue.push(myQueueItem);

        const refreshWorld = theItem => {
            if (this.collectionMode) {
                this.world.arrange({
                    immediately: theItem.options.collectionImmediately,
                    rows: this.collectionRows,
                    columns: this.collectionColumns,
                    layout: this.collectionLayout,
                    tileSize: this.collectionTileSize,
                    tileMargin: this.collectionTileMargin
                });
                this.world.setAutoRefigureSizes(true);
            }
        };

        const raiseAddItemFailed = ( event ) => {
            for (let i = 0; i < this._loadQueue.length; i++) {
                if (this._loadQueue[i] === myQueueItem) {
                    this._loadQueue.splice(i, 1);
                    break;
                }
            }

            if (this._loadQueue.length === 0) {
                refreshWorld(myQueueItem);
            }

            /**
             * Raised when an error occurs while adding a item.
             * @event add-item-failed
             * @memberOf OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
             * @property {String} message
             * @property {String} source
             * @property {Object} options The options passed to the addTiledImage method.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.raiseEvent( 'add-item-failed', event );

            if (originalError) {
                originalError(event);
            }
        };

        if ($.isArray(options.tileSource)) {
            setTimeout(function() {
                raiseAddItemFailed({
                    message: "[Viewer.addTiledImage] Sequences can not be added; add them one at a time instead.",
                    source: options.tileSource,
                    options: options
                });
            });
            return;
        }

        // ensure nobody provided such entry
        delete myQueueItem.tiledImage;
        options.success = event => {
            myQueueItem.tiledImage = event.item;
            myQueueItem.originalSuccess = originalSuccess;

            let queueItem, optionsClone;
            while (this._loadQueue.length) {
                queueItem = this._loadQueue[0];
                const tiledImage = queueItem.tiledImage;
                if (!tiledImage) {
                    break;
                }

                this._loadQueue.splice(0, 1);
                const tileSource = tiledImage.source;

                if (queueItem.options.replace) {
                    const replaced = queueItem.options.replaceItem;
                    const newIndex = this.world.getIndexOfItem(replaced);
                    if (newIndex !== -1) {
                        queueItem.options.index = newIndex;
                    }
                    if (!replaced._zombieCache && replaced.source.equals(tileSource)) {
                        replaced.allowZombieCache(true);
                    }
                    this.world.removeItem(replaced);
                }

                if (this.collectionMode) {
                    this.world.setAutoRefigureSizes(false);
                }

                if (this.navigator) {
                    optionsClone = $.extend({}, queueItem.options, {
                        replace: false, // navigator already removed the layer, nothing to replace
                        originalTiledImage: tiledImage,
                        tileSource: tileSource
                    });

                    this.navigator.addTiledImage(optionsClone);
                }

                this.world.addItem( tiledImage, {
                    index: queueItem.options.index
                });

                if (this._loadQueue.length === 0) {
                    //this restores the autoRefigureSizes flag to true.
                    refreshWorld(queueItem);
                }

                if (this.world.getItemCount() === 1 && !this.preserveViewport) {
                    this.viewport.goHome(true);
                }

                if (queueItem.originalSuccess) {
                    queueItem.originalSuccess({
                        item: tiledImage
                    });
                }

                // It might happen processReadyItems() is called after viewer.destroy()
                if (this.drawer) {
                    // This is necessary since drawer might react upon finalized tiled image, after
                    // all events have been processed.
                    this.drawer.tiledImageCreated(tiledImage);
                }
            }
        };
        options.error = raiseAddItemFailed;
        this.instantiateTiledImageClass(options);
    },

    /**
     * Create a TiledImage Instance. This instance is not integrated into the viewer
     * and can be used to for example draw custom data in offscreen fashion by instantiating
     * offscreen drawer, creating detached tiled images, forcing them to load certain region
     * and calling drawer.draw([my tiled images]).
     * @param {OpenSeadragon.TileSourceSpecifier} options options to create the image. Some properties
     *   are unused, these properties drive how the image is inserted into the world, and therefore
     *   they are not used in the pure creation of the TiledImage.
     * @return {OpenSeadragon.Promise<OpenSeadragon.TiledImage|object>} A promise that resolves to the created TiledImage.
     *   Also, old options.error and options.success callbacks can be used instead to handle the output.
     */
    instantiateTiledImageClass: function( options) {
        return this.instantiateTileSourceClass(options).then(event => {
            // add everybody at the front of the queue that's ready to go
            const tiledImage = new $.TiledImage({
                viewer: this,
                source: event.source,
                viewport: this.viewport,
                drawer: this.drawer,
                tileCache: this.tileCache,
                imageLoader: this.imageLoader,
                x: options.x,
                y: options.y,
                width: options.width,
                height: options.height,
                fitBounds: options.fitBounds,
                fitBoundsPlacement: options.fitBoundsPlacement,
                clip: options.clip,
                placeholderFillStyle: options.placeholderFillStyle,
                opacity: options.opacity,
                preload: options.preload,
                degrees: options.degrees,
                flipped: options.flipped,
                compositeOperation: options.compositeOperation,
                springStiffness: this.springStiffness,
                animationTime: this.animationTime,
                minZoomImageRatio: this.minZoomImageRatio,
                wrapHorizontal: this.wrapHorizontal,
                wrapVertical: this.wrapVertical,
                maxTilesPerFrame: this.maxTilesPerFrame,
                loadDestinationTilesOnAnimation: this.loadDestinationTilesOnAnimation,
                immediateRender: this.immediateRender,
                blendTime: this.blendTime,
                alwaysBlend: this.alwaysBlend,
                minPixelRatio: this.minPixelRatio,
                smoothTileEdgesMinZoom: this.smoothTileEdgesMinZoom,
                iOSDevice: this.iOSDevice,
                crossOriginPolicy: options.crossOriginPolicy,
                ajaxWithCredentials: options.ajaxWithCredentials,
                loadTilesWithAjax: options.loadTilesWithAjax,
                ajaxHeaders: options.ajaxHeaders,
                debugMode: this.debugMode,
                subPixelRoundingForTransparency: this.subPixelRoundingForTransparency,
                callTileLoadedWithCachedData: this.callTileLoadedWithCachedData,
                originalDataType: options.originalDataType
            });

            options.success({
                item: tiledImage
            });
            return tiledImage;
        }).catch(e => {
            if (options.error) {
                options.error(e);
                return e;
            }
            throw e;
        });
    },

    /**
     * Attempts to initialize a TileSource from various input types and configuration formats.
     * Handles string URLs, raw XML/JSON strings, inline configuration objects, or custom TileSource implementations.
     *
     * @function
     * @param {OpenSeadragon.TileSourceSpecifier} options options to create the image. Some properties
     * @return {OpenSeadragon.Promise<object>} A promise that resolves to info object carrying 'source' and 'message'.
     *   Message is provided only on error, in that case the source is reference to the original source parameter that
     *   was defining the TileSource. On success, the source is a TileSource instance.
     */
    instantiateTileSourceClass( options ) {
        return new $.Promise( ( resolve, reject ) => {
            if (options.placeholderFillStyle === undefined) {
                options.placeholderFillStyle = this.placeholderFillStyle;
            }
            if (options.opacity === undefined) {
                options.opacity = this.opacity;
            }
            if (options.preload === undefined) {
                options.preload = this.preload;
            }
            if (options.compositeOperation === undefined) {
                options.compositeOperation = this.compositeOperation;
            }
            if (options.crossOriginPolicy === undefined) {
                options.crossOriginPolicy = options.tileSource.crossOriginPolicy !== undefined ?
                    options.tileSource.crossOriginPolicy : this.crossOriginPolicy;
            }
            if (options.ajaxWithCredentials === undefined) {
                options.ajaxWithCredentials = this.ajaxWithCredentials;
            }
            if (options.loadTilesWithAjax === undefined) {
                options.loadTilesWithAjax = this.loadTilesWithAjax;
            }
            if (!$.isPlainObject(options.ajaxHeaders)) {
                options.ajaxHeaders = {};
            }

            let tileSource = options.tileSource;

            //allow plain xml strings or json strings to be parsed here
            if ( $.type( tileSource ) === 'string' ) {
                //xml should start with "<" and end with ">"
                if ( tileSource.match( /^\s*<.*>\s*$/ ) ) {
                    tileSource = $.parseXml( tileSource );
                    //json should start with "{" or "[" and end with "}" or "]"
                } else if ( tileSource.match(/^\s*[{[].*[}\]]\s*$/ ) ) {
                    try {
                        tileSource = $.parseJSON(tileSource);
                    } catch (e) {
                        //tileSource = tileSource;
                    }
                }
            }

            function waitUntilReady(tileSource, originalTileSource) {
                if (tileSource.ready) {
                    resolve({
                        source: tileSource
                    });
                } else {
                    tileSource.addHandler('ready', function (event) {
                        resolve({
                            source: event.tileSource
                        });
                    });
                    tileSource.addHandler('open-failed', function (event) {
                        reject({
                            message: event.message,
                            source: originalTileSource
                        });
                    });
                }
            }

            setTimeout(() => {
                if ( $.type( tileSource ) === 'string' ) {
                    //If its still a string it means it must be a url at this point
                    tileSource = new $.TileSource({
                        url: tileSource,
                        crossOriginPolicy: options.crossOriginPolicy !== undefined ?
                            options.crossOriginPolicy : this.crossOriginPolicy,
                        ajaxWithCredentials: this.ajaxWithCredentials,
                        ajaxHeaders: $.extend({}, this.ajaxHeaders, options.ajaxHeaders),
                        splitHashDataForPost: this.splitHashDataForPost,
                    });
                    waitUntilReady(tileSource, tileSource);
                } else if ($.isPlainObject(tileSource) || tileSource.nodeType) {
                    if (tileSource.crossOriginPolicy === undefined &&
                        (options.crossOriginPolicy !== undefined || this.crossOriginPolicy !== undefined)) {
                        tileSource.crossOriginPolicy = options.crossOriginPolicy !== undefined ?
                            options.crossOriginPolicy : this.crossOriginPolicy;
                    }
                    if (tileSource.ajaxWithCredentials === undefined) {
                        tileSource.ajaxWithCredentials = this.ajaxWithCredentials;
                    }

                    if ( $.isFunction( tileSource.getTileUrl ) ) {
                        //Custom tile source
                        const customTileSource = new $.TileSource( tileSource );
                        customTileSource.getTileUrl = tileSource.getTileUrl;
                        tileSource.ready = false;
                        waitUntilReady(customTileSource, tileSource);
                    } else {
                        //inline configuration
                        const $TileSource = $.TileSource.determineType( this, tileSource, null );
                        if ( !$TileSource ) {
                            reject({
                                message: "Unable to load TileSource",
                                source: tileSource,
                                error: true
                            });
                            return;
                        }
                        const tileOptions = $TileSource.prototype.configure.apply( this, [ tileSource ] );
                        tileOptions.ready = false;
                        waitUntilReady(new $TileSource(tileOptions), tileSource);
                    }
                } else {
                    //can assume it's already a tile source implementation, force inheritance
                    waitUntilReady(tileSource, tileSource);
                }
            });
        });
    },

    /**
     * Add a simple image to the viewer.
     * The options are the same as the ones in {@link OpenSeadragon.Viewer#addTiledImage}
     * except for options.tileSource which is replaced by options.url.
     * @function
     * @param {Object} options - See {@link OpenSeadragon.Viewer#addTiledImage}
     * for all the options
     * @param {String} options.url - The URL of the image to add.
     * @fires OpenSeadragon.World.event:add-item
     * @fires OpenSeadragon.Viewer.event:add-item-failed
     */
    addSimpleImage: function(options) {
        $.console.assert(options, "[Viewer.addSimpleImage] options is required");
        $.console.assert(options.url, "[Viewer.addSimpleImage] options.url is required");

        const opts = $.extend({}, options, {
            tileSource: {
                type: 'image',
                url:  options.url
            }
        });
        delete opts.url;
        this.addTiledImage(opts);
    },

    // deprecated
    addLayer: function( options ) {
        const _this = this;

        $.console.error( "[Viewer.addLayer] this function is deprecated; use Viewer.addTiledImage() instead." );

        const optionsClone = $.extend({}, options, {
            success: function(event) {
                _this.raiseEvent("add-layer", {
                    options: options,
                    drawer: event.item
                });
            },
            error: function(event) {
                _this.raiseEvent("add-layer-failed", event);
            }
        });

        this.addTiledImage(optionsClone);
        return this;
    },

    // deprecated
    getLayerAtLevel: function( level ) {
        $.console.error( "[Viewer.getLayerAtLevel] this function is deprecated; use World.getItemAt() instead." );
        return this.world.getItemAt(level);
    },

    // deprecated
    getLevelOfLayer: function( drawer ) {
        $.console.error( "[Viewer.getLevelOfLayer] this function is deprecated; use World.getIndexOfItem() instead." );
        return this.world.getIndexOfItem(drawer);
    },

    // deprecated
    getLayersCount: function() {
        $.console.error( "[Viewer.getLayersCount] this function is deprecated; use World.getItemCount() instead." );
        return this.world.getItemCount();
    },

    // deprecated
    setLayerLevel: function( drawer, level ) {
        $.console.error( "[Viewer.setLayerLevel] this function is deprecated; use World.setItemIndex() instead." );
        return this.world.setItemIndex(drawer, level);
    },

    // deprecated
    removeLayer: function( drawer ) {
        $.console.error( "[Viewer.removeLayer] this function is deprecated; use World.removeItem() instead." );
        return this.world.removeItem(drawer);
    },

    /**
     * Force the viewer to redraw its contents.
     * @returns {OpenSeadragon.Viewer} Chainable.
     */
    forceRedraw: function() {
        THIS[ this.hash ].forceRedraw = true;
        return this;
    },

    /**
     * Force the viewer to reset its size to match its container.
     */
    forceResize: function() {
        THIS[this.hash].needsResize = true;
        THIS[this.hash].forceResize = true;
    },

    /**
     * @function
     * @returns {OpenSeadragon.Viewer} Chainable.
     */
    bindSequenceControls: function(){

        //////////////////////////////////////////////////////////////////////////
        // Image Sequence Controls
        //////////////////////////////////////////////////////////////////////////
        const onFocusHandler          = $.delegate( this, onFocus );
        const onBlurHandler           = $.delegate( this, onBlur );
        const onNextHandler           = $.delegate( this, this.goToNextPage );
        const onPreviousHandler       = $.delegate( this, this.goToPreviousPage );
        const navImages               = this.navImages;
        let useGroup                  = true;

        if( this.showSequenceControl ){

            if( this.previousButton || this.nextButton ){
                //if we are binding to custom buttons then layout and
                //grouping is the responsibility of the page author
                useGroup = false;
            }

            this.previousButton = new $.Button({
                element:    this.previousButton ? $.getElement( this.previousButton ) : null,
                clickTimeThreshold: this.clickTimeThreshold,
                clickDistThreshold: this.clickDistThreshold,
                tooltip:    $.getString( "Tooltips.PreviousPage" ),
                srcRest:    resolveUrl( this.prefixUrl, navImages.previous.REST ),
                srcGroup:   resolveUrl( this.prefixUrl, navImages.previous.GROUP ),
                srcHover:   resolveUrl( this.prefixUrl, navImages.previous.HOVER ),
                srcDown:    resolveUrl( this.prefixUrl, navImages.previous.DOWN ),
                onRelease:  onPreviousHandler,
                onFocus:    onFocusHandler,
                onBlur:     onBlurHandler
            });

            this.nextButton = new $.Button({
                element:    this.nextButton ? $.getElement( this.nextButton ) : null,
                clickTimeThreshold: this.clickTimeThreshold,
                clickDistThreshold: this.clickDistThreshold,
                tooltip:    $.getString( "Tooltips.NextPage" ),
                srcRest:    resolveUrl( this.prefixUrl, navImages.next.REST ),
                srcGroup:   resolveUrl( this.prefixUrl, navImages.next.GROUP ),
                srcHover:   resolveUrl( this.prefixUrl, navImages.next.HOVER ),
                srcDown:    resolveUrl( this.prefixUrl, navImages.next.DOWN ),
                onRelease:  onNextHandler,
                onFocus:    onFocusHandler,
                onBlur:     onBlurHandler
            });

            if( !this.navPrevNextWrap ){
                this.previousButton.disable();
            }

            if (!this.tileSources || !this.tileSources.length) {
                this.nextButton.disable();
            }

            if( useGroup ){
                this.paging = new $.ButtonGroup({
                    buttons: [
                        this.previousButton,
                        this.nextButton
                    ],
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold
                });

                this.pagingControl = this.paging.element;

                if( this.toolbar ){
                    this.toolbar.addControl(
                        this.pagingControl,
                        {anchor: $.ControlAnchor.BOTTOM_RIGHT}
                    );
                }else{
                    this.addControl(
                        this.pagingControl,
                        {anchor: this.sequenceControlAnchor || $.ControlAnchor.TOP_LEFT}
                    );
                }
            }
        }
        return this;
    },


    /**
     * @function
     * @returns {OpenSeadragon.Viewer} Chainable.
     */
    bindStandardControls: function(){
        //////////////////////////////////////////////////////////////////////////
        // Navigation Controls
        //////////////////////////////////////////////////////////////////////////
        const beginZoomingInHandler   = $.delegate( this, this.startZoomInAction );
        const endZoomingHandler       = $.delegate( this, this.endZoomAction );
        const doSingleZoomInHandler   = $.delegate( this, this.singleZoomInAction );
        const beginZoomingOutHandler  = $.delegate( this, this.startZoomOutAction );
        const doSingleZoomOutHandler  = $.delegate( this, this.singleZoomOutAction );
        const onHomeHandler           = $.delegate( this, onHome );
        const onFullScreenHandler     = $.delegate( this, onFullScreen );
        const onRotateLeftHandler     = $.delegate( this, onRotateLeft );
        const onRotateRightHandler    = $.delegate( this, onRotateRight );
        const onFlipHandler           = $.delegate( this, onFlip);
        const onFocusHandler          = $.delegate( this, onFocus );
        const onBlurHandler           = $.delegate( this, onBlur );
        const navImages               = this.navImages;
        const buttons                 = [];
        let useGroup                = true;


        if ( this.showNavigationControl ) {

            if( this.zoomInButton || this.zoomOutButton ||
                this.homeButton || this.fullPageButton ||
                this.rotateLeftButton || this.rotateRightButton ||
                this.flipButton ) {
                //if we are binding to custom buttons then layout and
                //grouping is the responsibility of the page author
                useGroup = false;
            }

            if ( this.showZoomControl ) {
                buttons.push( this.zoomInButton = new $.Button({
                    element:    this.zoomInButton ? $.getElement( this.zoomInButton ) : null,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold,
                    tooltip:    $.getString( "Tooltips.ZoomIn" ),
                    srcRest:    resolveUrl( this.prefixUrl, navImages.zoomIn.REST ),
                    srcGroup:   resolveUrl( this.prefixUrl, navImages.zoomIn.GROUP ),
                    srcHover:   resolveUrl( this.prefixUrl, navImages.zoomIn.HOVER ),
                    srcDown:    resolveUrl( this.prefixUrl, navImages.zoomIn.DOWN ),
                    onPress:    beginZoomingInHandler,
                    onRelease:  endZoomingHandler,
                    onClick:    doSingleZoomInHandler,
                    onEnter:    beginZoomingInHandler,
                    onExit:     endZoomingHandler,
                    onFocus:    onFocusHandler,
                    onBlur:     onBlurHandler
                }));

                buttons.push( this.zoomOutButton = new $.Button({
                    element:    this.zoomOutButton ? $.getElement( this.zoomOutButton ) : null,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold,
                    tooltip:    $.getString( "Tooltips.ZoomOut" ),
                    srcRest:    resolveUrl( this.prefixUrl, navImages.zoomOut.REST ),
                    srcGroup:   resolveUrl( this.prefixUrl, navImages.zoomOut.GROUP ),
                    srcHover:   resolveUrl( this.prefixUrl, navImages.zoomOut.HOVER ),
                    srcDown:    resolveUrl( this.prefixUrl, navImages.zoomOut.DOWN ),
                    onPress:    beginZoomingOutHandler,
                    onRelease:  endZoomingHandler,
                    onClick:    doSingleZoomOutHandler,
                    onEnter:    beginZoomingOutHandler,
                    onExit:     endZoomingHandler,
                    onFocus:    onFocusHandler,
                    onBlur:     onBlurHandler
                }));
            }

            if ( this.showHomeControl ) {
                buttons.push( this.homeButton = new $.Button({
                    element:    this.homeButton ? $.getElement( this.homeButton ) : null,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold,
                    tooltip:    $.getString( "Tooltips.Home" ),
                    srcRest:    resolveUrl( this.prefixUrl, navImages.home.REST ),
                    srcGroup:   resolveUrl( this.prefixUrl, navImages.home.GROUP ),
                    srcHover:   resolveUrl( this.prefixUrl, navImages.home.HOVER ),
                    srcDown:    resolveUrl( this.prefixUrl, navImages.home.DOWN ),
                    onRelease:  onHomeHandler,
                    onFocus:    onFocusHandler,
                    onBlur:     onBlurHandler
                }));
            }

            if ( this.showFullPageControl ) {
                buttons.push( this.fullPageButton = new $.Button({
                    element:    this.fullPageButton ? $.getElement( this.fullPageButton ) : null,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold,
                    tooltip:    $.getString( "Tooltips.FullPage" ),
                    srcRest:    resolveUrl( this.prefixUrl, navImages.fullpage.REST ),
                    srcGroup:   resolveUrl( this.prefixUrl, navImages.fullpage.GROUP ),
                    srcHover:   resolveUrl( this.prefixUrl, navImages.fullpage.HOVER ),
                    srcDown:    resolveUrl( this.prefixUrl, navImages.fullpage.DOWN ),
                    onRelease:  onFullScreenHandler,
                    onFocus:    onFocusHandler,
                    onBlur:     onBlurHandler
                }));
            }

            if ( this.showRotationControl ) {
                buttons.push( this.rotateLeftButton = new $.Button({
                    element:    this.rotateLeftButton ? $.getElement( this.rotateLeftButton ) : null,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold,
                    tooltip:    $.getString( "Tooltips.RotateLeft" ),
                    srcRest:    resolveUrl( this.prefixUrl, navImages.rotateleft.REST ),
                    srcGroup:   resolveUrl( this.prefixUrl, navImages.rotateleft.GROUP ),
                    srcHover:   resolveUrl( this.prefixUrl, navImages.rotateleft.HOVER ),
                    srcDown:    resolveUrl( this.prefixUrl, navImages.rotateleft.DOWN ),
                    onRelease:  onRotateLeftHandler,
                    onFocus:    onFocusHandler,
                    onBlur:     onBlurHandler
                }));

                buttons.push( this.rotateRightButton = new $.Button({
                    element:    this.rotateRightButton ? $.getElement( this.rotateRightButton ) : null,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold,
                    tooltip:    $.getString( "Tooltips.RotateRight" ),
                    srcRest:    resolveUrl( this.prefixUrl, navImages.rotateright.REST ),
                    srcGroup:   resolveUrl( this.prefixUrl, navImages.rotateright.GROUP ),
                    srcHover:   resolveUrl( this.prefixUrl, navImages.rotateright.HOVER ),
                    srcDown:    resolveUrl( this.prefixUrl, navImages.rotateright.DOWN ),
                    onRelease:  onRotateRightHandler,
                    onFocus:    onFocusHandler,
                    onBlur:     onBlurHandler
                }));
            }

            if ( this.showFlipControl ) {
                buttons.push( this.flipButton = new $.Button({
                    element:    this.flipButton ? $.getElement( this.flipButton ) : null,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold,
                    tooltip:    $.getString( "Tooltips.Flip" ),
                    srcRest:    resolveUrl( this.prefixUrl, navImages.flip.REST ),
                    srcGroup:   resolveUrl( this.prefixUrl, navImages.flip.GROUP ),
                    srcHover:   resolveUrl( this.prefixUrl, navImages.flip.HOVER ),
                    srcDown:    resolveUrl( this.prefixUrl, navImages.flip.DOWN ),
                    onRelease:  onFlipHandler,
                    onFocus:    onFocusHandler,
                    onBlur:     onBlurHandler
                }));
            }

            if ( useGroup ) {
                this.buttonGroup = new $.ButtonGroup({
                    buttons:            buttons,
                    clickTimeThreshold: this.clickTimeThreshold,
                    clickDistThreshold: this.clickDistThreshold
                });

                this.navControl  = this.buttonGroup.element;
                this.addHandler( 'open', $.delegate( this, lightUp ) );

                if( this.toolbar ){
                    this.toolbar.addControl(
                        this.navControl,
                        {anchor: this.navigationControlAnchor || $.ControlAnchor.TOP_LEFT}
                    );
                } else {
                    this.addControl(
                        this.navControl,
                        {anchor: this.navigationControlAnchor || $.ControlAnchor.TOP_LEFT}
                    );
                }
            } else {
                this.customButtons = buttons;
            }

        }
        return this;
    },

    /**
     * Gets the active page of a sequence
     * @function
     * @returns {Number}
     */
    currentPage: function() {
        return this._sequenceIndex;
    },

    /**
     * @function
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:page
     */
    goToPage: function( page ){
        if( this.tileSources && page >= 0 && page < this.tileSources.length ){
            this._sequenceIndex = page;

            this._updateSequenceButtons( page );

            this.open( this.tileSources[ page ] );

            if( this.referenceStrip ){
                this.referenceStrip.setFocus( page );
            }

            /**
             * Raised when the page is changed on a viewer configured with multiple image sources (see {@link OpenSeadragon.Viewer#goToPage}).
             *
             * @event page
             * @memberof OpenSeadragon.Viewer
             * @type {Object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
             * @property {Number} page - The page index.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.raiseEvent( 'page', { page: page } );
        }

        return this;
    },

   /**
     * Adds an html element as an overlay to the current viewport.  Useful for
     * highlighting words or areas of interest on an image or other zoomable
     * interface. Unless the viewer has been configured with the preserveOverlays
     * option, overlays added via this method are removed when the viewport
     * is closed (including in sequence mode when changing page).
     * @method
     * @param {Element|String|Object} element - A reference to an element or an id for
     *      the element which will be overlaid. Or an Object specifying the configuration for the overlay.
     *      If using an object, see {@link OpenSeadragon.Overlay} for a list of
     *      all available options.
     * @param {OpenSeadragon.Point|OpenSeadragon.Rect} location - The point or
     *      rectangle which will be overlaid. This is a viewport relative location.
     * @param {OpenSeadragon.Placement} [placement=OpenSeadragon.Placement.TOP_LEFT] - The position of the
     *      viewport which the location coordinates will be treated as relative
     *      to.
     * @param {function} [onDraw] - If supplied the callback is called when the overlay
     *      needs to be drawn. It is the responsibility of the callback to do any drawing/positioning.
     *      It is passed position, size and element.
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:add-overlay
     */
    addOverlay: function( element, location, placement, onDraw ) {
        let options;
        if( $.isPlainObject( element ) ){
            options = element;
        } else {
            options = {
                element: element,
                location: location,
                placement: placement,
                onDraw: onDraw
            };
        }

        element = $.getElement( options.element );

        if ( getOverlayIndex( this.currentOverlays, element ) >= 0 ) {
            // they're trying to add a duplicate overlay
            return this;
        }

        const overlay = getOverlayObject( this, options);
        this.currentOverlays.push(overlay);
        overlay.drawHTML( this.overlaysContainer, this.viewport );

        /**
         * Raised when an overlay is added to the viewer (see {@link OpenSeadragon.Viewer#addOverlay}).
         *
         * @event add-overlay
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Element} element - The overlay element.
         * @property {OpenSeadragon.Point|OpenSeadragon.Rect} location
         * @property {OpenSeadragon.Placement} placement
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'add-overlay', {
            element: element,
            location: options.location,
            placement: options.placement
        });
        return this;
    },

    /**
     * Updates the overlay represented by the reference to the element or
     * element id moving it to the new location, relative to the new placement.
     * @method
     * @param {Element|String} element - A reference to an element or an id for
     *      the element which is overlaid.
     * @param {OpenSeadragon.Point|OpenSeadragon.Rect} location - The point or
     *      rectangle which will be overlaid. This is a viewport relative location.
     * @param {OpenSeadragon.Placement} [placement=OpenSeadragon.Placement.TOP_LEFT] - The position of the
     *      viewport which the location coordinates will be treated as relative
     *      to.
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:update-overlay
     */
    updateOverlay: function( element, location, placement ) {
        element = $.getElement( element );
        const i = getOverlayIndex( this.currentOverlays, element );

        if ( i >= 0 ) {
            this.currentOverlays[ i ].update( location, placement );
            THIS[ this.hash ].forceRedraw = true;
            /**
             * Raised when an overlay's location or placement changes
             * (see {@link OpenSeadragon.Viewer#updateOverlay}).
             *
             * @event update-overlay
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the
             * Viewer which raised the event.
             * @property {Element} element
             * @property {OpenSeadragon.Point|OpenSeadragon.Rect} location
             * @property {OpenSeadragon.Placement} placement
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.raiseEvent( 'update-overlay', {
                element: element,
                location: location,
                placement: placement
            });
        }
        return this;
    },

    /**
     * Removes an overlay identified by the reference element or element id
     * and schedules an update.
     * @method
     * @param {Element|String} element - A reference to the element or an
     *      element id which represent the ovelay content to be removed.
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:remove-overlay
     */
    removeOverlay: function( element ) {
        element = $.getElement( element );
        const i = getOverlayIndex( this.currentOverlays, element );

        if ( i >= 0 ) {
            this.currentOverlays[ i ].destroy();
            this.currentOverlays.splice( i, 1 );
            THIS[ this.hash ].forceRedraw = true;
            /**
             * Raised when an overlay is removed from the viewer
             * (see {@link OpenSeadragon.Viewer#removeOverlay}).
             *
             * @event remove-overlay
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the
             * Viewer which raised the event.
             * @property {Element} element - The overlay element.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.raiseEvent( 'remove-overlay', {
                element: element
            });
        }
        return this;
    },

    /**
     * Removes all currently configured Overlays from this Viewer and schedules
     * an update.
     * @method
     * @returns {OpenSeadragon.Viewer} Chainable.
     * @fires OpenSeadragon.Viewer.event:clear-overlay
     */
    clearOverlays: function() {
        while ( this.currentOverlays.length > 0 ) {
            this.currentOverlays.pop().destroy();
        }
        THIS[ this.hash ].forceRedraw = true;
        /**
         * Raised when all overlays are removed from the viewer (see {@link OpenSeadragon.Drawer#clearOverlays}).
         *
         * @event clear-overlay
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'clear-overlay', {} );
        return this;
    },

     /**
     * Finds an overlay identified by the reference element or element id
     * and returns it as an object, return null if not found.
     * @method
     * @param {Element|String} element - A reference to the element or an
     *      element id which represents the overlay content.
     * @returns {OpenSeadragon.Overlay} the matching overlay or null if none found.
     */
    getOverlayById: function( element ) {
        element = $.getElement( element );
        const i = getOverlayIndex( this.currentOverlays, element );

        if (i >= 0) {
            return this.currentOverlays[i];
        } else {
            return null;
        }
    },

    /**
     * Register drawer for shared updates
     * @param drawer
     * @private
     */
    _registerDrawer: function (drawer) {
        if (!this._drawerList) {
            this._drawerList = [];
        }
        this._drawerList.push(drawer);
    },
    /**
     * Unregister drawer from shared updates
     * @param drawer
     * @private
     */
    _unregisterDrawer: function (drawer) {
        if (!this._drawerList) {
            $.console.warn('Viewer._unregisterDrawer: cannot unregister on viewer that is not meant to share updates.');
            return;
        }
        this._drawerList.splice(this._drawerList.indexOf(drawer), 1);
    },

    /**
     * Updates the sequence buttons.
     * @function OpenSeadragon.Viewer.prototype._updateSequenceButtons
     * @private
     * @param {Number} Sequence Value
     */
    _updateSequenceButtons: function( page ) {

            if ( this.nextButton ) {
                if(!this.tileSources || this.tileSources.length - 1 === page) {
                    //Disable next button
                    if ( !this.navPrevNextWrap ) {
                        this.nextButton.disable();
                    }
                } else {
                    this.nextButton.enable();
                }
            }
            if ( this.previousButton ) {
                if ( page > 0 ) {
                    //Enable previous button
                    this.previousButton.enable();
                } else {
                    if ( !this.navPrevNextWrap ) {
                        this.previousButton.disable();
                    }
                }
            }
      },

    /**
     * Display a message in the viewport
     * @function OpenSeadragon.Viewer.prototype._showMessage
     * @private
     * @param {String} text message
     */
    _showMessage: function ( message ) {
        this._hideMessage();

        const div = $.makeNeutralElement( "div" );
        div.appendChild( document.createTextNode( message ) );

        this.messageDiv = $.makeCenteredNode( div );

        $.addClass(this.messageDiv, "openseadragon-message");

        this.container.appendChild( this.messageDiv );
    },

    /**
     * Hide any currently displayed viewport message
     * @function OpenSeadragon.Viewer.prototype._hideMessage
     * @private
     */
    _hideMessage: function () {
        const div = this.messageDiv;
        if (div) {
            div.parentNode.removeChild(div);
            delete this.messageDiv;
        }
    },

    /**
     * Gets this viewer's gesture settings for the given pointer device type.
     * @method
     * @param {String} type - The pointer device type to get the gesture settings for ("mouse", "touch", "pen", etc.).
     * @returns {OpenSeadragon.GestureSettings}
     */
    gestureSettingsByDeviceType: function ( type ) {
        switch ( type ) {
            case 'mouse':
                return this.gestureSettingsMouse;
            case 'touch':
                return this.gestureSettingsTouch;
            case 'pen':
                return this.gestureSettingsPen;
            default:
                return this.gestureSettingsUnknown;
        }
    },

    // private
    _drawOverlays: function() {
        const length = this.currentOverlays.length;
        for ( let i = 0; i < length; i++ ) {
            this.currentOverlays[ i ].drawHTML( this.overlaysContainer, this.viewport );
        }
    },

    /**
     * Cancel the "in flight" images.
     */
    _cancelPendingImages: function() {
        this._loadQueue = [];
    },

    /**
     * Removes the reference strip and disables displaying it.
     * @function
     */
    removeReferenceStrip: function() {
        this.showReferenceStrip = false;

        if (this.referenceStrip) {
            this.referenceStrip.destroy();
            this.referenceStrip = null;
        }
    },

    /**
     * Enables and displays the reference strip based on the currently set tileSources.
     * Works only when the Viewer has sequenceMode set to true.
     * @function
     */
    addReferenceStrip: function() {
        this.showReferenceStrip = true;

        if (this.sequenceMode) {
            if (this.referenceStrip) {
                return;
            }

            if (this.tileSources.length && this.tileSources.length > 1) {
                this.referenceStrip = new $.ReferenceStrip({
                    id:          this.referenceStripElement,
                    position:    this.referenceStripPosition,
                    sizeRatio:   this.referenceStripSizeRatio,
                    scroll:      this.referenceStripScroll,
                    height:      this.referenceStripHeight,
                    width:       this.referenceStripWidth,
                    tileSources: this.tileSources,
                    prefixUrl:   this.prefixUrl,
                    viewer:      this
                });

                this.referenceStrip.setFocus( this._sequenceIndex );
            }
        } else {
            $.console.warn('Attempting to display a reference strip while "sequenceMode" is off.');
        }
    },

    /**
     * Adds _updatePixelDensityRatio to the window resize event.
     * @private
     */
    _addUpdatePixelDensityRatioEvent: function() {
        this._updatePixelDensityRatioBind = this._updatePixelDensityRatio.bind(this);
        $.addEvent( window, 'resize', this._updatePixelDensityRatioBind );
    },

    /**
     * Removes _updatePixelDensityRatio from the window resize event.
     * @private
     */
    _removeUpdatePixelDensityRatioEvent: function() {
        $.removeEvent( window, 'resize', this._updatePixelDensityRatioBind );
    },

    /**
     * Update pixel density ratio and forces a resize operation.
     * @private
     */
     _updatePixelDensityRatio: function() {
        const previusPixelDensityRatio = $.pixelDensityRatio;
        const currentPixelDensityRatio = $.getCurrentPixelDensityRatio();
        if (previusPixelDensityRatio !== currentPixelDensityRatio) {
            $.pixelDensityRatio = currentPixelDensityRatio;
            this.forceResize();
        }
    },

    /**
     * Sets the image source to the source with index equal to
     * currentIndex - 1. Changes current image in sequence mode.
     * If specified, wraps around (see navPrevNextWrap in
     * {@link OpenSeadragon.Options})
     *
     * @method
     */

    goToPreviousPage: function () {
        let previous = this._sequenceIndex - 1;
        if(this.navPrevNextWrap && previous < 0){
            previous += this.tileSources.length;
        }
        this.goToPage( previous );
    },

    /**
     * Sets the image source to the source with index equal to
     * currentIndex + 1. Changes current image in sequence mode.
     * If specified, wraps around (see navPrevNextWrap in
     * {@link OpenSeadragon.Options})
     *
     * @method
     */
    goToNextPage: function () {
        let next = this._sequenceIndex + 1;
        if(this.navPrevNextWrap && next >= this.tileSources.length){
            next = 0;
        }
        this.goToPage( next );
    },

    isAnimating: function () {
        return THIS[ this.hash ].animating;
    },

    /**
     * Starts continuous zoom-in animation (typically bound to mouse-down on the zoom-in button).
     * @function
     * @memberof OpenSeadragon.Viewer.prototype
     */
    startZoomInAction: function () {
        THIS[ this.hash ].lastZoomTime = $.now();
        THIS[ this.hash ].zoomFactor = this.zoomPerSecond;
        THIS[ this.hash ].zooming = true;
        scheduleZoom( this );
    },

    /**
     * Starts continuous zoom-out animation (typically bound to mouse-down on the zoom-out button).
     * @function
     * @memberof OpenSeadragon.Viewer.prototype
     */
    startZoomOutAction: function () {
        THIS[ this.hash ].lastZoomTime = $.now();
        THIS[ this.hash ].zoomFactor = 1.0 / this.zoomPerSecond;
        THIS[ this.hash ].zooming = true;
        scheduleZoom( this );
    },

    /**
     * Stops any continuous zoom animation (typically bound to mouse-up/leave events on a button).
     * @function
     * @memberof OpenSeadragon.Viewer.prototype
     */
    endZoomAction: function () {
        THIS[ this.hash ].zooming = false;
    },

    /**
     * Performs single-step zoom-in operation (typically bound to click/enter on the zoom-in button).
     * @function
     * @memberof OpenSeadragon.Viewer.prototype
     */
    singleZoomInAction: function () {
        if ( this.viewport ) {
            THIS[ this.hash ].zooming = false;
            this.viewport.zoomBy(
                this.zoomPerClick / 1.0
            );
            this.viewport.applyConstraints();
        }
    },

    /**
     * Performs single-step zoom-out operation (typically bound to click/enter on the zoom-out button).
     * @function
     * @memberof OpenSeadragon.Viewer.prototype
     */
    singleZoomOutAction: function () {
        if ( this.viewport ) {
            THIS[ this.hash ].zooming = false;
            this.viewport.zoomBy(
                1.0 / this.zoomPerClick
            );
            this.viewport.applyConstraints();
        }
    },
});


/**
 * _getSafeElemSize is like getElementSize(), but refuses to return 0 for x or y,
 * which was causing some calling operations to return NaN.
 * @returns {Point}
 * @private
 */
function _getSafeElemSize (oElement) {
    oElement = $.getElement( oElement );

    return new $.Point(
        (oElement.clientWidth === 0 ? 1 : oElement.clientWidth),
        (oElement.clientHeight === 0 ? 1 : oElement.clientHeight)
    );
}

function getOverlayObject( viewer, overlay ) {
    if ( overlay instanceof $.Overlay ) {
        return overlay;
    }

    let element = null;
    if ( overlay.element ) {
        element = $.getElement( overlay.element );
    } else {
        const id = overlay.id ?
            overlay.id :
            "openseadragon-overlay-" + Math.floor( Math.random() * 10000000 );

        element = $.getElement( overlay.id );
        if ( !element ) {
            element         = document.createElement( "a" );
            element.href    = "#/overlay/" + id;
        }
        element.id = id;
        $.addClass( element, overlay.className ?
            overlay.className :
            "openseadragon-overlay"
        );
    }

    let location = overlay.location;
    let width = overlay.width;
    let height = overlay.height;
    if (!location) {
        let x = overlay.x;
        let y = overlay.y;
        if (overlay.px !== undefined) {
            const rect = viewer.viewport.imageToViewportRectangle(new $.Rect(
                overlay.px,
                overlay.py,
                width || 0,
                height || 0));
            x = rect.x;
            y = rect.y;
            width = width !== undefined ? rect.width : undefined;
            height = height !== undefined ? rect.height : undefined;
        }
        location = new $.Point(x, y);
    }

    let placement = overlay.placement;
    if (placement && $.type(placement) === "string") {
        placement = $.Placement[overlay.placement.toUpperCase()];
    }

    return new $.Overlay({
        element: element,
        location: location,
        placement: placement,
        onDraw: overlay.onDraw,
        checkResize: overlay.checkResize,
        width: width,
        height: height,
        rotationMode: overlay.rotationMode
    });
}

/**
 * Determines the index of a specific overlay element within an array of overlays.
 *
 * @private
 * @inner
 * @param {Array<Object>} overlays - The array of overlay objects, each containing an `element` property.
 * @param {Element} element - The DOM element of the overlay to find.
 * @returns {number} The index of the matching overlay in the array, or -1 if not found.
 */
function getOverlayIndex( overlays, element ) {
    for ( let i = overlays.length - 1; i >= 0; i-- ) {
        if ( overlays[ i ].element === element ) {
            return i;
        }
    }

    return -1;
}

///////////////////////////////////////////////////////////////////////////////
// Schedulers provide the general engine for animation
///////////////////////////////////////////////////////////////////////////////
function scheduleUpdate( viewer, updateFunc ){
    return $.requestAnimationFrame( function(){
        updateFunc( viewer );
    } );
}


//provides a sequence in the fade animation
function scheduleControlsFade( viewer ) {
    $.requestAnimationFrame( function(){
        updateControlsFade( viewer );
    });
}


//initiates an animation to hide the controls
function beginControlsAutoHide( viewer ) {
    if ( !viewer.autoHideControls ) {
        return;
    }
    viewer.controlsShouldFade = true;
    viewer.controlsFadeBeginTime =
        $.now() +
        viewer.controlsFadeDelay;

    window.setTimeout( function(){
        scheduleControlsFade( viewer );
    }, viewer.controlsFadeDelay );
}


//determines if fade animation is done or continues the animation
function updateControlsFade( viewer ) {
    if ( viewer.controlsShouldFade ) {
        let currentTime = $.now();
        let deltaTime = currentTime - viewer.controlsFadeBeginTime;
        let opacity = 1.0 - deltaTime / viewer.controlsFadeLength;

        opacity = Math.min( 1.0, opacity );
        opacity = Math.max( 0.0, opacity );

        for ( let i = viewer.controls.length - 1; i >= 0; i--) {
            if (viewer.controls[ i ].autoFade) {
                viewer.controls[ i ].setOpacity( opacity );
            }
        }

        if ( opacity > 0 ) {
            // fade again
            scheduleControlsFade( viewer );
        }
    }
}


//stop the fade animation on the controls and show them
function abortControlsAutoHide( viewer ) {
    viewer.controlsShouldFade = false;
    for ( let i = viewer.controls.length - 1; i >= 0; i-- ) {
        viewer.controls[ i ].setOpacity( 1.0 );
    }
}



///////////////////////////////////////////////////////////////////////////////
// Default view event handlers.
///////////////////////////////////////////////////////////////////////////////
function onFocus(){
    abortControlsAutoHide( this );
}

function onBlur(){
    beginControlsAutoHide( this );

}

function onCanvasContextMenu( event ) {
    const eventArgs = {
        tracker: event.eventSource,
        position: event.position,
        originalEvent: event.originalEvent,
        preventDefault: event.preventDefault
    };

    /**
     * Raised when a contextmenu event occurs in the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-contextmenu
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Boolean} preventDefault - Set to true to prevent the default user-agent's handling of the contextmenu event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-contextmenu', eventArgs );

    event.preventDefault = eventArgs.preventDefault;
}

/**
 * Maps keyboard events to corresponding navigation actions,
 * accounting for Shift modifier state.
 *
 * @private
 * @param {Object} event - Keyboard event object
 * Returns string Navigation action name (e.g. 'panUp') or null if unmapped
 *
 * Handles:
 * - Arrow/WASD keys with Shift for zoom
 * - Arrow/WASD keys without Shift for panning
 * - Equal(=)/Minus(-) keys for zoom
 */
function getActiveActionFromKey(code, shift) {
    switch (code) {
        case 'ArrowUp':
        case 'KeyW':
            return shift ? 'zoomIn' : 'panUp';
        case 'ArrowDown':
        case 'KeyS':
            return shift ? 'zoomOut' : 'panDown';
        case 'ArrowLeft':
        case 'KeyA':
            return 'panLeft';
        case 'ArrowRight':
        case 'KeyD':
            return 'panRight';
        case 'Equal':
            return 'zoomIn';
        case 'Minus':
            return 'zoomOut';
        default:
            return null;
    }
}

/**
 * Handles the keyup event on the viewer's canvas element.
 *
 * @private
 * For the released key, marks both the shifted and non-shifted navigation actions as inactive in the _activeActions object.
 * If either action is released before reaching the minimum frame threshold, sets that action as "virtually held" in _navActionVirtuallyHeld,
 * ensuring smooth completion of the minimum pan or zoom distance regardless of modifier key release order.
 */
function onCanvasKeyUp(event) {

    // Using arrow function to inherit 'this' from parent scope
    const processCombo = (code, shift) => {
        const action = getActiveActionFromKey(code, shift);

        if (action && this._activeActions[action]) {
            this._activeActions[action] = false;
            // If the action was released before the minimum frame threshold,
            // keep it "virtually held" for smoothness
            if (this._navActionFrames[action] < this._minNavActionFrames) {
                this._navActionVirtuallyHeld[action] = true;
            }
        }
    };

    // We don't know if the shift key was held down originally, so we check them both.
    // Clear both possible actions for this key
    const code = event.originalEvent.code;
    processCombo(code, true);
    processCombo(code, false);
}


function onCanvasKeyDown( event ) {

    const canvasKeyDownEventArgs = {
      originalEvent: event.originalEvent,
      preventDefaultAction: !this.keyboardNavEnabled,
      preventVerticalPan: event.preventVerticalPan || !this.panVertical,
      preventHorizontalPan: event.preventHorizontalPan || !this.panHorizontal
    };

    /**
     * Raised when a keyboard key is pressed and the focus is on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-key
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Boolean} preventDefaultAction - Set to true to prevent default keyboard behaviour. Default: false.
     * @property {Boolean} preventVerticalPan - Set to true to prevent keyboard vertical panning. Default: false.
     * @property {Boolean} preventHorizontalPan - Set to true to prevent keyboard horizontal panning. Default: false.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */

    this.raiseEvent('canvas-key', canvasKeyDownEventArgs);

    if ( !canvasKeyDownEventArgs.preventDefaultAction && !event.ctrl && !event.alt && !event.meta ) {

        const code = event.originalEvent.code;
        const shift = event.shift;
        const action = getActiveActionFromKey(code, shift);

        if (action && !this._activeActions[action]) {
            this._activeActions[action] = true; // Mark this action as held down in the viewer's internal tracking object
            this._navActionFrames[action] = 0; // Reset action frames
            event.preventDefault = true; // prevent browser scroll/zoom, etc
            return;
        }

        switch( event.keyCode ){
            case 48://0|)
                this.viewport.goHome();
                this.viewport.applyConstraints();
                event.preventDefault = true;
                break;
            case 82: //r - clockwise rotation/R - counterclockwise rotation
                if(event.shift){
                    if(this.viewport.flipped){
                        this.viewport.setRotation(this.viewport.getRotation() + this.rotationIncrement);
                    } else{
                        this.viewport.setRotation(this.viewport.getRotation() - this.rotationIncrement);
                    }
                }else{
                    if(this.viewport.flipped){
                        this.viewport.setRotation(this.viewport.getRotation() - this.rotationIncrement);
                    } else{
                        this.viewport.setRotation(this.viewport.getRotation() + this.rotationIncrement);
                    }
                }
                this.viewport.applyConstraints();
                event.preventDefault = true;
                break;
            case 70: //f/F
                this.viewport.toggleFlip();
                event.preventDefault = true;
                break;
            case 74: //j - previous image source
                this.goToPreviousPage();
                break;
            case 75: //k - next image source
                this.goToNextPage();
                break;
            default:
                //console.log( 'navigator keycode %s', event.keyCode );
                event.preventDefault = false;
                break;
        }
    } else {
        event.preventDefault = false;
    }
}

function onCanvasKeyPress( event ) {
    const canvasKeyPressEventArgs = {
      originalEvent: event.originalEvent,
    };

    /**
     * Raised when a keyboard key is pressed and the focus is on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-key-press
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */

    this.raiseEvent('canvas-key-press', canvasKeyPressEventArgs);
}

function onCanvasClick( event ) {
    let gestureSettings;

    const haveKeyboardFocus = document.activeElement === this.canvas;

    // If we don't have keyboard focus, request it.
    if ( !haveKeyboardFocus ) {
        this.canvas.focus();
    }
    if(this.viewport.flipped){
        event.position.x = this.viewport.getContainerSize().x - event.position.x;
    }

    const canvasClickEventArgs = {
        tracker: event.eventSource,
        position: event.position,
        quick: event.quick,
        shift: event.shift,
        originalEvent: event.originalEvent,
        originalTarget: event.originalTarget,
        preventDefaultAction: false
    };

    /**
     * Raised when a mouse press/release or touch/remove occurs on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-click
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Boolean} quick - True only if the clickDistThreshold and clickTimeThreshold are both passed. Useful for differentiating between clicks and drags.
     * @property {Boolean} shift - True if the shift key was pressed during this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Element} originalTarget - The DOM element clicked on.
     * @property {Boolean} preventDefaultAction - Set to true to prevent default click to zoom behaviour. Default: false.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */

    this.raiseEvent( 'canvas-click', canvasClickEventArgs);


    if ( !canvasClickEventArgs.preventDefaultAction && this.viewport && event.quick ) {
        gestureSettings = this.gestureSettingsByDeviceType( event.pointerType );

        if (gestureSettings.clickToZoom === true){
            this.viewport.zoomBy(
                event.shift ? 1.0 / this.zoomPerClick : this.zoomPerClick,
                gestureSettings.zoomToRefPoint ? this.viewport.pointFromPixel( event.position, true ) : null
            );
            this.viewport.applyConstraints();
        }

        if( gestureSettings.dblClickDragToZoom){
            if(THIS[ this.hash ].draggingToZoom === true){
                THIS[ this.hash ].lastClickTime = null;
                THIS[ this.hash ].draggingToZoom = false;
            }
            else{
                THIS[ this.hash ].lastClickTime = $.now();
            }
        }

    }
}

function onCanvasDblClick( event ) {
    let gestureSettings;

    const canvasDblClickEventArgs = {
        tracker: event.eventSource,
        position: event.position,
        shift: event.shift,
        originalEvent: event.originalEvent,
        preventDefaultAction: false
    };

    /**
     * Raised when a double mouse press/release or touch/remove occurs on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-double-click
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Boolean} shift - True if the shift key was pressed during this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Boolean} preventDefaultAction - Set to true to prevent default double tap to zoom behaviour. Default: false.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-double-click', canvasDblClickEventArgs);

    if ( !canvasDblClickEventArgs.preventDefaultAction && this.viewport ) {
        gestureSettings = this.gestureSettingsByDeviceType( event.pointerType );
        if ( gestureSettings.dblClickToZoom ) {
            this.viewport.zoomBy(
                event.shift ? 1.0 / this.zoomPerClick : this.zoomPerClick,
                gestureSettings.zoomToRefPoint ? this.viewport.pointFromPixel( event.position, true ) : null
            );
            this.viewport.applyConstraints();
        }
    }
}

function onCanvasDrag( event ) {
    let gestureSettings;

    const canvasDragEventArgs = {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        delta: event.delta,
        speed: event.speed,
        direction: event.direction,
        shift: event.shift,
        originalEvent: event.originalEvent,
        preventDefaultAction: false
    };

    /**
     * Raised when a mouse or touch drag operation occurs on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-drag
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {OpenSeadragon.Point} delta - The x,y components of the difference between start drag and end drag.
     * @property {Number} speed - Current computed speed, in pixels per second.
     * @property {Number} direction - Current computed direction, expressed as an angle counterclockwise relative to the positive X axis (-pi to pi, in radians). Only valid if speed > 0.
     * @property {Boolean} shift - True if the shift key was pressed during this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Boolean} preventDefaultAction - Set to true to prevent default drag to pan behaviour. Default: false.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-drag', canvasDragEventArgs);

    gestureSettings = this.gestureSettingsByDeviceType( event.pointerType );

    if(!canvasDragEventArgs.preventDefaultAction && this.viewport){

        if (gestureSettings.dblClickDragToZoom && THIS[ this.hash ].draggingToZoom){
            const factor = Math.pow( this.zoomPerDblClickDrag, event.delta.y / 50);
            this.viewport.zoomBy(factor);
        }
        else if (gestureSettings.dragToPan && !THIS[ this.hash ].draggingToZoom) {
            if( !this.panHorizontal ){
                event.delta.x = 0;
            }
            if( !this.panVertical ){
                event.delta.y = 0;
            }
            if(this.viewport.flipped){
                event.delta.x = -event.delta.x;
            }

            if( this.constrainDuringPan ){
                const delta = this.viewport.deltaPointsFromPixels( event.delta.negate() );

                this.viewport.centerSpringX.target.value += delta.x;
                this.viewport.centerSpringY.target.value += delta.y;

                const constrainedBounds = this.viewport.getConstrainedBounds();

                this.viewport.centerSpringX.target.value -= delta.x;
                this.viewport.centerSpringY.target.value -= delta.y;

                if (constrainedBounds.xConstrained) {
                    event.delta.x = 0;
                }

                if (constrainedBounds.yConstrained) {
                    event.delta.y = 0;
                }
            }
            this.viewport.panBy( this.viewport.deltaPointsFromPixels( event.delta.negate() ), gestureSettings.flickEnabled && !this.constrainDuringPan);
        }

    }

}

function onCanvasDragEnd( event ) {
    let gestureSettings;
    const canvasDragEndEventArgs = {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        speed: event.speed,
        direction: event.direction,
        shift: event.shift,
        originalEvent: event.originalEvent,
        preventDefaultAction: false
    };

    /**
     * Raised when a mouse or touch drag operation ends on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-drag-end
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Number} speed - Speed at the end of a drag gesture, in pixels per second.
     * @property {Number} direction - Direction at the end of a drag gesture, expressed as an angle counterclockwise relative to the positive X axis (-pi to pi, in radians). Only valid if speed > 0.
     * @property {Boolean} shift - True if the shift key was pressed during this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Boolean} preventDefaultAction - Set to true to prevent default drag-end flick behaviour. Default: false.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
     this.raiseEvent('canvas-drag-end', canvasDragEndEventArgs);

    gestureSettings = this.gestureSettingsByDeviceType( event.pointerType );

    if (!canvasDragEndEventArgs.preventDefaultAction && this.viewport) {
        if ( !THIS[ this.hash ].draggingToZoom &&
            gestureSettings.dragToPan &&
            gestureSettings.flickEnabled &&
            event.speed >= gestureSettings.flickMinSpeed) {
            let amplitudeX = 0;
            if (this.panHorizontal) {
                amplitudeX = gestureSettings.flickMomentum * event.speed *
                    Math.cos(event.direction);
            }
            let amplitudeY = 0;
            if (this.panVertical) {
                amplitudeY = gestureSettings.flickMomentum * event.speed *
                    Math.sin(event.direction);
            }
            const center = this.viewport.pixelFromPoint(
                this.viewport.getCenter(true));
            const target = this.viewport.pointFromPixel(
                new $.Point(center.x - amplitudeX, center.y - amplitudeY));
            this.viewport.panTo(target, false);
        }
        this.viewport.applyConstraints();
    }


    if( gestureSettings.dblClickDragToZoom && THIS[ this.hash ].draggingToZoom === true ){
        THIS[ this.hash ].draggingToZoom = false;
    }


}

function onCanvasEnter( event ) {
    /**
     * Raised when a pointer enters the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-enter
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Number} buttons - Current buttons pressed. A combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
     * @property {Number} pointers - Number of pointers (all types) active in the tracked element.
     * @property {Boolean} insideElementPressed - True if the left mouse button is currently being pressed and was initiated inside the tracked element, otherwise false.
     * @property {Boolean} buttonDownAny - Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-enter', {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        buttons: event.buttons,
        pointers: event.pointers,
        insideElementPressed: event.insideElementPressed,
        buttonDownAny: event.buttonDownAny,
        originalEvent: event.originalEvent
    });
}

function onCanvasLeave( event ) {
    /**
     * Raised when a pointer leaves the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-exit
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Number} buttons - Current buttons pressed. A combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
     * @property {Number} pointers - Number of pointers (all types) active in the tracked element.
     * @property {Boolean} insideElementPressed - True if the left mouse button is currently being pressed and was initiated inside the tracked element, otherwise false.
     * @property {Boolean} buttonDownAny - Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-exit', {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        buttons: event.buttons,
        pointers: event.pointers,
        insideElementPressed: event.insideElementPressed,
        buttonDownAny: event.buttonDownAny,
        originalEvent: event.originalEvent
    });
}

function onCanvasPress( event ) {

    /**
     * Raised when the primary mouse button is pressed or touch starts on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-press
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Boolean} insideElementPressed - True if the left mouse button is currently being pressed and was initiated inside the tracked element, otherwise false.
     * @property {Boolean} insideElementReleased - True if the cursor still inside the tracked element when the button was released.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-press', {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        insideElementPressed: event.insideElementPressed,
        insideElementReleased: event.insideElementReleased,
        originalEvent: event.originalEvent
    });


    const gestureSettings = this.gestureSettingsByDeviceType( event.pointerType );
    if ( gestureSettings.dblClickDragToZoom ){
         const lastClickTime = THIS[ this.hash ].lastClickTime;
         const currClickTime = $.now();

        if ( lastClickTime === null) {
            return;
        }

        if ((currClickTime - lastClickTime) < this.dblClickTimeThreshold) {
            THIS[ this.hash ].draggingToZoom = true;
        }

        THIS[ this.hash ].lastClickTime = null;
    }

}

function onCanvasRelease( event ) {
    /**
     * Raised when the primary mouse button is released or touch ends on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-release
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Boolean} insideElementPressed - True if the left mouse button is currently being pressed and was initiated inside the tracked element, otherwise false.
     * @property {Boolean} insideElementReleased - True if the cursor still inside the tracked element when the button was released.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-release', {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        insideElementPressed: event.insideElementPressed,
        insideElementReleased: event.insideElementReleased,
        originalEvent: event.originalEvent
    });
}

function onCanvasNonPrimaryPress( event ) {
    /**
     * Raised when any non-primary pointer button is pressed on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-nonprimary-press
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {Number} button - Button which caused the event.
     *      -1: none, 0: primary/left, 1: aux/middle, 2: secondary/right, 3: X1/back, 4: X2/forward, 5: pen eraser.
     * @property {Number} buttons - Current buttons pressed.
     *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-nonprimary-press', {
        tracker: event.eventSource,
        position: event.position,
        pointerType: event.pointerType,
        button: event.button,
        buttons: event.buttons,
        originalEvent: event.originalEvent
    });
}

function onCanvasNonPrimaryRelease( event ) {
    /**
     * Raised when any non-primary pointer button is released on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-nonprimary-release
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {Number} button - Button which caused the event.
     *      -1: none, 0: primary/left, 1: aux/middle, 2: secondary/right, 3: X1/back, 4: X2/forward, 5: pen eraser.
     * @property {Number} buttons - Current buttons pressed.
     *      Combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-nonprimary-release', {
        tracker: event.eventSource,
        position: event.position,
        pointerType: event.pointerType,
        button: event.button,
        buttons: event.buttons,
        originalEvent: event.originalEvent
    });
}

function onCanvasPinch( event ) {
    let centerPt;
    let lastCenterPt;
    let panByPt;

    const canvasPinchEventArgs = {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        gesturePoints: event.gesturePoints,
        lastCenter: event.lastCenter,
        center: event.center,
        lastDistance: event.lastDistance,
        distance: event.distance,
        shift: event.shift,
        originalEvent: event.originalEvent,
        preventDefaultPanAction: false,
        preventDefaultZoomAction: false,
        preventDefaultRotateAction: false
    };

    /**
     * Raised when a pinch event occurs on the {@link OpenSeadragon.Viewer#canvas} element.
     *
     * @event canvas-pinch
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {Array.<OpenSeadragon.MouseTracker.GesturePoint>} gesturePoints - Gesture points associated with the gesture. Velocity data can be found here.
     * @property {OpenSeadragon.Point} lastCenter - The previous center point of the two pinch contact points relative to the tracked element.
     * @property {OpenSeadragon.Point} center - The center point of the two pinch contact points relative to the tracked element.
     * @property {Number} lastDistance - The previous distance between the two pinch contact points in CSS pixels.
     * @property {Number} distance - The distance between the two pinch contact points in CSS pixels.
     * @property {Boolean} shift - True if the shift key was pressed during this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Boolean} preventDefaultPanAction - Set to true to prevent default pinch to pan behaviour. Default: false.
     * @property {Boolean} preventDefaultZoomAction - Set to true to prevent default pinch to zoom behaviour. Default: false.
     * @property {Boolean} preventDefaultRotateAction - Set to true to prevent default pinch to rotate behaviour. Default: false.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
     this.raiseEvent('canvas-pinch', canvasPinchEventArgs);

    if ( this.viewport ) {
        let gestureSettings = this.gestureSettingsByDeviceType( event.pointerType );
        if ( gestureSettings.pinchToZoom &&
                    (!canvasPinchEventArgs.preventDefaultPanAction || !canvasPinchEventArgs.preventDefaultZoomAction) ) {
            centerPt = this.viewport.pointFromPixel( event.center, true );
            if ( gestureSettings.zoomToRefPoint && !canvasPinchEventArgs.preventDefaultPanAction ) {
                lastCenterPt = this.viewport.pointFromPixel( event.lastCenter, true );
                panByPt = lastCenterPt.minus( centerPt );
                if( !this.panHorizontal ) {
                    panByPt.x = 0;
                }
                if( !this.panVertical ) {
                    panByPt.y = 0;
                }
                this.viewport.panBy(panByPt, true);
            }
            if ( !canvasPinchEventArgs.preventDefaultZoomAction ) {
                this.viewport.zoomBy( event.distance / event.lastDistance, centerPt, true );
            }
            this.viewport.applyConstraints();
        }
        if ( gestureSettings.pinchRotate && !canvasPinchEventArgs.preventDefaultRotateAction ) {
            // Pinch rotate
            const angle1 = Math.atan2(event.gesturePoints[0].currentPos.y - event.gesturePoints[1].currentPos.y,
                event.gesturePoints[0].currentPos.x - event.gesturePoints[1].currentPos.x);
            const angle2 = Math.atan2(event.gesturePoints[0].lastPos.y - event.gesturePoints[1].lastPos.y,
                event.gesturePoints[0].lastPos.x - event.gesturePoints[1].lastPos.x);
            centerPt = this.viewport.pointFromPixel( event.center, true );
            this.viewport.rotateTo(this.viewport.getRotation(true) + ((angle1 - angle2) * (180 / Math.PI)), centerPt, true);
        }
    }
}

function onCanvasFocus( event ) {

    /**
     * Raised when the {@link OpenSeadragon.Viewer#canvas} element gets keyboard focus.
     *
     * @event canvas-focus
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-focus', {
        tracker: event.eventSource,
        originalEvent: event.originalEvent
    });
}

function onCanvasBlur( event ) {

    // When canvas loses focus, clear all navigation key states.
    for (const action in this._activeActions) {
        this._activeActions[action] = false;
    }
    for (const action in this._navActionVirtuallyHeld) {
        this._navActionVirtuallyHeld[action] = false;
    }

    /**
     * Raised when the {@link OpenSeadragon.Viewer#canvas} element loses keyboard focus.
     *
     * @event canvas-blur
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'canvas-blur', {
        tracker: event.eventSource,
        originalEvent: event.originalEvent
    });
}

function onCanvasScroll( event ) {
    let canvasScrollEventArgs;
    let gestureSettings;
    let factor;

    /* Certain scroll devices fire the scroll event way too fast so we are injecting a simple adjustment to keep things
     * partially normalized. If we have already fired an event within the last 'minScrollDelta' milliseconds we skip
     * this one and wait for the next event. */
    const thisScrollTime = $.now();
    const deltaScrollTime = thisScrollTime - this._lastScrollTime;
    if (deltaScrollTime > this.minScrollDeltaTime) {
        this._lastScrollTime = thisScrollTime;

        canvasScrollEventArgs = {
            tracker: event.eventSource,
            position: event.position,
            scroll: event.scroll,
            shift: event.shift,
            originalEvent: event.originalEvent,
            preventDefaultAction: false,
            preventDefault: true
        };

        /**
         * Raised when a scroll event occurs on the {@link OpenSeadragon.Viewer#canvas} element (mouse wheel).
         *
         * @event canvas-scroll
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
         * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
         * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
         * @property {Number} scroll - The scroll delta for the event.
         * @property {Boolean} shift - True if the shift key was pressed during this event.
         * @property {Object} originalEvent - The original DOM event.
         * @property {Boolean} preventDefaultAction - Set to true to prevent default scroll to zoom behaviour. Default: false.
         * @property {Boolean} preventDefault - Set to true to prevent the default user-agent's handling of the wheel event. Default: true.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
         this.raiseEvent('canvas-scroll', canvasScrollEventArgs );

        if ( !canvasScrollEventArgs.preventDefaultAction && this.viewport ) {
            if(this.viewport.flipped){
                event.position.x = this.viewport.getContainerSize().x - event.position.x;
            }

            gestureSettings = this.gestureSettingsByDeviceType( event.pointerType );
            if ( gestureSettings.scrollToZoom ) {
                factor = Math.pow( this.zoomPerScroll, event.scroll );
                this.viewport.zoomBy(
                    factor,
                    gestureSettings.zoomToRefPoint ? this.viewport.pointFromPixel( event.position, true ) : null
                );
                this.viewport.applyConstraints();
            }
        }

        event.preventDefault = canvasScrollEventArgs.preventDefault;
    } else {
        event.preventDefault = true;
    }
}

function onContainerEnter( event ) {
    THIS[ this.hash ].mouseInside = true;
    abortControlsAutoHide( this );
    /**
     * Raised when the cursor enters the {@link OpenSeadragon.Viewer#container} element.
     *
     * @event container-enter
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Number} buttons - Current buttons pressed. A combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
     * @property {Number} pointers - Number of pointers (all types) active in the tracked element.
     * @property {Boolean} insideElementPressed - True if the left mouse button is currently being pressed and was initiated inside the tracked element, otherwise false.
     * @property {Boolean} buttonDownAny - Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'container-enter', {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        buttons: event.buttons,
        pointers: event.pointers,
        insideElementPressed: event.insideElementPressed,
        buttonDownAny: event.buttonDownAny,
        originalEvent: event.originalEvent
    });
}

function onContainerLeave( event ) {
    if ( event.pointers < 1 ) {
        THIS[ this.hash ].mouseInside = false;
        if ( !THIS[ this.hash ].animating ) {
            beginControlsAutoHide( this );
        }
    }
    /**
     * Raised when the cursor leaves the {@link OpenSeadragon.Viewer#container} element.
     *
     * @event container-exit
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {String} pointerType - "mouse", "touch", "pen", etc.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Number} buttons - Current buttons pressed. A combination of bit flags 0: none, 1: primary (or touch contact), 2: secondary, 4: aux (often middle), 8: X1 (often back), 16: X2 (often forward), 32: pen eraser.
     * @property {Number} pointers - Number of pointers (all types) active in the tracked element.
     * @property {Boolean} insideElementPressed - True if the left mouse button is currently being pressed and was initiated inside the tracked element, otherwise false.
     * @property {Boolean} buttonDownAny - Was the button down anywhere in the screen during the event. <span style="color:red;">Deprecated. Use buttons instead.</span>
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.raiseEvent( 'container-exit', {
        tracker: event.eventSource,
        pointerType: event.pointerType,
        position: event.position,
        buttons: event.buttons,
        pointers: event.pointers,
        insideElementPressed: event.insideElementPressed,
        buttonDownAny: event.buttonDownAny,
        originalEvent: event.originalEvent
    });
}


///////////////////////////////////////////////////////////////////////////////
// Page update routines ( aka Views - for future reference )
///////////////////////////////////////////////////////////////////////////////

function updateMulti( viewer ) {
    updateOnce( viewer );

    // Request the next frame, unless we've been closed
    if ( viewer.isOpen() ) {
        viewer._updateRequestId = scheduleUpdate( viewer, updateMulti );
    } else {
        viewer._updateRequestId = false;
    }
}

function doViewerResize(viewer, containerSize){
    const viewport = viewer.viewport;
    const zoom = viewport.getZoom();
    const center = viewport.getCenter();
    viewport.resize(containerSize, viewer.preserveImageSizeOnResize);
    viewport.panTo(center, true);
    let resizeRatio;
    if (viewer.preserveImageSizeOnResize) {
        resizeRatio = THIS[viewer.hash].prevContainerSize.x / containerSize.x;
    } else {
        const origin = new $.Point(0, 0);
        const prevDiag = new $.Point(THIS[viewer.hash].prevContainerSize.x, THIS[viewer.hash].prevContainerSize.y).distanceTo(origin);
        const newDiag = new $.Point(containerSize.x, containerSize.y).distanceTo(origin);
        resizeRatio = newDiag / prevDiag * THIS[viewer.hash].prevContainerSize.x / containerSize.x;
    }
    viewport.zoomTo(zoom * resizeRatio, null, true);
    THIS[viewer.hash].prevContainerSize = containerSize;
    THIS[viewer.hash].forceRedraw = true;
    THIS[viewer.hash].needsResize = false;
    THIS[viewer.hash].forceResize = false;
}

function handleNavKeys(viewer) {
    // Iterate over all navigation actions.
    for (const action in viewer._activeActions) {
        if (viewer._activeActions[action] || viewer._navActionVirtuallyHeld[action]) {
            viewer._navActionFrames[action]++;
            if (viewer._navActionFrames[action] >= viewer._minNavActionFrames) {
                viewer._navActionVirtuallyHeld[action] = false;
            }
        }
    }

    // Helper for action state
    function isDown(action) {
        return viewer._activeActions[action] || viewer._navActionVirtuallyHeld[action];
    }

    // Use the viewer's configured pan amount
    const pixels = viewer.pixelsPerArrowPress / 10;
    const panDelta = viewer.viewport.deltaPointsFromPixels(new OpenSeadragon.Point(pixels, pixels));

    // 1. Zoom actions (priority: zoom disables pan)
    if (isDown('zoomIn')) {
        viewer.viewport.zoomBy(1.01, null, true);
        viewer.viewport.applyConstraints();
        return;
    }
    if (isDown('zoomOut')) {
        viewer.viewport.zoomBy(0.99, null, true);
        viewer.viewport.applyConstraints();
        return;
    }

    // 2. Pan actions
    let dx = 0;
    let dy = 0;

    if (!viewer.preventVerticalPan) {
        if (isDown('panUp')) {
            dy -= panDelta.y;
        }
        if (isDown('panDown')) {
            dy += panDelta.y;
        }
    }

    if (!viewer.preventHorizontalPan) {
        if (isDown('panLeft')) {
            dx -= panDelta.x;
        }
        if (isDown('panRight')) {
            dx += panDelta.x;
        }
    }

    if (dx !== 0 || dy !== 0) {
        viewer.viewport.panBy(new OpenSeadragon.Point(dx, dy), true);
        viewer.viewport.applyConstraints();
    }
}

function updateOnce( viewer ) {

    handleNavKeys(viewer);

    //viewer.profiler.beginUpdate();

    if (viewer._opening || !THIS[viewer.hash]) {
        return;
    }

    let viewerWasResized = false;
    if (viewer.autoResize || THIS[viewer.hash].forceResize){
        let containerSize;
        if(viewer._autoResizePolling){
            containerSize = _getSafeElemSize(viewer.container);
            const prevContainerSize = THIS[viewer.hash].prevContainerSize;
            if (!containerSize.equals(prevContainerSize)) {
                THIS[viewer.hash].needsResize = true;
            }
        }
        if(THIS[viewer.hash].needsResize){
            doViewerResize(viewer, containerSize || _getSafeElemSize(viewer.container));
            viewerWasResized = true;
        }

    }



    const viewportChange = viewer.viewport.update() || viewerWasResized;
    let animated = viewer.world.update(viewportChange) || viewportChange;

    if (viewportChange) {
        /**
         * Raised when any spring animation update occurs (zoom, pan, etc.),
         * before the viewer has drawn the new location.
         *
         * @event viewport-change
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        viewer.raiseEvent('viewport-change');
    }

    if( viewer.referenceStrip ){
        animated = viewer.referenceStrip.update( viewer.viewport ) || animated;
    }

    const currentAnimating = THIS[ viewer.hash ].animating;

    if ( !currentAnimating && animated ) {
        /**
         * Raised when any spring animation starts (zoom, pan, etc.).
         *
         * @event animation-start
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        viewer.raiseEvent( "animation-start" );
        abortControlsAutoHide( viewer );
    }

    const isAnimationFinished = currentAnimating && !animated;

    if ( isAnimationFinished ) {
        THIS[ viewer.hash ].animating = false;
    }

    if ( animated || isAnimationFinished || THIS[ viewer.hash ].forceRedraw || viewer.world.needsDraw() ) {
        drawWorld( viewer );
        viewer._drawOverlays();
        if( viewer.navigator ){
          viewer.navigator.update( viewer.viewport );
        }

        THIS[ viewer.hash ].forceRedraw = false;

        if (animated) {
            /**
             * Raised when any spring animation update occurs (zoom, pan, etc.),
             * after the viewer has drawn the new location.
             *
             * @event animation
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            viewer.raiseEvent( "animation" );
        }
    }

    if ( isAnimationFinished ) {
        /**
         * Raised when any spring animation ends (zoom, pan, etc.).
         *
         * @event animation-finish
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        viewer.raiseEvent( "animation-finish" );

        if ( !THIS[ viewer.hash ].mouseInside ) {
            beginControlsAutoHide( viewer );
        }
    }

    THIS[ viewer.hash ].animating = animated;

    //viewer.profiler.endUpdate();
}

function drawWorld( viewer ) {
    viewer.imageLoader.clear();
    viewer.world.draw();

    /**
     * <em>- Needs documentation -</em>
     *
     * @event update-viewport
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    viewer.raiseEvent( 'update-viewport', {} );
}

///////////////////////////////////////////////////////////////////////////////
// Navigation Controls
///////////////////////////////////////////////////////////////////////////////
function resolveUrl( prefix, url ) {
    return prefix ? prefix + url : url;
}


function scheduleZoom( viewer ) {
    $.requestAnimationFrame( $.delegate( viewer, doZoom ) );
}


function doZoom() {
    if ( THIS[ this.hash ].zooming && this.viewport) {
        const currentTime     = $.now();
        const deltaTime       = currentTime - THIS[ this.hash ].lastZoomTime;
        const adjustedFactor  = Math.pow( THIS[ this.hash ].zoomFactor, deltaTime / 1000 );

        this.viewport.zoomBy( adjustedFactor );
        this.viewport.applyConstraints();
        THIS[ this.hash ].lastZoomTime = currentTime;
        scheduleZoom( this );
    }
}


function lightUp() {
    if (this.buttonGroup) {
        this.buttonGroup.emulateEnter();
        this.buttonGroup.emulateLeave();
    }
}


function onHome() {
    if ( this.viewport ) {
        this.viewport.goHome();
    }
}


function onFullScreen() {
    if ( this.isFullPage() && !$.isFullScreen() ) {
        // Is fullPage but not fullScreen
        this.setFullPage( false );
    } else {
        this.setFullScreen( !this.isFullPage() );
    }
    // correct for no mouseout event on change
    if ( this.buttonGroup ) {
        this.buttonGroup.emulateLeave();
    }
    this.fullPageButton.element.focus();
    if ( this.viewport ) {
        this.viewport.applyConstraints();
    }
}

function onRotateLeft() {
    if ( this.viewport ) {
        let currRotation = this.viewport.getRotation();

        if ( this.viewport.flipped ){
          currRotation += this.rotationIncrement;
        } else {
          currRotation -= this.rotationIncrement;
        }
        this.viewport.setRotation(currRotation);
    }
}

function onRotateRight() {
    if ( this.viewport ) {
        let currRotation = this.viewport.getRotation();

        if ( this.viewport.flipped ){
          currRotation -= this.rotationIncrement;
        } else {
          currRotation += this.rotationIncrement;
        }
        this.viewport.setRotation(currRotation);
    }
}
/**
 * Note: When pressed flip control button
 */
function onFlip() {
   this.viewport.toggleFlip();
}

/**
 * Return the drawer type string for a candidate (string or DrawerBase constructor).
 * Used to normalize drawerCandidates to strings so includes('canvas') is reliable.
 * @private
 * @param {string|Function} candidate - Drawer type string or constructor
 * @returns {string|undefined} Type string, or undefined if not resolvable
 */
function getDrawerTypeString(candidate) {
    if (typeof candidate === 'string') {
        return candidate;
    }
    const proto = candidate && candidate.prototype;
    if (proto && proto instanceof OpenSeadragon.DrawerBase && $.isFunction(proto.getType)) {
        return proto.getType.call(candidate);
    }
    return undefined;
}

/**
 * Return the list of drawer type strings that 'auto' expands to (platform-dependent).
 * Uses the same detection as determineDrawer('auto'): on iOS-like devices, ['canvas'] only;
 * on all other platforms, ['webgl', 'canvas'] so webgl is tried first and canvas next if WebGL fails.
 * @private
 * @returns {string[]}
 */
function getAutoDrawerCandidates() {
    // Our WebGL drawer is not as performant on iOS at the moment, so we use canvas there.
    // Note that modern iPads report themselves as Mac, so we also check for coarse pointer.
    const isPrimaryTouch = window.matchMedia('(pointer: coarse)').matches;
    const isIOSDevice = /iPad|iPhone|iPod|Mac/.test(navigator.userAgent) && isPrimaryTouch;
    return isIOSDevice ? ['canvas'] : ['webgl', 'canvas'];
}

/**
 * Find drawer
 */
$.determineDrawer = function( id ){
    if (id === 'auto') {
        // Same platform detection as getAutoDrawerCandidates(); first entry is the preferred drawer type.
        id = getAutoDrawerCandidates()[0];
    }

    for (const property in OpenSeadragon) {
        const drawer = OpenSeadragon[ property ];
        const proto = drawer.prototype;
        if( proto &&
            proto instanceof OpenSeadragon.DrawerBase &&
            $.isFunction( proto.getType ) &&
            proto.getType.call( drawer ) === id
        ){
            return drawer;
        }
    }
    return null;
};

}( OpenSeadragon ));

/*
 * OpenSeadragon - Navigator
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class Navigator
 * @classdesc The Navigator provides a small view of the current image as fixed
 * while representing the viewport as a moving box serving as a frame
 * of reference in the larger viewport as to which portion of the image
 * is currently being examined.  The navigator's viewport can be interacted
 * with using the keyboard or the mouse.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.Viewer
 * @extends OpenSeadragon.EventSource
 * @param {Object} options - Navigator options
 * @param {Element} [options.element] - An element to use for the navigator.
 * @param {String} [options.id] - Id of the element to use for the navigator. However, this is ignored if {@link options.element} is provided.
 */
$.Navigator = function( options ){

    const viewer = options.viewer;
    const _this = this;
    let viewerSize;
    let navigatorSize;

    //We may need to create a new element and id if they did not
    //provide the id for the existing element or the element itself
    if( options.element || options.id ){
        if ( options.element ) {
            if ( options.id ){
                $.console.warn("Given option.id for Navigator was ignored since option.element was provided and is being used instead.");
            }

            // Don't overwrite the element's id if it has one already
            if ( options.element.id ) {
                options.id = options.element.id;
            } else {
                options.id = 'navigator-' + $.now();
            }

            this.element = options.element;
        } else {
            this.element = document.getElementById( options.id );
        }

        options.controlOptions  = {
            anchor:           $.ControlAnchor.NONE,
            attachToViewer:   false,
            autoFade:         false
        };
    } else {
        options.id              = 'navigator-' + $.now();
        this.element            = $.makeNeutralElement( "div" );
        options.controlOptions  = {
            anchor:           $.ControlAnchor.TOP_RIGHT,
            attachToViewer:   true,
            autoFade:         options.autoFade
        };

        if( options.position ){
            if( 'BOTTOM_RIGHT' === options.position ){
               options.controlOptions.anchor = $.ControlAnchor.BOTTOM_RIGHT;
            } else if( 'BOTTOM_LEFT' === options.position ){
               options.controlOptions.anchor = $.ControlAnchor.BOTTOM_LEFT;
            } else if( 'TOP_RIGHT' === options.position ){
               options.controlOptions.anchor = $.ControlAnchor.TOP_RIGHT;
            } else if( 'TOP_LEFT' === options.position ){
               options.controlOptions.anchor = $.ControlAnchor.TOP_LEFT;
            } else if( 'ABSOLUTE' === options.position ){
               options.controlOptions.anchor = $.ControlAnchor.ABSOLUTE;
               options.controlOptions.top = options.top;
               options.controlOptions.left = options.left;
               options.controlOptions.height = options.height;
               options.controlOptions.width = options.width;
            }
        }
    }
    this.element.id         = options.id;
    this.element.className  += ' navigator';

    options = $.extend( true, {
        sizeRatio:     $.DEFAULT_SETTINGS.navigatorSizeRatio
    }, options, {
        element:                this.element,
        tabIndex:               -1, // No keyboard navigation, omit from tab order
        //These need to be overridden to prevent recursion since
        //the navigator is a viewer and a viewer has a navigator
        showNavigator:          false,
        mouseNavEnabled:        false,
        showNavigationControl:  false,
        showSequenceControl:    false,
        immediateRender:        true,
        blendTime:              0,
        animationTime:          options.animationTime,
        // disable autoResize since resize behavior is implemented differently by the navigator
        autoResize:             false,
        // prevent resizing the navigator from adding unwanted space around the image
        minZoomImageRatio:      1.0,
        background:             options.background,
        opacity:                options.opacity,
        borderColor:            options.borderColor,
        displayRegionColor:     options.displayRegionColor
    });

    options.minPixelRatio = this.minPixelRatio = viewer.minPixelRatio;

    $.setElementTouchActionNone( this.element );

    this.borderWidth = 2;
    //At some browser magnification levels the display regions lines up correctly, but at some there appears to
    //be a one pixel gap.
    this.fudge = new $.Point(1, 1);
    this.totalBorderWidths = new $.Point(this.borderWidth * 2, this.borderWidth * 2).minus(this.fudge);


    if ( options.controlOptions.anchor !== $.ControlAnchor.NONE ) {
        (function( style, borderWidth ){
            style.margin        = '0px';
            style.border        = borderWidth + 'px solid ' + options.borderColor;
            style.padding       = '0px';
            style.background    = options.background;
            style.opacity       = options.opacity;
            style.overflow      = 'hidden';
        }( this.element.style, this.borderWidth));
    }

    this.displayRegion           = $.makeNeutralElement( "div" );
    this.displayRegion.id        = this.element.id + '-displayregion';
    this.displayRegion.className = 'displayregion';

    (function( style, borderWidth ){
        style.position      = 'relative';
        style.top           = '0px';
        style.left          = '0px';
        style.fontSize      = '0px';
        style.overflow      = 'hidden';
        style.border        = borderWidth + 'px solid ' + options.displayRegionColor;
        style.margin        = '0px';
        style.padding       = '0px';
        style.background    = 'transparent';

        // We use square bracket notation on the statement below, because float is a keyword.
        // This is important for the Google Closure compiler, if nothing else.
        /*jshint sub:true */
        style['float']      = 'left'; //Webkit

        style.cssFloat      = 'left'; //Firefox
        style.zIndex        = 999999999;
        style.cursor        = 'default';
        style.boxSizing     = 'content-box';
    }( this.displayRegion.style, this.borderWidth ));
    $.setElementPointerEventsNone( this.displayRegion );
    $.setElementTouchActionNone( this.displayRegion );

    this.displayRegionContainer = $.makeNeutralElement("div");
    this.displayRegionContainer.id = this.element.id + '-displayregioncontainer';
    this.displayRegionContainer.className = "displayregioncontainer";
    this.displayRegionContainer.style.width = "100%";
    this.displayRegionContainer.style.height = "100%";
    $.setElementPointerEventsNone( this.displayRegionContainer );
    $.setElementTouchActionNone( this.displayRegionContainer );

    viewer.addControl(
        this.element,
        options.controlOptions
    );

    this._resizeWithViewer = options.controlOptions.anchor !== $.ControlAnchor.ABSOLUTE &&
        options.controlOptions.anchor !== $.ControlAnchor.NONE;

    if (options.width && options.height) {
        this.setWidth(options.width);
        this.setHeight(options.height);
    } else if ( this._resizeWithViewer ) {
        viewerSize = $.getElementSize( viewer.element );
        this.element.style.height = Math.round( viewerSize.y * options.sizeRatio ) + 'px';
        this.element.style.width  = Math.round( viewerSize.x * options.sizeRatio ) + 'px';
        this.oldViewerSize = viewerSize;
        navigatorSize = $.getElementSize( this.element );
        this.elementArea = navigatorSize.x * navigatorSize.y;
    }

    this.oldContainerSize = new $.Point( 0, 0 );

    $.Viewer.apply( this, [ options ] );

    this.displayRegionContainer.appendChild(this.displayRegion);
    this.element.getElementsByTagName('div')[0].appendChild(this.displayRegionContainer);

    function rotate(degrees, immediately) {
        _setTransformRotate(_this.displayRegionContainer, degrees);
        _setTransformRotate(_this.displayRegion, -degrees);
        _this.viewport.setRotation(degrees, immediately);
    }
    if (options.navigatorRotate) {
        const degrees = options.viewer.viewport ?
            options.viewer.viewport.getRotation() :
            options.viewer.degrees || 0;

        rotate(degrees, true);
        options.viewer.addHandler("rotate", function (args) {
            rotate(args.degrees, args.immediately);
        });
    }


    // Remove the base class' (Viewer's) innerTracker and replace it with our own
    this.innerTracker.destroy();
    this.innerTracker = new $.MouseTracker({
        userData:        'Navigator.innerTracker',
        element:         this.element, //this.canvas,
        dragHandler:     $.delegate( this, onCanvasDrag ),
        clickHandler:    $.delegate( this, onCanvasClick ),
        releaseHandler:  $.delegate( this, onCanvasRelease ),
        scrollHandler:   $.delegate( this, onCanvasScroll ),
        preProcessEventHandler: function (eventInfo) {
            if (eventInfo.eventType === 'wheel') {
                //don't scroll the page up and down if the user is scrolling
                //in the navigator
                eventInfo.preventDefault = true;
            }
        }
    });
    this.outerTracker.userData = 'Navigator.outerTracker';

    // this.innerTracker is attached to this.element...we need to allow pointer
    //   events to pass through this Viewer's canvas/container elements so implicit
    //   pointer capture works on touch devices
    //TODO an alternative is to attach the new MouseTracker to this.canvas...not
    //   sure why it isn't already (see MouseTracker constructor call above)
    $.setElementPointerEventsNone( this.canvas );
    $.setElementPointerEventsNone( this.container );

    this.addHandler("reset-size", function() {
        if (_this.viewport) {
            _this.viewport.goHome(true);
        }
    });

    viewer.world.addHandler("item-index-change", function(event) {
        window.setTimeout(function(){
            const item = _this.world.getItemAt(event.previousIndex);
            _this.world.setItemIndex(item, event.newIndex);
        }, 1);
    });

    viewer.world.addHandler("remove-item", function(event) {
        const theirItem = event.item;
        const myItem = _this._getMatchingItem(theirItem);
        if (myItem) {
            _this.world.removeItem(myItem);
        }
    });

    this.update(viewer.viewport);
};

$.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /** @lends OpenSeadragon.Navigator.prototype */{

    /**
     * Used to notify the navigator when its size has changed. Especially useful when the navigator is resizable.
     * @function
     */
    updateSize: function () {
        if ( this.viewport ) {
            const containerSize = new $.Point(
                    (this.container.clientWidth === 0 ? 1 : this.container.clientWidth),
                    (this.container.clientHeight === 0 ? 1 : this.container.clientHeight)
                );

            if ( !containerSize.equals( this.oldContainerSize ) ) {
                this.viewport.resize( containerSize, true );
                this.viewport.goHome(true);
                this.oldContainerSize = containerSize;
                this.world.update();
                this.world.draw();
                this.update(this.viewer.viewport);
            }
        }
    },

    /**
     * Explicitly sets the width of the navigator, in web coordinates. Disables automatic resizing.
     * @param {Number|String} width - the new width, either a number of pixels or a CSS string, such as "100%"
     */
    setWidth: function(width) {
        this.width = width;
        this.element.style.width = typeof (width) === "number" ? (width + 'px') : width;
        this._resizeWithViewer = false;
        this.updateSize();
    },

    /**
     * Explicitly sets the height of the navigator, in web coordinates. Disables automatic resizing.
     * @param {Number|String} height - the new height, either a number of pixels or a CSS string, such as "100%"
     */
    setHeight: function(height) {
        this.height = height;
        this.element.style.height = typeof (height) === "number" ? (height + 'px') : height;
        this._resizeWithViewer = false;
        this.updateSize();
    },

    /**
      * Flip navigator element
      * @param {Boolean} state - Flip state to set.
      */
    setFlip: function(state) {
      this.viewport.setFlip(state);

      this.setDisplayTransform(this.viewer.viewport.getFlip() ? "scale(-1,1)" : "scale(1,1)");
      return this;
    },

    setDisplayTransform: function(rule) {
      setElementTransform(this.canvas, rule);
      setElementTransform(this.element, rule);
    },

    /**
     * Used to update the navigator minimap's viewport rectangle when a change in the viewer's viewport occurs.
     * @function
     * @param {OpenSeadragon.Viewport} [viewport] The viewport to display. Default: the viewport this navigator is tracking.
     */
    update: function( viewport ) {
        let viewerSize;
        let newWidth;
        let newHeight;
        let bounds;
        let topleft;
        let bottomright;

        if(!viewport){
            viewport = this.viewer.viewport;
        }

        viewerSize = $.getElementSize( this.viewer.element );
        if ( this._resizeWithViewer && viewerSize.x && viewerSize.y && !viewerSize.equals( this.oldViewerSize ) ) {
            this.oldViewerSize = viewerSize;

            if ( this.maintainSizeRatio || !this.elementArea) {
                newWidth  = viewerSize.x * this.sizeRatio;
                newHeight = viewerSize.y * this.sizeRatio;
            } else {
                newWidth = Math.sqrt(this.elementArea * (viewerSize.x / viewerSize.y));
                newHeight = this.elementArea / newWidth;
            }

            this.element.style.width  = Math.round( newWidth ) + 'px';
            this.element.style.height = Math.round( newHeight ) + 'px';

            if (!this.elementArea) {
                this.elementArea = newWidth * newHeight;
            }

            this.updateSize();
        }

        if (viewport && this.viewport) {
            bounds      = viewport.getBoundsNoRotate(true);
            topleft     = this.viewport.pixelFromPointNoRotate(bounds.getTopLeft(), false);
            bottomright = this.viewport.pixelFromPointNoRotate(bounds.getBottomRight(), false)
                .minus( this.totalBorderWidths );

            if (!this.navigatorRotate) {
                const degrees = viewport.getRotation(true);
                _setTransformRotate(this.displayRegion, -degrees);
            }

            //update style for navigator-box
            const style = this.displayRegion.style;
            style.display = this.world.getItemCount() ? 'block' : 'none';

            style.top = topleft.y.toFixed(2) + "px";
            style.left = topleft.x.toFixed(2) + "px";

            const width = bottomright.x - topleft.x;
            const height = bottomright.y - topleft.y;
            // make sure width and height are non-negative so IE doesn't throw
            style.width  = Math.round( Math.max( width, 0 ) ) + 'px';
            style.height = Math.round( Math.max( height, 0 ) ) + 'px';
        }

    },

    // overrides Viewer.addTiledImage
    addTiledImage: function(options) {
        const _this = this;

        const original = options.originalTiledImage;
        delete options.original;

        const optionsClone = $.extend({}, options, {
            success: function(event) {
                const myItem = event.item;
                myItem._originalForNavigator = original;
                _this._matchBounds(myItem, original, true);
                _this._matchOpacity(myItem, original);
                _this._matchCompositeOperation(myItem, original);

                function matchBounds() {
                    _this._matchBounds(myItem, original);
                }

                function matchOpacity() {
                    _this._matchOpacity(myItem, original);
                }

                function matchCompositeOperation() {
                    _this._matchCompositeOperation(myItem, original);
                }

                original.addHandler('bounds-change', matchBounds);
                original.addHandler('clip-change', matchBounds);
                original.addHandler('opacity-change', matchOpacity);
                original.addHandler('composite-operation-change', matchCompositeOperation);
            }
        });

        return $.Viewer.prototype.addTiledImage.apply(this, [optionsClone]);
    },

    destroy: function() {
        return $.Viewer.prototype.destroy.apply(this);
    },

    // private
    _getMatchingItem: function(theirItem) {
        const count = this.world.getItemCount();
        for (let i = 0; i < count; i++) {
            let item = this.world.getItemAt(i);
            if (item._originalForNavigator === theirItem) {
                return item;
            }
        }

        return null;
    },

    // private
    _matchBounds: function(myItem, theirItem, immediately) {
        const bounds = theirItem.getBoundsNoRotate();
        myItem.setPosition(bounds.getTopLeft(), immediately);
        myItem.setWidth(bounds.width, immediately);
        myItem.setRotation(theirItem.getRotation(), immediately);
        myItem.setClip(theirItem.getClip());
        myItem.setFlip(theirItem.getFlip());
    },

    // private
    _matchOpacity: function(myItem, theirItem) {
        myItem.setOpacity(theirItem.opacity);
    },

    // private
    _matchCompositeOperation: function(myItem, theirItem) {
        myItem.setCompositeOperation(theirItem.compositeOperation);
    }
});


/**
 * @private
 * @inner
 * @function
 */
function onCanvasClick( event ) {
  const canvasClickEventArgs = {
    tracker: event.eventSource,
    position: event.position,
    quick: event.quick,
    shift: event.shift,
    originalEvent: event.originalEvent,
    preventDefaultAction: false
  };
  /**
   * Raised when a click event occurs on the {@link OpenSeadragon.Viewer#navigator} element.
   *
   * @event navigator-click
   * @memberof OpenSeadragon.Viewer
   * @type {object}
   * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
   * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
   * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
   * @property {Boolean} quick - True only if the clickDistThreshold and clickTimeThreshold are both passed. Useful for differentiating between clicks and drags.
   * @property {Boolean} shift - True if the shift key was pressed during this event.
   * @property {Object} originalEvent - The original DOM event.
   * @property {?Object} userData - Arbitrary subscriber-defined object.
   * @property {Boolean} preventDefaultAction - Set to true to prevent default click to zoom behaviour. Default: false.
   */

   this.viewer.raiseEvent('navigator-click', canvasClickEventArgs);

   if ( !canvasClickEventArgs.preventDefaultAction && event.quick && this.viewer.viewport && (this.panVertical || this.panHorizontal)) {
    if(this.viewer.viewport.flipped) {
      event.position.x = this.viewport.getContainerSize().x - event.position.x;
    }
    const target = this.viewport.pointFromPixel(event.position);
    if (!this.panVertical) {
      // perform only horizonal pan
      target.y = this.viewer.viewport.getCenter(true).y;
    } else if (!this.panHorizontal) {
      // perform only vertical pan
      target.x = this.viewer.viewport.getCenter(true).x;
    }
    this.viewer.viewport.panTo(target);
    this.viewer.viewport.applyConstraints();
  }

}

/**
 * @private
 * @inner
 * @function
 */
function onCanvasDrag( event ) {
    const canvasDragEventArgs = {
      tracker: event.eventSource,
      position: event.position,
      delta: event.delta,
      speed: event.speed,
      direction: event.direction,
      shift: event.shift,
      originalEvent: event.originalEvent,
      preventDefaultAction: false
    };
    /**
     * Raised when a drag event occurs on the {@link OpenSeadragon.Viewer#navigator} element.
     *
     * @event navigator-drag
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {OpenSeadragon.Point} delta - The x,y components of the difference between start drag and end drag.
     * @property {Number} speed - Current computed speed, in pixels per second.
     * @property {Number} direction - Current computed direction, expressed as an angle counterclockwise relative to the positive X axis (-pi to pi, in radians). Only valid if speed > 0.
     * @property {Boolean} shift - True if the shift key was pressed during this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     * @property {Boolean} preventDefaultAction - Set to true to prevent default drag to pan behaviour. Default: false.
     */
     this.viewer.raiseEvent('navigator-drag', canvasDragEventArgs);

     if ( !canvasDragEventArgs.preventDefaultAction && this.viewer.viewport ) {
       if( !this.panHorizontal ){
            event.delta.x = 0;
        }
        if( !this.panVertical ){
            event.delta.y = 0;
        }

        if(this.viewer.viewport.flipped){
            event.delta.x = -event.delta.x;
        }

        this.viewer.viewport.panBy(
            this.viewport.deltaPointsFromPixels(
                event.delta
            )
        );
        if( this.viewer.constrainDuringPan ){
            this.viewer.viewport.applyConstraints();
        }
    }
}


/**
 * @private
 * @inner
 * @function
 */
function onCanvasRelease( event ) {
    if ( event.insideElementPressed && this.viewer.viewport ) {
        this.viewer.viewport.applyConstraints();
    }
}


/**
 * @private
 * @inner
 * @function
 */
function onCanvasScroll( event ) {
    const eventArgs = {
        tracker: event.eventSource,
        position: event.position,
        scroll: event.scroll,
        shift: event.shift,
        originalEvent: event.originalEvent,
        preventDefault: event.preventDefault
    };

    /**
     * Raised when a scroll event occurs on the {@link OpenSeadragon.Viewer#navigator} element (mouse wheel, touch pinch, etc.).
     *
     * @event navigator-scroll
     * @memberof OpenSeadragon.Viewer
     * @type {object}
     * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
     * @property {OpenSeadragon.MouseTracker} tracker - A reference to the MouseTracker which originated this event.
     * @property {OpenSeadragon.Point} position - The position of the event relative to the tracked element.
     * @property {Number} scroll - The scroll delta for the event.
     * @property {Boolean} shift - True if the shift key was pressed during this event.
     * @property {Object} originalEvent - The original DOM event.
     * @property {Boolean} preventDefault - Set to true to prevent the default user-agent's handling of the wheel event.
     * @property {?Object} userData - Arbitrary subscriber-defined object.
     */
    this.viewer.raiseEvent( 'navigator-scroll', eventArgs );

    event.preventDefault = eventArgs.preventDefault;
}

/**
    * @function
    * @private
    * @param {Object} element
    * @param {Number} degrees
    */
function _setTransformRotate( element, degrees ) {
  setElementTransform(element, "rotate(" + degrees + "deg)");
}

function setElementTransform( element, rule ) {
  element.style.webkitTransform = rule;
  element.style.mozTransform = rule;
  element.style.msTransform = rule;
  element.style.oTransform = rule;
  element.style.transform = rule;
}

}( OpenSeadragon ));

/*
 * OpenSeadragon - getString/setString
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

//TODO: I guess this is where the i18n needs to be reimplemented.  I'll look
//      into existing patterns for i18n in javascript but i think that mimicking
//      pythons gettext might be a reasonable approach.
const I18N = {
    Errors: {
        Dzc:            "Sorry, we don't support Deep Zoom Collections!",
        Dzi:            "Hmm, this doesn't appear to be a valid Deep Zoom Image.",
        Xml:            "Hmm, this doesn't appear to be a valid Deep Zoom Image.",
        ImageFormat:    "Sorry, we don't support {0}-based Deep Zoom Images.",
        Security:       "It looks like a security restriction stopped us from " +
                        "loading this Deep Zoom Image.",
        Status:         "This space unintentionally left blank ({0} {1}).",
        OpenFailed:     "Unable to open {0}: {1}"
    },

    Tooltips: {
        FullPage:       "Toggle full page",
        Home:           "Go home",
        ZoomIn:         "Zoom in",
        ZoomOut:        "Zoom out",
        NextPage:       "Next page",
        PreviousPage:   "Previous page",
        RotateLeft:     "Rotate left",
        RotateRight:    "Rotate right",
        Flip:           "Flip Horizontally"
    }
};

$.extend( $, /** @lends OpenSeadragon */{

    /**
     * @function
     * @param {String} property
     */
    getString: function( prop ) {

        const props   = prop.split('.');
        let string  = null;
        const args    = arguments;
        let container = I18N;
        let i;

        for (i = 0; i < props.length - 1; i++) {
            // in case not a subproperty
            container = container[ props[ i ] ] || {};
        }
        string = container[ props[ i ] ];

        if ( typeof ( string ) !== "string" ) {
            $.console.error( "Untranslated source string:", prop );
            string = ""; // FIXME: this breaks gettext()-style convention, which would return source
        }

        return string.replace(/\{\d+\}/g, function(capture) {
            const i = parseInt( capture.match( /\d+/ ), 10 ) + 1;
            return i < args.length ?
                args[ i ] :
                "";
        });
    },

    /**
     * @function
     * @param {String} property
     * @param {*} value
     */
    setString: function( prop, value ) {

        const props     = prop.split('.');
        let container = I18N;
        let i;

        for ( i = 0; i < props.length - 1; i++ ) {
            if ( !container[ props[ i ] ] ) {
                container[ props[ i ] ] = {};
            }
            container = container[ props[ i ] ];
        }

        container[ props[ i ] ] = value;
    }

});

}( OpenSeadragon ));

/*
 * OpenSeadragon - Point
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class Point
 * @classdesc A Point is really used as a 2-dimensional vector, equally useful for
 * representing a point on a plane, or the height and width of a plane
 * not requiring any other frame of reference.
 *
 * @memberof OpenSeadragon
 * @param {Number} [x] The vector component 'x'. Defaults to the origin at 0.
 * @param {Number} [y] The vector component 'y'. Defaults to the origin at 0.
 */
$.Point = function( x, y ) {
    /**
     * The vector component 'x'.
     * @member {Number} x
     * @memberof OpenSeadragon.Point#
     */
    this.x = typeof ( x ) === "number" ? x : 0;
    /**
     * The vector component 'y'.
     * @member {Number} y
     * @memberof OpenSeadragon.Point#
     */
    this.y = typeof ( y ) === "number" ? y : 0;
};

/** @lends OpenSeadragon.Point.prototype */
$.Point.prototype = {
    /**
     * @function
     * @returns {OpenSeadragon.Point} a duplicate of this Point
     */
    clone: function() {
        return new $.Point(this.x, this.y);
    },

    /**
     * Add another Point to this point and return a new Point.
     * @function
     * @param {OpenSeadragon.Point} point The point to add vector components.
     * @returns {OpenSeadragon.Point} A new point representing the sum of the
     *  vector components
     */
    plus: function( point ) {
        return new $.Point(
            this.x + point.x,
            this.y + point.y
        );
    },

    /**
     * Subtract another Point to this point and return a new Point.
     * @function
     * @param {OpenSeadragon.Point} point The point to subtract vector components.
     * @returns {OpenSeadragon.Point} A new point representing the subtraction of the
     *  vector components
     */
    minus: function( point ) {
        return new $.Point(
            this.x - point.x,
            this.y - point.y
        );
    },

    /**
     * Multiply this point by a factor and return a new Point.
     * @function
     * @param {Number} factor The factor to multiply vector components.
     * @returns {OpenSeadragon.Point} A new point representing the multiplication
     *  of the vector components by the factor
     */
    times: function( factor ) {
        return new $.Point(
            this.x * factor,
            this.y * factor
        );
    },

    /**
     * Divide this point by a factor and return a new Point.
     * @function
     * @param {Number} factor The factor to divide vector components.
     * @returns {OpenSeadragon.Point} A new point representing the division of the
     *  vector components by the factor
     */
    divide: function( factor ) {
        return new $.Point(
            this.x / factor,
            this.y / factor
        );
    },

    /**
     * Compute the opposite of this point and return a new Point.
     * @function
     * @returns {OpenSeadragon.Point} A new point representing the opposite of the
     *  vector components
     */
    negate: function() {
        return new $.Point( -this.x, -this.y );
    },

    /**
     * Compute the distance between this point and another point.
     * @function
     * @param {OpenSeadragon.Point} point The point to compute the distance with.
     * @returns {Number} The distance between the 2 points
     */
    distanceTo: function( point ) {
        return Math.sqrt(
            Math.pow( this.x - point.x, 2 ) +
            Math.pow( this.y - point.y, 2 )
        );
    },

    /**
     * Compute the squared distance between this point and another point.
     * Useful for optimizing things like comparing distances.
     * @function
     * @param {OpenSeadragon.Point} point The point to compute the squared distance with.
     * @returns {Number} The squared distance between the 2 points
     */
    squaredDistanceTo: function( point ) {
        return Math.pow( this.x - point.x, 2 ) +
            Math.pow( this.y - point.y, 2 );
    },

    /**
     * Apply a function to each coordinate of this point and return a new point.
     * @function
     * @param {function} func The function to apply to each coordinate.
     * @returns {OpenSeadragon.Point} A new point with the coordinates computed
     * by the specified function
     */
    apply: function( func ) {
        return new $.Point( func( this.x ), func( this.y ) );
    },

    /**
     * Check if this point is equal to another one.
     * @function
     * @param {OpenSeadragon.Point} point The point to compare this point with.
     * @returns {Boolean} true if they are equal, false otherwise.
     */
    equals: function( point ) {
        return (
            point instanceof $.Point
        ) && (
            this.x === point.x
        ) && (
            this.y === point.y
        );
    },

    /**
     * Rotates the point around the specified pivot
     * From http://stackoverflow.com/questions/4465931/rotate-rectangle-around-a-point
     * @function
     * @param {Number} degress to rotate around the pivot.
     * @param {OpenSeadragon.Point} [pivot=(0,0)] Point around which to rotate.
     * Defaults to the origin.
     * @returns {OpenSeadragon.Point}. A new point representing the point rotated around the specified pivot
     */
    rotate: function (degrees, pivot) {
        pivot = pivot || new $.Point(0, 0);
        let cos;
        let sin;
        // Avoid float computations when possible
        if (degrees % 90 === 0) {
            const d = $.positiveModulo(degrees, 360);
            switch (d) {
                case 0:
                    cos = 1;
                    sin = 0;
                    break;
                case 90:
                    cos = 0;
                    sin = 1;
                    break;
                case 180:
                    cos = -1;
                    sin = 0;
                    break;
                case 270:
                    cos = 0;
                    sin = -1;
                    break;
            }
        } else {
            const angle = degrees * Math.PI / 180.0;
            cos = Math.cos(angle);
            sin = Math.sin(angle);
        }
        const x = cos * (this.x - pivot.x) - sin * (this.y - pivot.y) + pivot.x;
        const y = sin * (this.x - pivot.x) + cos * (this.y - pivot.y) + pivot.y;
        return new $.Point(x, y);
    },

    /**
     * Convert this point to a string in the format (x,y) where x and y are
     * rounded to the nearest integer.
     * @function
     * @returns {String} A string representation of this point.
     */
    toString: function() {
        return "(" + (Math.round(this.x * 100) / 100) + "," + (Math.round(this.y * 100) / 100) + ")";
    }
};

}( OpenSeadragon ));

/*
 * OpenSeadragon - TileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){


/**
 * @typedef {Object} OpenSeadragon.TileSourceOptions
 * @property {String} [options.url]
 *      The URL for the data necessary for this TileSource.
 * @property {String} [options.referenceStripThumbnailUrl]
 *      The URL for a thumbnail image to be used by the reference strip
 * @property {Function} [options.success]
 *      A function to be called upon successful creation.
 * @property {Boolean} [options.ajaxWithCredentials]
 *      If this TileSource needs to make an AJAX call, this specifies whether to set
 *      the XHR's withCredentials (for accessing secure data).
 * @property {Object} [options.ajaxHeaders]
 *      A set of headers to include in AJAX requests.
 * @property {Boolean} [options.splitHashDataForPost]
 *      First occurrence of '#' in the options.url is used to split URL
 *      and the latter part is treated as POST data (applies to getImageInfo(...))
 *      Does not work if getImageInfo() is overridden and used (see the options description)
 * @property {Number} [options.width]
 *      Width of the source image at max resolution in pixels.
 * @property {Number} [options.height]
 *      Height of the source image at max resolution in pixels.
 * @property {Number} [options.tileSize]
 *      The size of the tiles to assumed to make up each pyramid layer in pixels.
 *      Tile size determines the point at which the image pyramid must be
 *      divided into a matrix of smaller images.
 *      Use options.tileWidth and options.tileHeight to support non-square tiles.
 * @property {Number} [options.tileWidth]
 *      The width of the tiles to assumed to make up each pyramid layer in pixels.
 * @property {Number} [options.tileHeight]
 *      The height of the tiles to assumed to make up each pyramid layer in pixels.
 * @property {Number} [options.tileOverlap]
 *      The number of pixels each tile is expected to overlap touching tiles.
 * @property {Number} [options.minLevel]
 *      The minimum level to attempt to load.
 * @property {Number} [options.maxLevel]
 *      The maximum level to attempt to load.
 * @property {Boolean} [options.ready=true]
 *      If true, the event 'ready' is called immediately after the TileSource is created.
 *      This is important because some flows rely on immediate initialization, which
 *      computes additional properties like dimensions or aspect ratio.
 *
 *
 *      TODO: could be removed completely:
 *        - do not use Tiled Image's getImageInfo, implement it separately
 *        - call getImageInfo as perviously, by default just call raiseEvent('ready', { tileSource: this })
 */

/**
 * @class TileSource
 * @classdesc The TileSource contains the most basic implementation required to create a
 * smooth transition between layers in an image pyramid. It has only a single key
 * interface that must be implemented to complete its key functionality:
 * 'getTileUrl'.  It also has several optional interfaces that can be
 * implemented if a new TileSource wishes to support configuration via a simple
 * object or array ('configure') and if the tile source supports or requires
 * configuration via retrieval of a document on the network ala AJAX or JSONP,
 * ('getImageInfo').
 * <br/>
 * By default the image pyramid is split into N layers where the image's longest
 * side in M (in pixels), where N is the smallest integer which satisfies
 *      <strong>2^(N+1) >= M</strong>.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.EventSource
 * @param {OpenSeadragon.TileSourceOptions|string} options
 *      You can either specify a URL, or literally define the TileSource (by specifying
 *      width, height, tileSize, tileOverlap, minLevel, and maxLevel). For the former,
 *      the extending class is expected to implement 'supports' and 'configure'.
 *      Note that _in this case, the child class of getImageInfo() is ignored!_
 *      For the latter, the construction is assumed to occur through
 *      the extending classes implementation of 'configure'.
 */
$.TileSource = function( options ) {

    // NOTE! Manually rewriting this to a class syntax is problematic, since apply(...) would have to be overridden
    //   static apply( target, args ) {...}
    // and check if target inherits TileSource and if not, copy all props to the __proto__ of the target
    $.EventSource.apply( this );


    /**
     * The URL of the image to be loaded. Can be undefined if the configuration happened
     * via plain object or class injection
     * @member {String} url
     * @memberof OpenSeadragon.TileSource#
     */
    this.url = null;
    /**
     * Ratio of width to height
     * @member {Number} aspectRatio
     * @memberof OpenSeadragon.TileSource#
     */
    /**
     * Vector storing x and y dimensions ( width and height respectively ).
     * @member {OpenSeadragon.Point} dimensions
     * @memberof OpenSeadragon.TileSource#
     */
    /**
     * The overlap in pixels each tile shares with its adjacent neighbors.
     * @member {Number} tileOverlap
     * @memberof OpenSeadragon.TileSource#
     */
    /**
     * The minimum pyramid level this tile source supports or should attempt to load.
     * @member {Number} minLevel
     * @memberof OpenSeadragon.TileSource#
     */
    /**
     * The maximum pyramid level this tile source supports or should attempt to load.
     * @member {Number} maxLevel
     * @memberof OpenSeadragon.TileSource#
     */
    /**
     *
     * @member {Boolean} ready
     * @memberof OpenSeadragon.TileSource#
     */

    this.addHandler('ready', e => {
        const source = e.tileSource;
        //explicit configuration via positional args in constructor
        //or the more idiomatic 'options' object
        this.ready       = true;
        this.aspectRatio = (source.width && source.height) ?
            (source.width / source.height) : 1;
        this.dimensions  = new $.Point( source.width, source.height );

        if ( source.tileSize ){
            this._tileWidth = this._tileHeight = source.tileSize;
            delete this.tileSize;
        } else {
            if( source.tileWidth ){
                // We were passed tileWidth in options, but we want to rename it
                // with a leading underscore to make clear that it is not safe to directly modify it
                this._tileWidth = source.tileWidth;
                delete this.tileWidth;
            } else {
                this._tileWidth = 0;
            }

            if( source.tileHeight ){
                // See note above about renaming this.tileWidth
                this._tileHeight = source.tileHeight;
                delete this.tileHeight;
            } else {
                this._tileHeight = 0;
            }
        }

        this.tileOverlap = source.tileOverlap ? source.tileOverlap : 0;
        this.minLevel    = source.minLevel ? source.minLevel : 0;
        this.maxLevel    = ( undefined !== source.maxLevel && null !== source.maxLevel ) ?
            source.maxLevel : (
                ( source.width && source.height ) ? Math.ceil(
                    Math.log( Math.max( source.width, source.height ) ) /
                    Math.log( 2 )
                ) : 0
            );
        if( source.success && $.isFunction( source.success ) ){
            source.success( this );
        }
    }, null, Infinity); // important! go first to finish initialization

    if( 'string' === $.type( options ) ){
        this.url = options;
        options = undefined;
    } else {
        //we allow options to override anything we don't treat as
        //required via idiomatic options or which is functionally
        //set depending on the state of the readiness of this tile
        //source
        $.extend( true, this, options );
    }

    if (this.url && !this.ready) {
        //in case the getImageInfo method is overridden and/or implies an
        //async mechanism set some safe defaults first
        this.aspectRatio = 1;
        this.dimensions  = new $.Point( 10, 10 );
        this._tileWidth  = 0;
        this._tileHeight = 0;
        this.tileOverlap = 0;
        this.minLevel    = 0;
        this.maxLevel    = 0;
        this.ready       = false;
        this._uniqueIdentifier = this.url;
        //configuration via url implies the extending class
        //implements and 'configure'
        setTimeout(() => this.getImageInfo(this.url)); //needs async in case someone exits immediately
    } else {
        this._uniqueIdentifier = Math.floor(Math.random() * 1e10).toString(36);
        // by default it used to fire immediately, so make the ready default
        if (this.ready || this.ready === undefined) {
            this.raiseEvent('ready', { tileSource: this });
        } else {
            setTimeout(() => this.raiseEvent('ready', { tileSource: this }));
        }
    }
    return this;
};

/** @lends OpenSeadragon.TileSource.prototype */
$.TileSource.prototype = {

    getTileSize: function( level ) {
        $.console.error(
            "[TileSource.getTileSize] is deprecated. " +
            "Use TileSource.getTileWidth() and TileSource.getTileHeight() instead"
        );
        return this._tileWidth;
    },

    /**
     * Return the tileWidth for a given level.
     * Subclasses should override this if tileWidth can be different at different levels
     *   such as in IIIFTileSource.  Code should use this function rather than reading
     *   from ._tileWidth directly.
     * @function
     * @param {Number} level
     */
    getTileWidth: function( level ) {
        if (!this._tileWidth) {
            return this.getTileSize(level);
        }
        return this._tileWidth;
    },

    /**
     * Return the tileHeight for a given level.
     * Subclasses should override this if tileHeight can be different at different levels
     *   such as in IIIFTileSource.  Code should use this function rather than reading
     *   from ._tileHeight directly.
     * @function
     * @param {Number} level
     */
    getTileHeight: function( level ) {
        if (!this._tileHeight) {
            return this.getTileSize(level);
        }
        return this._tileHeight;
    },

    /**
     * Set the maxLevel to the given level, and perform the memoization of
     * getLevelScale with the new maxLevel. This function can be useful if the
     * memoization is required before the first call of getLevelScale, or both
     * memoized getLevelScale and maxLevel should be changed accordingly.
     * @function
     * @param {Number} level
     */
    setMaxLevel: function( level ) {
        this.maxLevel = level;
        this._memoizeLevelScale();
    },

    /**
     * @function
     * @param {Number} level
     */
    getLevelScale: function( level ) {
        // if getLevelScale is not memoized, we generate the memoized version
        // at the first call and return the result
        this._memoizeLevelScale();
        return this.getLevelScale( level );
    },

    // private
    _memoizeLevelScale: function() {
        // see https://github.com/openseadragon/openseadragon/issues/22
        // we use the tilesources implementation of getLevelScale to generate
        // a memoized re-implementation
        const levelScaleCache = {};
        let i;
        for( i = 0; i <= this.maxLevel; i++ ){
            levelScaleCache[ i ] = 1 / Math.pow(2, this.maxLevel - i);
        }
        this.getLevelScale = function( _level ){
            return levelScaleCache[ _level ];
        };
    },

    /**
     * @function
     * @param {Number} level
     */
    getNumTiles: function( level ) {
        const scale = this.getLevelScale( level );
        const x = Math.ceil( scale * this.dimensions.x / this.getTileWidth(level) );
        const y = Math.ceil( scale * this.dimensions.y / this.getTileHeight(level) );

        return new $.Point( x, y );
    },

    /**
     * @function
     * @param {Number} level
     */
    getPixelRatio: function( level ) {
        const imageSizeScaled = this.dimensions.times( this.getLevelScale( level ) );
        const rx = 1.0 / imageSizeScaled.x * $.pixelDensityRatio;
        const ry = 1.0 / imageSizeScaled.y * $.pixelDensityRatio;

        return new $.Point(rx, ry);
    },


    /**
     * @function
     * @returns {Number} The highest level in this tile source that can be contained in a single tile.
     */
    getClosestLevel: function() {
        let i;
        let tiles;

        for (i = this.minLevel + 1; i <= this.maxLevel; i++){
            tiles = this.getNumTiles(i);
            if (tiles.x > 1 || tiles.y > 1) {
                break;
            }
        }

        return i - 1;
    },

    /**
     * @function
     * @param {Number} level
     * @param {OpenSeadragon.Point} point
     */
    getTileAtPoint: function(level, point) {
        const validPoint = point.x >= 0 && point.x <= 1 &&
            point.y >= 0 && point.y <= 1 / this.aspectRatio;
        $.console.assert(validPoint, "[TileSource.getTileAtPoint] must be called with a valid point.");


        const widthScaled = this.dimensions.x * this.getLevelScale(level);
        const pixelX = point.x * widthScaled;
        const pixelY = point.y * widthScaled;

        let x = Math.floor(pixelX / this.getTileWidth(level));
        let y = Math.floor(pixelY / this.getTileHeight(level));

        // When point.x == 1 or point.y == 1 / this.aspectRatio we want to
        // return the last tile of the row/column
        if (point.x >= 1) {
            x = this.getNumTiles(level).x - 1;
        }
        const EPSILON = 1e-15;
        if (point.y >= 1 / this.aspectRatio - EPSILON) {
            y = this.getNumTiles(level).y - 1;
        }

        return new $.Point(x, y);
    },

    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @param {Boolean} [isSource=false] Whether to return the source bounds of the tile.
     * @returns {OpenSeadragon.Rect} Either where this tile fits (in normalized coordinates) or the
     * portion of the tile to use as the source of the drawing operation (in pixels), depending on
     * the isSource parameter.
     */
    getTileBounds: function( level, x, y, isSource ) {
        const dimensionsScaled = this.dimensions.times( this.getLevelScale( level ) );
        const tileWidth = this.getTileWidth(level);
        const tileHeight = this.getTileHeight(level);
        const px = ( x === 0 ) ? 0 : tileWidth * x - this.tileOverlap;
        const py = ( y === 0 ) ? 0 : tileHeight * y - this.tileOverlap;
        let sx = tileWidth + ( x === 0 ? 1 : 2 ) * this.tileOverlap;
        let sy = tileHeight + ( y === 0 ? 1 : 2 ) * this.tileOverlap;
        const scale = 1.0 / dimensionsScaled.x;

        sx = Math.min( sx, dimensionsScaled.x - px );
        sy = Math.min( sy, dimensionsScaled.y - py );

        if (isSource) {
            return new $.Rect(0, 0, sx, sy);
        }

        return new $.Rect( px * scale, py * scale, sx * scale, sy * scale );
    },


    /**
     * Responsible for retrieving, and caching the
     * image metadata pertinent to this TileSources implementation.
     * There are three scenarios of opening a tile source: providing a parseable string, plain object, or an URL.
     * This method is only called by OSD if the TileSource configuration is a non-parseable string (~url).
     *
     * Note: you can access the properties sent to the TileSource constructor via the options object
     * directly on 'this' reference.
     *
     * The string can contain a hash `#` symbol, followed by
     * key=value arguments. If this is the case, this method sends this
     * data as a POST body.
     *
     * @function
     * @param {String} url
     * @throws {Error}
     */
    getImageInfo: function( url ) {
        const _this = this;
        let callbackName;
        let callback;
        let readySource;
        let options;
        let urlParts;
        let filename;
        let lastDot;


        if( url ) {
            urlParts = url.split( '/' );
            filename = urlParts[ urlParts.length - 1 ];
            lastDot  = filename.lastIndexOf( '.' );
            if ( lastDot > -1 ) {
                urlParts[ urlParts.length - 1 ] = filename.slice( 0, lastDot );
            }
        }

        let postData = null;
        if (this.splitHashDataForPost) {
            const hashIdx = url.indexOf("#");
            if (hashIdx !== -1) {
                postData = url.substring(hashIdx + 1);
                url = url.substr(0, hashIdx);
            }
        }

        callback = function( data ){
            if( typeof (data) === "string" ) {
                data = $.parseXml( data );
            }
            const $TileSource = $.TileSource.determineType( _this, data, url );
            if ( !$TileSource ) {
                /**
                 * Raised when an error occurs loading a TileSource.
                 *
                 * @event open-failed
                 * @memberof OpenSeadragon.TileSource
                 * @type {object}
                 * @property {OpenSeadragon.TileSource} eventSource - A reference to the TileSource which raised the event.
                 * @property {String} message
                 * @property {String} source
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent( 'open-failed', { message: "Unable to load TileSource", source: url } );
                return;
            }

            options = $TileSource.prototype.configure.apply( _this, [ data, url, postData ]);
            if (options.ajaxWithCredentials === undefined) {
                options.ajaxWithCredentials = _this.ajaxWithCredentials;
            }

            options.ready = true;  // force synchronous finish
            readySource = new $TileSource( options );
            _this.ready = true;
            /**
             * Raised when a TileSource is opened and initialized.
             *
             * @event ready
             * @memberof OpenSeadragon.TileSource
             * @type {object}
             * @property {OpenSeadragon.TileSource} eventSource - A reference to the TileSource which raised the event.
             * @property {Object} tileSource
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            _this.raiseEvent( 'ready', { tileSource: readySource } );
        };

        if( url.match(/\.js$/) ){
            //TODO: Its not very flexible to require tile sources to end jsonp
            //      request for info  with a url that ends with '.js' but for
            //      now it's the only way I see to distinguish uniformly.
            callbackName = url.split('/').pop().replace('.js', '');
            $.jsonp({
                url: url,
                async: false,
                callbackName: callbackName,
                callback: callback
            });
        } else {
            // request info via xhr asynchronously.
            $.makeAjaxRequest( {
                url: url,
                postData: postData,
                withCredentials: this.ajaxWithCredentials,
                headers: this.ajaxHeaders,
                success: function( xhr ) {
                    const data = processResponse( xhr );
                    callback( data );
                },
                error: function ( xhr, exc ) {
                    let msg;

                    /*
                        IE < 10 will block XHR requests to different origins. Any property access on the request
                        object will raise an exception which we'll attempt to handle by formatting the original
                        exception rather than the second one raised when we try to access xhr.status
                     */
                    try {
                        msg = "HTTP " + xhr.status + " attempting to load TileSource: " + url;
                    } catch ( e ) {
                        let formattedExc;
                        if ( typeof ( exc ) === "undefined" || !exc.toString ) {
                            formattedExc = "Unknown error";
                        } else {
                            formattedExc = exc.toString();
                        }

                        msg = formattedExc + " attempting to load TileSource: " + url;
                    }

                    $.console.error(msg);

                    /***
                     * Raised when an error occurs loading a TileSource.
                     *
                     * @event open-failed
                     * @memberof OpenSeadragon.TileSource
                     * @type {object}
                     * @property {OpenSeadragon.TileSource} eventSource - A reference to the TileSource which raised the event.
                     * @property {String} message
                     * @property {String} source
                     * @property {String} postData - HTTP POST data (usually but not necessarily in k=v&k2=v2... form,
                     *      see TileSource::getTilePostData) or null
                     * @property {?Object} userData - Arbitrary subscriber-defined object.
                     */
                    _this.raiseEvent( 'open-failed', {
                        message: msg,
                        source: url,
                        postData: postData
                    });
                }
            });
        }

    },

    /**
     * Responsible for determining if the particular TileSource supports the
     * data format ( and allowed to apply logic against the url the data was
     * loaded from, if any ). Overriding implementations are expected to do
     * something smart with data and / or url to determine support.  Also
     * understand that iteration order of TileSources is not guaranteed so
     * please make sure your data or url is expressive enough to ensure a simple
     * and sufficient mechanism for clear determination.
     * @function
     * @param {String|Object|Array|Document} data
     * @param {String} url - the url the data was loaded
     *      from if any.
     * @returns {Boolean}
     */
    supports: function( data, url ) {
        return false;
    },

    /**
     * Check whether two tileSources are equal. This is used for example
     * when replacing tile-sources, which turns on the zombie cache before
     * old item removal.
     * @param {OpenSeadragon.TileSource} otherSource
     * @returns {Boolean}
     */
    equals: function (otherSource) {
        return this === otherSource;
    },

    /**
     * Determines if this tile source data can be batched.
     * @return {boolean}
     */
    batchEnabled() {
        return false;
    },

    /**
     * Determines if a tile request from a source (even itself!) can be batched with this source.
     * By default, returns false -> in this case, each tile falls to a single bucket alone.
     * @param {OpenSeadragon.TileSource} otherSource
     * @return {boolean}
     */
    batchCompatible(otherSource) {
        return false;
    },

    /**
     * Maximum batch size. Can, for example, be derived from (average) tile size of the source.
     * @return {number} integer, number of max jobs per batch
     */
    batchMaxJobs() {
        return -1;
    },

    /**
     * How long to wait with a batch before processing. Big timeout means larger
     * batches with fewer requests, at the cost of slower loading.
     * @return {number} milliseconds to wait for tiles to be added to the batch before processing
     */
    batchTimeout() {
        return 5;
    },

    /**
     * Responsible for parsing and configuring the
     * image metadata pertinent to this TileSources implementation.
     * This method is not implemented by this class other than to throw an Error
     * announcing you have to implement it.  Because of the variety of tile
     * server technologies, and various specifications for building image
     * pyramids, this method is here to allow easy integration.
     * @function
     * @param {String|Object|Array|Document} data
     * @param {String} url - the url the data was loaded
     *      from if any.
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null value obtained from
     *      the protocol URL after '#' sign if flag splitHashDataForPost set to 'true'
     * @returns {Object} options - A dictionary of keyword arguments sufficient
     *      to configure the tile source constructor (include all values you want to
     *      instantiate the TileSource subclass with - what _options_ object should contain).
     * @throws {Error}
     */
    configure: function( data, url, postData ) {
        throw new Error( "Method not implemented." );
    },

    /**
     * Shall this source need to free some objects
     * upon unloading, it must be done here. For example, canvas
     * size must be set to 0 for safari to free.
     * @param {OpenSeadragon.Viewer} viewer
     */
    destroy: function ( viewer ) {
        //no-op
    },

    /**
     * Responsible for retrieving the url which will return an image for the
     * region specified by the given x, y, and level components.
     * This method is not implemented by this class other than to throw an Error
     * announcing you have to implement it.  Because of the variety of tile
     * server technologies, and various specifications for building image
     * pyramids, this method is here to allow easy integration.
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @returns {String|Function} url - A string for the url or a function that returns a url string.
     * @throws {Error}
     */
    getTileUrl: function( level, x, y ) {
        throw new Error( "Method not implemented." );
    },

    /**
     * Must use AJAX in order to work, i.e. loadTilesWithAjax = true is set.
     * If a value is returned, ajax issues POST request to the tile url.
     * If null is returned, ajax issues GET request.
     * The return value must comply to the header 'content type'.
     *
     * Examples (USED HEADER --> getTilePostData CODE):
     * 'Content-type': 'application/x-www-form-urlencoded' -->
     *   return "key1=value=1&key2=value2";
     *
     * 'Content-type': 'application/x-www-form-urlencoded' -->
     *   return JSON.stringify({key: "value", number: 5});
     *
     * 'Content-type': 'multipart/form-data' -->
     *   let result = new FormData();
     *   result.append("data", myData);
     *   return result;
     *
     * IMPORTANT: in case you move all the logic on image fetching
     * to post data, you must re-define 'getTileHashKey(...)' to
     * stay unique for different tile images.
     *
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @returns {*|null} post data to send with tile configuration request
     */
    getTilePostData: function( level, x, y ) {
        return null;
    },

    /**
     * Responsible for retrieving the headers which will be attached to the image request for the
     * region specified by the given x, y, and level components.
     * This option is only relevant if {@link OpenSeadragon.Options}.loadTilesWithAjax is set to true.
     * The headers returned here will override headers specified at the Viewer or TiledImage level.
     * Specifying a falsy value for a header will clear its existing value set at the Viewer or
     * TiledImage level (if any).
     *
     * Note that the headers of existing tiles don't automatically change when this function
     * returns updated headers. To do that, you need to call {@link OpenSeadragon.Viewer#setAjaxHeaders}
     * and propagate the changes.
     *
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @returns {Object}
     */
    getTileAjaxHeaders: function( level, x, y ) {
        return {};
    },

    /**
     * The tile cache object is uniquely determined by this key and used to lookup
     * the image data in cache: keys should be different if images are different.
     *
     * You can return falsey tile cache key, in which case the tile will
     * be created without invoking ImageJob --- but with data=null. Then,
     * you are responsible for manually creating the cache data. This is useful
     * particularly if you want to use empty TiledImage with client-side derived data
     * only. The default tile-cache key is then called "" - an empty string.
     *
     * Note: default behaviour does not take into account post data.
     * @param {Number} level tile level it was fetched with
     * @param {Number} x x-coordinate in the pyramid level
     * @param {Number} y y-coordinate in the pyramid level
     * @param {String} url the tile was fetched with
     * @param {Object} ajaxHeaders the tile was fetched with
     * @param {*} postData data the tile was fetched with (type depends on getTilePostData(..) return type)
     * @return {?String} can return the cache key or null, in that case an empty cache is initialized
     *   without downloading any data for internal use: user has to define the cache contents manually, via
     *   the cache interface of this class.
     */
    getTileHashKey: function(level, x, y, url, ajaxHeaders, postData) {
        function withHeaders(hash) {
            return ajaxHeaders ? hash + "+" + JSON.stringify(ajaxHeaders) : hash;
        }

        if (typeof url !== "string") {
            return withHeaders(this._uniqueIdentifier + ":" + level + "/" + x + "_" + y);
        }
        return withHeaders(url);
    },

    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     */
    tileExists: function( level, x, y ) {
        const numTiles = this.getNumTiles( level );
        return level >= this.minLevel &&
            level <= this.maxLevel &&
            x >= 0 &&
            y >= 0 &&
            x < numTiles.x &&
            y < numTiles.y;
    },

    /**
     * Decide whether tiles have transparency: this is crucial for correct images blending.
     * Overriden on a tile level by setting tile.hasTransparency = true;
     * @param context2D unused, deprecated argument
     * @param url tile.getUrl() value for given tile
     * @param ajaxHeaders tile.ajaxHeaders value for given tile
     * @param post tile.post value for given tile
     * @returns {boolean} true if the image has transparency
     */
    hasTransparency: function(context2D, url, ajaxHeaders, post) {
        return url.match('.png');
    },

    /**
     * Download tile data. The context attribute is the reference to the job object itself, which is extended
     * by ImageLoader.addJob(options) options object, so there are also properties like context.source (reference to self).
     *
     * Note that if you override this function, you should override also downloadTileAbort().
     * @param {OpenSeadragon.ImageJob} context job context that you have to call finish(...) on.
     */
    downloadTileStart: function (context) {
        // Load the tile with an AJAX request if the loadWithAjax option is
        // set. Otherwise load the image by setting the source property of the image object.

        // TODO: the cors/creds is not optimal here:
        //  - XMLHttpRequest can only setup credentials flag, so `ajaxWithCredentials` is a boolean
        //  - <img> item can turn on/off cors, and include credentials if cors on, therefore `crossOriginPolicy` can have three values (one is null)
        //  --> we should merge these flags to a single value to avoid confusion with usage, and use modern fetch that can setup also cors to have consistent behavior
        if (context.loadWithAjax) {
            context.userData.request = $.makeAjaxRequest({
                url: context.src,
                withCredentials: context.ajaxWithCredentials,
                headers: context.ajaxHeaders,
                responseType: "arraybuffer",
                postData: context.postData,
                success: function(request) {
                    let blb;
                    // Make the raw data into a blob.
                    // BlobBuilder fallback adapted from
                    // http://stackoverflow.com/questions/15293694/blob-constructor-browser-compatibility
                    try {
                        blb = new window.Blob([request.response]);
                    } catch (e) {
                        const BlobBuilder = (
                            window.BlobBuilder ||
                            window.WebKitBlobBuilder ||
                            window.MozBlobBuilder ||
                            window.MSBlobBuilder
                        );
                        if (e.name === 'TypeError' && BlobBuilder) {
                            const bb = new BlobBuilder();
                            bb.append(request.response);
                            blb = bb.getBlob();
                        }
                    }
                    // If the blob is empty for some reason consider the image load a failure.
                    if (blb.size === 0) {
                        context.fail("[downloadTileStart] Empty image response.", request);
                    } else {
                        context.finish(blb, request, "rasterBlob");
                    }
                },
                error: function(request) {
                    context.fail("[downloadTileStart] Image load aborted - XHR error", request);
                }
            });
        } else {
            // While we could just do this one-liner, we found out that downloading the data _before_ a cache is initialized
            // works better in general cases. Network access is the most error-prone part, and this scenario better supports
            // all default use-cases, including the fact that retry logic works only at this stage, not on the cache level.
            //  context.finish(context.src, null, "__private__imageUrl");

            const image = new Image();
            context.userData.imageRequest = image;
            image.onload = function () {
                image.onload = image.onerror = image.onabort = null;
                context.finish(image, null, "image");
            };
            image.onabort = image.onerror = function() {
                image.onload = image.onerror = image.onabort = null;
                context.fail("[downloadTileStart] Image load aborted or errored out.", null);
            };
            if (typeof context.crossOriginPolicy === "string") {
                image.crossOrigin = context.crossOriginPolicy;
            }
            image.src = context.src;
        }
    },

    /**
     * Provide means of aborting the execution.
     * Note that if you override this function, you should override also downloadTileStart().
     * Note that calling job.abort() would create an infinite loop!
     *
     * @param {OpenSeadragon.ImageJob} context job, the same object as with downloadTileStart(..)
     * @param {*} [context.userData] - Empty object to attach (and mainly read) your own data.
     */
    downloadTileAbort: function (context) {
        if (context.userData.request) {
            context.userData.request.abort();
        }
        if (context.userData.imageRequest) {
            const image = context.userData.imageRequest;
            image.onload = image.onerror = image.onabort = null;
            image.src = "";
        }
    },

    /**
     * Handles the fetching of multiple tiles in a single operation.
     * The TileSource is responsible for calling finish/fail on each of the individual job items
     * carried by batchJob.jobs. Avoid calling finish/fail on `batchJob` itself.
     *
     * Note that failed batch jobs are retried in non-batched mode. You should therefore
     * have a valid downloadTileStart implementation in any case.
     *
     * @param {OpenSeadragon.BatchImageJob} batchJob - The batch job containing .jobs array
     */
    downloadTileBatchStart(batchJob) {
        // Fallback default implementation: process individually.
        // Real implementations (e.g. for sprite sheets) should override this and use true batched approach.
        for (let i = 0; i < batchJob.jobs.length; i++) {
            this.downloadTileStart(batchJob.jobs[i]);
        }
    },

    /**
     * Handles abortion of the fetching of multiple tiles.
     * @param {OpenSeadragon.BatchImageJob} batchJob
     */
    downloadTileBatchAbort(batchJob) {
        for (let i = 0; i < batchJob.jobs.length; i++) {
            this.downloadTileAbort(batchJob.jobs[i]);
        }
    },

    /**
     * Create cache object from the result of the download process. The
     * cacheObject parameter should be used to attach the data to, there are no
     * conventions on how it should be stored - all the logic is implemented within *TileCache() functions.
     *
     * Note that
     *  - data is cached automatically as cacheObject.data
     *  - if you override any of *TileCache() functions, you should override all of them.
     *  - these functions might be called over shared cache object managed by other TileSources simultaneously.
     * @param {OpenSeadragon.CacheRecord} cacheObject context cache object
     * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object
     * @param {OpenSeadragon.Tile} tile instance the cache was created with
     * @deprecated
     */
    createTileCache: function(cacheObject, data, tile) {
        $.console.error("[TileSource.createTileCache] has been deprecated. Use cache API of a tile instead.");
        //no-op, we create the cache automatically
    },

    /**
     * Cache object destructor, unset all properties you created to allow GC collection.
     * Note that if you override any of *TileCache() functions, you should override all of them.
     * Note that these functions might be called over shared cache object managed by other TileSources simultaneously.
     * Original cache data is cacheObject.data, but do not delete it manually! It is taken care for,
     * you might break things.
     * @param {OpenSeadragon.CacheRecord} cacheObject context cache object
     * @deprecated
     */
    destroyTileCache: function (cacheObject) {
        $.console.error("[TileSource.destroyTileCache] has been deprecated. Use cache API of a tile instead.");
        //no-op, handled internally
    },

    /**
     * Raw data getter, should return anything that is compatible with the system, or undefined
     * if the system can handle it.
     * @param {OpenSeadragon.CacheRecord} cacheObject context cache object
     * @returns {OpenSeadragon.Promise<?>} cache data
     * @deprecated
     */
    getTileCacheData: function(cacheObject) {
        $.console.error("[TileSource.getTileCacheData] has been deprecated. Use cache API of a tile instead.");
        return cacheObject.getDataAs(undefined, false);
    },

    /**
     * Compatibility image element getter
     *  - plugins might need image representation of the data
     *  - div HTML rendering relies on image element presence
     * Note that if you override any of *TileCache() functions, you should override all of them.
     * Note that these functions might be called over shared cache object managed by other TileSources simultaneously.
     *  @param {OpenSeadragon.CacheRecord} cacheObject context cache object
     *  @returns {Image} cache data as an Image
     *  @deprecated
     */
    getTileCacheDataAsImage: function(cacheObject) {
        $.console.error("[TileSource.getTileCacheDataAsImage] has been deprecated. Use cache API of a tile instead.");
        return cacheObject.getImage();
    },

    /**
     * Compatibility context 2D getter
     *  - most heavily used rendering method is a canvas-based approach,
     *    convert the data to a canvas and return it's 2D context
     * Note that if you override any of *TileCache() functions, you should override all of them.
     * @param {OpenSeadragon.CacheRecord} cacheObject context cache object
     * @returns {CanvasRenderingContext2D} context of the canvas representation of the cache data
     * @deprecated
     */
    getTileCacheDataAsContext2D: function(cacheObject) {
        $.console.error("[TileSource.getTileCacheDataAsContext2D] has been deprecated. Use cache API of a tile instead.");
        return cacheObject.getRenderedContext();
    }
};


$.extend( true, $.TileSource.prototype, $.EventSource.prototype );


/**
 * Decides whether to try to process the response as xml, json, or hand back
 * the text
 * @private
 * @inner
 * @function
 * @param {XMLHttpRequest} xhr - the completed network request
 */
function processResponse( xhr ){
    const responseText = xhr.responseText;
    let status       = xhr.status;
    let statusText;
    let data;

    if ( !xhr ) {
        throw new Error( $.getString( "Errors.Security" ) );
    } else if ( xhr.status !== 200 && xhr.status !== 0 ) {
        status     = xhr.status;
        statusText = ( status === 404 ) ?
            "Not Found" :
            xhr.statusText;
        throw new Error( $.getString( "Errors.Status", status, statusText ) );
    }

    if( responseText.match(/^\s*<.*/) ){
        try{
            data = ( xhr.responseXML && xhr.responseXML.documentElement ) ?
                xhr.responseXML :
                $.parseXml( responseText );
        } catch (e){
            data = xhr.responseText;
        }
    }else if( responseText.match(/\s*[{[].*/) ){
        try{
            data = $.parseJSON(responseText);
        } catch(e){
            data =  responseText;
        }
    }else{
        data = responseText;
    }
    return data;
}


/**
 * Determines the TileSource Implementation by introspection of OpenSeadragon
 * namespace, calling each TileSource implementation of 'isType'
 * @private
 * @inner
 * @function
 * @param {Object|Array|Document} data - the tile source configuration object
 * @param {String} url - the url where the tile source configuration object was
 *      loaded from, if any.
 */
$.TileSource.determineType = function( tileSource, data, url ){
    for( const property in OpenSeadragon ){
        if( property.match(/.+TileSource$/) &&
            $.isFunction( OpenSeadragon[ property ] ) &&
            $.isFunction( OpenSeadragon[ property ].prototype.supports ) &&
            OpenSeadragon[ property ].prototype.supports.call( tileSource, data, url )
        ){
            return OpenSeadragon[ property ];
        }
    }

    $.console.error( "No TileSource was able to open %s %s", url, data );

    return null;
};


}( OpenSeadragon ));

/*
 * OpenSeadragon - DziTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class DziTileSource
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.TileSource
 * @param {Number|Object} width - the pixel width of the image or the idiomatic
 *      options object which is used instead of positional arguments.
 * @param {Number} height
 * @param {Number} tileSize
 * @param {Number} tileOverlap
 * @param {String} tilesUrl
 * @param {String} fileFormat
 * @param {OpenSeadragon.DisplayRect[]} displayRects
 * @property {String} tilesUrl
 * @property {String} fileFormat
 * @property {OpenSeadragon.DisplayRect[]} displayRects
 */
$.DziTileSource = function( width, height, tileSize, tileOverlap, tilesUrl, fileFormat, displayRects, minLevel, maxLevel ) {
    let level;
    let options;

    if( $.isPlainObject( width ) ){
        options = width;
    }else{
        options = {
            width: arguments[ 0 ],
            height: arguments[ 1 ],
            tileSize: arguments[ 2 ],
            tileOverlap: arguments[ 3 ],
            tilesUrl: arguments[ 4 ],
            fileFormat: arguments[ 5 ],
            displayRects: arguments[ 6 ],
            minLevel: arguments[ 7 ],
            maxLevel: arguments[ 8 ]
        };
    }

    this._levelRects  = {};
    this.tilesUrl     = options.tilesUrl;
    this.fileFormat   = options.fileFormat;
    this.displayRects = options.displayRects;
    this.queryParams  = options.queryParams || "";

    if ( this.displayRects ) {
        for ( let i = this.displayRects.length - 1; i >= 0; i-- ) {
            const rect = this.displayRects[ i ];
            for ( level = rect.minLevel; level <= rect.maxLevel; level++ ) {
                if ( !this._levelRects[ level ] ) {
                    this._levelRects[ level ] = [];
                }
                this._levelRects[ level ].push( rect );
            }
        }
    }

    $.TileSource.apply( this, [ options ] );

};

$.extend( $.DziTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.DziTileSource.prototype */{


    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @function
     * @param {Object|Array} data
     * @param {String} [url]
     */
    supports: function( data, url ){
        let ns;
        if ( data.Image ) {
            ns = data.Image.xmlns;
        } else if ( data.documentElement) {
            if ("Image" === data.documentElement.localName || "Image" === data.documentElement.tagName) {
                ns = data.documentElement.namespaceURI;
            }
        }

        ns = (ns || '').toLowerCase();

        return (ns.indexOf('schemas.microsoft.com/deepzoom/2008') !== -1 ||
            ns.indexOf('schemas.microsoft.com/deepzoom/2009') !== -1);
    },

    /**
     *
     * @function
     * @param {Object|XMLDocument} data - the raw configuration
     * @param {String} url - the url the data was retrieved from if any.
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
     * @returns {Object} options - A dictionary of keyword arguments sufficient
     *      to configure this tile sources constructor.
     */
    configure: function( data, url, postData ){

        let options;

        if( !$.isPlainObject(data) ){

            options = configureFromXML( this, data );

        }else{

            options = configureFromObject( this, data );
        }

        if (url && !options.tilesUrl) {
            options.tilesUrl = url.replace(
                    /([^/]+?)(\.(dzi|xml|js)?(\?[^/]*)?)?\/?$/, '$1_files/');

            if (url.search(/\.(dzi|xml|js)\?/) !== -1) {
                options.queryParams = url.match(/\?.*/);
            }else{
                options.queryParams = '';
            }
        }

        return options;
    },


    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     */
    getTileUrl: function( level, x, y ) {
        return [ this.tilesUrl, level, '/', x, '_', y, '.', this.fileFormat, this.queryParams ].join( '' );
    },


    /**
     * Equality comparator
     */
    equals: function(otherSource) {
        return otherSource && this.tilesUrl === otherSource.tilesUrl;
    },


    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     */
    tileExists: function( level, x, y ) {
        const rects = this._levelRects[ level ];
        let scale;
        let xMin;
        let yMin;
        let xMax;
        let yMax;

        if ((this.minLevel && level < this.minLevel) || (this.maxLevel && level > this.maxLevel)) {
            return false;
        }

        if ( !rects || !rects.length ) {
            return true;
        }

        for (let i = rects.length - 1; i >= 0; i-- ) {
            const rect = rects[ i ];

            if ( level < rect.minLevel || level > rect.maxLevel ) {
                continue;
            }

            scale = this.getLevelScale( level );
            xMin = rect.x * scale;
            yMin = rect.y * scale;
            xMax = xMin + rect.width * scale;
            yMax = yMin + rect.height * scale;

            xMin = Math.floor( xMin / this._tileWidth );
            yMin = Math.floor( yMin / this._tileWidth ); // DZI tiles are square, so we just use _tileWidth
            xMax = Math.ceil( xMax / this._tileWidth );
            yMax = Math.ceil( yMax / this._tileWidth );

            if ( xMin <= x && x < xMax && yMin <= y && y < yMax ) {
                return true;
            }
        }

        return false;
    }
});


/**
 * @private
 * @inner
 * @function
 */
function configureFromXML( tileSource, xmlDoc ){

    if ( !xmlDoc || !xmlDoc.documentElement ) {
        throw new Error( $.getString( "Errors.Xml" ) );
    }

    const root           = xmlDoc.documentElement;
    const rootName       = root.localName || root.tagName;
    const ns             = xmlDoc.documentElement.namespaceURI;
    let configuration  = null;
    const displayRects   = [];
    let dispRectNodes;
    let dispRectNode;
    let rectNode;
    let sizeNode;
    let i;

    if ( rootName === "Image" ) {

        try {
            sizeNode = root.getElementsByTagName("Size" )[ 0 ];
            if (sizeNode === undefined) {
                sizeNode = root.getElementsByTagNameNS(ns, "Size" )[ 0 ];
            }

            configuration = {
                Image: {
                    xmlns:       "http://schemas.microsoft.com/deepzoom/2008",
                    Url:         root.getAttribute( "Url" ),
                    Format:      root.getAttribute( "Format" ),
                    DisplayRect: null,
                    Overlap:     parseInt( root.getAttribute( "Overlap" ), 10 ),
                    TileSize:    parseInt( root.getAttribute( "TileSize" ), 10 ),
                    Size: {
                        Height: parseInt( sizeNode.getAttribute( "Height" ), 10 ),
                        Width:  parseInt( sizeNode.getAttribute( "Width" ), 10 )
                    }
                }
            };

            if ( !$.imageFormatSupported( configuration.Image.Format ) ) {
                throw new Error(
                    $.getString( "Errors.ImageFormat", configuration.Image.Format.toUpperCase() )
                );
            }

            dispRectNodes = root.getElementsByTagName("DisplayRect" );
            if (dispRectNodes === undefined) {
                dispRectNodes = root.getElementsByTagNameNS(ns, "DisplayRect" )[ 0 ];
            }

            for ( i = 0; i < dispRectNodes.length; i++ ) {
                dispRectNode = dispRectNodes[ i ];
                rectNode     = dispRectNode.getElementsByTagName("Rect" )[ 0 ];
                if (rectNode === undefined) {
                    rectNode = dispRectNode.getElementsByTagNameNS(ns, "Rect" )[ 0 ];
                }

                displayRects.push({
                    Rect: {
                        X: parseInt( rectNode.getAttribute( "X" ), 10 ),
                        Y: parseInt( rectNode.getAttribute( "Y" ), 10 ),
                        Width: parseInt( rectNode.getAttribute( "Width" ), 10 ),
                        Height: parseInt( rectNode.getAttribute( "Height" ), 10 ),
                        MinLevel: parseInt( dispRectNode.getAttribute( "MinLevel" ), 10 ),
                        MaxLevel: parseInt( dispRectNode.getAttribute( "MaxLevel" ), 10 )
                    }
                });
            }

            if( displayRects.length ){
                configuration.Image.DisplayRect = displayRects;
            }

            return configureFromObject( tileSource, configuration );

        } catch ( e ) {
            throw (e instanceof Error) ?
                e :
                new Error( $.getString("Errors.Dzi") );
        }
    } else if ( rootName === "Collection" ) {
        throw new Error( $.getString( "Errors.Dzc" ) );
    } else if ( rootName === "Error" ) {
        const messageNode = root.getElementsByTagName("Message")[0];
        const message = messageNode.firstChild.nodeValue;
        throw new Error(message);
    }

    throw new Error( $.getString( "Errors.Dzi" ) );
}

/**
 * @private
 * @inner
 * @function
 */
function configureFromObject( tileSource, configuration ){
    const imageData     = configuration.Image;
    const tilesUrl      = imageData.Url;
    const fileFormat    = imageData.Format;
    const sizeData      = imageData.Size;
    const dispRectData  = imageData.DisplayRect || [];
    const width         = parseInt( sizeData.Width, 10 );
    const height        = parseInt( sizeData.Height, 10 );
    const tileSize      = parseInt( imageData.TileSize, 10 );
    const tileOverlap   = parseInt( imageData.Overlap, 10 );
    const displayRects  = [];
    let rectData;

    //TODO: need to figure out out to better handle image format compatibility
    //      which actually includes additional file formats like xml and pdf
    //      and plain text for various tilesource implementations to avoid low
    //      level errors.
    //
    //      For now, just don't perform the check.
    //
    /*if ( !imageFormatSupported( fileFormat ) ) {
        throw new Error(
            $.getString( "Errors.ImageFormat", fileFormat.toUpperCase() )
        );
    }*/

    for (let i = 0; i < dispRectData.length; i++ ) {
        rectData = dispRectData[ i ].Rect;

        displayRects.push( new $.DisplayRect(
            parseInt( rectData.X, 10 ),
            parseInt( rectData.Y, 10 ),
            parseInt( rectData.Width, 10 ),
            parseInt( rectData.Height, 10 ),
            parseInt( rectData.MinLevel, 10 ),
            parseInt( rectData.MaxLevel, 10 )
        ));
    }

    return $.extend(true, {
        width: width, /* width *required */
        height: height, /* height *required */
        tileSize: tileSize, /* tileSize *required */
        tileOverlap: tileOverlap, /* tileOverlap *required */
        minLevel: null, /* minLevel */
        maxLevel: null, /* maxLevel */
        tilesUrl: tilesUrl, /* tilesUrl */
        fileFormat: fileFormat, /* fileFormat */
        displayRects: displayRects /* displayRects */
    }, configuration );

}

}( OpenSeadragon ));

/*
 * OpenSeadragon - IIIFTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class IIIFTileSource
 * @classdesc A client implementation of the International Image Interoperability Framework
 * Format: Image API 1.0 - 3.0
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.TileSource
 * @see http://iiif.io/api/image/
 * @param {String} [options.tileFormat='jpg']
 *      The extension that will be used when requiring tiles.
 */
$.IIIFTileSource = function( options ){

    /* eslint-disable camelcase */

    $.extend( true, this, options );

    /* Normalizes v3-style 'id' keys to an "_id" internal property */
    this._id = this["@id"] || this["id"] || this['identifier'] || null;

    if ( !( this.height && this.width && this._id) ) {
        throw new Error( 'IIIF required parameters (width, height, or id) not provided.' );
    }

    options.tileSizePerScaleFactor = {};

    this.tileFormat = this.tileFormat || 'jpg';

    this.version = options.version;

    this.isLevel0 = checkLevel0( options );

    // N.B. 2.0 renamed scale_factors to scaleFactors
    if ( this.tile_width && this.tile_height ) {
        options.tileWidth = this.tile_width;
        options.tileHeight = this.tile_height;
    } else if ( this.tile_width ) {
        options.tileSize = this.tile_width;
    } else if ( this.tile_height ) {
        options.tileSize = this.tile_height;
    } else if ( this.tiles ) {
        // Version 2.0 forwards
        if ( this.tiles.length === 1 ) {
            options.tileWidth  = this.tiles[0].width;
            // Use height if provided, otherwise assume square tiles and use width.
            options.tileHeight = this.tiles[0].height || this.tiles[0].width;
            this.scale_factors = this.tiles[0].scaleFactors;
        } else {
            // Multiple tile sizes at different levels
            this.scale_factors = [];
            for (let t = 0; t < this.tiles.length; t++ ) {
                for (let sf = 0; sf < this.tiles[t].scaleFactors.length; sf++) {
                    const scaleFactor = this.tiles[t].scaleFactors[sf];
                    this.scale_factors.push(scaleFactor);
                    options.tileSizePerScaleFactor[scaleFactor] = {
                        width: this.tiles[t].width,
                        height: this.tiles[t].height || this.tiles[t].width
                    };
                }
            }
        }
    } else if ( canBeTiled(options) ) {
        // use the largest of tileOptions that is smaller than the short dimension
        const shortDim = Math.min( this.height, this.width );
        const tileOptions = [256, 512, 1024];
        const smallerTiles = [];

        for ( let c = 0; c < tileOptions.length; c++ ) {
            if ( tileOptions[c] <= shortDim ) {
                smallerTiles.push( tileOptions[c] );
            }
        }

        if ( smallerTiles.length > 0 ) {
            options.tileSize = Math.max.apply( null, smallerTiles );
        } else {
            // If we're smaller than 256, just use the short side.
            options.tileSize = shortDim;
        }
    } else if (this.sizes && this.sizes.length > 0) {
        // This info.json can't be tiled, but we can still construct a legacy pyramid from the sizes array.
        // In this mode, IIIFTileSource will call functions from the abstract baseTileSource or the
        // LegacyTileSource instead of performing IIIF tiling.
        this.emulateLegacyImagePyramid = true;

        options.levels = constructLevels( this );
        // use the largest available size to define tiles
        $.extend( true, options, {
            width: options.levels[ options.levels.length - 1 ].width,
            height: options.levels[ options.levels.length - 1 ].height,
            tileSize: Math.max( options.height, options.width ),
            tileOverlap: 0,
            minLevel: 0,
            maxLevel: options.levels.length - 1
        });
        this.levels = options.levels;
    } else {
        $.console.error("Nothing in the info.json to construct image pyramids from");
    }

    if (!options.maxLevel && !this.emulateLegacyImagePyramid) {
        if (!this.scale_factors) {
            options.maxLevel = Number(Math.round(Math.log(Math.max(this.width, this.height), 2)));
        } else {
            const maxScaleFactor = Math.max.apply(null, this.scale_factors);
            options.maxLevel = Math.round(Math.log(maxScaleFactor) * Math.LOG2E);
        }
    }

    // Create an array with precise resolution sizes if these have been supplied through the 'sizes' object
    if( this.sizes ) {
        let sizeLength = this.sizes.length;

        // Create a copy of the sizes list and sort in ascending order
        const sortedSizes = this.sizes.slice().sort(( size1, size2 ) => size1.width - size2.width);

        // List may or may not include the full resolution size (should be last after sorting): add it if necessary
        if( sortedSizes[sizeLength - 1].width < this.width && sortedSizes[sizeLength - 1].height < this.height ) {
            sortedSizes.push( {width: this.width, height: this.height} );
            sizeLength++;
        }

        // Only try to use 'sizes' if the number of dimensions within exactly matches the number of resolution levels (maxLevel+1)
        if ( sizeLength === options.maxLevel + 1 ) {

            // If we have a list of scaleFactors, make sure each of our sizes really corresponds to the listed scales
            let isResolutionList = 1;
            if ( this.scale_factors && this.scale_factors.length === sizeLength ) {
                for ( let i = 0; i < sizeLength; i++ ) {
                    const factor = this.scale_factors[sizeLength - i - 1]; // Scale factor order is inverted
                    if ( Math.round( this.width / sortedSizes[i].width ) !== factor ||
                         Math.round( this.height / sortedSizes[i].height ) !== factor ) {
                        isResolutionList = 0;
                        break;
                    }
                }
            }
            // The 'sizes' array does indeed contain a list of resolution levels, so assign our sorted array
            if ( isResolutionList === 1 ) {
                this.levelSizes = sortedSizes;
            }
        }
    }

    $.TileSource.apply( this, [ options ] );
};

$.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.IIIFTileSource.prototype */{
    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @function
     * @param {Object|Array} data
     * @param {String} [url] - url
     */

    supports: function( data, url ) {
        // Version 2.0 and forwards
        if (data.protocol && data.protocol === 'http://iiif.io/api/image') {
            return true;
        // Version 1.1
        } else if ( data['@context'] && (
            data['@context'] === "http://library.stanford.edu/iiif/image-api/1.1/context.json" ||
            data['@context'] === "http://iiif.io/api/image/1/context.json") ) {
            // N.B. the iiif.io context is wrong, but where the representation lives so likely to be used
            return true;

        // Version 1.0
        } else if ( data.profile &&
            data.profile.indexOf("http://library.stanford.edu/iiif/image-api/compliance.html") === 0) {
            return true;
        } else if ( data.identifier && data.width && data.height ) {
            return true;
        } else if ( data.documentElement &&
            "info" === data.documentElement.tagName &&
            "http://library.stanford.edu/iiif/image-api/ns/" ===
                data.documentElement.namespaceURI) {
            return true;

        // Not IIIF
        } else {
            return false;
        }
    },

    /**
     * A static function used to prepare an incoming IIIF Image API info.json
     * response for processing by the tile handler. Normalizes data for all
     * versions of IIIF (1.0, 1.1, 2.x, 3.x) and returns a data object that
     * may be passed to the IIIFTileSource.
     *
     * @function
     * @static
     * @param {Object} data - the raw configuration
     * @param {String} url - the url configuration was retrieved from
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
     * @returns {Object} A normalized IIIF data object
     * @example <caption>IIIF 2.x Info Looks like this</caption>
     * {
     * "@context": "http://iiif.io/api/image/2/context.json",
     * "@id": "http://iiif.example.com/prefix/1E34750D-38DB-4825-A38A-B60A345E591C",
     * "protocol": "http://iiif.io/api/image",
     * "height": 1024,
     * "width": 775,
     * "tiles" : [{"width":256, "scaleFactors":[1,2,4,8]}],
     *  "profile": ["http://iiif.io/api/image/2/level1.json", {
     *    "qualities": [ "native", "bitonal", "grey", "color" ],
     *    "formats": [ "jpg", "png", "gif" ]
     *   }]
     * }
     */
    configure: function( data, url, postData ){
        // Try to deduce our version and fake it upwards if needed
        if ( !$.isPlainObject(data) ) {
            const options = configureFromXml10( data );
            options['@context'] = "http://iiif.io/api/image/1.0/context.json";
            options["@id"] = url.replace('/info.xml', '');
            options.version = 1;
            return options;
        } else {
            if ( !data['@context'] ) {
                data['@context'] = 'http://iiif.io/api/image/1.0/context.json';
                data["@id"] = url.replace('/info.json', '');
                data.version = 1;
            } else {
                let context = data['@context'];
                if (Array.isArray(context)) {
                    for (let i = 0; i < context.length; i++) {
                        if (typeof context[i] === 'string' &&
                            ( /^http:\/\/iiif\.io\/api\/image\/[1-3]\/context\.json$/.test(context[i]) ||
                            context[i] === 'http://library.stanford.edu/iiif/image-api/1.1/context.json' ) ) {
                            context = context[i];
                            break;
                        }
                    }
                }
                switch (context) {
                    case 'http://iiif.io/api/image/1/context.json':
                    case 'http://library.stanford.edu/iiif/image-api/1.1/context.json':
                        data.version = 1;
                        break;
                    case 'http://iiif.io/api/image/2/context.json':
                        data.version = 2;
                        break;
                    case 'http://iiif.io/api/image/3/context.json':
                        data.version = 3;
                        break;
                    default:
                        $.console.error('Data has a @context property which contains no known IIIF context URI.');
                }
            }

            if (data.preferredFormats) {
                for (let f = 0; f < data.preferredFormats.length; f++ ) {
                    if ( $.imageFormatSupported(data.preferredFormats[f]) ) {
                        data.tileFormat = data.preferredFormats[f];
                        break;
                    }
                }
            }
            return data;
        }
    },

    /**
     * Return the tileWidth for the given level.
     * @function
     * @param {Number} level
     */
    getTileWidth: function( level ) {

        if(this.emulateLegacyImagePyramid) {
            return $.TileSource.prototype.getTileWidth.call(this, level);
        }

        const scaleFactor = Math.pow(2, this.maxLevel - level);

        if (this.tileSizePerScaleFactor && this.tileSizePerScaleFactor[scaleFactor]) {
            return this.tileSizePerScaleFactor[scaleFactor].width;
        }
        return this._tileWidth;
    },

    /**
     * Return the tileHeight for the given level.
     * @function
     * @param {Number} level
     */
    getTileHeight: function( level ) {

        if(this.emulateLegacyImagePyramid) {
            return $.TileSource.prototype.getTileHeight.call(this, level);
        }

        const scaleFactor = Math.pow(2, this.maxLevel - level);

        if (this.tileSizePerScaleFactor && this.tileSizePerScaleFactor[scaleFactor]) {
            return this.tileSizePerScaleFactor[scaleFactor].height;
        }
        return this._tileHeight;
    },

    /**
     * @function
     * @param {Number} level
     */
    getLevelScale: function ( level ) {

        if(this.emulateLegacyImagePyramid) {
            let levelScale = NaN;
            if (this.levels.length > 0 && level >= this.minLevel && level <= this.maxLevel) {
                levelScale =
                    this.levels[level].width /
                    this.levels[this.maxLevel].width;
            }
            return levelScale;
        }

        return $.TileSource.prototype.getLevelScale.call(this, level);
    },

    /**
     * @function
     * @param {Number} level
     */
    getNumTiles: function( level ) {

        if(this.emulateLegacyImagePyramid) {
            const scale = this.getLevelScale(level);
            if (scale) {
                return new $.Point(1, 1);
            } else {
                return new $.Point(0, 0);
            }
        }

        // Use supplied list of scaled resolution sizes if these exist
        if( this.levelSizes ) {
            const levelSize = this.levelSizes[level];
            const x = Math.ceil( levelSize.width / this.getTileWidth(level) );
            const y = Math.ceil( levelSize.height / this.getTileHeight(level) );
            return new $.Point( x, y );
        }
        // Otherwise call default TileSource->getNumTiles() function
        else {
            return $.TileSource.prototype.getNumTiles.call(this, level);
        }
    },


    /**
     * @function
     * @param {Number} level
     * @param {OpenSeadragon.Point} point
     */
    getTileAtPoint: function( level, point ) {

        if(this.emulateLegacyImagePyramid) {
            return new $.Point(0, 0);
        }

        // Use supplied list of scaled resolution sizes if these exist
        if( this.levelSizes ) {

            const validPoint = point.x >= 0 && point.x <= 1 &&
                             point.y >= 0 && point.y <= 1 / this.aspectRatio;
            $.console.assert(validPoint, "[TileSource.getTileAtPoint] must be called with a valid point.");

            const widthScaled = this.levelSizes[level].width;
            const pixelX = point.x * widthScaled;
            const pixelY = point.y * widthScaled;

            let x = Math.floor(pixelX / this.getTileWidth(level));
            let y = Math.floor(pixelY / this.getTileHeight(level));

            // When point.x == 1 or point.y == 1 / this.aspectRatio we want to
            // return the last tile of the row/column
            if (point.x >= 1) {
                x = this.getNumTiles(level).x - 1;
            }
            const EPSILON = 1e-15;
            if (point.y >= 1 / this.aspectRatio - EPSILON) {
                y = this.getNumTiles(level).y - 1;
            }

            return new $.Point(x, y);
        }

        // Otherwise call default TileSource->getTileAtPoint() function
        return $.TileSource.prototype.getTileAtPoint.call(this, level, point);
    },


    /**
     * Responsible for retrieving the url which will return an image for the
     * region specified by the given x, y, and level components.
     * @function
     * @param {Number} level - z index
     * @param {Number} x
     * @param {Number} y
     * @throws {Error}
     */
    getTileUrl: function( level, x, y ){

        if(this.emulateLegacyImagePyramid) {
            let url = null;
            if ( this.levels.length > 0 && level >= this.minLevel && level <= this.maxLevel ) {
                url = this.levels[ level ].url;
            }
            return url;
        }

        //# constants
        const IIIF_ROTATION = '0';
            //## get the scale (level as a decimal)
        const scale = Math.pow( 0.5, this.maxLevel - level );
            //# image dimensions at this level
        let levelWidth;
        let levelHeight;

            //## iiif region
        let tileWidth;
        let tileHeight;
        let iiifTileSizeWidth;
        let iiifTileSizeHeight;
        let iiifRegion;
        let iiifTileX;
        let iiifTileY;
        let iiifTileW;
        let iiifTileH;
        let iiifSize;
        let iiifSizeW;
        let iiifSizeH;
        let iiifQuality;

        // Use supplied list of scaled resolution sizes if these exist
        if( this.levelSizes ) {
            levelWidth = this.levelSizes[level].width;
            levelHeight = this.levelSizes[level].height;
        }
        // Otherwise calculate the sizes ourselves
        else {
            levelWidth = Math.ceil( this.width * scale );
            levelHeight = Math.ceil( this.height * scale );
        }

        tileWidth = this.getTileWidth(level);
        tileHeight = this.getTileHeight(level);
        iiifTileSizeWidth = Math.round( tileWidth / scale );
        iiifTileSizeHeight = Math.round( tileHeight / scale );
        if (this.version === 1) {
            iiifQuality = "native." + this.tileFormat;
        } else {
            iiifQuality = "default." + this.tileFormat;
        }
        if ( levelWidth < tileWidth && levelHeight < tileHeight ){
            if ( this.version === 2 && levelWidth === this.width ) {
                iiifSize = "full";
            } else if ( this.version === 3 && levelWidth === this.width && levelHeight === this.height ) {
                iiifSize = "max";
            } else if ( this.version === 3 ) {
                iiifSize = levelWidth + "," + levelHeight;
            } else {
                iiifSize = levelWidth + ",";
            }
            iiifRegion = 'full';
        } else {
            iiifTileX = x * iiifTileSizeWidth;
            iiifTileY = y * iiifTileSizeHeight;
            iiifTileW = Math.min( iiifTileSizeWidth, this.width - iiifTileX );
            iiifTileH = Math.min( iiifTileSizeHeight, this.height - iiifTileY );
            if ( x === 0 && y === 0 && iiifTileW === this.width && iiifTileH === this.height ) {
                iiifRegion = "full";
            } else {
                iiifRegion = [ iiifTileX, iiifTileY, iiifTileW, iiifTileH ].join( ',' );
            }
            iiifSizeW = Math.min( tileWidth, levelWidth - (x * tileWidth) );
            iiifSizeH = Math.min( tileHeight, levelHeight - (y * tileHeight) );
            if ( this.version === 2 && iiifSizeW === this.width ) {
                iiifSize = "full";
            } else if ( this.version === 3 && iiifSizeW === this.width && iiifSizeH === this.height ) {
                iiifSize = "max";
            } else if (this.isLevel0 && this.version < 3) {
                iiifSize = iiifSizeW + ",";
            } else {
                iiifSize = iiifSizeW + "," + iiifSizeH;
            }
        }
        const uri = [ this._id, iiifRegion, iiifSize, IIIF_ROTATION, iiifQuality ].join( '/' );

        return uri;
    },

    /**
     * Equality comparator
     */
    equals: function(otherSource) {
        return otherSource && this._id === otherSource._id;
    },

    __testonly__: {
        canBeTiled: canBeTiled,
        constructLevels: constructLevels
    }

  });

    /**
     * Determine whether we have a level 0 compliance profile
     * @function
     * @param {Object} options
     * @param {Array|String} options.profile
     * @returns {Boolean}
     */
    function checkLevel0 ( options ) {
        const level0Profiles = [
            "http://library.stanford.edu/iiif/image-api/compliance.html#level0",
            "http://library.stanford.edu/iiif/image-api/1.1/compliance.html#level0",
            "http://iiif.io/api/image/2/level0.json",
            "level0",
            "https://iiif.io/api/image/3/level0.json"
        ];
        const profileLevel = Array.isArray(options.profile) ? options.profile[0] : options.profile;
        const isLevel0 = (level0Profiles.indexOf(profileLevel) !== -1);
        return isLevel0;
    }


    /**
     * Determine whether arbitrary tile requests can be made against a service with the given profile
     * @function
     * @param {Object} options
     * @param {Array|String} options.profile
     * @param {Number} options.version
     * @param {String[]} options.extraFeatures
     * @returns {Boolean}
     */
    function canBeTiled ( options ) {
        const isLevel0 = checkLevel0( options );
        let hasCanonicalSizeFeature = false;
        if ( options.version === 2 && options.profile.length > 1 && options.profile[1].supports ) {
            hasCanonicalSizeFeature = options.profile[1].supports.indexOf( "sizeByW" ) !== -1;
        }
        if ( options.version === 3 && options.extraFeatures ) {
            hasCanonicalSizeFeature = options.extraFeatures.indexOf( "sizeByWh" ) !== -1;
        }
        return !isLevel0 || hasCanonicalSizeFeature;
    }


    /**
     * Build the legacy pyramid URLs (one tile per level)
     * @function
     * @param {object} options - infoJson
     * @throws {Error}
     */
    function constructLevels(options) {
        const levels = [];
        for(let i = 0; i < options.sizes.length; i++) {
            levels.push({
                url: options._id + '/full/' + options.sizes[i].width + ',' +
                    (options.version === 3 ? options.sizes[i].height : '') +
                    '/0/default.' + options.tileFormat,
                width: options.sizes[i].width,
                height: options.sizes[i].height
            });
        }
        return levels.sort(function(a, b) {
            return a.width - b.width;
        });
    }


    function configureFromXml10(xmlDoc) {
        //parse the xml
        if ( !xmlDoc || !xmlDoc.documentElement ) {
            throw new Error( $.getString( "Errors.Xml" ) );
        }

        const root            = xmlDoc.documentElement;
        const rootName        = root.tagName;
        let configuration   = null;

        if ( rootName === "info" ) {
            try {
                configuration = {};
                parseXML10( root, configuration );
                return configuration;

            } catch ( e ) {
                throw (e instanceof Error) ?
                    e :
                    new Error( $.getString("Errors.IIIF") );
            }
        }
        throw new Error( $.getString( "Errors.IIIF" ) );
    }

    function parseXML10( node, configuration, property ) {
        if ( node.nodeType === 3 && property ) {//text node
            let value = node.nodeValue.trim();
            if( value.match(/^\d*$/)){
                value = Number( value );
            }
            if( !configuration[ property ] ){
                configuration[ property ] = value;
            }else{
                if( !$.isArray( configuration[ property ] ) ){
                    configuration[ property ] = [ configuration[ property ] ];
                }
                configuration[ property ].push( value );
            }
        } else if( node.nodeType === 1 ){
            for( let i = 0; i < node.childNodes.length; i++ ){
                parseXML10( node.childNodes[ i ], configuration, node.nodeName );
            }
        }
    }



}( OpenSeadragon ));

/**
 * OpenSeadragon - IIPTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *
 *
 */


(function($) {

  /**
   * @class IIPTileSource
   * @classdesc A tilesource implementation for the Internet Imaging Protocol (IIP).
   *
   * @memberof OpenSeadragon
   * @extends OpenSeadragon.TileSource
   * @see https://iipimage.sourceforge.io
   *
   * @param {String} iipsrv      - IIPImage host server path (ex: "https://host/fcgi-bin/iipsrv.fcgi" or "/fcgi-bin/iipsrv.fcgi")
   * @param {String} image       - Image path and name on server (ex: "image.tif")
   * @param {String} [format]    - Tile output format (default: "jpg")
   * @param {Object} [transform] - Object containing image processing transforms
   *                                        (supported transform: "stack","quality","contrast","color","invert",
   *                                                              "colormap," "gamma","minmax","twist","hillshade".
   *                                        See https://iipimage.sourceforge.io/documentation/protocol for how to use)
   *
   * Example: tileSources: {
   *            iipsrv:    "/fcgi-bin/iipsrv.fcgi",
   *            image:     "test.tif",
   *            transform: {
   *              gamma: 1.5,
   *              invert: true
   *            }
   *          }
   */

  $.IIPTileSource = function(options) {

    $.EventSource.call( this );

    if( options && options.iipsrv && options.image ){
      $.extend( this, options );
      this.aspectRatio = 1;
      this.dimensions  = new $.Point( 10, 10 );
      this._tileWidth  = 0;
      this._tileHeight = 0;
      this.tileOverlap = 0;
      this.minLevel    = 0;
      this.maxLevel    = 0;
      this.ready       = false;

      // Query server for image metadata
      const url = this.getMetadataUrl();
      this.getImageInfo( url );
    }
  };


  $.extend($.IIPTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.IIPTileSource.prototype */ {

    /**
     * Return URL string for image metadata
     * @function
     * @returns {String} url - The IIP URL needed for image metadata
     */
    getMetadataUrl: function() {
      return this.iipsrv + '?FIF=' + this.image + '&obj=IIP,1.0&obj=Max-size&obj=Tile-size&obj=Resolution-number&obj=Resolutions';
    },


    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @function
     * @param {Object|Array} data
     * @param {String} [url]
     */
    supports: function(data, url) {
      // Configuration must supply the IIP server endpoint and the image name
      return ( data && ("iipsrv" in data) && ("image" in data) );
    },


    /**
     * Parse IIP protocol response
     * @function
     * @param {Object|Array} data - raw metadata from an IIP server
     */
    parseIIP: function( data ) {

      // Full image size
      let tmp = data.split( "Max-size:" );
      if(!tmp[1]){
        throw new Error( "No Max-size returned" );
      }
      let size = tmp[1].split(" ");
      this.width = parseInt( size[0], 10 );
      this.height = parseInt( size[1], 10 );
      this.dimensions = new $.Point( this.width, this.height );

      // Calculate aspect ratio
      this.aspectRatio = this.width / this.height;

      // Tile size
      tmp = data.split( "Tile-size:" );
      if(!tmp[1]){
        throw new Error( "No Tile-size returned" );
      }
      size = tmp[1].split(" ");
      this._tileWidth = parseInt(size[0], 10);
      this._tileHeight = parseInt(size[1], 10);

      // Number of resolution levels
      tmp = data.split( "Resolution-number:" );
      const numRes = parseInt(tmp[1], 10);
      this.minLevel = 0;
      this.maxLevel = numRes - 1;
      this.tileOverlap = 0;

      // Size of each resolution
      tmp = data.split( "Resolutions:" );
      size = tmp[1].split(",");
      const len = size.length;
      this.levelSizes = new Array(len);
      for( let n = 0; n < len; n++ ) {
        const res = size[n].split(" ");
        const w = parseInt(res[0], 10);
        const h = parseInt(res[1], 10);
        this.levelSizes[n] = {width: w, height: h};
      }
    },


    /**
     * Retrieve image metadata from an IIP-compatible server
     *
     * @function
     * @param {String} url
     * @throws {Error}
     */
    getImageInfo: function( url ) {

      const _this = this;

      $.makeAjaxRequest( {
        url: url,
        type: "GET",
        async: false,
        withCredentials: this.ajaxWithCredentials,
        headers: this.ajaxHeaders,
        success: function( xhr ) {
          try {
            OpenSeadragon[ "IIPTileSource" ].prototype.parseIIP.call( _this, xhr.responseText );
            _this.ready = true;
            _this.raiseEvent( 'ready', { tileSource: _this } );
          }
          catch( e ) {
            const msg = "IIPTileSource: Error parsing IIP metadata: " + e.message;
            _this.raiseEvent( 'open-failed', { message: msg, source: url } );
          }
        },
        error: function ( xhr, exc ) {
          const msg = "IIPTileSource: Unable to get IIP metadata from " + url;
          $.console.error( msg );
          _this.raiseEvent( 'open-failed', { message: msg, source: url });
        }
      });
    },


    /**
     * Parse and configure the image metadata
     * @function
     * @param {String|Object|Array|Document} data
     * @param {String} url - the url the data was loaded
     *      from if any.
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null value obtained from
     *      the protocol URL after '#' sign if flag splitHashDataForPost set to 'true'
     * @returns {Object} options - A dictionary of keyword arguments sufficient
     *      to configure the tile source constructor (include all values you want to
     *      instantiate the TileSource subclass with - what _options_ object should contain).
     * @throws {Error}
     */
    configure: function( options, url, postData ) {
      return options;
    },


    /**
     * @function
     * @param {Number} level
     */
    getNumTiles: function( level ) {
        const levelSize = this.levelSizes[level];
        let x = Math.ceil( levelSize.width / this._tileWidth );
        let y = Math.ceil( levelSize.height / this._tileHeight );
        return new $.Point( x, y );
    },


    /**
     * Determine the url which will return an image for the region specified by the given x, y, and level components.
     * Takes into account image processing parameters that have been set in constructor
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     */
    getTileUrl: function(level, x, y) {

      // Get the exact size of this level and calculate the number of tiles across
      const levelSize = this.levelSizes[level];
      const ntlx = Math.ceil( levelSize.width / this._tileWidth );

      // Set the base URL
      let url = this.iipsrv + '?FIF=' + this.image + '&';

      // Apply any image procesing transform
      if( this.transform ){

        if( this.transform.stack ) {
          url += 'SDS=' + this.transform.stack + '&';
        }
        if( this.transform.contrast ) {
          url += 'CNT=' + this.transform.contrast + '&';
        }
        if( this.transform.gamma ) {
          url += 'GAM=' + this.transform.gamma + '&';
        }
        if( this.transform.invert && this.transform.invert === true ) {
          url += 'INV&';
        }
        if( this.transform.color ) {
          url += 'COL=' + this.transform.color + '&';
        }
        if( this.transform.twist ) {
          url += 'CTW=' + this.transform.twist + '&';
        }
        if( this.transform.convolution ) {
          url += 'CNV=' + this.transform.convolution + '&';
        }
        if( this.transform.quality ) {
          url += 'QLT=' + this.transform.quality + '&';
        }
        if( this.transform.colormap ) {
          url += 'CMP=' + this.transform.colormap + '&';
        }
        if( this.transform.minmax ) {
          url += 'MINMAX=' + this.transform.minmax + '&';
        }
        if( this.transform.hillshade ) {
          url += 'SHD=' + this.transform.hillshade + '&';
        }
      }

      // Our output command depends on the requested image format
      let format = "JTL";
      if (this.format === "png") {
          format = "PTL";
      } else if (this.format === "webp" ) {
          format = "WTL";
      } else if (this.format === "avif" ) {
          format = "ATL";
      }

      // Calculate the tile index for this resolution
      const tile = (y * ntlx) + x;

      return url + format + '=' + level + ',' + tile;
    }

  });

  $.extend( true, $.IIPTileSource.prototype, $.EventSource.prototype );

}(OpenSeadragon));

/**
 * OpenSeadragon - IrisTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *
 *
 */

(function($) {

  /**
   * @class IrisTileSource
   * @classdesc A tilesource implementation for use with Iris images.
   *
   * @memberof OpenSeadragon
   * @extends OpenSeadragon.TileSource
   *
   * @param {String} type       - iris
   * @param {String} serverUrl  - Iris host server path (ex: "http://localhost:3000")
   * @param {String} slideId    - Image id (ex: "12345" for 12345.iris)
   * @param {Object} [metadata] - Optional metadata object to use instead of fetching
   *
   * Example: tileSources: {
   *            type:         "iris",
   *            serverUrl:    "http://localhost:3000",
   *            slideId:      "12345"
   *          }
   */

  $.IrisTileSource = function(options) {

    $.TileSource.apply(this, [options]);
    if (!options.serverUrl || !options.slideId) {
      throw new Error("IrisTileSource requires serverUrl and slideId");
    }
    this.serverUrl = options.serverUrl;
    this.slideId = options.slideId;
    this.ready = false;

    if (options.metadata) {
      this.parseMetadata(options.metadata);
      this.ready = true;
      this.raiseEvent('ready', { tileSource: this });
    } else {
      const url = this.getMetadataUrl();
      this.getImageInfo(url);
    }
  };

  $.extend($.IrisTileSource.prototype, $.TileSource.prototype, {
    /**
     * Return URL string for image metadata
     * @function
     * @returns {String} url - The Iris metadata URL
     */
    getMetadataUrl: function() {
      return this.serverUrl + '/slides/' + this.slideId + '/metadata';
    },

    /**
     * Determine if the data implies the image service is supported by this tile source.
     * @function
     * @param {Object} data - The raw metadata object to check
     * @returns {Boolean} - True if supported, false otherwise
     */
    supports: function(data) {
      return (data && data.type === "iris" && data.serverUrl && data.slideId);
    },

    /**
     * Parse Iris protocol metadata response
     * @function
     * @param {Object} data - Raw metadata from Iris server
     */
    parseMetadata: function(data) {
      this._tileWidth = 256;
      this._tileHeight = 256;

      this.tileSize = this._tileWidth;
      this.tileOverlap = 0;

      const layers = data.extent.layers;

      const maxLayer = layers.length - 1;
      const maxScale = layers[maxLayer].scale;

      this.width = Math.ceil(data.extent.width * maxScale);
      this.height = Math.ceil(data.extent.height * maxScale);

      this.dimensions = new $.Point(this.width, this.height);
      this.aspectRatio = this.width / this.height;
      this.levelSizes = layers.map(level => ({
        width: Math.ceil(level.x_tiles * this._tileWidth),
        height: Math.ceil(level.y_tiles * this._tileHeight),
        xTiles: Math.ceil(level.x_tiles),
        yTiles: Math.ceil(level.y_tiles)
      }));

      this.levelScales = layers.map(level => level.scale / maxScale);

      this.minLevel = 0;
      this.maxLevel = Math.ceil(this.levelSizes.length - 1);
    },

    /**
     * Retrieve image metadata from an Iris-compatible server
     * @function
     * @param {String} url - The metadata URL
     */
    getImageInfo: function(url) {
      const _this = this;

      $.makeAjaxRequest({
        url: url,
        type: "GET",
        async: true,
        success: function(xhr) {
          try {
            const data = JSON.parse(xhr.responseText);
            _this.parseMetadata(data);
            _this.ready = true;
            _this.raiseEvent('ready', { tileSource: _this });
          }
          catch (e) {
            const msg = "IrisTileSource: Error parsing metadata: " + e.message;
            $.console.error(msg);
            _this.raiseEvent('open-failed', { message: msg, source: url });
          }
        },
        error: function(xhr, exc) {
          const msg = "IrisTileSource: Unable to get metadata from " + url;
          $.console.error(msg);
          _this.raiseEvent('open-failed', { message: msg, source: url });
        }
      });
    },

    /**
     * Get the number of tiles at a given level
     * @function
     * @param {Number} level - The image depth level
     * @returns {OpenSeadragon.Point} - Number of tiles in x and y directions
     */
    getNumTiles: function(level) {
      if (level < this.minLevel || level > this.maxLevel || !this.levelSizes[level]) {
        return new $.Point(0, 0);
      }
      return new $.Point(
        Math.ceil(this.levelSizes[level].xTiles),
        Math.ceil(this.levelSizes[level].yTiles)
      );
    },

    /**
     * Determine the URL which will return an image for the region specified by the given x, y, and level components.
     * @function
     * @param {Number} level - The zoom level
     * @param {Number} x - The x tile index
     * @param {Number} y - The y tile index
     * @returns {String} - The tile URL
     */
    getTileUrl: function(level, x, y) {
      const pos = y * this.levelSizes[level].xTiles + x;
      return `${this.serverUrl}/slides/${this.slideId}/layers/${level}/tiles/${pos}`;
    },

    /**
     * Get the scale for a given level
     * @function
     * @param {Number} level - The image depth level
     * @returns {Number} - The scale for the level
     */
    getLevelScale: function(level) {
      return this.levelScales[level];
    },

    /**
     * Retrieve and immediately return the options object
     * @function
     * @param {Object} options - Options object
     * @returns {Object} - The options object
     */
    configure: function (options) {
      return options;
    }
  });

  $.extend(true, $.IrisTileSource.prototype, $.EventSource.prototype);

}(OpenSeadragon));

/*
 * OpenSeadragon - OsmTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/*
 * Derived from the OSM tile source in Rainer Simon's seajax-utils project
 * <http://github.com/rsimon/seajax-utils>.  Rainer Simon has contributed
 * the included code to the OpenSeadragon project under the New BSD license;
 * see <https://github.com/openseadragon/openseadragon/issues/58>.
 */


(function( $ ){

/**
 * @class OsmTileSource
 * @classdesc A tilesource implementation for OpenStreetMap.<br><br>
 *
 * Note 1. Zoomlevels. Deep Zoom and OSM define zoom levels differently. In  Deep
 * Zoom, level 0 equals an image of 1x1 pixels. In OSM, level 0 equals an image of
 * 256x256 levels (see http://gasi.ch/blog/inside-deep-zoom-2). I.e. there is a
 * difference of log2(256)=8 levels.<br><br>
 *
 * Note 2. Image dimension. According to the OSM Wiki
 * (http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Zoom_levels)
 * the highest Mapnik zoom level has 262.144x262.144 tiles, with a 256x256
 * pixel size. I.e. the Deep Zoom image dimension is 67.108.864x67.108.864
 * pixels.
 * OSM now supports higher max zoom (e.g. 19), but this default is
 * based on zoom level 18: 2^18 tiles * 256px.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.TileSource
 * @param {Number|Object} width - the pixel width of the image or the idiomatic
 *      options object which is used instead of positional arguments.
 * @param {Number} height
 * @param {Number} tileSize
 * @param {Number} tileOverlap
 * @param {String} tilesUrl
 */
$.OsmTileSource = function( width, height, tileSize, tileOverlap, tilesUrl ) {
    let options;

    if( $.isPlainObject( width ) ){
        options = width;
    }else{
        options = {
            width: arguments[0],
            height: arguments[1],
            tileSize: arguments[2],
            tileOverlap: arguments[3],
            tilesUrl: arguments[4]
        };
    }
    //apply default setting for standard public OpenStreatMaps service
    //but allow them to be specified so fliks can host there own instance
    //or apply against other services supportting the same standard
    if( !options.width || !options.height ){
        options.width = 67108864;
        options.height = 67108864;
    }
    if( !options.tileSize ){
        options.tileSize = 256;
        options.tileOverlap = 0;
    }
    if( !options.tilesUrl ){
        options.tilesUrl = "http://tile.openstreetmap.org/";
    }
    options.minLevel = 8;

    $.TileSource.apply( this, [ options ] );

};

$.extend( $.OsmTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.OsmTileSource.prototype */{


    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @function
     * @param {Object|Array} data
     * @param {String} [url]
     */
    supports: function( data, url ){
        return (
            data.type &&
            "openstreetmaps" === data.type
        );
    },

    /**
     *
     * @function
     * @param {Object} data - the raw configuration
     * @param {String} url - the url the data was retrieved from if any.
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
     * @returns {Object} options - A dictionary of keyword arguments sufficient
     *      to configure this tile sources constructor.
     */
    configure: function( data, url, postData ){
        return data;
    },


    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     */
    getTileUrl: function( level, x, y ) {
        return this.tilesUrl + (level - 8) + "/" + x + "/" + y + ".png";
    },

    /**
     * Equality comparator
     */
    equals: function(otherSource) {
        return otherSource && this.tilesUrl === otherSource.tilesUrl;
    }
});


}( OpenSeadragon ));

/*
 * OpenSeadragon - TmsTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/*
 * Derived from the TMS tile source in Rainer Simon's seajax-utils project
 * <http://github.com/rsimon/seajax-utils>.  Rainer Simon has contributed
 * the included code to the OpenSeadragon project under the New BSD license;
 * see <https://github.com/openseadragon/openseadragon/issues/58>.
 */


(function( $ ){

/**
 * @class TmsTileSource
 * @classdesc A tilesource implementation for Tiled Map Services (TMS).
 * TMS tile scheme ( [ as supported by OpenLayers ] is described here
 * ( http://openlayers.org/dev/examples/tms.html ).
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.TileSource
 * @param {Number|Object} width - the pixel width of the image or the idiomatic
 *      options object which is used instead of positional arguments.
 * @param {Number} height
 * @param {Number} tileSize
 * @param {Number} tileOverlap
 * @param {String} tilesUrl
 */
$.TmsTileSource = function( width, height, tileSize, tileOverlap, tilesUrl ) {
    let options;

    if( $.isPlainObject( width ) ){
        options = width;
    }else{
        options = {
            width: arguments[0],
            height: arguments[1],
            tileSize: arguments[2],
            tileOverlap: arguments[3],
            tilesUrl: arguments[4]
        };
    }
    // TMS has integer multiples of 256 for width/height and adds buffer
    // if necessary -> account for this!
    const bufferedWidth = Math.ceil(options.width / 256) * 256;
    const bufferedHeight = Math.ceil(options.height / 256) * 256;
    let max;

    // Compute number of zoomlevels in this tileset
    if (bufferedWidth > bufferedHeight) {
        max = bufferedWidth / 256;
    } else {
        max = bufferedHeight / 256;
    }
    options.maxLevel = Math.ceil(Math.log(max) / Math.log(2)) - 1;
    options.tileSize = 256;
    options.width = bufferedWidth;
    options.height = bufferedHeight;

    $.TileSource.apply( this, [ options ] );

};

$.extend( $.TmsTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.TmsTileSource.prototype */{


    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @function
     * @param {Object|Array} data
     * @param {String} [url]
     */
    supports: function( data, url ){
        return ( data.type && "tiledmapservice" === data.type );
    },

    /**
     *
     * @function
     * @param {Object} data - the raw configuration
     * @param {String} url - the url the data was retrieved from if any.
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
     * @returns {Object} options - A dictionary of keyword arguments sufficient
     *      to configure this tile sources constructor.
     */
    configure: function( data, url, postData ){
        return data;
    },


    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     */
    getTileUrl: function( level, x, y ) {
        // Convert from Deep Zoom definition to TMS zoom definition
        const yTiles = this.getNumTiles( level ).y - 1;

        return this.tilesUrl + level + "/" + x + "/" + (yTiles - y) + ".png";
    },

    /**
     * Equality comparator
     */
    equals: function (otherSource) {
        return otherSource && this.tilesUrl === otherSource.tilesUrl;
    }
});


}( OpenSeadragon ));

(function($) {

    /**
     * @class ZoomifyTileSource
     * @classdesc A tilesource implementation for the zoomify format.
     *
     * A description of the format can be found here:
     * https://ecommons.cornell.edu/bitstream/handle/1813/5410/Introducing_Zoomify_Image.pdf
     *
     * There are two ways of creating a zoomify tilesource for openseadragon
     *
     * 1) Supplying all necessary information in the tilesource object. A minimal example object for this method looks like this:
     *
     * {
     *      type: "zoomifytileservice",
     *      width: 1000,
     *      height: 1000,
     *      tilesUrl: "/test/data/zoomify/"
     * }
     *
     * The tileSize is set to 256 (the usual Zoomify default) when it is not defined. The tileUrl must the path to the image _directory_.
     *
     * 2) Loading image metadata from xml file: (CURRENTLY NOT SUPPORTED)
     *
     * When creating zoomify formatted images one "xml" like file with name ImageProperties.xml
     * will be created as well. Here is an example of such a file:
     *
     * <IMAGE_PROPERTIES WIDTH="1000" HEIGHT="1000" NUMTILES="21" NUMIMAGES="1" VERSION="1.8" TILESIZE="256" />
     *
     * To use this xml file as metadata source you must supply the path to the ImageProperties.xml file and leave out all other parameters:
     * As stated above, this method of loading a zoomify tilesource is currently not supported
     *
     * {
     *      type: "zoomifytileservice",
     *      tilesUrl: "/test/data/zoomify/ImageProperties.xml"
     * }

    *
    * @memberof OpenSeadragon
     * @extends OpenSeadragon.TileSource
     * @param {Number} width - the pixel width of the image.
     * @param {Number} height
     * @param {Number} tileSize
     * @param {String} tilesUrl
     */
    $.ZoomifyTileSource = function(options) {
        if(typeof options.tileSize === 'undefined'){
            options.tileSize = 256;
        }

        if(typeof options.fileFormat === 'undefined'){
            options.fileFormat = 'jpg';
            this.fileFormat = options.fileFormat;
        }

        const currentImageSize = {
            x: options.width,
            y: options.height
        };
        options.imageSizes = [{
            x: options.width,
            y: options.height
        }];
        options.gridSize = [this._getGridSize(options.width, options.height, options.tileSize)];

        while (parseInt(currentImageSize.x, 10) > options.tileSize || parseInt(currentImageSize.y, 10) > options.tileSize) {
            currentImageSize.x = Math.floor(currentImageSize.x / 2);
            currentImageSize.y = Math.floor(currentImageSize.y / 2);
            options.imageSizes.push({
                x: currentImageSize.x,
                y: currentImageSize.y
            });
            options.gridSize.push(this._getGridSize(currentImageSize.x, currentImageSize.y, options.tileSize));
        }
        options.imageSizes.reverse();
        options.gridSize.reverse();
        options.minLevel = 0;
        options.maxLevel = options.gridSize.length - 1;

        $.TileSource.apply(this, [options]);
    };

    $.extend($.ZoomifyTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.ZoomifyTileSource.prototype */ {

        //private
        _getGridSize: function(width, height, tileSize) {
            return {
                x: Math.ceil(width / tileSize),
                y: Math.ceil(height / tileSize)
            };
        },

        //private
        _calculateAbsoluteTileNumber: function(level, x, y) {
            let num = 0;
            let size = {};

            //Sum up all tiles below the level we want the number of tiles
            for (let z = 0; z < level; z++) {
                size = this.gridSize[z];
                num += size.x * size.y;
            }
            //Add the tiles of the level
            size = this.gridSize[level];
            num += size.x * y + x;
            return num;
        },

        /**
         * Determine if the data and/or url imply the image service is supported by
         * this tile source.
         * @function
         * @param {Object|Array} data
         * @param {String} [url]
         */
        supports: function(data, url) {
            return (data.type && "zoomifytileservice" === data.type);
        },

        /**
         *
         * @function
         * @param {Object} data - the raw configuration
         * @param {String} url - the url the data was retrieved from if any.
         * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
         * @returns {Object} options - A dictionary of keyword arguments sufficient
         *      to configure this tile sources constructor.
         */
        configure: function(data, url, postData) {
            return data;
        },

        /**
         * @function
         * @param {Number} level
         * @param {Number} x
         * @param {Number} y
         */
        getTileUrl: function(level, x, y) {
            //console.log(level);
            let result = 0;
            const num = this._calculateAbsoluteTileNumber(level, x, y);
            result = Math.floor(num / 256);
            return this.tilesUrl + 'TileGroup' + result + '/' + level + '-' + x + '-' + y + '.' + this.fileFormat;

        },

        /**
         * Equality comparator
         */
        equals: function (otherSource) {
            return otherSource && this.tilesUrl === otherSource.tilesUrl;
        }
    });

}(OpenSeadragon));


/*
 * OpenSeadragon - LegacyTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class LegacyTileSource
 * @classdesc The LegacyTileSource allows simple, traditional image pyramids to be loaded
 * into an OpenSeadragon Viewer.  Basically, this translates to the historically
 * common practice of starting with a 'master' image, maybe a tiff for example,
 * and generating a set of 'service' images like one or more thumbnails, a medium
 * resolution image and a high resolution image in standard web formats like
 * png or jpg.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.TileSource
 * @param {Array} levels An array of file descriptions, each is an object with
 *      a 'url', a 'width', and a 'height'.  Overriding classes can expect more
 *      properties but these properties are sufficient for this implementation.
 *      Additionally, the levels are required to be listed in order from
 *      smallest to largest.
 * @property {Number} aspectRatio
 * @property {Number} dimensions
 * @property {Number} tileSize
 * @property {Number} tileOverlap
 * @property {Number} minLevel
 * @property {Number} maxLevel
 * @property {Array}  levels
 */
$.LegacyTileSource = function( levels ) {

    let options;
    let width;
    let height;

    if( $.isArray( levels ) ){
        options = {
            type: 'legacy-image-pyramid',
            levels: levels
        };
    }

    //clean up the levels to make sure we support all formats
    options.levels = filterFiles( options.levels );

    if ( options.levels.length > 0 ) {
        width = options.levels[ options.levels.length - 1 ].width;
        height = options.levels[ options.levels.length - 1 ].height;
    }
    else {
        width = 0;
        height = 0;
        $.console.error( "No supported image formats found" );
    }

    $.extend( true, options, {
        width: width,
        height: height,
        tileSize: Math.max( height, width ),
        tileOverlap: 0,
        minLevel: 0,
        maxLevel: options.levels.length > 0 ? options.levels.length - 1 : 0
    } );

    $.TileSource.apply( this, [ options ] );

    this.levels = options.levels;
};

$.extend( $.LegacyTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.LegacyTileSource.prototype */{
    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @function
     * @param {Object|Array} data
     * @param {String} [url]
     */
    supports: function( data, url ){
        return (
            data.type &&
            "legacy-image-pyramid" === data.type
        ) || (
            data.documentElement &&
            "legacy-image-pyramid" === data.documentElement.getAttribute('type')
        );
    },


    /**
     *
     * @function
     * @param {Object|XMLDocument} configuration - the raw configuration
     * @param {String} dataUrl - the url the data was retrieved from if any.
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
     * @returns {Object} options - A dictionary of keyword arguments sufficient
     *      to configure this tile sources constructor.
     */
    configure: function( configuration, dataUrl, postData ){

        let options;

        if( !$.isPlainObject(configuration) ){

            options = configureFromXML( this, configuration );

        }else{

            options = configureFromObject( this, configuration );
        }

        return options;

    },

    /**
     * @function
     * @param {Number} level
     */
    getLevelScale: function ( level ) {
        let levelScale = NaN;
        if ( this.levels.length > 0 && level >= this.minLevel && level <= this.maxLevel ) {
            levelScale =
                this.levels[ level ].width /
                this.levels[ this.maxLevel ].width;
        }
        return levelScale;
    },

    /**
     * @function
     * @param {Number} level
     */
    getNumTiles: function( level ) {
        const scale = this.getLevelScale( level );
        if ( scale ){
            return new $.Point( 1, 1 );
        } else {
            return new $.Point( 0, 0 );
        }
    },

    /**
     * This method is not implemented by this class other than to throw an Error
     * announcing you have to implement it.  Because of the variety of tile
     * server technologies, and various specifications for building image
     * pyramids, this method is here to allow easy integration.
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @throws {Error}
     */
    getTileUrl: function ( level, x, y ) {
        let url = null;
        if ( this.levels.length > 0 && level >= this.minLevel && level <= this.maxLevel ) {
            url = this.levels[ level ].url;
        }
        return url;
    },

    /**
     * Equality comparator
     */
    equals: function (otherSource) {
        if (!otherSource || !otherSource.levels || otherSource.levels.length !== this.levels.length) {
            return false;
        }
        for (let i = this.minLevel; i <= this.maxLevel; i++) {
            if (this.levels[i].url !== otherSource.levels[i].url) {
                return false;
            }
        }
        return true;
    }
} );

/**
 * This method removes any files from the Array which don't conform to our
 * basic requirements for a 'level' in the LegacyTileSource.
 * @private
 * @inner
 * @function
 */
function filterFiles( files ){
    const filtered = [];
    let file;

    for( let i = 0; i < files.length; i++ ){
        file = files[ i ];
        if( file.height &&
            file.width &&
            file.url ){
            //This is sufficient to serve as a level
            filtered.push({
                url: file.url,
                width: Number( file.width ),
                height: Number( file.height )
            });
        }
        else {
            $.console.error( 'Unsupported image format: %s', file.url ? file.url : '<no URL>' );
        }
    }

    return filtered.sort(function(a, b) {
        return a.height - b.height;
    });

}

/**
 * @private
 * @inner
 * @function
 */
function configureFromXML( tileSource, xmlDoc ){

    if ( !xmlDoc || !xmlDoc.documentElement ) {
        throw new Error( $.getString( "Errors.Xml" ) );
    }

    const root         = xmlDoc.documentElement;
    const rootName     = root.tagName;
    let conf         = null;
    let levels       = [];
    let level;

    if ( rootName === "image" ) {

        try {
            conf = {
                type:        root.getAttribute( "type" ),
                levels:      []
            };

            levels = root.getElementsByTagName( "level" );
            for ( let i = 0; i < levels.length; i++ ) {
                level = levels[ i ];

                conf.levels.push({
                    url:    level.getAttribute( "url" ),
                    width:  parseInt( level.getAttribute( "width" ), 10 ),
                    height: parseInt( level.getAttribute( "height" ), 10 )
                });
            }

            return configureFromObject( tileSource, conf );

        } catch ( e ) {
            throw (e instanceof Error) ?
                e :
                new Error( 'Unknown error parsing Legacy Image Pyramid XML.' );
        }
    } else if ( rootName === "collection" ) {
        throw new Error( 'Legacy Image Pyramid Collections not yet supported.' );
    } else if ( rootName === "error" ) {
        throw new Error( 'Error: ' + xmlDoc );
    }

    throw new Error( 'Unknown element ' + rootName );
}

/**
 * @private
 * @inner
 * @function
 */
function configureFromObject( tileSource, configuration ){

    return configuration.levels;

}

}( OpenSeadragon ));

/*
 * OpenSeadragon - ImageTileSource
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
(function ($) {
/**
 * @class ImageTileSource
 * @classdesc The ImageTileSource allows a simple image to be loaded
 * into an OpenSeadragon Viewer.
 * There are 2 ways to open an ImageTileSource:
 * 1. viewer.open({type: 'image', url: fooUrl});
 * 2. viewer.open(new OpenSeadragon.ImageTileSource({url: fooUrl}));
 *
 * With the first syntax, the crossOriginPolicy, ajaxWithCredentials and
 * useCanvas options are inherited from the viewer if they are not
 * specified directly in the options object.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.TileSource
 * @param {Object} options Options object.
 * @param {String} options.url URL of the image
 * @param {Boolean} [options.buildPyramid=true] If set to true (default), a
 * pyramid will be built internally to provide a better downsampling.
 * @param {String|Boolean} [options.crossOriginPolicy=false] Valid values are
 * 'Anonymous', 'use-credentials', and false. If false, image requests will
 * not use CORS preventing internal pyramid building for images from other
 * domains.
 * @param {String|Boolean} [options.ajaxWithCredentials=false] Whether to set
 * the withCredentials XHR flag for AJAX requests (when loading tile sources).
 * @param {Boolean} [options.useCanvas=true] Set to false to prevent any use
 * of the canvas API.
 */
$.ImageTileSource = class extends $.TileSource {

    constructor(props) {
        super($.extend({
            buildPyramid: true,
            crossOriginPolicy: false,
            ajaxWithCredentials: false,
        }, props));
    }

    /**
     * Determine if the data and/or url imply the image service is supported by
     * this tile source.
     * @function
     * @param {Object|Array} data
     * @param {String} [url]
     */
    supports(data, url) {
        return data.type && data.type === "image";
    }
    /**
     *
     * @function
     * @param {Object} options - the options
     * @param {String} dataUrl - the url the image was retrieved from, if any.
     * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
     * @returns {Object} options - A dictionary of keyword arguments sufficient
     *      to configure this tile sources constructor.
     */
    configure(options, dataUrl, postData) {
        return options;
    }
    /**
     * Responsible for retrieving, and caching the
     * image metadata pertinent to this TileSources implementation.
     * @function
     * @param {String} url
     * @throws {Error}
     */
    getImageInfo(url) {
        const image = new Image(),
            _this = this;

        if (this.crossOriginPolicy) {
            image.crossOrigin = this.crossOriginPolicy;
        }

        $.addEvent(image, 'load', function () {
            _this.width = image.naturalWidth;
            _this.height = image.naturalHeight;
            _this.tileWidth = _this.width;
            _this.tileHeight = _this.height;
            _this.tileOverlap = 0;
            _this.minLevel = 0;
            _this.image = image;
            _this.levels = _this._buildLevels(image);
            _this.maxLevel = _this.levels.length - 1;

            // Note: this event is documented elsewhere, in TileSource
            _this.raiseEvent('ready', {tileSource: _this});
        });

        $.addEvent(image, 'error', function () {
            _this.image = null;
            // Note: this event is documented elsewhere, in TileSource
            _this.raiseEvent('open-failed', {
                message: "Error loading image at " + url,
                source: url
            });
        });

        image.src = url;
    }
    /**
     * @function
     * @param {Number} level
     */
    getLevelScale(level) {
        let levelScale = NaN;
        if (level >= this.minLevel && level <= this.maxLevel) {
            levelScale =
                this.levels[level].width /
                this.levels[this.maxLevel].width;
        }
        return levelScale;
    }
    /**
     * @function
     * @param {Number} level
     */
    getNumTiles(level) {
        if (this.getLevelScale(level)) {
            return new $.Point(1, 1);
        }
        return new $.Point(0, 0);
    }
    /**
     * Retrieves a tile url
     * @function
     * @param {Number} level Level of the tile
     * @param {Number} x x coordinate of the tile
     * @param {Number} y y coordinate of the tile
     */
    getTileUrl(level, x, y) {
        if (level === this.maxLevel) {
            return this.url; //for original image, preserve url
        }
        //make up url by positional args
        return `${this.url}?l=${level}&x=${x}&y=${y}`;
    }

    /**
     * Equality comparator
     */
    equals(otherSource) {
        return this.url === otherSource.url;
    }

    getTilePostData(level, x, y) {
        return {level: level, x: x, y: y};
    }

    /**
     * Retrieves a tile context 2D
     * @deprecated
     */
    getContext2D(level, x, y) {
        $.console.error('Using [TiledImage.getContext2D] (for plain images only) is deprecated. ' +
            'Use overridden downloadTileStart (https://openseadragon.github.io/examples/advanced-data-model/) instead.');
        return this._createContext2D();
    }

    downloadTileStart(job) {
        const tileData = job.postData;
        if (tileData.level === this.maxLevel) {
            job.finish(this.image, null, "image");
            return;
        }

        if (tileData.level >= this.minLevel && tileData.level <= this.maxLevel) {
            const levelData = this.levels[tileData.level];
            const context = this._createContext2D(this.image, levelData.width, levelData.height);
            job.finish(context, null, "context2d");
            return;
        }
        job.fail(`Invalid level ${tileData.level} for plain image source. Did you forget to set buildPyramid=true?`);
    }

    downloadTileAbort(job) {
        //no-op
    }

    // private
    //
    // Builds the different levels of the pyramid if possible
    // (i.e. if canvas API enabled and no canvas tainting issue).
    _buildLevels(image) {
        const levels = [{
            url: image.src,
            width: image.naturalWidth,
            height:  image.naturalHeight
        }];

        if (!this.buildPyramid || !$.supportsCanvas || !this.useCanvas) {
            return levels;
        }

        let currentWidth = image.naturalWidth,
            currentHeight = image.naturalHeight;
        // We build smaller levels until either width or height becomes
        // 2 pixel wide.
        while (currentWidth >= 2 && currentHeight >= 2) {
            currentWidth = Math.floor(currentWidth / 2);
            currentHeight = Math.floor(currentHeight / 2);

            levels.push({
                width: currentWidth,
                height: currentHeight,
            });
        }
        return levels.reverse();
    }


    _createContext2D(data, w, h) {
        const canvas = document.createElement("canvas"),
            context = canvas.getContext("2d");


        canvas.width = w;
        canvas.height = h;
        context.drawImage(data, 0, 0, w, h);
        return context;
    }
};

}(OpenSeadragon));

/*
 * OpenSeadragon - TileSourceCollection
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function($) {

// deprecated
$.TileSourceCollection = function(tileSize, tileSources, rows, layout) {
    $.console.error('TileSourceCollection is deprecated; use World instead');
};

}(OpenSeadragon));

/*
 * OpenSeadragon - Queue
 *
 * Copyright (C) 2024 OpenSeadragon contributors (modified)
 * Copyright (C) Google Inc., The Closure Library Authors.
 * https://github.com/google/closure-library
 *
 * SPDX-License-Identifier: Apache-2.0
 */

(function($) {

const OpenSeadragon = $; // alias for JSDoc

/**
 * @class OpenSeadragon.PriorityQueue
 * @classdesc Fast priority queue. Implemented as a Heap.
 */
OpenSeadragon.PriorityQueue = class PriorityQueue {

    /**
     * @param {?OpenSeadragon.PriorityQueue} optHeap Optional Heap or
     *     Object to initialize heap with.
     */
    constructor(optHeap = undefined) {
        /**
         * The nodes of the heap.
         *
         * This is a densely packed array containing all nodes of the heap, using
         * the standard flat representation of a tree as an array (i.e. element [0]
         * at the top, with [1] and [2] as the second row, [3] through [6] as the
         * third, etc). Thus, the children of element `i` are `2i+1` and `2i+2`, and
         * the parent of element `i` is `⌊(i-1)/2⌋`.
         *
         * The only invariant is that children's keys must be greater than parents'.
         *
         * @private
         */
        this.nodes_ = [];

        if (optHeap) {
            this.insertAll(optHeap);
        }
    }

    /**
     * Insert the given value into the heap with the given key.
     * @param {K} key The key.
     * @param {V} value The value.
     */
    insert(key, value) {
        this.insertNode(new Node(key, value));
    }

    /**
     * Insert node item.
     * @param node
     */
    insertNode(node) {
        const nodes = this.nodes_;
        node.index = nodes.length;
        nodes.push(node);
        this.moveUp_(node.index);
    }

    /**
     * Adds multiple key-value pairs from another Heap or Object
     * @param {?OpenSeadragon.PriorityQueue} heap Object containing the data to add.
     */
    insertAll(heap) {
        let keys, values;
        if (heap instanceof $.PriorityQueue) {
            keys = heap.getKeys();
            values = heap.getValues();

            // If it is a heap and the current heap is empty, I can rely on the fact
            // that the keys/values are in the correct order to put in the underlying
            // structure.
            if (this.getCount() <= 0) {
                const nodes = this.nodes_;
                for (let i = 0; i < keys.length; i++) {
                    const node = new Node(keys[i], values[i]);
                    node.index = nodes.length;
                    nodes.push(node);
                }
                return;
            }
        } else {
            throw "insertAll supports only OpenSeadragon.PriorityQueue object!";
        }

        for (let i = 0; i < keys.length; i++) {
            this.insert(keys[i], values[i]);
        }
    }

    /**
     * Retrieves and removes the root value of this heap.
     * @return {Node} The root node item removed from the root of the heap. Returns
     *     undefined if the heap is empty.
     */
    remove() {
        const nodes = this.nodes_;
        const count = nodes.length;
        const rootNode = nodes[0];
        if (count <= 0) {
            return undefined;
        } else if (count == 1) {  // eslint-disable-line
            nodes.length = 0;
        } else {
            nodes[0] = nodes.pop();
            if (nodes[0]) {
                nodes[0].index = 0;
            }
            this.moveDown_(0);
        }
        if (rootNode) {
            delete rootNode.index;
        }
        return rootNode;
    }

    /**
     * Retrieves but does not remove the root value of this heap.
     * @return {V} The value at the root of the heap. Returns
     *     undefined if the heap is empty.
     */
    peek() {
        const nodes = this.nodes_;
        if (nodes.length == 0) {  // eslint-disable-line
            return undefined;
        }
        return nodes[0].value;
    }

    /**
     * Retrieves but does not remove the key of the root node of this heap.
     * @return {string} The key at the root of the heap. Returns undefined if the
     *     heap is empty.
     */
    peekKey() {
        return this.nodes_[0] && this.nodes_[0].key;
    }

    /**
     * Move the node up in hierarchy
     * @param {Node} node the node
     * @param {K} key new ley, must be smaller than current key
     */
    decreaseKey(node, key) {
        if (node.index === undefined) {
            node.key = key;
            this.insertNode(node);
        } else {
            node.key = key;
            this.moveUp_(node.index);
        }
    }

    /**
     * Moves the node at the given index down to its proper place in the heap.
     * @param {number} index The index of the node to move down.
     * @private
     */
    moveDown_(index) {
        const nodes = this.nodes_;
        const count = nodes.length;

        // Save the node being moved down.
        const node = nodes[index];
        // While the current node has a child.
        while (index < (count >> 1)) {
            const leftChildIndex = this.getLeftChildIndex_(index);
            const rightChildIndex = this.getRightChildIndex_(index);

            // Determine the index of the smaller child.
            const smallerChildIndex = rightChildIndex < count &&
            nodes[rightChildIndex].key < nodes[leftChildIndex].key ?
                rightChildIndex :
                leftChildIndex;

            // If the node being moved down is smaller than its children, the node
            // has found the correct index it should be at.
            if (nodes[smallerChildIndex].key > node.key) {
                break;
            }

            // If not, then take the smaller child as the current node.
            nodes[index] = nodes[smallerChildIndex];
            nodes[index].index = index;
            index = smallerChildIndex;
        }
        nodes[index] = node;
        if (node) {
            node.index = index;
        }
    }

    /**
     * Moves the node at the given index up to its proper place in the heap.
     * @param {number} index The index of the node to move up.
     * @private
     */
    moveUp_(index) {
        const nodes = this.nodes_;
        const node = nodes[index];

        // While the node being moved up is not at the root.
        while (index > 0) {
            // If the parent is greater than the node being moved up, move the parent
            // down.
            const parentIndex = this.getParentIndex_(index);
            if (nodes[parentIndex].key > node.key) {
                nodes[index] = nodes[parentIndex];
                nodes[index].index = index;
                index = parentIndex;
            } else {
                break;
            }
        }
        nodes[index] = node;
        if (node) {
            node.index = index;
        }
    }

    /**
     * Gets the index of the left child of the node at the given index.
     * @param {number} index The index of the node to get the left child for.
     * @return {number} The index of the left child.
     * @private
     */
    getLeftChildIndex_(index) {
        return index * 2 + 1;
    }

    /**
     * Gets the index of the right child of the node at the given index.
     * @param {number} index The index of the node to get the right child for.
     * @return {number} The index of the right child.
     * @private
     */
    getRightChildIndex_(index) {
        return index * 2 + 2;
    }

    /**
     * Gets the index of the parent of the node at the given index.
     * @param {number} index The index of the node to get the parent for.
     * @return {number} The index of the parent.
     * @private
     */
    getParentIndex_(index) {
        return (index - 1) >> 1;
    }

    /**
     * Gets the values of the heap.
     * @return {!Array<*>} The values in the heap.
     */
    getValues() {
        return this.nodes_.map(n => n.value);
    }

    /**
     * Gets the keys of the heap.
     * @return {!Array<string>} The keys in the heap.
     */
    getKeys() {
        return this.nodes_.map(n => n.key);
    }

    /**
     * Whether the heap contains the given value.
     * @param {V} val The value to check for.
     * @return {boolean} Whether the heap contains the value.
     */
    containsValue(val) {
        return this.nodes_.some((node) => node.value == val);  // eslint-disable-line
    }

    /**
     * Whether the heap contains the given key.
     * @param {string} key The key to check for.
     * @return {boolean} Whether the heap contains the key.
     */
    containsKey(key) {
        return this.nodes_.some((node) => node.value == key);  // eslint-disable-line
    }

    /**
     * Clones a heap and returns a new heap
     * @return {!OpenSeadragon.PriorityQueue} A new Heap with the same key-value pairs.
     */
    clone() {
        return new $.PriorityQueue(this);
    }

    /**
     * The number of key-value pairs in the map
     * @return {number} The number of pairs.
     */
    getCount() {
        return this.nodes_.length;
    }

    /**
     * Returns true if this heap contains no elements.
     * @return {boolean} Whether this heap contains no elements.
     */
    isEmpty() {
        return this.nodes_.length === 0;
    }

    /**
     * Removes all elements from the heap.
     */
    clear() {
        this.nodes_.length = 0;
    }
};

/**
 * @private
 */
OpenSeadragon.PriorityQueue.Node = class Node {
    constructor(key, value) {
        /**
         * The key.
         * @type {K}
         * @private
         */
        this.key = key;

        /**
         * The value.
         * @type {V}
         * @private
         */
        this.value = value;

        /**
         * The node index value. Updated in the heap.
         * @type {number}
         * @private
         */
        this.index = 0;
    }

    clone() {
        return new Node(this.key, this.value);
    }
};

}(OpenSeadragon));

/*
 * OpenSeadragon.converter (static property)
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors

 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function($){

const OpenSeadragon = $; // alias for JSDoc

/**
 * modified from https://gist.github.com/Prottoy2938/66849e04b0bac459606059f5f9f3aa1a
 * @private
 */
class WeightedGraph {
    constructor() {
        this.adjacencyList = {};
        this.vertices = {};
    }

    /**
     * Add vertex to graph
     * @param vertex unique vertex ID
     * @return {boolean} true if inserted, false if exists (no-op)
     */
    addVertex(vertex) {
        if (!this.vertices[vertex]) {
            this.vertices[vertex] = new $.PriorityQueue.Node(0, vertex);
            this.adjacencyList[vertex] = [];
            return true;
        }
        return false;
    }

    /**
     * Add edge to graph
     * @param vertex1 id, must exist by calling addVertex()
     * @param vertex2 id, must exist by calling addVertex()
     * @param weight
     * @param transform function that transforms on path vertex1 -> vertex2
     * @return {boolean} true if new edge, false if replaced existing
     */
    addEdge(vertex1, vertex2, weight, transform) {
        if (weight < 0) {
            $.console.error("WeightedGraph: negative weights will make for invalid shortest path computation!");
        }
        const outgoingPaths = this.adjacencyList[vertex1],
            replacedEdgeIndex = outgoingPaths.findIndex(edge => edge.target === this.vertices[vertex2]),
            newEdge = { target: this.vertices[vertex2], origin: this.vertices[vertex1], weight, transform };
        if (replacedEdgeIndex < 0) {
            this.adjacencyList[vertex1].push(newEdge);
            return true;
        }
        this.adjacencyList[vertex1][replacedEdgeIndex] = newEdge;
        return false;
    }

    /**
     * @return {{path: ConversionStep[], cost: number}|undefined} cheapest path from start to finish
     */
    dijkstra(start, finish) {
        const path = []; //to return at end
        if (start === finish) {
            return { path: path, cost: 0 };
        }
        const nodes = new OpenSeadragon.PriorityQueue();
        let smallestNode;
        //build up initial state
        for (let vertex in this.vertices) {
            vertex = this.vertices[vertex];
            if (vertex.value === start) {
                vertex.key = 0; //keys are known distances
                nodes.insertNode(vertex);
            } else {
                vertex.key = Infinity;
                delete vertex.index;
            }
            vertex._previous = null;
        }
        // as long as there is something to visit
        while (nodes.getCount() > 0) {
            smallestNode = nodes.remove();
            if (smallestNode.value === finish) {
                break;
            }
            const neighbors = this.adjacencyList[smallestNode.value];
            for (const neighborKey in neighbors) {
                const edge = neighbors[neighborKey];
                //relax node
                const newCost = smallestNode.key + edge.weight;
                const nextNeighbor = edge.target;
                if (newCost < nextNeighbor.key) {
                    nextNeighbor._previous = smallestNode;
                    //key change
                    nodes.decreaseKey(nextNeighbor, newCost);
                }
            }
        }

        if (!smallestNode || !smallestNode._previous || smallestNode.value !== finish) {
            return undefined; //no path
        }

        const finalCost = smallestNode.key; //final weight last node

        // done, build the shortest path
        while (smallestNode._previous) {
            //backtrack
            const to = smallestNode.value,
                parent = smallestNode._previous,
                from = parent.value;

            path.push(this.adjacencyList[from].find(x => x.target.value === to));
            smallestNode = parent;
        }

        return {
            path: path.reverse(),
            cost: finalCost
        };
    }
}

let _imageConversionWorker;
let _conversionId = 0;
// id -> { resolve, reject, timer? }
const _pendingConversions = new Map();
let __warnedNoSAB = false;
const __hasSAB = typeof SharedArrayBuffer !== 'undefined' && self.crossOriginIsolated === true;

function getIBWorker() {
    if (_imageConversionWorker) {
        return _imageConversionWorker;
    }

    const code = `
self.onmessage = async (e) => {
  const { id, op, } = e.data;
  let error;
  try {
    if (op === 'decodeFromBlob') {
      const bmp = await createImageBitmap(e.data.blob, { colorSpaceConversion: 'none' });
      postMessage({ id, ok: true, bmp }, [bmp]);
      return;
    }
    if (op === 'decodeFromBytes') {
      const u8 = new Uint8Array(e.data.bytes);
      const b  = new Blob([u8], { type: e.data.mime || '' });
      const bmp = await createImageBitmap(b, { colorSpaceConversion: 'none' });
      postMessage({ id, ok: true, bmp }, [bmp]);
      return;
    }
    if (op === 'fetchDecode') {
      const res = await fetch(e.data.url, e.data.setup);
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const b = await res.blob();
      const bmp = await createImageBitmap(b, { colorSpaceConversion: 'none' });
      postMessage({ id, ok: true, bmp }, [bmp]);
      return;
    }
    error = 'Unknown op: ' + op;
  } catch (err) {
    error = String(err && err.message || err);
  }
  postMessage({ id, ok: false, err: error });
};
`;
    // eslint-disable-next-line compat/compat
    const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }));
    _imageConversionWorker = new Worker(url);

    _imageConversionWorker.onmessage = (e) => {
        const { id, ok, bmp, err } = e.data || {};
        const entry = _pendingConversions.get(id);
        if (!entry) {
            return;
        }
        _pendingConversions.delete(id);
        if (entry.timer) {
            clearTimeout(entry.timer);
            entry.timer = null;
        }
        if (ok) {
            entry.resolve(bmp);
        } else {
            entry.reject(new Error(err));
        }
    };

    _imageConversionWorker.onerror = (e) => {
        for (const [, entry] of _pendingConversions) {
            if (entry.timer) {
                clearTimeout(entry.timer);
                entry.timer = null;
            }
            entry.reject(new Error('Worker error'));
        }
        _pendingConversions.clear();
    };
    return _imageConversionWorker;
}

function postWorker(op, payload, { timeoutMs = 15000 } = {}) {
    const worker = getIBWorker();
    const id = ++_conversionId;

    return new $.Promise((resolve, reject) => {
        // possibly test $.supportsPromise here as well...
        payload.id = id;
        payload.op = op;

        const entry = { resolve, reject, timer: null };
        if (timeoutMs > 0) {
            entry.timer = setTimeout(() => {
                entry.timer = null;
                _pendingConversions.delete(id);
                reject(new Error(`Worker timeout (${op})`));
            }, timeoutMs);
        }
        _pendingConversions.set(id, entry);

        if (op === 'decodeFromBytes') {
            if (__hasSAB) {
                const u8 = payload.u8;
                // eslint-disable-next-line no-undef
                const sab = new SharedArrayBuffer(u8.byteLength);
                new Uint8Array(sab).set(u8);
                worker.postMessage({ id, op, bytes: sab, mime: payload.mime });
            } else {
                if (!__warnedNoSAB) {
                    __warnedNoSAB = true;
                    console.warn('[Converter] SharedArrayBuffer unavailable; falling back to ArrayBuffer.');
                }
                const u8 = payload.u8;
                const tight = (u8.byteOffset === 0 && u8.byteLength === u8.buffer.byteLength) ? u8 : u8.slice();
                worker.postMessage({ id, op, bytes: tight.buffer, mime: payload.mime }, [tight.buffer]);
            }
            return;
        }

        worker.postMessage(payload);
    });
}

/**
 * Edge.transform function on the conversion path in OpenSeadragon.converter.getConversionPath().
 *  It can be also conversion to undefined if used as destructor implementation.
 *
 * @callback TypeConverter
 * @memberof OpenSeadragon
 * @param {OpenSeadragon.Tile} tile reference tile that owns the data
 * @param {any} data data in the input format
 * @returns {any} data in the output format
 */

/**
 * Destructor called every time a data type is to be destroyed or converted to another type.
 *
 * @callback TypeDestructor
 * @memberof OpenSeadragon
 * @param {any} data data in the format the destructor is registered for
 * @returns {any} can return any value that is carried over to the caller if desirable.
 *   Note: not used by the OSD cache system.
 */

/**
 * Node on the conversion path in OpenSeadragon.converter.getConversionPath().
 *
 * @typedef {Object} ConversionStep
 * @memberof OpenSeadragon
 * @param {OpenSeadragon.PriorityQueue.Node} target - Target node of the conversion step.
 *  Its value is the target format.
 * @param {OpenSeadragon.PriorityQueue.Node} origin - Origin node of the conversion step.
 *  Its value is the origin format.
 * @param {number} weight cost of the conversion
 * @param {TypeConverter} transform the conversion itself
 */

/**
 * Class that orchestrates automated data types conversion. Do not instantiate
 * this class, use OpenSeadragon.converter - a global instance, instead.
 *
 * Types are defined to closely describe the data type, e.g. "url" is insufficient,
 * because url can point to many different data types. Another bad example is 'canvas'
 * as canvas can have different underlying rendering implementations and thus differ
 * in behavior. The following data types supported by
 * OpenSeadragon core are:
 * - "image" - HTMLImageElement, an <image> object
 * - "context2d" - HtmlRenderingContext2D, a 2D canvas context
 * - "rasterBlob" - Blob, a binary file-like object carrying image data
 * - "imageBitmap" - an ImageBitmap object
 *
 * The system uses these to deliver desired data from TileSource (which implements fetching logics)
 * through plugins to the renderer with preserving data type compatibility. Typical example is:
 *  TiledImage downloads and creates Image object with type 'image'. It submits
 *  to the system object of data type 'image'. The system runs this object through
 *  possible plugins integrated into the invalidation routine (by default none),
 *  and finishes by conversion for the WebGL renderer, which would most likely be "image"
 *  object, because the conversion in this case is not even necessary, as the drawer publishes
 *  the image type as one of its supported ones.
 *  If some plugin required context2d type, the pipeline would deliver this type and used
 *  it also for WebGL, as texture loading function accepts canvas object as well as image.
 *
 * @class OpenSeadragon.DataTypeConverter
 * @memberOf OpenSeadragon
 */
OpenSeadragon.DataTypeConverter = class DataTypeConverter {

    constructor() {
        this.graph = new WeightedGraph();
        this.destructors = {};
        this.copyings = {};

        // Teaching OpenSeadragon built-in conversions:
        const imageCreator = (tile, url) => new $.Promise((resolve, reject) => {
            if (!$.supportsAsync) {
                return reject("Not supported in sync mode!");
            }
            const img = new Image();
            img.onerror = img.onabort = e => reject(`Failed to load image: ${url}`);
            img.onload = () => resolve(img);
            if (tile.tiledImage && tile.tiledImage.crossOriginPolicy) {
                img.crossOrigin = tile.tiledImage.crossOriginPolicy;
            }
            img.src = url;
            return undefined;
        });
        const canvasContextCreator = (tile, imageData) => {
            const canvas = document.createElement('canvas');
            canvas.width = imageData.width;
            canvas.height = imageData.height;
            const context = canvas.getContext('2d', { willReadFrequently: true });
            context.drawImage(imageData, 0, 0);
            return context;
        };

        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
            // eslint-disable-next-line compat/compat
            const url = (window.URL || window.webkitURL).createObjectURL(blob);
            if (!$.supportsAsync) {
                return reject("Not supported in sync mode!");
            }
            const img = new Image();
            img.onerror = img.onabort = e => {
                // eslint-disable-next-line compat/compat
                (window.URL || window.webkitURL).revokeObjectURL(blob);
                reject(e);
            };
            img.onload = () => {
                // eslint-disable-next-line compat/compat
                (window.URL || window.webkitURL).revokeObjectURL(blob);
                resolve(img);
            };
            img.decoding = 'async';
            img.src = url;
            return undefined;
        }), 1, 2);

        this.learn("context2d", "rasterBlob", (tile, ctx) => new $.Promise((resolve, reject) => {
            if (!$.supportsAsync) {
                return reject("Not supported in sync mode!");
            }
            ctx.canvas.toBlob(resolve);
            return undefined;
        }), 1, 2);

        // rasterBlob -> imageBitmap (preferred fast path)
        this.learn("rasterBlob", "imageBitmap", (tile, blob) => new $.Promise((resolve, reject) => {
            if (!$.supportsAsync) {
                return reject("Not supported in sync mode!");
            }
            if (_imageConversionWorker) {
                postWorker('decodeFromBlob', { blob }).then(resolve).catch(reject);
            } else {
                // Fallback main thread
                createImageBitmap(blob, { colorSpaceConversion: 'none' }).then(resolve).catch(reject);
            }
            return undefined;
        }), 1, 1);

        this.learn("imageBitmap", "context2d", (tile, bmp) => {
            const canvas = document.createElement('canvas');
            canvas.width = bmp.width;
            canvas.height = bmp.height;
            const ctx = canvas.getContext('2d', { willReadFrequently: true });
            ctx.drawImage(bmp, 0, 0);
            return ctx;
        }, 1, 2);

        this.learn("image", "imageBitmap", (tile, img) => {
            return createImageBitmap(img, { colorSpaceConversion: 'none' });
        }, 1, 2);
        this.learn("image", "context2d", canvasContextCreator, 1, 2);

        //Copies
        this.learn("image", "image", (tile, image) => imageCreator(tile, image.src), 1, 1);
        this.learn("context2d", "context2d", (tile, ctx) => canvasContextCreator(tile, ctx.canvas));
        this.learn("rasterBlob", "rasterBlob", (tile, blob) => blob, 0, 1); //blobs are immutable, no need to copy
        this.learn("imageBitmap", "imageBitmap", (tile, bmp) => new $.Promise((resolve, reject) => {
            try {
                if (!$.supportsAsync) {
                    return reject("Not supported in sync mode!");
                }
                if (!bmp) {
                    return reject(new Error("No ImageBitmap to copy"));
                }

                if (typeof OffscreenCanvas !== 'undefined' && bmp.width && bmp.height) {
                    const oc = new OffscreenCanvas(bmp.width, bmp.height);
                    const ctx = oc.getContext('2d', { willReadFrequently: false });
                    ctx.drawImage(bmp, 0, 0);

                    if (typeof oc.transferToImageBitmap === 'function') {
                        const copy = oc.transferToImageBitmap();
                        return resolve(copy);
                    }
                    return createImageBitmap(oc, { colorSpaceConversion: 'none' }).then(resolve);
                }
                // Fallback
                return createImageBitmap(bmp, { colorSpaceConversion: 'none' }).then(resolve);
            } catch (e) {
                return reject(e);
            }
        }), 1, 1);
        /**
         * Free up canvas memory
         * (iOS 12 or higher on 2GB RAM device has only 224MB canvas memory,
         * and Safari keeps canvas until its height and width will be set to 0).
         */
        this.learnDestroy("context2d", ctx => {
            ctx.canvas.width = 0;
            ctx.canvas.height = 0;
        });
    }

    /**
     * Unique identifier (unlike toString.call(x)) to be guessed
     * from the data value. This type guess is more strict than
     * OpenSeadragon.type() implementation, but for most type recognition
     * this test relies on the output of OpenSeadragon.type().
     *
     * Note: although we try to implement the type guessing, do
     * not rely on this functionality! Prefer explicit type declaration.
     *
     * @function guessType
     * @param x object to get unique identifier for
     *  - can be array, in that case, alphabetically-ordered list of inner unique types
     *    is returned (null, undefined are ignored)
     *  - if $.isPlainObject(x) is true, then the object can define
     *    getType function to specify its type
     *  - otherwise, toString.call(x) is applied to get the parameter description
     * @return {string} unique variable descriptor
     */
    guessType(x) {
        if (Array.isArray(x)) {
            const types = [];
            for (const item of x) {
                if (item === undefined || item === null) {
                    continue;
                }

                const type = this.guessType(item);
                if (!types.includes(type)) {
                    types.push(type);
                }
            }
            types.sort();
            return `Array [${types.join(",")}]`;
        }

        const guessType = $.type(x);
        if (guessType === "dom-node") {
            //distinguish nodes
            return guessType.nodeName.toLowerCase();
        }

        if (guessType === "object") {
            if ($.isFunction(x.getType)) {
                return x.getType();
            }
        }
        return guessType;
    }

    /**
     * Teach the system to convert data type 'from' -> 'to'
     * @param {string} from unique ID of the data item 'from'
     * @param {string} to unique ID of the data item 'to'
     * @param {OpenSeadragon.TypeConverter} callback converter that takes two arguments: a tile reference, and
     *  a data object of a type 'from'; and converts this data object to type 'to'. It can return also the value
     *  wrapped in a Promise (returned in resolve) or it can be async function.
     * @param {Number} [costPower=0] positive cost class of the conversion, smaller or equal than 7.
     *   Should reflect the actual cost of the conversion:
     *   - if nothing must be done and only reference is retrieved (or a constant operation done),
     *     return 0 (default)
     *   - if a linear amount of work is necessary,
     *     return 1
     *   ... and so on, basically the number in O() complexity power exponent (for simplification)
     * @param {Number} [costMultiplier=1] multiplier of the cost class, e.g. O(3n^2) would
     *   use costPower=2, costMultiplier=3; can be between 1 and 10^5
     */
    learn(from, to, callback, costPower = 0, costMultiplier = 1) {
        $.console.assert(costPower >= 0 && costPower <= 7, "[DataTypeConverter] Conversion costPower must be between <0, 7>.");
        $.console.assert($.isFunction(callback), "[DataTypeConverter:learn] Callback must be a valid function!");

        if (from === to) {
            this.copyings[to] = callback;
        } else {
            //we won't know if somebody added multiple edges, though it will choose some edge anyway
            costPower++;
            costMultiplier = Math.min(Math.max(costMultiplier, 1), 10 ^ 5);
            this.graph.addVertex(from);
            this.graph.addVertex(to);
            this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback);
            this._known = {}; //invalidate precomputed paths :/
        }
    }

    /**
     * Teach the system to destroy data type 'type'
     * for example, textures loaded to GPU have to be also manually removed when not needed anymore.
     * Needs to be defined only when the created object has extra deletion process.
     * @param {string} type
     * @param {OpenSeadragon.TypeDestructor} callback destructor, receives the object created,
     *   it is basically a type conversion to 'undefined' - thus the type.
     */
    learnDestroy(type, callback) {
        this.destructors[type] = callback;
    }

    /**
     * Convert data item x of type 'from' to any of the 'to' types, chosen is the cheapest known conversion.
     * Data is destroyed upon conversion. For different behavior, implement your conversion using the
     * path rules obtained from getConversionPath().
     * Note: conversion DOES NOT COPY data if [to] contains type 'from' (e.g., the cheapest conversion is no conversion).
     * It automatically calls destructor on immediate types, but NOT on the x and the result. You should call these
     * manually if these should be destroyed.
     * @param {OpenSeadragon.Tile} tile
     * @param {any} data data item to convert
     * @param {string} from data item type
     * @param {string} to desired type(s)
     * @return {OpenSeadragon.Promise<?>} promise resolution with type 'to', or rejection if conversion failed.
     */
    convert(tile, data, from, ...to) {
        const conversionPath = this.getConversionPath(from, to);
        if (!conversionPath) {
            $.console.error(`[OpenSeadragon.converter.convert] Conversion ${from} ---> ${to} cannot be done!`);
            return $.Promise.resolve();
        }

        const stepCount = conversionPath.length;
        const _this = this;
        const step = (x, i, destroy = true) => {
            if (i >= stepCount) {
                return $.Promise.resolve(x);
            }
            const edge = conversionPath[i];
            let y;
            try {
                y = edge.transform(tile, x);
            } catch (err) {
                if (destroy) {
                    _this.destroy(x, edge.origin.value);
                }
                return $.Promise.reject(`[OpenSeadragon.converter.convert] sync failure (while converting using ${edge.origin.value} -> ${edge.target.value})`);
            }
            if (y === undefined) {
                if (destroy) {
                    _this.destroy(x, edge.origin.value);
                }
                return $.Promise.reject(`[OpenSeadragon.converter.convert] data mid result undefined value (while converting using ${edge.origin.value} -> ${edge.target.value})`);
            }
            //node.value holds the type string
            if (destroy) {
                _this.destroy(x, edge.origin.value);
            }
            const result = $.type(y) === "promise" ? y : $.Promise.resolve(y);
            return result.then(res => step(res, i + 1));
        };
        //destroy only mid-results, but not the original value
        return step(data, 0, false);
    }

    /**
     * Copy the data item given.
     * @param {OpenSeadragon.Tile} tile
     * @param {any} data data item to convert
     * @param {string} type data type
     * @return {OpenSeadragon.Promise<?>|undefined} promise resolution with data passed from constructor
     */
    copy(tile, data, type) {
        const copyTransform = this.copyings[type];
        if (copyTransform) {
            const y = copyTransform(tile, data);
            return $.type(y) === "promise" ? y : $.Promise.resolve(y);
        }
        $.console.warn(`[OpenSeadragon.converter.copy] is not supported with type %s`, type);
        return $.Promise.resolve(undefined);
    }

    /**
     * Destroy the data item given.
     * @param {string} type data type
     * @param {any} data
     * @return {OpenSeadragon.Promise<any>|undefined} promise resolution with data passed from constructor, or undefined
     *  if not such conversion exists
     */
    destroy(data, type) {
        const destructor = this.destructors[type];
        if (destructor) {
            const y = destructor(data);
            return $.type(y) === "promise" ? y : $.Promise.resolve(y);
        }
        return undefined;
    }

    /**
     * Get possible system type conversions and cache result.
     * @param {string} from data item type
     * @param {string|string[]} to array of accepted types
     * @return {ConversionStep[]|undefined} array of required conversions (returns empty array
     *  for from===to), or undefined if the system cannot convert between given types.
     *  Each object has 'transform' function that converts between neighbouring types, such
     *  that x = arr[i].transform(x) is valid input for converter arr[i+1].transform(), e.g.
     *  arr[i+1].transform(arr[i].transform( ... )) is a valid conversion procedure.
     *
     *  Note: if a function is returned, it is a callback called once the data is ready.
     */
    getConversionPath(from, to) {
        let bestConverterPath;
        let knownFrom = this._known[from];
        if (!knownFrom) {
            this._known[from] = knownFrom = {};
        }

        if (Array.isArray(to)) {
            $.console.assert(to.length > 0, "[getConversionPath] conversion 'to' type must be defined.");
            let bestCost = Infinity;

            for (const outType of to) {
                let conversion = knownFrom[outType];
                if (conversion === undefined) {
                    knownFrom[outType] = conversion = this.graph.dijkstra(from, outType);
                }
                if (conversion && bestCost > conversion.cost) {
                    bestConverterPath = conversion;
                    bestCost = conversion.cost;
                }
            }
        } else {
            $.console.assert(typeof to === "string", "[getConversionPath] conversion 'to' type must be defined.");
            bestConverterPath = knownFrom[to];
            if (bestConverterPath === undefined) {
                bestConverterPath = this.graph.dijkstra(from, to);
                this._known[from][to] = bestConverterPath;
            }
        }

        return bestConverterPath ? bestConverterPath.path : undefined;
    }

    /**
     * Get the final type of the conversion path.
     * @param {ConversionStep[]} path
     * @return {undefined|string}  undefined if invalid path
     */
    getConversionPathFinalType(path) {
        if (!path || !path.length) {
            return undefined;
        }
        return path[path.length - 1].target.value;
    }

    /**
     * Return a list of known conversion types
     * @return {string[]}
     */
    getKnownTypes() {
        return Object.keys(this.graph.vertices);
    }

    /**
     * Check whether given type is known to the converter
     * @param {string} type type to test
     * @return {boolean}
     */
    existsType(type) {
        return !!this.graph.vertices[type];
    }
};

/**
 * Static converter available throughout OpenSeadragon.
 *
 * Built-in conversions include types:
 *  - context2d    canvas 2d context
 *  - image        HTMLImage element
 *  - url    url string carrying or pointing to 2D raster data
 *  - canvas       HTMLCanvas element
 *
 * @type OpenSeadragon.DataTypeConverter
 * @memberOf OpenSeadragon
 */
$.converter = new $.DataTypeConverter();

// Image URL -> image private conversion, used in tests (was public originally, but made private to
// discourage bad practices by forcing conversion API to deal with URLs that download data
$.converter.learn("__private__imageUrl", "imageBitmap", (tile, url) => new $.Promise((resolve, reject) => {
    if (!$.supportsAsync) {
        return reject("Not supported in sync mode!");
    }
    let setup;
    if (tile.tiledImage && tile.tiledImage.crossOriginPolicy) {
        const policy = tile.tiledImage.crossOriginPolicy;
        if (policy === 'anonymous') {
            setup = {
                mode: 'cors',
                credentials: 'omit',
            };
        } else if (policy === 'use-credentials') {
            setup = {
                mode: 'cors',
                credentials: 'include',
            };
        } else if (policy) {
            $.console.error(`Unsupported crossOriginPolicy ${policy}. Ignoring the property.`);
        }
    }
    if (_imageConversionWorker) {
        return postWorker('fetchDecode', { url, setup }).then(resolve).catch(reject);
    }
    // Fallback to the main thread
    // eslint-disable-next-line compat/compat
    return fetch(url, setup).then(res => {
        if (!res.ok) {
            throw new Error(`HTTP ${res.status} loading ${url}`);
        }
        return res.blob();
    }).then(blob => createImageBitmap(blob, { colorSpaceConversion: 'none' })
    ).then(resolve).catch(reject);
}), 1, 1);
$.converter.learn("__private__imageUrl", "__private__imageUrl", (tile, url) => url, 0, 1); //strings are immutable, no need to copy
}(OpenSeadragon));

/*
 * OpenSeadragon - Button
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * An enumeration of button states
 * @member ButtonState
 * @memberof OpenSeadragon
 * @static
 * @type {Object}
 * @property {Number} REST
 * @property {Number} GROUP
 * @property {Number} HOVER
 * @property {Number} DOWN
 */
$.ButtonState = {
    REST:   0,
    GROUP:  1,
    HOVER:  2,
    DOWN:   3
};

/**
 * @class Button
 * @classdesc Manages events, hover states for individual buttons, tool-tips, as well
 * as fading the buttons out when the user has not interacted with them
 * for a specified period.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.EventSource
 * @param {Object} options
 * @param {Element} [options.element=null] Element to use as the button. If not specified, an HTML &lt;div&gt; element is created.
 * @param {String} [options.tooltip=null] Provides context help for the button when the
 *  user hovers over it.
 * @param {String} [options.srcRest=null] URL of image to use in 'rest' state.
 * @param {String} [options.srcGroup=null] URL of image to use in 'up' state.
 * @param {String} [options.srcHover=null] URL of image to use in 'hover' state.
 * @param {String} [options.srcDown=null] URL of image to use in 'down' state.
 * @param {Number} [options.fadeDelay=0] How long to wait before fading.
 * @param {Number} [options.fadeLength=2000] How long should it take to fade the button.
 * @param {OpenSeadragon.EventHandler} [options.onPress=null] Event handler callback for {@link OpenSeadragon.Button.event:press}.
 * @param {OpenSeadragon.EventHandler} [options.onRelease=null] Event handler callback for {@link OpenSeadragon.Button.event:release}.
 * @param {OpenSeadragon.EventHandler} [options.onClick=null] Event handler callback for {@link OpenSeadragon.Button.event:click}.
 * @param {OpenSeadragon.EventHandler} [options.onEnter=null] Event handler callback for {@link OpenSeadragon.Button.event:enter}.
 * @param {OpenSeadragon.EventHandler} [options.onExit=null] Event handler callback for {@link OpenSeadragon.Button.event:exit}.
 * @param {OpenSeadragon.EventHandler} [options.onFocus=null] Event handler callback for {@link OpenSeadragon.Button.event:focus}.
 * @param {OpenSeadragon.EventHandler} [options.onBlur=null] Event handler callback for {@link OpenSeadragon.Button.event:blur}.
 * @param {Object} [options.userData=null] Arbitrary object to be passed unchanged to any attached handler methods.
 */
$.Button = function( options ) {

    const _this = this;

    $.EventSource.call( this );

    $.extend( true, this, {

        tooltip:            null,
        srcRest:            null,
        srcGroup:           null,
        srcHover:           null,
        srcDown:            null,
        clickTimeThreshold: $.DEFAULT_SETTINGS.clickTimeThreshold,
        clickDistThreshold: $.DEFAULT_SETTINGS.clickDistThreshold,
        /**
         * How long to wait before fading.
         * @member {Number} fadeDelay
         * @memberof OpenSeadragon.Button#
         */
        fadeDelay:          0,
        /**
         * How long should it take to fade the button.
         * @member {Number} fadeLength
         * @memberof OpenSeadragon.Button#
         */
        fadeLength:         2000,
        onPress:            null,
        onRelease:          null,
        onClick:            null,
        onEnter:            null,
        onExit:             null,
        onFocus:            null,
        onBlur:             null,
        userData:           null

    }, options );

    /**
     * The button element.
     * @member {Element} element
     * @memberof OpenSeadragon.Button#
     */
    this.element = options.element || $.makeNeutralElement("div");

    //if the user has specified the element to bind the control to explicitly
    //then do not add the default control images
    if ( !options.element ) {
        this.imgRest      = $.makeTransparentImage( this.srcRest );
        this.imgGroup     = $.makeTransparentImage( this.srcGroup );
        this.imgHover     = $.makeTransparentImage( this.srcHover );
        this.imgDown      = $.makeTransparentImage( this.srcDown );

        this.imgRest.alt  =
        this.imgGroup.alt =
        this.imgHover.alt =
        this.imgDown.alt  =
            this.tooltip;

        // Allow pointer events to pass through the img elements so implicit
        //   pointer capture works on touch devices
        $.setElementPointerEventsNone( this.imgRest );
        $.setElementPointerEventsNone( this.imgGroup );
        $.setElementPointerEventsNone( this.imgHover );
        $.setElementPointerEventsNone( this.imgDown );

        this.element.style.position = "relative";
        $.setElementTouchActionNone( this.element );

        this.imgGroup.style.position =
        this.imgHover.style.position =
        this.imgDown.style.position  =
            "absolute";

        this.imgGroup.style.top =
        this.imgHover.style.top =
        this.imgDown.style.top  =
            "0px";

        this.imgGroup.style.left =
        this.imgHover.style.left =
        this.imgDown.style.left  =
            "0px";

        this.imgHover.style.visibility =
        this.imgDown.style.visibility  =
            "hidden";

        this.element.appendChild( this.imgRest );
        this.element.appendChild( this.imgGroup );
        this.element.appendChild( this.imgHover );
        this.element.appendChild( this.imgDown );
    }


    this.addHandler("press", this.onPress);
    this.addHandler("release", this.onRelease);
    this.addHandler("click", this.onClick);
    this.addHandler("enter", this.onEnter);
    this.addHandler("exit", this.onExit);
    this.addHandler("focus", this.onFocus);
    this.addHandler("blur", this.onBlur);

    /**
     * The button's current state.
     * @member {OpenSeadragon.ButtonState} currentState
     * @memberof OpenSeadragon.Button#
     */
    this.currentState = $.ButtonState.GROUP;

    // When the button last began to fade.
    this.fadeBeginTime  = null;
    // Whether this button should fade after user stops interacting with the viewport.
    this.shouldFade     = false;

    this.element.style.display  = "inline-block";
    this.element.style.position = "relative";
    this.element.title          = this.tooltip;

    /**
     * Tracks mouse/touch/key events on the button.
     * @member {OpenSeadragon.MouseTracker} tracker
     * @memberof OpenSeadragon.Button#
     */
    this.tracker = new $.MouseTracker({

        userData:           'Button.tracker',
        element:            this.element,
        clickTimeThreshold: this.clickTimeThreshold,
        clickDistThreshold: this.clickDistThreshold,

        enterHandler: function( event ) {
            if ( event.insideElementPressed ) {
                inTo( _this, $.ButtonState.DOWN );
                /**
                 * Raised when the cursor enters the Button element.
                 *
                 * @event enter
                 * @memberof OpenSeadragon.Button
                 * @type {object}
                 * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
                 * @property {Object} originalEvent - The original DOM event.
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent( "enter", { originalEvent: event.originalEvent } );
            } else if ( !event.buttonDownAny ) {
                inTo( _this, $.ButtonState.HOVER );
            }
        },

        focusHandler: function ( event ) {
            _this.tracker.enterHandler( event );
            /**
             * Raised when the Button element receives focus.
             *
             * @event focus
             * @memberof OpenSeadragon.Button
             * @type {object}
             * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
             * @property {Object} originalEvent - The original DOM event.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            _this.raiseEvent( "focus", { originalEvent: event.originalEvent } );
        },

        leaveHandler: function( event ) {
            outTo( _this, $.ButtonState.GROUP );
            if ( event.insideElementPressed ) {
                /**
                 * Raised when the cursor leaves the Button element.
                 *
                 * @event exit
                 * @memberof OpenSeadragon.Button
                 * @type {object}
                 * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
                 * @property {Object} originalEvent - The original DOM event.
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent( "exit", { originalEvent: event.originalEvent } );
            }
        },

        blurHandler: function ( event ) {
            _this.tracker.leaveHandler( event );
            /**
             * Raised when the Button element loses focus.
             *
             * @event blur
             * @memberof OpenSeadragon.Button
             * @type {object}
             * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
             * @property {Object} originalEvent - The original DOM event.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            _this.raiseEvent( "blur", { originalEvent: event.originalEvent } );
        },

        pressHandler: function ( event ) {
            inTo( _this, $.ButtonState.DOWN );
            /**
             * Raised when a mouse button is pressed or touch occurs in the Button element.
             *
             * @event press
             * @memberof OpenSeadragon.Button
             * @type {object}
             * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
             * @property {Object} originalEvent - The original DOM event.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            _this.raiseEvent( "press", { originalEvent: event.originalEvent } );
        },

        releaseHandler: function( event ) {
            if ( event.insideElementPressed && event.insideElementReleased ) {
                outTo( _this, $.ButtonState.HOVER );
                /**
                 * Raised when the mouse button is released or touch ends in the Button element.
                 *
                 * @event release
                 * @memberof OpenSeadragon.Button
                 * @type {object}
                 * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
                 * @property {Object} originalEvent - The original DOM event.
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent( "release", { originalEvent: event.originalEvent } );
            } else if ( event.insideElementPressed ) {
                outTo( _this, $.ButtonState.GROUP );
            } else {
                inTo( _this, $.ButtonState.HOVER );
            }
        },

        clickHandler: function( event ) {
            if ( event.quick ) {
                /**
                 * Raised when a mouse button is pressed and released or touch is initiated and ended in the Button element within the time and distance threshold.
                 *
                 * @event click
                 * @memberof OpenSeadragon.Button
                 * @type {object}
                 * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
                 * @property {Object} originalEvent - The original DOM event.
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent("click", { originalEvent: event.originalEvent });
            }
        },

        keyHandler: function( event ){
            //console.log( "%s : handling key %s!", _this.tooltip, event.keyCode);
            if( 13 === event.keyCode ){
                /***
                 * Raised when a mouse button is pressed and released or touch is initiated and ended in the Button element within the time and distance threshold.
                 *
                 * @event click
                 * @memberof OpenSeadragon.Button
                 * @type {object}
                 * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
                 * @property {Object} originalEvent - The original DOM event.
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent( "click", { originalEvent: event.originalEvent } );
                /***
                 * Raised when the mouse button is released or touch ends in the Button element.
                 *
                 * @event release
                 * @memberof OpenSeadragon.Button
                 * @type {object}
                 * @property {OpenSeadragon.Button} eventSource - A reference to the Button which raised the event.
                 * @property {Object} originalEvent - The original DOM event.
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                _this.raiseEvent( "release", { originalEvent: event.originalEvent } );

                event.preventDefault = true;
            } else{
                event.preventDefault = false;
            }
        }

    });

    outTo( this, $.ButtonState.REST );
};

$.extend( $.Button.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.Button.prototype */{

    /**
     * Used by a button container element (e.g. a ButtonGroup) to transition the button state
     * to ButtonState.GROUP.
     * @function
     */
    notifyGroupEnter: function() {
        inTo( this, $.ButtonState.GROUP );
    },

    /**
     * Used by a button container element (e.g. a ButtonGroup) to transition the button state
     * to ButtonState.REST.
     * @function
     */
    notifyGroupExit: function() {
        outTo( this, $.ButtonState.REST );
    },

    /**
     * @function
     */
    disable: function(){
        this.notifyGroupExit();
        this.element.disabled = true;
        this.tracker.setTracking(false);
        $.setElementOpacity( this.element, 0.2, true );
    },

    /**
     * @function
     */
    enable: function(){
        this.element.disabled = false;
        this.tracker.setTracking(true);
        $.setElementOpacity( this.element, 1.0, true );
        this.notifyGroupEnter();
    },

    destroy: function() {
        if (this.imgRest) {
            this.element.removeChild(this.imgRest);
            this.imgRest = null;
        }
        if (this.imgGroup) {
            this.element.removeChild(this.imgGroup);
            this.imgGroup = null;
        }
        if (this.imgHover) {
            this.element.removeChild(this.imgHover);
            this.imgHover = null;
        }
        if (this.imgDown) {
            this.element.removeChild(this.imgDown);
            this.imgDown = null;
        }
        this.removeAllHandlers();
        this.tracker.destroy();
        this.element = null;
    }

});


function scheduleFade( button ) {
    $.requestAnimationFrame(function(){
        updateFade( button );
    });
}

function updateFade( button ) {
    let currentTime,
        deltaTime,
        opacity;

    if ( button.shouldFade ) {
        currentTime = $.now();
        deltaTime   = currentTime - button.fadeBeginTime;
        opacity     = 1.0 - deltaTime / button.fadeLength;
        opacity     = Math.min( 1.0, opacity );
        opacity     = Math.max( 0.0, opacity );

        if( button.imgGroup ){
            $.setElementOpacity( button.imgGroup, opacity, true );
        }
        if ( opacity > 0 ) {
            // fade again
            scheduleFade( button );
        }
    }
}

function beginFading( button ) {
    button.shouldFade = true;
    button.fadeBeginTime = $.now() + button.fadeDelay;
    window.setTimeout( function(){
        scheduleFade( button );
    }, button.fadeDelay );
}

function stopFading( button ) {
    button.shouldFade = false;
    if( button.imgGroup ){
        $.setElementOpacity( button.imgGroup, 1.0, true );
    }
}

function inTo( button, newState ) {

    if( button.element.disabled ){
        return;
    }

    if ( newState >= $.ButtonState.GROUP &&
         button.currentState === $.ButtonState.REST ) {
        stopFading( button );
        button.currentState = $.ButtonState.GROUP;
    }

    if ( newState >= $.ButtonState.HOVER &&
         button.currentState === $.ButtonState.GROUP ) {
        if( button.imgHover ){
            button.imgHover.style.visibility = "";
        }
        button.currentState = $.ButtonState.HOVER;
    }

    if ( newState >= $.ButtonState.DOWN &&
         button.currentState === $.ButtonState.HOVER ) {
        if( button.imgDown ){
            button.imgDown.style.visibility = "";
        }
        button.currentState = $.ButtonState.DOWN;
    }
}


function outTo( button, newState ) {

    if( button.element.disabled ){
        return;
    }

    if ( newState <= $.ButtonState.HOVER &&
         button.currentState === $.ButtonState.DOWN ) {
        if( button.imgDown ){
            button.imgDown.style.visibility = "hidden";
        }
        button.currentState = $.ButtonState.HOVER;
    }

    if ( newState <= $.ButtonState.GROUP &&
         button.currentState === $.ButtonState.HOVER ) {
        if( button.imgHover ){
            button.imgHover.style.visibility = "hidden";
        }
        button.currentState = $.ButtonState.GROUP;
    }

    if ( newState <= $.ButtonState.REST &&
         button.currentState === $.ButtonState.GROUP ) {
        beginFading( button );
        button.currentState = $.ButtonState.REST;
    }
}



}( OpenSeadragon ));

/*
 * OpenSeadragon - ButtonGroup
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){
/**
 * @class ButtonGroup
 * @classdesc Manages events on groups of buttons.
 *
 * @memberof OpenSeadragon
 * @param {Object} options - A dictionary of settings applied against the entire group of buttons.
 * @param {Array} options.buttons Array of buttons
 * @param {Element} [options.element] Element to use as the container
 **/
$.ButtonGroup = function( options ) {

    $.extend( true, this, {
        /**
         * An array containing the buttons themselves.
         * @member {Array} buttons
         * @memberof OpenSeadragon.ButtonGroup#
         */
        buttons:            [],
        clickTimeThreshold: $.DEFAULT_SETTINGS.clickTimeThreshold,
        clickDistThreshold: $.DEFAULT_SETTINGS.clickDistThreshold,
        labelText:          ""
    }, options );

    // copy the button elements  TODO: Why?
    let buttons = this.buttons.concat([]),
        _this = this,
        i;

    /**
     * The shared container for the buttons.
     * @member {Element} element
     * @memberof OpenSeadragon.ButtonGroup#
     */
    this.element = options.element || $.makeNeutralElement( "div" );

    // TODO What if there IS an options.group specified?
    if( !options.group ){
        this.element.style.display = "inline-block";
        //this.label   = $.makeNeutralElement( "label" );
        //TODO: support labels for ButtonGroups
        //this.label.innerHTML = this.labelText;
        //this.element.appendChild( this.label );
        for ( i = 0; i < buttons.length; i++ ) {
            this.element.appendChild( buttons[ i ].element );
        }
    }

    $.setElementTouchActionNone( this.element );

    /**
     * Tracks mouse/touch/key events across the group of buttons.
     * @member {OpenSeadragon.MouseTracker} tracker
     * @memberof OpenSeadragon.ButtonGroup#
     */
    this.tracker = new $.MouseTracker({
        userData:           'ButtonGroup.tracker',
        element:            this.element,
        clickTimeThreshold: this.clickTimeThreshold,
        clickDistThreshold: this.clickDistThreshold,
        enterHandler: function ( event ) {
            let i;
            for ( i = 0; i < _this.buttons.length; i++ ) {
                _this.buttons[ i ].notifyGroupEnter();
            }
        },
        leaveHandler: function ( event ) {
            let i;
            if ( !event.insideElementPressed ) {
                for ( i = 0; i < _this.buttons.length; i++ ) {
                    _this.buttons[ i ].notifyGroupExit();
                }
            }
        },
    });
};

/** @lends OpenSeadragon.ButtonGroup.prototype */
$.ButtonGroup.prototype = {

    /**
     * Adds the given button to this button group.
     *
     * @function
     * @param {OpenSeadragon.Button} button
     */
    addButton: function( button ){
        this.buttons.push(button);
        this.element.appendChild(button.element);
    },

    /**
     * TODO: Figure out why this is used on the public API and if a more useful
     * api can be created.
     * @function
     * @private
     */
    emulateEnter: function() {
        this.tracker.enterHandler( { eventSource: this.tracker } );
    },

    /**
     * TODO: Figure out why this is used on the public API and if a more useful
     * api can be created.
     * @function
     * @private
     */
    emulateLeave: function() {
        this.tracker.leaveHandler( { eventSource: this.tracker } );
    },

    destroy: function() {
        while (this.buttons.length) {
            const button = this.buttons.pop();
            this.element.removeChild(button.element);
            button.destroy();
        }
        this.tracker.destroy();
        this.element = null;
    }
};


}( OpenSeadragon ));

/*
 * OpenSeadragon - Rect
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function($) {

/**
 * @class Rect
 * @classdesc A Rectangle is described by it top left coordinates (x, y), width,
 * height and degrees of rotation around (x, y).
 * Note that the coordinate system used is the one commonly used with images:
 * x increases when going to the right
 * y increases when going to the bottom
 * degrees increases clockwise with 0 being the horizontal
 *
 * The constructor normalizes the rectangle to always have 0 <= degrees < 90
 *
 * @memberof OpenSeadragon
 * @param {Number} [x=0] The vector component 'x'.
 * @param {Number} [y=0] The vector component 'y'.
 * @param {Number} [width=0] The vector component 'width'.
 * @param {Number} [height=0] The vector component 'height'.
 * @param {Number} [degrees=0] Rotation of the rectangle around (x,y) in degrees.
 */
$.Rect = function(x, y, width, height, degrees) {
    /**
     * The vector component 'x'.
     * @member {Number} x
     * @memberof OpenSeadragon.Rect#
     */
    this.x = typeof (x) === "number" ? x : 0;
    /**
     * The vector component 'y'.
     * @member {Number} y
     * @memberof OpenSeadragon.Rect#
     */
    this.y = typeof (y) === "number" ? y : 0;
    /**
     * The vector component 'width'.
     * @member {Number} width
     * @memberof OpenSeadragon.Rect#
     */
    this.width  = typeof (width) === "number" ? width : 0;
    /**
     * The vector component 'height'.
     * @member {Number} height
     * @memberof OpenSeadragon.Rect#
     */
    this.height = typeof (height) === "number" ? height : 0;

    /**
     * The rotation of the rectangle, in degrees.
     * @member {Number} degrees
     * @memberof OpenSeadragon.Rect#
     */
    this.degrees = typeof (degrees) === "number" ? degrees : 0;

    // Normalizes the rectangle.
    this.degrees = $.positiveModulo(this.degrees, 360);
    let newTopLeft, newWidth;
    if (this.degrees >= 270) {
        newTopLeft = this.getTopRight();
        this.x = newTopLeft.x;
        this.y = newTopLeft.y;
        newWidth = this.height;
        this.height = this.width;
        this.width = newWidth;
        this.degrees -= 270;
    } else if (this.degrees >= 180) {
        newTopLeft = this.getBottomRight();
        this.x = newTopLeft.x;
        this.y = newTopLeft.y;
        this.degrees -= 180;
    } else if (this.degrees >= 90) {
        newTopLeft = this.getBottomLeft();
        this.x = newTopLeft.x;
        this.y = newTopLeft.y;
        newWidth = this.height;
        this.height = this.width;
        this.width = newWidth;
        this.degrees -= 90;
    }
};

/**
 * Builds a rectangle having the 3 specified points as summits.
 * @static
 * @memberof OpenSeadragon.Rect
 * @param {OpenSeadragon.Point} topLeft
 * @param {OpenSeadragon.Point} topRight
 * @param {OpenSeadragon.Point} bottomLeft
 * @returns {OpenSeadragon.Rect}
 */
$.Rect.fromSummits = function(topLeft, topRight, bottomLeft) {
    const width = topLeft.distanceTo(topRight);
    const height = topLeft.distanceTo(bottomLeft);
    const diff = topRight.minus(topLeft);
    let radians = Math.atan(diff.y / diff.x);
    if (diff.x < 0) {
        radians += Math.PI;
    } else if (diff.y < 0) {
        radians += 2 * Math.PI;
    }
    return new $.Rect(
        topLeft.x,
        topLeft.y,
        width,
        height,
        radians / Math.PI * 180);
};

/** @lends OpenSeadragon.Rect.prototype */
$.Rect.prototype = {
    /**
     * @function
     * @returns {OpenSeadragon.Rect} a duplicate of this Rect
     */
    clone: function() {
        return new $.Rect(
            this.x,
            this.y,
            this.width,
            this.height,
            this.degrees);
    },

    /**
     * The aspect ratio is simply the ratio of width to height.
     * @function
     * @returns {Number} The ratio of width to height.
     */
    getAspectRatio: function() {
        return this.width / this.height;
    },

    /**
     * Provides the coordinates of the upper-left corner of the rectangle as a
     * point.
     * @function
     * @returns {OpenSeadragon.Point} The coordinate of the upper-left corner of
     *  the rectangle.
     */
    getTopLeft: function() {
        return new $.Point(
            this.x,
            this.y
        );
    },

    /**
     * Provides the coordinates of the bottom-right corner of the rectangle as a
     * point.
     * @function
     * @returns {OpenSeadragon.Point} The coordinate of the bottom-right corner of
     *  the rectangle.
     */
    getBottomRight: function() {
        return new $.Point(this.x + this.width, this.y + this.height)
            .rotate(this.degrees, this.getTopLeft());
    },

    /**
     * Provides the coordinates of the top-right corner of the rectangle as a
     * point.
     * @function
     * @returns {OpenSeadragon.Point} The coordinate of the top-right corner of
     *  the rectangle.
     */
    getTopRight: function() {
        return new $.Point(this.x + this.width, this.y)
            .rotate(this.degrees, this.getTopLeft());
    },

    /**
     * Provides the coordinates of the bottom-left corner of the rectangle as a
     * point.
     * @function
     * @returns {OpenSeadragon.Point} The coordinate of the bottom-left corner of
     *  the rectangle.
     */
    getBottomLeft: function() {
        return new $.Point(this.x, this.y + this.height)
            .rotate(this.degrees, this.getTopLeft());
    },

    /**
     * Computes the center of the rectangle.
     * @function
     * @returns {OpenSeadragon.Point} The center of the rectangle as represented
     *  as represented by a 2-dimensional vector (x,y)
     */
    getCenter: function() {
        return new $.Point(
            this.x + this.width / 2.0,
            this.y + this.height / 2.0
        ).rotate(this.degrees, this.getTopLeft());
    },

    /**
     * Returns the width and height component as a vector OpenSeadragon.Point
     * @function
     * @returns {OpenSeadragon.Point} The 2 dimensional vector representing the
     *  width and height of the rectangle.
     */
    getSize: function() {
        return new $.Point(this.width, this.height);
    },

    /**
     * Determines if two Rectangles have equivalent components.
     * @function
     * @param {OpenSeadragon.Rect} rectangle The Rectangle to compare to.
     * @returns {Boolean} 'true' if all components are equal, otherwise 'false'.
     */
    equals: function(other) {
        return (other instanceof $.Rect) &&
            this.x === other.x &&
            this.y === other.y &&
            this.width === other.width &&
            this.height === other.height &&
            this.degrees === other.degrees;
    },

    /**
    * Multiply all dimensions (except degrees) in this Rect by a factor and
    * return a new Rect.
    * @function
    * @param {Number} factor The factor to multiply vector components.
    * @returns {OpenSeadragon.Rect} A new rect representing the multiplication
    *  of the vector components by the factor
    */
    times: function(factor) {
        return new $.Rect(
            this.x * factor,
            this.y * factor,
            this.width * factor,
            this.height * factor,
            this.degrees);
    },

    /**
    * Translate/move this Rect by a vector and return new Rect.
    * @function
    * @param {OpenSeadragon.Point} delta The translation vector.
    * @returns {OpenSeadragon.Rect} A new rect with altered position
    */
    translate: function(delta) {
        return new $.Rect(
            this.x + delta.x,
            this.y + delta.y,
            this.width,
            this.height,
            this.degrees);
    },

    /**
     * Returns the smallest rectangle that will contain this and the given
     * rectangle bounding boxes.
     * @param {OpenSeadragon.Rect} rect
     * @returns {OpenSeadragon.Rect} The new rectangle.
     */
    union: function(rect) {
        const thisBoundingBox = this.getBoundingBox();
        const otherBoundingBox = rect.getBoundingBox();

        const left = Math.min(thisBoundingBox.x, otherBoundingBox.x);
        const top = Math.min(thisBoundingBox.y, otherBoundingBox.y);
        const right = Math.max(
            thisBoundingBox.x + thisBoundingBox.width,
            otherBoundingBox.x + otherBoundingBox.width);
        const bottom = Math.max(
            thisBoundingBox.y + thisBoundingBox.height,
            otherBoundingBox.y + otherBoundingBox.height);

        return new $.Rect(
            left,
            top,
            right - left,
            bottom - top);
    },

    /**
     * Returns the bounding box of the intersection of this rectangle with the
     * given rectangle.
     * @param {OpenSeadragon.Rect} rect
     * @returns {OpenSeadragon.Rect} the bounding box of the intersection
     * or null if the rectangles don't intersect.
     */
    intersection: function(rect) {
        // Simplified version of Weiler Atherton clipping algorithm
        // https://en.wikipedia.org/wiki/Weiler%E2%80%93Atherton_clipping_algorithm
        // Because we just want the bounding box of the intersection,
        // we can just compute the bounding box of:
        // 1. all the summits of this which are inside rect
        // 2. all the summits of rect which are inside this
        // 3. all the intersections of rect and this
        const EPSILON = 0.0000000001;

        const intersectionPoints = [];

        const thisTopLeft = this.getTopLeft();
        if (rect.containsPoint(thisTopLeft, EPSILON)) {
            intersectionPoints.push(thisTopLeft);
        }
        const thisTopRight = this.getTopRight();
        if (rect.containsPoint(thisTopRight, EPSILON)) {
            intersectionPoints.push(thisTopRight);
        }
        const thisBottomLeft = this.getBottomLeft();
        if (rect.containsPoint(thisBottomLeft, EPSILON)) {
            intersectionPoints.push(thisBottomLeft);
        }
        const thisBottomRight = this.getBottomRight();
        if (rect.containsPoint(thisBottomRight, EPSILON)) {
            intersectionPoints.push(thisBottomRight);
        }

        const rectTopLeft = rect.getTopLeft();
        if (this.containsPoint(rectTopLeft, EPSILON)) {
            intersectionPoints.push(rectTopLeft);
        }
        const rectTopRight = rect.getTopRight();
        if (this.containsPoint(rectTopRight, EPSILON)) {
            intersectionPoints.push(rectTopRight);
        }
        const rectBottomLeft = rect.getBottomLeft();
        if (this.containsPoint(rectBottomLeft, EPSILON)) {
            intersectionPoints.push(rectBottomLeft);
        }
        const rectBottomRight = rect.getBottomRight();
        if (this.containsPoint(rectBottomRight, EPSILON)) {
            intersectionPoints.push(rectBottomRight);
        }

        const thisSegments = this._getSegments();
        const rectSegments = rect._getSegments();
        for (let i = 0; i < thisSegments.length; i++) {
            const thisSegment = thisSegments[i];
            for (let j = 0; j < rectSegments.length; j++) {
                const rectSegment = rectSegments[j];
                const intersect = getIntersection(thisSegment[0], thisSegment[1],
                    rectSegment[0], rectSegment[1]);
                if (intersect) {
                    intersectionPoints.push(intersect);
                }
            }
        }

        // Get intersection point of segments [a,b] and [c,d]
        function getIntersection(a, b, c, d) {
            // http://stackoverflow.com/a/1968345/1440403
            const abVector = b.minus(a);
            const cdVector = d.minus(c);

            const denom = -cdVector.x * abVector.y + abVector.x * cdVector.y;
            if (denom === 0) {
                return null;
            }

            const s = (abVector.x * (a.y - c.y) - abVector.y * (a.x - c.x)) / denom;
            const t = (cdVector.x * (a.y - c.y) - cdVector.y * (a.x - c.x)) / denom;

            if (-EPSILON <= s && s <= 1 - EPSILON &&
                -EPSILON <= t && t <= 1 - EPSILON) {
                return new $.Point(a.x + t * abVector.x, a.y + t * abVector.y);
            }
            return null;
        }

        if (intersectionPoints.length === 0) {
            return null;
        }

        let minX = intersectionPoints[0].x;
        let maxX = intersectionPoints[0].x;
        let minY = intersectionPoints[0].y;
        let maxY = intersectionPoints[0].y;
        for (let k = 1; k < intersectionPoints.length; k++) {
            const point = intersectionPoints[k];
            if (point.x < minX) {
                minX = point.x;
            }
            if (point.x > maxX) {
                maxX = point.x;
            }
            if (point.y < minY) {
                minY = point.y;
            }
            if (point.y > maxY) {
                maxY = point.y;
            }
        }
        return new $.Rect(minX, minY, maxX - minX, maxY - minY);
    },

    // private
    _getSegments: function() {
        const topLeft = this.getTopLeft();
        const topRight = this.getTopRight();
        const bottomLeft = this.getBottomLeft();
        const bottomRight = this.getBottomRight();
        return [[topLeft, topRight],
            [topRight, bottomRight],
            [bottomRight, bottomLeft],
            [bottomLeft, topLeft]];
    },

    /**
     * Rotates a rectangle around a point.
     * @function
     * @param {Number} degrees The angle in degrees to rotate.
     * @param {OpenSeadragon.Point} [pivot] The point about which to rotate.
     * Defaults to the center of the rectangle.
     * @returns {OpenSeadragon.Rect}
     */
    rotate: function(degrees, pivot) {
        degrees = $.positiveModulo(degrees, 360);
        if (degrees === 0) {
            return this.clone();
        }

        pivot = pivot || this.getCenter();
        const newTopLeft = this.getTopLeft().rotate(degrees, pivot);
        const newTopRight = this.getTopRight().rotate(degrees, pivot);

        let diff = newTopRight.minus(newTopLeft);
        // Handle floating point error
        diff = diff.apply(function(x) {
            const EPSILON = 1e-15;
            return Math.abs(x) < EPSILON ? 0 : x;
        });
        let radians = Math.atan(diff.y / diff.x);
        if (diff.x < 0) {
            radians += Math.PI;
        } else if (diff.y < 0) {
            radians += 2 * Math.PI;
        }
        return new $.Rect(
            newTopLeft.x,
            newTopLeft.y,
            this.width,
            this.height,
            radians / Math.PI * 180);
    },

    /**
     * Retrieves the smallest horizontal (degrees=0) rectangle which contains
     * this rectangle.
     * @returns {OpenSeadragon.Rect}
     */
    getBoundingBox: function() {
        if (this.degrees === 0) {
            return this.clone();
        }
        const topLeft = this.getTopLeft();
        const topRight = this.getTopRight();
        const bottomLeft = this.getBottomLeft();
        const bottomRight = this.getBottomRight();
        const minX = Math.min(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x);
        const maxX = Math.max(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x);
        const minY = Math.min(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y);
        const maxY = Math.max(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y);
        return new $.Rect(
            minX,
            minY,
            maxX - minX,
            maxY - minY);
    },

    /**
     * Retrieves the smallest horizontal (degrees=0) rectangle which contains
     * this rectangle and has integers x, y, width and height
     * @returns {OpenSeadragon.Rect}
     */
    getIntegerBoundingBox: function() {
        const boundingBox = this.getBoundingBox();
        const x = Math.floor(boundingBox.x);
        const y = Math.floor(boundingBox.y);
        const width = Math.ceil(boundingBox.width + boundingBox.x - x);
        const height = Math.ceil(boundingBox.height + boundingBox.y - y);
        return new $.Rect(x, y, width, height);
    },

    /**
     * Determines whether a point is inside this rectangle (edge included).
     * @function
     * @param {OpenSeadragon.Point} point
     * @param {Number} [epsilon=0] the margin of error allowed
     * @returns {Boolean} true if the point is inside this rectangle, false
     * otherwise.
     */
    containsPoint: function(point, epsilon) {
        epsilon = epsilon || 0;

        // See http://stackoverflow.com/a/2752754/1440403 for explanation
        const topLeft = this.getTopLeft();
        const topRight = this.getTopRight();
        const bottomLeft = this.getBottomLeft();
        const topDiff = topRight.minus(topLeft);
        const leftDiff = bottomLeft.minus(topLeft);

        return ((point.x - topLeft.x) * topDiff.x +
            (point.y - topLeft.y) * topDiff.y >= -epsilon) &&

            ((point.x - topRight.x) * topDiff.x +
            (point.y - topRight.y) * topDiff.y <= epsilon) &&

            ((point.x - topLeft.x) * leftDiff.x +
            (point.y - topLeft.y) * leftDiff.y >= -epsilon) &&

            ((point.x - bottomLeft.x) * leftDiff.x +
            (point.y - bottomLeft.y) * leftDiff.y <= epsilon);
    },

    /**
     * Provides a string representation of the rectangle which is useful for
     * debugging.
     * @function
     * @returns {String} A string representation of the rectangle.
     */
    toString: function() {
        return "[" +
            (Math.round(this.x * 100) / 100) + ", " +
            (Math.round(this.y * 100) / 100) + ", " +
            (Math.round(this.width * 100) / 100) + "x" +
            (Math.round(this.height * 100) / 100) + ", " +
            (Math.round(this.degrees * 100) / 100) + "deg" +
            "]";
    }
};


}(OpenSeadragon));

/*
 * OpenSeadragon - ReferenceStrip
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function ( $ ) {

// dictionary from id to private properties
const THIS = {};

/**
 *  The CollectionDrawer is a reimplementation if the Drawer API that
 *  focuses on allowing a viewport to be redefined as a collection
 *  of smaller viewports, defined by a clear number of rows and / or
 *  columns of which each item in the matrix of viewports has its own
 *  source.
 *
 *  This idea is a reexpression of the idea of dzi collections
 *  which allows a clearer algorithm to reuse the tile sources already
 *  supported by OpenSeadragon, in heterogeneous or homogeneous
 *  sequences just like mixed groups already supported by the viewer
 *  for the purpose of image sequnces.
 *
 *  TODO:   The difficult part of this feature is figuring out how to express
 *          this functionality as a combination of the functionality already
 *          provided by Drawer, Viewport, TileSource, and Navigator.  It may
 *          require better abstraction at those points in order to efficiently
 *          reuse those paradigms.
 */
/**
 * @class ReferenceStrip
 * @memberof OpenSeadragon
 * @param {Object} options
 */
$.ReferenceStrip = function ( options ) {

    const _this       = this;
    const viewer      = options.viewer;
    const viewerSize  = $.getElementSize( viewer.element );
    let element;
    let i;

    //We may need to create a new element and id if they did not
    //provide the id for the existing element
    if ( !options.id ) {
        options.id              = 'referencestrip-' + $.now();
        this.element            = $.makeNeutralElement( "div" );
        this.element.id         = options.id;
        this.element.className  = 'referencestrip';
    }

    options = $.extend( true, {
        sizeRatio:  $.DEFAULT_SETTINGS.referenceStripSizeRatio,
        position:   $.DEFAULT_SETTINGS.referenceStripPosition,
        scroll:     $.DEFAULT_SETTINGS.referenceStripScroll,
        clickTimeThreshold:  $.DEFAULT_SETTINGS.clickTimeThreshold
    }, options, {
        element:                this.element
    } );

    $.extend( this, options );
    //Private state properties
    THIS[this.id] = {
        animating:           false
    };

    this.minPixelRatio = this.viewer.minPixelRatio;

    this.element.tabIndex = 0;

    const style = this.element.style;
    style.marginTop     = '0px';
    style.marginRight   = '0px';
    style.marginBottom  = '0px';
    style.marginLeft    = '0px';
    style.left          = '0px';
    style.bottom        = '0px';
    style.border        = '0px';
    style.background    = '#000';
    style.position      = 'relative';

    $.setElementTouchActionNone( this.element );

    $.setElementOpacity( this.element, 0.8 );

    this.viewer = viewer;
    this.tracker = new $.MouseTracker( {
        userData:       'ReferenceStrip.tracker',
        element:        this.element,
        clickHandler:   $.delegate( this, onStripClick ),
        dragHandler:    $.delegate( this, onStripDrag ),
        scrollHandler:  $.delegate( this, onStripScroll ),
        enterHandler:   $.delegate( this, onStripEnter ),
        leaveHandler:   $.delegate( this, onStripLeave ),
        keyDownHandler: $.delegate( this, onKeyDown ),
        keyHandler:     $.delegate( this, onKeyPress ),
        preProcessEventHandler: function (eventInfo) {
            if (eventInfo.eventType === 'wheel') {
                eventInfo.preventDefault = true;
            }
        }
    } );

    //Controls the position and orientation of the reference strip and sets the
    //appropriate width and height
    if ( options.width && options.height ) {
        this.element.style.width  = options.width + 'px';
        this.element.style.height = options.height + 'px';
        viewer.addControl(
            this.element,
            { anchor: $.ControlAnchor.BOTTOM_LEFT }
        );
    } else {
        if ( "horizontal" === options.scroll ) {
            this.element.style.width = (
                viewerSize.x *
                options.sizeRatio *
                viewer.tileSources.length
            ) + ( 12 * viewer.tileSources.length ) + 'px';

            this.element.style.height = (
                viewerSize.y *
                options.sizeRatio
            ) + 'px';

            viewer.addControl(
                this.element,
                { anchor: $.ControlAnchor.BOTTOM_LEFT }
            );
        } else {
            this.element.style.height = (
                viewerSize.y *
                options.sizeRatio *
                viewer.tileSources.length
            ) + ( 12 * viewer.tileSources.length ) + 'px';

            this.element.style.width = (
                viewerSize.x *
                options.sizeRatio
            ) + 'px';

            viewer.addControl(
                this.element,
                { anchor: $.ControlAnchor.TOP_LEFT }
            );

        }
    }

    this.panelWidth = ( viewerSize.x * this.sizeRatio ) + 8;
    this.panelHeight = ( viewerSize.y * this.sizeRatio ) + 8;
    this.panels = [];
    this.miniViewers = {};

    /*jshint loopfunc:true*/
    for ( i = 0; i < viewer.tileSources.length; i++ ) {

        element = $.makeNeutralElement( 'div' );
        element.id = this.element.id + "-" + i;

        element.style.width         = _this.panelWidth + 'px';
        element.style.height        = _this.panelHeight + 'px';
        element.style.display       = 'inline';
        element.style['float']      = 'left'; //Webkit
        element.style.cssFloat      = 'left'; //Firefox
        element.style.padding       = '2px';
        $.setElementTouchActionNone( element );
        $.setElementPointerEventsNone( element );

        this.element.appendChild( element );

        element.activePanel = false;

        this.panels.push( element );

    }
    loadPanels( this, this.scroll === 'vertical' ? viewerSize.y : viewerSize.x, 0 );
    this.setFocus( 0 );

};

/** @lends OpenSeadragon.ReferenceStrip.prototype */
$.ReferenceStrip.prototype = {

    /**
     * @function
     */
    setFocus: function ( page ) {
        const element      = this.element.querySelector('#' + this.element.id + '-' + page );
        const viewerSize   = $.getElementSize( this.viewer.canvas );
        const scrollWidth  = Number( this.element.style.width.replace( 'px', '' ) );
        const scrollHeight = Number( this.element.style.height.replace( 'px', '' ) );
        const offsetLeft   = -Number( this.element.style.marginLeft.replace( 'px', '' ) );
        const offsetTop    = -Number( this.element.style.marginTop.replace( 'px', '' ) );
        let offset;

        if ( this.currentSelected !== element ) {
            if ( this.currentSelected ) {
                this.currentSelected.style.background = '#000';
            }
            this.currentSelected = element;
            this.currentSelected.style.background = '#999';

            if ( 'horizontal' === this.scroll ) {
                //right left
                offset = ( Number( page ) ) * ( this.panelWidth + 3 );
                if ( offset > offsetLeft + viewerSize.x - this.panelWidth ) {
                    offset = Math.min( offset, ( scrollWidth - viewerSize.x ) );
                    this.element.style.marginLeft = -offset + 'px';
                    loadPanels( this, viewerSize.x, -offset );
                } else if ( offset < offsetLeft ) {
                    offset = Math.max( 0, offset - viewerSize.x / 2 );
                    this.element.style.marginLeft = -offset + 'px';
                    loadPanels( this, viewerSize.x, -offset );
                }
            } else {
                offset = ( Number( page ) ) * ( this.panelHeight + 3 );
                if ( offset > offsetTop + viewerSize.y - this.panelHeight ) {
                    offset = Math.min( offset, ( scrollHeight - viewerSize.y ) );
                    this.element.style.marginTop = -offset + 'px';
                    loadPanels( this, viewerSize.y, -offset );
                } else if ( offset < offsetTop ) {
                    offset = Math.max( 0, offset - viewerSize.y / 2 );
                    this.element.style.marginTop = -offset + 'px';
                    loadPanels( this, viewerSize.y, -offset );
                }
            }

            this.currentPage = page;
            onStripEnter.call( this, { eventSource: this.tracker } );
        }
    },

    /**
     * @function
     */
    update: function () {
        if ( THIS[this.id].animating ) {
            // $.console.log( 'image reference strip update' );
            return true;
        }
        return false;
    },

    destroy: function() {
        if (this.miniViewers) {
          for (const key in this.miniViewers) {
            this.miniViewers[key].destroy();
          }
        }

        this.tracker.destroy();

        if (this.element) {
            this.viewer.removeControl( this.element );
        }
    }

};


/**
 * @private
 * @inner
 * @function
 */
function onStripClick( event ) {
    if ( event.quick ) {
        let page;

        if ( 'horizontal' === this.scroll ) {
            // +4px fix to solve problem with precision on thumbnail selection if there is a lot of them
            page = Math.floor(event.position.x / (this.panelWidth + 4));
        } else {
            page = Math.floor(event.position.y / this.panelHeight);
        }

        this.viewer.goToPage( page );
    }

    this.element.focus();
}


/**
 * @private
 * @inner
 * @function
 */
function onStripDrag( event ) {

    this.dragging = true;
    if ( this.element ) {
        const offsetLeft   = Number( this.element.style.marginLeft.replace( 'px', '' ) );
        const offsetTop    = Number( this.element.style.marginTop.replace( 'px', '' ) );
        const scrollWidth  = Number( this.element.style.width.replace( 'px', '' ) );
        const scrollHeight = Number( this.element.style.height.replace( 'px', '' ) );
        const viewerSize   = $.getElementSize( this.viewer.canvas );

        if ( 'horizontal' === this.scroll ) {
            if ( -event.delta.x > 0 ) {
                //forward
                if ( offsetLeft > -( scrollWidth - viewerSize.x ) ) {
                    this.element.style.marginLeft = ( offsetLeft + ( event.delta.x * 2 ) ) + 'px';
                    loadPanels( this, viewerSize.x, offsetLeft + ( event.delta.x * 2 ) );
                }
            } else if ( -event.delta.x < 0 ) {
                //reverse
                if ( offsetLeft < 0 ) {
                    this.element.style.marginLeft = ( offsetLeft + ( event.delta.x * 2 ) ) + 'px';
                    loadPanels( this, viewerSize.x, offsetLeft + ( event.delta.x * 2 ) );
                }
            }
        } else {
            if ( -event.delta.y > 0 ) {
                //forward
                if ( offsetTop > -( scrollHeight - viewerSize.y ) ) {
                    this.element.style.marginTop = ( offsetTop + ( event.delta.y * 2 ) ) + 'px';
                    loadPanels( this, viewerSize.y, offsetTop + ( event.delta.y * 2 ) );
                }
            } else if ( -event.delta.y < 0 ) {
                //reverse
                if ( offsetTop < 0 ) {
                    this.element.style.marginTop = ( offsetTop + ( event.delta.y * 2 ) ) + 'px';
                    loadPanels( this, viewerSize.y, offsetTop + ( event.delta.y * 2 ) );
                }
            }
        }
    }

}



/**
 * @private
 * @inner
 * @function
 */
function onStripScroll( event ) {
    if ( this.element ) {
        const offsetLeft   = Number( this.element.style.marginLeft.replace( 'px', '' ) );
        const offsetTop    = Number( this.element.style.marginTop.replace( 'px', '' ) );
        const scrollWidth  = Number( this.element.style.width.replace( 'px', '' ) );
        const scrollHeight = Number( this.element.style.height.replace( 'px', '' ) );
        const viewerSize   = $.getElementSize( this.viewer.canvas );

        if ( 'horizontal' === this.scroll ) {
            if ( event.scroll > 0 ) {
                //forward
                if ( offsetLeft > -( scrollWidth - viewerSize.x ) ) {
                    this.element.style.marginLeft = ( offsetLeft - ( event.scroll * 60 ) ) + 'px';
                    loadPanels( this, viewerSize.x, offsetLeft - ( event.scroll * 60 ) );
                }
            } else if ( event.scroll < 0 ) {
                //reverse
                if ( offsetLeft < 0 ) {
                    this.element.style.marginLeft = ( offsetLeft - ( event.scroll * 60 ) ) + 'px';
                    loadPanels( this, viewerSize.x, offsetLeft - ( event.scroll * 60 ) );
                }
            }
        } else {
            if ( event.scroll < 0 ) {
                //scroll up
                if ( offsetTop > viewerSize.y - scrollHeight ) {
                    this.element.style.marginTop = ( offsetTop + ( event.scroll * 60 ) ) + 'px';
                    loadPanels( this, viewerSize.y, offsetTop + ( event.scroll * 60 ) );
                }
            } else if ( event.scroll > 0 ) {
                //scroll dowm
                if ( offsetTop < 0 ) {
                    this.element.style.marginTop = ( offsetTop + ( event.scroll * 60 ) ) + 'px';
                    loadPanels( this, viewerSize.y, offsetTop + ( event.scroll * 60 ) );
                }
            }
        }

        event.preventDefault = true;
    }
}


function loadPanels( strip, viewerSize, scroll ) {
    let panelSize;
    let activePanelsStart;
    let activePanelsEnd;
    let miniViewer;
    let i;
    let element;

    if ( 'horizontal' === strip.scroll ) {
        panelSize = strip.panelWidth;
    } else {
        panelSize = strip.panelHeight;
    }
    activePanelsStart = Math.ceil( viewerSize / panelSize ) + 5;
    activePanelsEnd = Math.ceil( ( Math.abs( scroll ) + viewerSize ) / panelSize ) + 1;
    activePanelsStart = activePanelsEnd - activePanelsStart;
    activePanelsStart = activePanelsStart < 0 ? 0 : activePanelsStart;

    for ( i = activePanelsStart; i < activePanelsEnd && i < strip.panels.length; i++ ) {
        element = strip.panels[i];
        if ( !element.activePanel ) {
            let miniTileSource;
            const originalTileSource = strip.viewer.tileSources[i];
            if (originalTileSource.referenceStripThumbnailUrl) {
                miniTileSource = {
                    type: 'image',
                    url: originalTileSource.referenceStripThumbnailUrl
                };
            } else {
                miniTileSource = originalTileSource;
            }
            miniViewer = new $.Viewer( {
                id:                     element.id,
                tileSources:            [miniTileSource],
                element:                element,
                navigatorSizeRatio:     strip.sizeRatio,
                showNavigator:          false,
                mouseNavEnabled:        false,
                showNavigationControl:  false,
                showSequenceControl:    false,
                immediateRender:        true,
                blendTime:              0,
                animationTime:          0,
                loadTilesWithAjax:      strip.viewer.loadTilesWithAjax,
                ajaxHeaders:            strip.viewer.ajaxHeaders,
                viewer:                 strip.viewer,
                // TODO: make possible for users to ensure the sub-drawer is the same type as the base parent drawer
                drawer:                 'canvas', //always use canvas for the reference strip
            } );
            // Allow pointer events to pass through miniViewer's canvas/container
            //   elements so implicit pointer capture works on touch devices
            $.setElementPointerEventsNone( miniViewer.canvas );
            $.setElementPointerEventsNone( miniViewer.container );
            // We'll use event delegation from the reference strip element instead of
            //   handling events on every miniViewer
            miniViewer.innerTracker.setTracking( false );
            miniViewer.outerTracker.setTracking( false );

            strip.miniViewers[element.id] = miniViewer;

            element.activePanel = true;
        }
    }
}


/**
 * @private
 * @inner
 * @function
 */
function onStripEnter( event ) {
    const element = event.eventSource.element;

    //$.setElementOpacity(element, 0.8);

    //element.style.border = '1px solid #555';
    //element.style.background = '#000';

    if ( 'horizontal' === this.scroll ) {

        //element.style.paddingTop = "0px";
        element.style.marginBottom = "0px";

    } else {

        //element.style.paddingRight = "0px";
        element.style.marginLeft = "0px";

    }
}


/**
 * @private
 * @inner
 * @function
 */
function onStripLeave( event ) {
    const element = event.eventSource.element;

    if ( 'horizontal' === this.scroll ) {

        //element.style.paddingTop = "10px";
        element.style.marginBottom = "-" + ( $.getElementSize( element ).y / 2 ) + "px";

    } else {

        //element.style.paddingRight = "10px";
        element.style.marginLeft = "-" + ( $.getElementSize( element ).x / 2 ) + "px";

    }
}


/**
 * @private
 * @inner
 * @function
 */
function onKeyDown( event ) {
    //console.log( event.keyCode );

    if ( !event.ctrl && !event.alt && !event.meta ) {
        switch ( event.keyCode ) {
            case 38: //up arrow
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: 1, shift: null } );
                event.preventDefault = true;
                break;
            case 40: //down arrow
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: -1, shift: null } );
                event.preventDefault = true;
                break;
            case 37: //left arrow
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: -1, shift: null } );
                event.preventDefault = true;
                break;
            case 39: //right arrow
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: 1, shift: null } );
                event.preventDefault = true;
                break;
            default:
                //console.log( 'navigator keycode %s', event.keyCode );
                event.preventDefault = false;
                break;
        }
    } else {
        event.preventDefault = false;
    }
}


/**
 * @private
 * @inner
 * @function
 */
function onKeyPress( event ) {
    //console.log( event.keyCode );

    if ( !event.ctrl && !event.alt && !event.meta ) {
        switch ( event.keyCode ) {
            case 61: //=|+
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: 1, shift: null } );
                event.preventDefault = true;
                break;
            case 45: //-|_
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: -1, shift: null } );
                event.preventDefault = true;
                break;
            case 48: //0|)
            case 119: //w
            case 87: //W
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: 1, shift: null } );
                event.preventDefault = true;
                break;
            case 115: //s
            case 83: //S
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: -1, shift: null } );
                event.preventDefault = true;
                break;
            case 97: //a
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: -1, shift: null } );
                event.preventDefault = true;
                break;
            case 100: //d
                onStripScroll.call( this, { eventSource: this.tracker, position: null, scroll: 1, shift: null } );
                event.preventDefault = true;
                break;
            default:
                //console.log( 'navigator keycode %s', event.keyCode );
                event.preventDefault = false;
                break;
        }
    } else {
        event.preventDefault = false;
    }
}

}(OpenSeadragon));

/*
 * OpenSeadragon - DisplayRect
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class DisplayRect
 * @classdesc A display rectangle is very similar to {@link OpenSeadragon.Rect} but adds two
 * fields, 'minLevel' and 'maxLevel' which denote the supported zoom levels
 * for this rectangle.
 *
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.Rect
 * @param {Number} x The vector component 'x'.
 * @param {Number} y The vector component 'y'.
 * @param {Number} width The vector component 'height'.
 * @param {Number} height The vector component 'width'.
 * @param {Number} minLevel The lowest zoom level supported.
 * @param {Number} maxLevel The highest zoom level supported.
 */
$.DisplayRect = function( x, y, width, height, minLevel, maxLevel ) {
    $.Rect.apply( this, [ x, y, width, height ] );

    /**
     * The lowest zoom level supported.
     * @member {Number} minLevel
     * @memberof OpenSeadragon.DisplayRect#
     */
    this.minLevel = minLevel;
    /**
     * The highest zoom level supported.
     * @member {Number} maxLevel
     * @memberof OpenSeadragon.DisplayRect#
     */
    this.maxLevel = maxLevel;
};

$.extend( $.DisplayRect.prototype, $.Rect.prototype );

}( OpenSeadragon ));

/*
 * OpenSeadragon - Spring
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class Spring
 * @memberof OpenSeadragon
 * @param {Object} options - Spring configuration settings.
 * @param {Number} options.springStiffness - Spring stiffness. Must be greater than zero.
 * The closer to zero, the closer to linear animation.
 * @param {Number} options.animationTime - Animation duration per spring, in seconds.
 * Must be zero or greater.
 * @param {Number} [options.initial=0] - Initial value of spring.
 * @param {Boolean} [options.exponential=false] - Whether this spring represents
 * an exponential scale (such as zoom) and should be animated accordingly. Note that
 * exponential springs must have non-zero values.
 */
$.Spring = function( options ) {
    const args = arguments;

    if( typeof ( options ) !== 'object' ){
        //allows backward compatible use of ( initialValue, config ) as
        //constructor parameters
        options = {
            initial: args.length && typeof ( args[ 0 ] ) === "number" ?
                args[ 0 ] :
                undefined,
            /**
             * Spring stiffness.
             * @member {Number} springStiffness
             * @memberof OpenSeadragon.Spring#
             */
            springStiffness: args.length > 1 ?
                args[ 1 ].springStiffness :
                5.0,
            /**
             * Animation duration per spring.
             * @member {Number} animationTime
             * @memberof OpenSeadragon.Spring#
             */
            animationTime: args.length > 1 ?
                args[ 1 ].animationTime :
                1.5
        };
    }

    $.console.assert(typeof options.springStiffness === "number" && options.springStiffness !== 0,
        "[OpenSeadragon.Spring] options.springStiffness must be a non-zero number");

    $.console.assert(typeof options.animationTime === "number" && options.animationTime >= 0,
        "[OpenSeadragon.Spring] options.animationTime must be a number greater than or equal to 0");

    if (options.exponential) {
        this._exponential = true;
        delete options.exponential;
    }

    $.extend( true, this, options);

    /**
     * @member {Object} current
     * @memberof OpenSeadragon.Spring#
     * @property {Number} value
     * @property {Number} time
     */
    this.current = {
        value: typeof ( this.initial ) === "number" ?
            this.initial :
            (this._exponential ? 0 : 1),
        time:  $.now() // always work in milliseconds
    };

    $.console.assert(!this._exponential || this.current.value !== 0,
        "[OpenSeadragon.Spring] value must be non-zero for exponential springs");

    /**
     * @member {Object} start
     * @memberof OpenSeadragon.Spring#
     * @property {Number} value
     * @property {Number} time
     */
    this.start = {
        value: this.current.value,
        time:  this.current.time
    };

    /**
     * @member {Object} target
     * @memberof OpenSeadragon.Spring#
     * @property {Number} value
     * @property {Number} time
     */
    this.target = {
        value: this.current.value,
        time:  this.current.time
    };

    if (this._exponential) {
        this.start._logValue = Math.log(this.start.value);
        this.target._logValue = Math.log(this.target.value);
        this.current._logValue = Math.log(this.current.value);
    }
};

/** @lends OpenSeadragon.Spring.prototype */
$.Spring.prototype = {

    /**
     * @function
     * @param {Number} target
     */
    resetTo: function( target ) {
        $.console.assert(!this._exponential || target !== 0,
            "[OpenSeadragon.Spring.resetTo] target must be non-zero for exponential springs");

        this.start.value = this.target.value = this.current.value = target;
        this.start.time = this.target.time = this.current.time = $.now();

        if (this._exponential) {
            this.start._logValue = Math.log(this.start.value);
            this.target._logValue = Math.log(this.target.value);
            this.current._logValue = Math.log(this.current.value);
        }
    },

    /**
     * @function
     * @param {Number} target
     */
    springTo: function( target ) {
        $.console.assert(!this._exponential || target !== 0,
            "[OpenSeadragon.Spring.springTo] target must be non-zero for exponential springs");

        this.start.value  = this.current.value;
        this.start.time   = this.current.time;
        this.target.value = target;
        this.target.time  = this.start.time + 1000 * this.animationTime;

        if (this._exponential) {
            this.start._logValue = Math.log(this.start.value);
            this.target._logValue = Math.log(this.target.value);
        }
    },

    /**
     * @function
     * @param {Number} delta
     */
    shiftBy: function( delta ) {
        this.start.value  += delta;
        this.target.value += delta;

        if (this._exponential) {
            $.console.assert(this.target.value !== 0 && this.start.value !== 0,
                "[OpenSeadragon.Spring.shiftBy] spring value must be non-zero for exponential springs");

            this.start._logValue = Math.log(this.start.value);
            this.target._logValue = Math.log(this.target.value);
        }
    },

    setExponential: function(value) {
        this._exponential = value;

        if (this._exponential) {
            $.console.assert(this.current.value !== 0 && this.target.value !== 0 && this.start.value !== 0,
                "[OpenSeadragon.Spring.setExponential] spring value must be non-zero for exponential springs");

            this.start._logValue = Math.log(this.start.value);
            this.target._logValue = Math.log(this.target.value);
            this.current._logValue = Math.log(this.current.value);
        }
    },

    /**
     * @function
     * @returns true if the spring is still updating its value, false if it is
     * already at the target value.
     */
    update: function() {
        this.current.time  = $.now();

        let startValue, targetValue;
        if (this._exponential) {
            startValue = this.start._logValue;
            targetValue = this.target._logValue;
        } else {
            startValue = this.start.value;
            targetValue = this.target.value;
        }

        if(this.current.time >= this.target.time){
            this.current.value = this.target.value;
        } else {
            let currentValue = startValue +
                    ( targetValue - startValue ) *
                    transform(
                        this.springStiffness,
                        ( this.current.time - this.start.time ) /
                        ( this.target.time - this.start.time )
                    );

            if (this._exponential) {
                this.current.value = Math.exp(currentValue);
            } else {
                this.current.value = currentValue;
            }
        }

        return this.current.value !== this.target.value;
    },

    /**
     * Returns whether the spring is at the target value
     * @function
     * @returns {Boolean} True if at target value, false otherwise
     */
    isAtTargetValue: function() {
        return this.current.value === this.target.value;
    }
};

/**
 * @private
 */
function transform( stiffness, x ) {
    return ( 1.0 - Math.exp( stiffness * -x ) ) /
        ( 1.0 - Math.exp( -stiffness ) );
}

}( OpenSeadragon ));

/*
 * OpenSeadragon - ImageLoader
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors

 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function($){

/**
 * @class ImageJob
 * @classdesc Handles downloading of a single image.
 *
 * @memberof OpenSeadragon
 * @param {Object} options - Options for this ImageJob.
 * @param {String} [options.src] - URL of image to download.
 * @param {Tile} [options.tile] - Tile that belongs the data to.
 * @param {TileSource} [options.source] - Image loading strategy
 * @param {String} [options.loadWithAjax] - Whether to load this image with AJAX.
 * @param {String} [options.ajaxHeaders] - Headers to add to the image request if using AJAX.
 * @param {Boolean} [options.ajaxWithCredentials] - Whether to set withCredentials on AJAX requests.
 * @param {String} [options.crossOriginPolicy] - CORS policy to use for downloads
 * @param {String} [options.postData] - HTTP POST data (usually but not necessarily in k=v&k2=v2... form,
 *      see TileSource::getTilePostData) or null
 * @param {Function} [options.callback] - Called once image has been downloaded.
 * @param {Function} [options.abort] - Called when this image job is aborted.
 * @param {Number} [options.timeout] - The max number of milliseconds that this image job may take to complete.
 * @param {Number} [options.tries] - Actual number of the current try.
 */
$.ImageJob = function(options) {

    /**
     * Private parameter. Called automatically once image has been downloaded
     *   (triggered by finish).
     * @member {function} callback
     * @memberof OpenSeadragon.ImageJob#
     * @private
     */

    /**
     * URL of image (or other data item that will be rendered) to download.
     * @member {string} src
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * Tile that owns the load. Note the data might be shared between tiles.
     * @member {OpenSeadragon.Tile} tile
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * TileSource that initiated the load and owns the tile. Note the data might be shared between tiles and tile sources.
     * @member {OpenSeadragon.TileSource} source
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * Whether to load this image with AJAX.
     * @member {boolean} loadWithAjax
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * Headers to add to the image request if using AJAX.
     * @member {Object.<string, string>} ajaxHeaders
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * Whether to set withCredentials on AJAX requests.
     * @member {boolean} ajaxWithCredentials
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * CORS policy to use for downloads
     * @member {String} crossOriginPolicy
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * HTTP POST data to send with the request
     * @member {(String|Object)} [postData] - HTTP POST data (usually but not necessarily
     *   in k=v&k2=v2... form, see TileSource::getTilePostData) or null
     * @memberof OpenSeadragon.ImageJob#
     */

    /**
     * Data object which will contain downloaded image data.
     * @member {Image|*} data data object, by default an Image object (depends on TileSource)
     * @memberof OpenSeadragon.ImageJob#
     */
    this.data = null;

    /**
     * User workspace to populate with helper variables
     * @member {*} userData to append custom data and avoid namespace collision
     * @memberof OpenSeadragon.ImageJob#
     */
    this.userData = {};

    /**
     * Error message holder. The final error message, default null (set by finish).
     * @member {string} error message
     * @memberof OpenSeadragon.ImageJob#
     * @private
     */
    this.errorMsg = null;

    /**
     * Private parameter. The max number of milliseconds that
     *   this image job may take to complete.
     * @member {number} timeout
     * @memberof OpenSeadragon.ImageJob#
     * @private
     */
    this.timeout = $.DEFAULT_SETTINGS.timeout;

    /**
     * Flag if part of batch query.
     * @member {boolean} isBatched
     * @memberof OpenSeadragon.ImageJob#
     * @private
     */
    this.isBatched = false;


    $.extend(true, this, {
        jobId: null,
        tries: 0,
    }, options);
};

$.ImageJob.prototype = {
    /**
     * Starts the image job.
     * @method
     * @private
     * @memberof OpenSeadragon.ImageJob#
     */
    start: function() {
        this.tries++;

        const self = this;
        const selfAbort = this.abort;

        this.jobId = window.setTimeout(function () {
            self.fail("Image load exceeded timeout (" + self.timeout + " ms)", null);
        }, this.timeout);

        /**
         * Called automatically when the job times out.
         *   Usage: if you decide to abort the request (no fail/finish will be called), call context.abort().
         * @member {function} abort
         * @memberof OpenSeadragon.ImageJob#
         */
        this.abort = function() {
            // this should call finish or fail
            self.source.downloadTileAbort(self);
            if (typeof selfAbort === "function") {
                selfAbort();
            }
            self.fail("Image load aborted.", null);
        };

        this.source.downloadTileStart(this);
    },

    /**
     * Prepares the image job to be part of batched mode. It does not override abort
     * callback and does not set timeout, nor call any tile source APIs. Managed by parent batch.
     * @method
     * @private
     * @memberof OpenSeadragon.ImageJob#
     */
    prepareForBatch: function() {
        this.tries++;
        this.jobId = -1;  // ensures methods above work, calling clearTimeout is noop
    },

    /**
     * Finish this job. Should be called unless abort() was executed upon successful data retrieval.
     *   Usage: context.finish(data, request, dataType=undefined). Pass the downloaded data object
     *   add also reference to an ajax request if used. Optionally, specify what data type the data is.
     * @param {*} data data that has been downloaded
     * @param {XMLHttpRequest} request reference to the request if used
     * @param {string} dataType data type identifier
     *   fallback compatibility behavior: dataType treated as errorMessage if data is falsey value
     * @memberof OpenSeadragon.ImageJob#
     */
    finish: function(data, request, dataType) {
        if (!this.jobId) {
            return;
        }
        // old behavior, no deprecation due to possible finish calls with invalid data item (e.g. different error)
        if (isInvalidData(data)) {
            this.fail(dataType || "[downloadTileStart->finish()] Retrieved data is invalid!", request);
            return;
        }

        this.data = data;
        this.request = request;
        this.errorMsg = null;
        this.dataType = dataType;

        window.clearTimeout(this.jobId);
        this.jobId = null;

        this.callback(this);
    },

    /**
     * Finish this job as a failure. Should be called unless abort() was executed upon unsuccessful request.
     *   Usage: context.fail(errMessage, request). Provide error message in case of failure,
     *   add also reference to an ajax request if used.
     * @param {string} errorMessage description upon failure
     * @param {XMLHttpRequest} request reference to the request if used
     */
    fail: function(errorMessage, request) {
        this.data = null;
        this.request = request;
        this.errorMsg = errorMessage;
        this.dataType = null;

        if (this.jobId) {
            window.clearTimeout(this.jobId);
            this.jobId = null;
        }

        this.callback(this);
    }
};

/**
 * @class BatchImageJob
 * @memberof OpenSeadragon
 * @classdesc Wraps a group of ImageJobs as a single unit of work for the ImageLoader queue.
 * It mimics the ImageJob API so it can be managed in a similar way.
 * @param {Object} options
 * @param {TileSource} options.source
 * @param {Array<OpenSeadragon.ImageJob>} options.jobs
 * @param {Function} [options.callback]
 * @param {Function} [options.abort]
 */
$.BatchImageJob = function(options) {
    $.extend(true, this, {
        timeout: $.DEFAULT_SETTINGS.timeout,
        jobId: null,
        data: null,
        dataType: null,
        errorMsg: null
    }, options);

    this.jobs = options.jobs || [];
    this.source = options.source;
};

$.BatchImageJob.prototype = {
    /**
     * Starts the batch job.
     */
    start: function() {
        this._finishedJobs = 0;
        const self = this;

        // Set timeout for the whole batch
        this.jobId = window.setTimeout(function () {
            self.fail("Batch image load exceeded timeout (" + self.timeout + " ms)", null);
        }, this.timeout);

        this.abort = function() {
            // we don't call job.start() for each job, so abort is callable here
            self.source.downloadTileBatchAbort(self);
            for (let j of this.jobs) {
                // Abort only running jobs by checking jobId. In theory, all should finish at once,
                // but we cannot enforce the logic executed by each batch job.
                if (j.jobId && j.abort) {
                    j.abort();
                }
            }
        };

        const wrap = (fn, job) => {
            return (...args) => {
                if (!this.jobId) {
                    return;
                }
                this._finishedJobs++;
                fn.call(job, ...args);
                if (this._finishedJobs === this.jobs.length) {
                    window.clearTimeout(this.jobId);
                    this.jobId = null;
                    if (this.callback) {
                        this.callback(this);
                    }
                }
            };
        };

        for (let j of this.jobs) {
            // Handle timeout securely
            j.finish = wrap(j.finish, j);
            j.fail = wrap(j.fail, j);
            j.prepareForBatch();
        }

        this.source.downloadTileBatchStart(this);
    },

    /**
     * Finish is defined as not to throw when accidentally used, but should not be called.
     */
    finish: function(data, request, dataType) {
        $.console.error('Finish call on batch job is not desirable: call finish on individual child jobs!', data, request);
    },

    /**
     * Finish all batched jobs as a failure. This is available mainly for ImageLoader class logics,
     * implementations should fail/finish/abort individual jobs directly.
     * @param {string} errorMessage description upon failure
     * @param {XMLHttpRequest} request reference to the request if used
     */
    fail: function(errorMessage, request) {
        this.data = null;
        this.request = request;
        this.errorMsg = errorMessage;
        this.dataType = null;

        // Fail before setting jobId to null, which is checked for in wrapped fail call.
        for (let i = 0; i < this.jobs.length; i++) {
            if (this.jobs[i].jobId) { // If still running
                this.jobs[i].fail(errorMessage || "Batch failed", request);
            }
        }

        if (this.jobId) {
            window.clearTimeout(this.jobId);
            this.jobId = null;
        }

        if (this.callback) {
            this.callback(this);
        }
    }
};

/**
 * @class ImageLoader
 * @memberof OpenSeadragon
 * @classdesc Handles downloading of a set of images using asynchronous queue pattern.
 * You generally won't have to interact with the ImageLoader directly.
 * @param {Object} options - Options for this ImageLoader.
 * @param {Number} [options.jobLimit] - The number of concurrent image requests. See imageLoaderLimit in {@link OpenSeadragon.Options} for details.
 * @param {Number} [options.timeout] - The max number of milliseconds that an image job may take to complete.
 */
$.ImageLoader = function(options) {

    $.extend(true, this, {
        jobLimit:       $.DEFAULT_SETTINGS.imageLoaderLimit,
        timeout:        $.DEFAULT_SETTINGS.timeout,
        jobQueue:       [],
        failedTiles:    [],
        jobsInProgress: 0
    }, options);

    this._batchBuckets = [];
};

/** @lends OpenSeadragon.ImageLoader.prototype */
$.ImageLoader.prototype = {

    /**
     * Add an unloaded image to the loader queue.
     * @method
     * @param {Object} options - Options for this job.
     * @param {TileSource} options.source - Image loading strategy definition
     * @param {String} [options.src] - URL of image to download.
     * @param {Tile} [options.tile] - Tile that belongs the data to. The tile instance
     *      is not internally used and serves for custom TileSources implementations.
     * @param {String} [options.loadWithAjax] - Whether to load this image with AJAX.
     * @param {String} [options.ajaxHeaders] - Headers to add to the image request if using AJAX.
     * @param {String|Boolean} [options.crossOriginPolicy] - CORS policy to use for downloads
     * @param {String} [options.postData] - POST parameters (usually but not necessarily in k=v&k2=v2... form,
     *      see TileSource::getTilePostData) or null
     * @param {Boolean} [options.ajaxWithCredentials] - Whether to set withCredentials on AJAX
     *      requests.
     * @param {Function} [options.callback] - Called once image has been downloaded.
     * @param {Function} [options.abort] - Called when this image job is aborted.
     * @returns {boolean} true if job was immediatelly started, false if queued
     */
    addJob: function(options) {
        if (!options.source) {
            $.console.error('ImageLoader.prototype.addJob() requires [options.source]...');
            options.source = $.TileSource.prototype;
        }

        const _this = this,
            jobOptions = {
                src: options.src,
                tile: options.tile || {},
                source: options.source,
                loadWithAjax: options.loadWithAjax,
                ajaxHeaders: options.loadWithAjax ? options.ajaxHeaders : null,
                crossOriginPolicy: options.crossOriginPolicy,
                ajaxWithCredentials: options.ajaxWithCredentials,
                postData: options.postData,
                callback: (job) => completeJob(_this, job, options.callback),
                abort: options.abort,
                timeout: this.timeout
            },
            newJob = new $.ImageJob(jobOptions);

        const sourceWantsBatching = options.source && options.source.batchEnabled();
        if (sourceWantsBatching) {
            // Mark job as batched so completeJob knows not to decrement global counters
            newJob.isBatched = true;
            this._stageJobForBatching(newJob, options.source);
            return false;
        }

        if ( !this.jobLimit || this.jobsInProgress < this.jobLimit ) {
            newJob.start();
            this.jobsInProgress++;
            return true;
        }
        this.jobQueue.push( newJob );
        return false;
    },

    /**
     * Internal method to group jobs.
     * @private
     */
    _stageJobForBatching: function(newJob, source) {
        let bucket = null;
        for (let i = 0; i < this._batchBuckets.length; i++) {
            if (this._batchBuckets[i].source.batchCompatible(source)) {
                bucket = this._batchBuckets[i];
                break;
            }
        }

        if (bucket && !bucket.timer) {
            $.console.error(
                'Attempted to add a new job to a batch bucket that has already been flushed. ' +
                'Creating a new batch bucket for this source. ' +
                'Check batch logic and timing if this happens frequently. ' +
                'Bucket source:', source, 'Job ID:', newJob && newJob.jobId
            );
            bucket = null;
        }

        if (!bucket) {
            bucket = {
                source: source,
                jobs: [],
                timer: null,
                waitTimeout: source.batchTimeout(),
                maxJobs: source.batchMaxJobs()
            };
            bucket.timer = setTimeout(() => this._flushBatchBucket(bucket), bucket.waitTimeout);
            this._batchBuckets.push(bucket);
        }

        bucket.jobs.push(newJob);

        if (bucket.maxJobs >= 1 && bucket.jobs.length >= bucket.maxJobs) {
            clearTimeout(bucket.timer);
            this._flushBatchBucket(bucket);
        }
    },

    /**
     * Flushes a specific bucket, creating a BatchJob and submitting it to the main queue logic.
     * @private
     */
    _flushBatchBucket: function(bucket) {
        bucket.timer = null;
        const index = this._batchBuckets.indexOf(bucket);
        if (index > -1) {
            this._batchBuckets.splice(index, 1);
        }

        if (bucket.jobs.length === 0) {
            return;
        }

        const _this = this;
        const batchJob = new $.BatchImageJob({
            source: bucket.source,
            jobs: bucket.jobs,
            timeout: this.timeout,
            callback: (job) => completeBatchJob(_this, job),
            // no abort here
        });

        if ( !this.jobLimit || this.jobsInProgress < this.jobLimit ) {
            batchJob.start();
            this.jobsInProgress++;
        } else {
            this.jobQueue.push(batchJob);
        }
    },

    /**
     * @returns {boolean} true if a job can be submitted
     */
    canAcceptNewJob() {
        return !this.jobLimit || this.jobsInProgress < this.jobLimit;
    },

    /**
     * Clear any unstarted image loading jobs from the queue.
     * @method
     */
    clear: function() {
        for( let i = 0; i < this.jobQueue.length; i++ ) {
            const job = this.jobQueue[i];
            if ( typeof job.abort === "function" ) {
                job.abort();
            }
        }
        this.jobQueue = [];

        if (this._batchBuckets) {
            for (let i = 0; i < this._batchBuckets.length; i++) {
                const bucket = this._batchBuckets[i];
                clearTimeout(bucket.timer);
                bucket.timer = null;
                // Jobs in buckets haven't started, no abort needed typically, just drop refs
            }
            this._batchBuckets = [];
        }
    }
};

/**
 * Cleans up ImageJob once completed. Restarts job after tileRetryDelay seconds if failed
 * but max tileRetryMax times
 * @method
 * @private
 * @param loader - ImageLoader used to start job.
 * @param {OpenSeadragon.ImageJob} job - The ImageJob that has completed.
 * @param callback - Called once cleanup is finished.
 */
function completeJob(loader, job, callback) {
    if (job.errorMsg && job.data === null && job.tries < 1 + loader.tileRetryMax) {
        // Retries are ran separately.
        job.isBatched = false;
        loader.failedTiles.push(job);
    }

    // CRITICAL: Child batch job items are marked as batched - do NOT decrement.
    if (!job.isBatched) {
        loader.jobsInProgress--;
    }

    if (loader.canAcceptNewJob() && loader.jobQueue.length > 0) {
        let nextJob = loader.jobQueue.shift();
        nextJob.start();
        loader.jobsInProgress++;
    }

    if (loader.tileRetryMax > 0 && loader.jobQueue.length === 0) {
        if (loader.canAcceptNewJob() && loader.failedTiles.length > 0) {
            let nextJob = loader.failedTiles.shift();
            setTimeout(function () {
                nextJob.start();
            }, loader.tileRetryDelay);
            loader.jobsInProgress++;
        }
    }

    if (callback) {
        callback(job.data, job.errorMsg, job.request, job.dataType, job.tries);
    }
}

/**
 * Cleans up BatchImageJob once completed. Explicit here so it's easier to debug,
 * In fact batch job does not need to do anything except decrementing counter.
 * @method
 * @private
 * @param loader - ImageLoader used to start job.
 * @param {BatchImageJob} job - The ImageJob that has completed.
 */
function completeBatchJob(loader, job) {
    loader.jobsInProgress--;
    job.jobs.length = 0; // make sure items are detached
}

// Consistent data validity checker
function isInvalidData(dataItem) {
    return dataItem === null || dataItem === undefined || dataItem === false;
}

}(OpenSeadragon));

/*
 * OpenSeadragon - Tile
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class Tile
 * @memberof OpenSeadragon
 * @param {Number} level The zoom level this tile belongs to.
 * @param {Number} x The vector component 'x'.
 * @param {Number} y The vector component 'y'.
 * @param {OpenSeadragon.Rect} bounds Where this tile fits, in normalized
 *      coordinates.
 * @param {Boolean} exists Is this tile a part of a sparse image? ( Also has
 *      this tile failed to load? )
 * @param {String|Function} url The URL of this tile's image or a function that returns a url.
 * @param {CanvasRenderingContext2D} [context2D=undefined] The context2D of this tile if it
 *  *      is provided directly by the tile source. Deprecated: use Tile::addCache(...) instead.
 * @param {Boolean} loadWithAjax Whether this tile image should be loaded with an AJAX request .
 * @param {Object} ajaxHeaders The headers to send with this tile's AJAX request (if applicable).
 * @param {OpenSeadragon.Rect} sourceBounds The portion of the tile to use as the source of the
 *      drawing operation, in pixels. Note that this only works when drawing with canvas; when drawing
 *      with HTML the entire tile is always used.
 * @param {String} postData HTTP POST data (usually but not necessarily in k=v&k2=v2... form,
 *      see TileSource::getTilePostData) or null
 * @param {String} cacheKey key to act as a tile cache, must be unique for tiles with unique image data
 */
$.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, ajaxHeaders, sourceBounds, postData, cacheKey) {
    /**
     * The zoom level this tile belongs to.
     * @member {Number} level
     * @memberof OpenSeadragon.Tile#
     */
    this.level   = level;
    /**
     * The vector component 'x'.
     * @member {Number} x
     * @memberof OpenSeadragon.Tile#
     */
    this.x       = x;
    /**
     * The vector component 'y'.
     * @member {Number} y
     * @memberof OpenSeadragon.Tile#
     */
    this.y       = y;
    /**
     * Where this tile fits, in normalized coordinates
     * @member {OpenSeadragon.Rect} bounds
     * @memberof OpenSeadragon.Tile#
     */
    this.bounds  = bounds;
    /**
     * Where this tile fits, in normalized coordinates, after positioning
     * @member {OpenSeadragon.Rect} positionedBounds
     * @memberof OpenSeadragon.Tile#
     */
    this.positionedBounds  = new OpenSeadragon.Rect(bounds.x, bounds.y, bounds.width, bounds.height);
    /**
     * The portion of the tile to use as the source of the drawing operation, in pixels. Note that
     * this property is ignored with HTML drawer where the whole tile is always drawn.
     * @member {OpenSeadragon.Rect} sourceBounds
     * @memberof OpenSeadragon.Tile#
     */
    this.sourceBounds = sourceBounds;
    /**
     * Is this tile a part of a sparse image? Also has this tile failed to load?
     * @member {Boolean} exists
     * @memberof OpenSeadragon.Tile#
     */
    this.exists  = exists;
    /**
     * Private property to hold string url or url retriever function.
     * Consumers should access via Tile.getUrl()
     * @member {String|Function} url
     * @memberof OpenSeadragon.Tile#
     * @private
     */
    this._url     = url;
    /**
     * Post parameters for this tile. For example, it can be an URL-encoded string
     * in k1=v1&k2=v2... format, or a JSON, or a FormData instance... or null if no POST request used
     * @member {String} postData HTTP POST data (usually but not necessarily in k=v&k2=v2... form,
     *      see TileSource::getTilePostData) or null
     * @memberof OpenSeadragon.Tile#
     */
    this.postData  = postData;
    /**
     * The context2D of this tile if it is provided directly by the tile source.
     * @member {CanvasRenderingContext2D} context2D
     * @memberOf OpenSeadragon.Tile#
     */
    if (context2D) {
        this.context2D = context2D;
    }
    /**
     * Whether to load this tile's image with an AJAX request.
     * @member {Boolean} loadWithAjax
     * @memberof OpenSeadragon.Tile#
     */
    this.loadWithAjax = loadWithAjax;
    /**
     * The headers to be used in requesting this tile's image.
     * Only used if loadWithAjax is set to true.
     * @member {Object} ajaxHeaders
     * @memberof OpenSeadragon.Tile#
     */
    this.ajaxHeaders = ajaxHeaders;

    if (cacheKey === undefined) {
        $.console.warn("Tile constructor needs 'cacheKey' variable: creation tile cache" +
            " in Tile class is deprecated. TileSource.prototype.getTileHashKey will be used.");
        cacheKey = $.TileSource.prototype.getTileHashKey(level, x, y, url, ajaxHeaders, postData);
    }

    this._cKey = cacheKey || "";
    this._ocKey = cacheKey || "";

    /**
     * Is this tile loaded?
     * @member {Boolean} loaded
     * @memberof OpenSeadragon.Tile#
     */
    this.loaded  = false;
    /**
     * Is this tile loading?
     * @member {Boolean} loading
     * @memberof OpenSeadragon.Tile#
     */
    this.loading = false;
    /**
     * This tile's position on screen, in pixels.
     * @member {OpenSeadragon.Point} position
     * @memberof OpenSeadragon.Tile#
     */
    this.position   = null;
    /**
     * This tile's size on screen, in pixels.
     * @member {OpenSeadragon.Point} size
     * @memberof OpenSeadragon.Tile#
     */
    this.size       = null;
    /**
     * Whether to flip the tile when rendering.
     * @member {Boolean} flipped
     * @memberof OpenSeadragon.Tile#
     */
    this.flipped    = false;
    /**
     * The start time of this tile's blending.
     * @member {Number} blendStart
     * @memberof OpenSeadragon.Tile#
     */
    this.blendStart = null;
    /**
     * The current opacity this tile should be.
     * @member {Number} opacity
     * @memberof OpenSeadragon.Tile#
     */
    this.opacity    = null;
    /**
     * The squared distance of this tile to the viewport center.
     * Use for comparing tiles.
     * @member {Number} squaredDistance
     * @memberof OpenSeadragon.Tile#
     * @private
     */
    this.squaredDistance   = null;
    /**
     * The visibility score of this tile.
     * @member {Number} visibility
     * @memberof OpenSeadragon.Tile#
     */
    this.visibility = null;

    /**
     * The transparency indicator of this tile.
     * @member {Boolean} hasTransparency true if tile contains transparency for correct rendering
     * @memberof OpenSeadragon.Tile#
     */
    this.hasTransparency = false;

    /**
     * Whether this tile is currently being drawn.
     * @member {Boolean} beingDrawn
     * @memberof OpenSeadragon.Tile#
     */
    this.beingDrawn     = false;

    /**
     * Timestamp the tile was last touched.
     * @member {Number} lastTouchTime
     * @memberof OpenSeadragon.Tile#
     */
    this.lastTouchTime  = 0;

    /**
     * Whether this tile is in the right-most column for its level.
     * @member {Boolean} isRightMost
     * @memberof OpenSeadragon.Tile#
     */
    this.isRightMost = false;

    /**
     * Whether this tile is in the bottom-most row for its level.
     * @member {Boolean} isBottomMost
     * @memberof OpenSeadragon.Tile#
     */
    this.isBottomMost = false;

    /**
     * Owner of this tile. Do not change this property manually.
     * @member {OpenSeadragon.TiledImage}
     * @memberof OpenSeadragon.Tile#
     */
    this.tiledImage = null;
    /**
     * Array of cached tile data associated with the tile.
     * @member {Object}
     * @private
     */
    this._caches = {};
    /**
     * Processing flag, exempt the tile from removal when there are ongoing updates
     * @member {Boolean|Number}
     * @private
     */
    this.processing = false;
    /**
     * Processing promise, resolves when the tile exits processing, or
     * resolves immediatelly if not in the processing state.
     * @member {OpenSeadragon.Promise<OpenSeadragon.Tile>}
     * @private
     */
    this.processingPromise = $.Promise.resolve(this);
};

/** @lends OpenSeadragon.Tile.prototype */
$.Tile.prototype = {

    /**
     * Provides a string representation of this tiles level and (x,y)
     * components.
     * @function
     * @returns {String}
     */
    toString: function() {
        return this.level + "/" + this.x + "_" + this.y;
    },

    /**
     * The unique main cache key for this tile. Created automatically
     *  from the given tiledImage.source.getTileHashKey(...) implementation.
     * @member {String} cacheKey
     * @memberof OpenSeadragon.Tile#
     * @private
     */
    get cacheKey() {
        return this._cKey;
    },
    set cacheKey(value) {
        if (value === this.cacheKey) {
            return;
        }
        const cache = this.getCache(value);
        if (!cache) {
            // It's better to first set cache, then change the key to existing one. Warn if otherwise.
            $.console.warn("[Tile.cacheKey] should not be set manually. Use addCache() with setAsMain=true.");
        }
        this._updateMainCacheKey(value);
    },

    /**
     * By default equal to tile.cacheKey, marks a cache associated with this tile
     * that holds the cache original data (it was loaded with). In case you
     * change the tile data, the tile original data should be left with the cache
     * 'originalCacheKey' and the new, modified data should be stored in cache 'cacheKey'.
     * This key is used in cache resolution: in case new tile data is requested, if
     * this cache key exists in the cache it is loaded.
     * @member {String} originalCacheKey
     * @memberof OpenSeadragon.Tile#
     * @private
     */
    set originalCacheKey(value) {
        throw "Original Cache Key cannot be managed manually!";
    },
    get originalCacheKey() {
        return this._ocKey;
    },

    /**
     * The Image object for this tile.
     * @member {Object} image
     * @memberof OpenSeadragon.Tile#
     * @deprecated
     * @returns {Image}
     */
    get image() {
        $.console.error("[Tile.image] property has been deprecated. Use [Tile.getData] instead.");
        return this.getImage();
    },

    /**
     * The URL of this tile's image.
     * @member {String} url
     * @memberof OpenSeadragon.Tile#
     * @deprecated
     * @returns {String}
     */
    get url() {
        $.console.error("[Tile.url] property has been deprecated. Use [Tile.getUrl] instead.");
        return this.getUrl();
    },

    /**
     * The HTML div element for this tile
     * @member {Element} element
     * @memberof OpenSeadragon.Tile#
     * @deprecated
     */
    get element() {
        $.console.error("Tile::element property is deprecated. Use cache API instead. Moreover, this property might be unstable.");
        const cache = this.getCache();
        if (!cache || !cache.loaded) {
            return null;
        }
        if (cache.type !== OpenSeadragon.HTMLDrawer.canvasCacheType || cache.type !== OpenSeadragon.HTMLDrawer.imageCacheType) {
            $.console.error("Access to HtmlDrawer property via Tile instance: HTMLDrawer must be used!");
            return null;
        }
        return cache.data.element;
    },

    /**
     * The HTML img element for this tile.
     * @member {Element} imgElement
     * @memberof OpenSeadragon.Tile#
     * @deprecated
     */
    get imgElement() {
        $.console.error("Tile::imgElement property is deprecated. Use cache API instead. Moreover, this property might be unstable.");
        const cache = this.getCache();
        if (!cache || !cache.loaded) {
            return null;
        }
        if (cache.type !== OpenSeadragon.HTMLDrawer.canvasCacheType || cache.type !== OpenSeadragon.HTMLDrawer.imageCacheType) {
            $.console.error("Access to HtmlDrawer property via Tile instance: HTMLDrawer must be used!");
            return null;
        }
        return cache.data.imgElement;
    },

    /**
     * The alias of this.element.style.
     * @member {String} style
     * @memberof OpenSeadragon.Tile#
     * @deprecated
     */
    get style() {
        $.console.error("Tile::style property is deprecated. Use cache API instead. Moreover, this property might be unstable.");
        const cache = this.getCache();
        if (!cache || !cache.loaded) {
            return null;
        }
        if (cache.type !== OpenSeadragon.HTMLDrawer.canvasCacheType || cache.type !== OpenSeadragon.HTMLDrawer.imageCacheType) {
            $.console.error("Access to HtmlDrawer property via Tile instance: HTMLDrawer must be used!");
            return null;
        }
        return cache.data.style;
    },

    /**
     * Get the Image object for this tile.
     * @returns {?Image}
     * @deprecated
     */
    getImage: function() {
        $.console.error("[Tile.getImage] property has been deprecated. Use 'tile-invalidated' routine event instead.");
        //this method used to ensure the underlying data model conformed to given type - convert instead of getData()
        const cache = this.getCache(this.cacheKey);
        if (!cache) {
            return undefined;
        }
        cache.transformTo("image");
        return cache.data;
    },

    /**
     * Get the url string for this tile.
     * @returns {String}
     */
    getUrl: function() {
        if (typeof this._url === 'function') {
            return this._url();
        }

        return this._url;
    },

    /**
     * Get the CanvasRenderingContext2D instance for tile image data drawn
     * onto Canvas if enabled and available
     * @deprecated
     * @returns {CanvasRenderingContext2D|undefined}
     */
    getCanvasContext: function() {
        $.console.error("[Tile.getCanvasContext] property has been deprecated. Use 'tile-invalidated' routine event instead.");
        //this method used to ensure the underlying data model conformed to given type - convert instead of getData()
        const cache = this.getCache(this.cacheKey);
        if (!cache) {
            return undefined;
        }
        cache.transformTo("context2d");
        return cache.data;
    },

    /**
     * The context2D of this tile if it is provided directly by the tile source.
     * @deprecated
     * @type {CanvasRenderingContext2D}
     */
    get context2D() {
        $.console.error("[Tile.context2D] property has been deprecated. Use 'tile-invalidated' routine event instead.");
        return this.getCanvasContext();
    },

    /**
     * The context2D of this tile if it is provided directly by the tile source.
     * @deprecated
     */
    set context2D(value) {
        $.console.error("[Tile.context2D] property has been deprecated. Use 'tile-invalidated' routine event instead.");
        const cache = this._caches[this.cacheKey];
        if (cache) {
            this.removeCache(this.cacheKey);
        }
        this.addCache(this.cacheKey, value, 'context2d', true, false);
    },

    /**
     * The default cache for this tile.
     * @deprecated
     * @type OpenSeadragon.CacheRecord
     */
    get cacheImageRecord() {
        $.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::getCache.");
        return this.getCache(this.cacheKey);
    },

    /**
     * The default cache for this tile.
     * @deprecated
     */
    set cacheImageRecord(value) {
        $.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::addCache.");
        const cache = this._caches[this.cacheKey];

        if (cache) {
            this.removeCache(this.cacheKey);
        }

        if (value) {
            if (value.loaded) {
                this.addCache(this.cacheKey, value.data, value.type, true, false);
            } else {
                value.await().then(x => this.addCache(this.cacheKey, x, value.type, true, false));
            }
        }
    },

    /**
     * Cache key for main cache that is 'cache-equal', but different from original cache key
     * @return {string}
     * @private
     */
    buildDistinctMainCacheKey: function () {
        return this.cacheKey === this.originalCacheKey ? "mod://" + this.originalCacheKey : this.cacheKey;
    },

    /**
     * Read tile cache data object (CacheRecord)
     * @param {string} [key=this.cacheKey] cache key to read that belongs to this tile
     * @return {OpenSeadragon.CacheRecord}
     * @private
     */
    getCache: function(key = this._cKey) {
        const cache = this._caches[key];
        if (cache) {
            cache.withTileReference(this);
        }
        return cache;
    },

    /**
     * Create tile cache for given data object.
     *
     * Using `setAsMain` updates also main tile cache key - the main cache key used to draw this tile.
     * In that case, the cache should be ready to be rendered immediatelly (converted to one of the supported formats
     * of the currently employed drawer).
     *
     * NOTE: if the existing cache already exists,
     * data parameter is ignored and inherited from the existing cache object.
     * WARNING: if you override main tile cache key to point to a different cache, the invalidation routine
     * will no longer work. If you need to modify tile main data, prefer to use invalidation routine instead.
     *
     * @param {string} key cache key, if unique, new cache object is created, else existing cache attached
     * @param {*} data this data will be IGNORED if cache already exists; therefore if
     *   `typeof data === 'function'` holds (both async and normal functions), the data is called to obtain
     *   the data item: this is an optimization to load data only when necessary.
     * @param {string} [type=undefined] data type, will be guessed if not provided (not recommended),
     *   if data is a callback the type is a mandatory field, not setting it results in undefined behaviour
     * @param {boolean} [setAsMain=false] if true, the key will be set as the tile.cacheKey,
     *   no effect if key === this.cacheKey
     * @param [_safely=true] private
     * @returns {OpenSeadragon.CacheRecord|null} - The cache record the tile was attached to.
     * @private
     */
    addCache: function(key, data, type = undefined, setAsMain = false, _safely = true) {
        const tiledImage = this.tiledImage;
        if (!tiledImage) {
            return null; //async can access outside its lifetime
        }

        if (!type) {
            if (!this.__typeWarningReported) {
                $.console.warn(this, "[Tile.addCache] called without type specification. " +
                    "Automated deduction is potentially unsafe: prefer specification of data type explicitly.");
                this.__typeWarningReported = true;
            }
            if (typeof data === 'function') {
                $.console.error("[TileCache.cacheTile] options.data as a callback requires type argument! Current is " + type);
            }
            type = $.converter.guessType(data);
        }

        const overwritesMainCache = key === this.cacheKey;
        if (_safely && (overwritesMainCache || setAsMain)) {
            // Need to get the supported type for rendering out of the active drawer.
            const supportedTypes = tiledImage.getDrawer().getSupportedDataFormats();
            const conversion = $.converter.getConversionPath(type, supportedTypes);
            $.console.assert(conversion, "[Tile.addCache] data was set for the default tile cache we are unable" +
                `to render. Make sure OpenSeadragon.converter was taught to convert ${type} to (one of): ${conversion.toString()}`);
        }

        const cachedItem = tiledImage._tileCache.cacheTile({
            data: data,
            dataType: type,
            tile: this,
            cacheKey: key,
            cutoff: tiledImage.source.getClosestLevel(),
        });
        const havingRecord = this._caches[key];
        if (havingRecord !== cachedItem) {
            this._caches[key] = cachedItem;
            if (havingRecord) {
                havingRecord.removeTile(this);
                tiledImage._tileCache.safeUnloadCache(havingRecord);
            }
        }

        // Update cache key if differs and main requested
        if (!overwritesMainCache && setAsMain) {
            this._updateMainCacheKey(key);
        }
        return cachedItem;
    },


    /**
     * Add cache object to the tile
     *
     * @param {string} key cache key, if unique, new cache object is created, else existing cache attached
     * @param {OpenSeadragon.CacheRecord} cache the cache object to attach to this tile
     * @param {boolean} [setAsMain=false] if true, the key will be set as the tile.cacheKey,
     *   no effect if key === this.cacheKey
     * @param [_safely=true] private
     * @returns {OpenSeadragon.CacheRecord|null} - Returns cache parameter reference if attached.
     * @private
     */
    setCache(key, cache, setAsMain = false, _safely = true) {
        const tiledImage = this.tiledImage;
        if (!tiledImage) {
            return null; //async can access outside its lifetime
        }

        const overwritesMainCache = key === this.cacheKey;
        if (_safely) {
            $.console.assert(cache instanceof $.CacheRecord, "[Tile.setCache] cache must be a CacheRecord object!");
            if (overwritesMainCache || setAsMain) {
                // Need to get the supported type for rendering out of the active drawer.
                const supportedTypes = tiledImage.getDrawer().getSupportedDataFormats();
                const conversion = $.converter.getConversionPath(cache.type, supportedTypes);
                $.console.assert(conversion, "[Tile.setCache] data was set for the default tile cache we are unable" +
                    `to render. Make sure OpenSeadragon.converter was taught to convert ${cache.type} to (one of): ${conversion.toString()}`);
            }
        }

        const havingRecord = this._caches[key];
        if (havingRecord !== cache) {
            this._caches[key] = cache;
            cache.addTile(this);  // keep reference bidirectional
            if (havingRecord) {
                havingRecord.removeTile(this);
                tiledImage._tileCache.safeUnloadCache(havingRecord);
            }
        }

        // Update cache key if differs and main requested
        if (!overwritesMainCache && setAsMain) {
            this._updateMainCacheKey(key);
        }
        return cache;
    },

    /**
     * Sets the main cache key for this tile and
     * performs necessary updates
     * @param value
     * @private
     */
    _updateMainCacheKey: function(value) {
        let ref = this._caches[this._cKey];
        if (ref) {
            // make sure we free drawer internal cache if people change cache key externally
            ref.destroyInternalCache();
        }
        this._cKey = value;
    },

    /**
     * Get the number of caches available to this tile
     * @returns {number} number of caches
     */
    getCacheSize: function() {
        return Object.keys(this._caches).length;
    },

    /**
     * Free tile cache. Removes by default the cache record if no other tile uses it.
     * @param {string} key cache key, required
     * @param {boolean} [freeIfUnused=true] set to false if zombie should be created
     * @return {OpenSeadragon.CacheRecord|undefined} reference to the cache record if it was removed,
     *   undefined if removal was refused to perform (e.g. does not exist, it is an original data target etc.)
     * @private
     */
    removeCache: function(key, freeIfUnused = true) {
        const deleteTarget = this._caches[key];
        if (!deleteTarget) {
            // try to erase anyway in case the cache got stuck in memory
            this.tiledImage._tileCache.unloadCacheForTile(this, key, freeIfUnused, true);
            return undefined;
        }

        const currentMainKey = this.cacheKey,
            originalDataKey = this.originalCacheKey,
            sameBuiltinKeys = currentMainKey === originalDataKey;

        if (!sameBuiltinKeys && originalDataKey === key) {
            $.console.warn("[Tile.removeCache] original data must not be manually deleted: other parts of the code might rely on it!",
                "If you want the tile not to preserve the original data, toggle of data perseverance in tile.setData().");
            return undefined;
        }

        if (currentMainKey === key) {
            if (!sameBuiltinKeys && this._caches[originalDataKey]) {
                // if we have original data let's revert back
                this._updateMainCacheKey(originalDataKey);
            } else {
                $.console.warn("[Tile.removeCache] trying to remove the only cache that can be used to draw the tile!",
                    "If you want to remove the main cache, first set different cache as main with tile.addCache()");
                return undefined;
            }
        }
        if (this.tiledImage._tileCache.unloadCacheForTile(this, key, freeIfUnused, false)) {
            //if we managed to free tile from record, we are sure we decreased cache count
            delete this._caches[key];
        }
        return deleteTarget;
    },

    /**
     * Get the ratio between current and original size.
     * @function
     * @deprecated
     * @returns {number}
     */
    getScaleForEdgeSmoothing: function() {
        // getCanvasContext is deprecated and so should be this method.
        $.console.warn("[Tile.getScaleForEdgeSmoothing] is deprecated, the following error is the consequence:");
        const context = this.getCanvasContext();
        if (!context) {
            $.console.warn(
                '[Tile.drawCanvas] attempting to get tile scale %s when tile\'s not cached',
                this.toString());
            return 1;
        }
        return context.canvas.width / (this.size.x * $.pixelDensityRatio);
    },

    /**
     * Get a translation vector that when applied to the tile position produces integer coordinates.
     * Needed to avoid swimming and twitching.
     * @function
     * @param {Number} [scale=1] - Scale to be applied to position.
     * @returns {OpenSeadragon.Point}
     */
    getTranslationForEdgeSmoothing: function(scale, canvasSize, sketchCanvasSize) {
        // The translation vector must have positive values, otherwise the image goes a bit off
        // the sketch canvas to the top and left and we must use negative coordinates to repaint it
        // to the main canvas. In that case, some browsers throw:
        // INDEX_SIZE_ERR: DOM Exception 1: Index or size was negative, or greater than the allowed value.
        const x = Math.max(1, Math.ceil((sketchCanvasSize.x - canvasSize.x) / 2));
        const y = Math.max(1, Math.ceil((sketchCanvasSize.y - canvasSize.y) / 2));
        return new $.Point(x, y).minus(
            this.position
                .times($.pixelDensityRatio)
                .times(scale || 1)
                .apply(function(x) {
                    return x % 1;
                })
        );
    },

    /**
     * Reflect that a cache object was renamed. Called internally from TileCache.
     * Do NOT call manually.
     * @function
     * @private
     */
    reflectCacheRenamed: function (oldKey, newKey) {
        let cache = this._caches[oldKey];
        if (!cache) {
            return;  // nothing to fix
        }
        // Do update via private refs, old key no longer exists in cache
        if (oldKey === this._ocKey) {
            this._ocKey = newKey;
        }
        if (oldKey === this._cKey) {
            this._cKey = newKey;
        }
        // Working key is never updated, it will be invalidated (but do not dereference cache, just fix the pointers)
        this._caches[newKey] = cache;
        delete this._caches[oldKey];
    },

    /**
     * Check if two tiles are data-equal
     * @param {OpenSeadragon.Tile} tile
     */
    equals(tile) {
         return this._ocKey === tile._ocKey;
    },

    /**
     * Removes tile from the system: it will still be present in the
     * OSD memory, but marked as loaded=false, and its data will be erased if erase set to true.
     * @param {boolean} [erase=false]
     */
    unload: function(erase = false) {
        if (!this.loaded) {
            return;
        }
        this.tiledImage._tileCache.unloadTile(this, erase);
    },

    /**
     * this method shall be called only by cache system when the tile is already empty of data
     * @private
     */
    _unload: function () {
        this.tiledImage = null;
        this._caches    = {};
        this.loaded     = false;
        this.loading    = false;
        this._cKey      = this._ocKey;
    }
};

}( OpenSeadragon ));

/*
 * OpenSeadragon - Overlay
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function($) {

    /**
     * An enumeration of positions that an overlay may be assigned relative to
     * the viewport.
     * It is identical to OpenSeadragon.Placement but is kept for backward
     * compatibility.
     * @member OverlayPlacement
     * @memberof OpenSeadragon
     * @see OpenSeadragon.Placement
     * @static
     * @readonly
     * @type {Object}
     * @property {Number} CENTER
     * @property {Number} TOP_LEFT
     * @property {Number} TOP
     * @property {Number} TOP_RIGHT
     * @property {Number} RIGHT
     * @property {Number} BOTTOM_RIGHT
     * @property {Number} BOTTOM
     * @property {Number} BOTTOM_LEFT
     * @property {Number} LEFT
     */
    $.OverlayPlacement = $.Placement;

    /**
     * An enumeration of possible ways to handle overlays rotation
     * @member OverlayRotationMode
     * @memberOf OpenSeadragon
     * @static
     * @readonly
     * @property {Number} NO_ROTATION The overlay ignore the viewport rotation.
     * @property {Number} EXACT The overlay use CSS 3 transforms to rotate with
     * the viewport. If the overlay contains text, it will get rotated as well.
     * @property {Number} BOUNDING_BOX The overlay adjusts for rotation by
     * taking the size of the bounding box of the rotated bounds.
     * Only valid for overlays with Rect location and scalable in both directions.
     */
    $.OverlayRotationMode = $.freezeObject({
        NO_ROTATION: 1,
        EXACT: 2,
        BOUNDING_BOX: 3
    });

    /**
     * @class Overlay
     * @classdesc Provides a way to float an HTML element on top of the viewer element.
     *
     * @memberof OpenSeadragon
     * @param {Object} options
     * @param {Element} options.element
     * @param {OpenSeadragon.Point|OpenSeadragon.Rect} options.location - The
     * location of the overlay on the image. If a {@link OpenSeadragon.Point}
     * is specified, the overlay will be located at this location with respect
     * to the placement option. If a {@link OpenSeadragon.Rect} is specified,
     * the overlay will be placed at this location with the corresponding width
     * and height and placement TOP_LEFT.
     * @param {OpenSeadragon.Placement} [options.placement=OpenSeadragon.Placement.TOP_LEFT]
     * Defines what part of the overlay should be at the specified options.location
     * @param {OpenSeadragon.Overlay.OnDrawCallback} [options.onDraw]
     * @param {Boolean} [options.checkResize=true] Set to false to avoid to
     * check the size of the overlay every time it is drawn in the directions
     * which are not scaled. It will improve performances but will cause a
     * misalignment if the overlay size changes.
     * @param {Number} [options.width] The width of the overlay in viewport
     * coordinates. If specified, the width of the overlay will be adjusted when
     * the zoom changes.
     * @param {Number} [options.height] The height of the overlay in viewport
     * coordinates. If specified, the height of the overlay will be adjusted when
     * the zoom changes.
     * @param {Boolean} [options.rotationMode=OpenSeadragon.OverlayRotationMode.EXACT]
     * How to handle the rotation of the viewport.
     */
    $.Overlay = function(element, location, placement) {

        /**
         * onDraw callback signature used by {@link OpenSeadragon.Overlay}.
         *
         * @callback OnDrawCallback
         * @memberof OpenSeadragon.Overlay
         * @param {OpenSeadragon.Point} position
         * @param {OpenSeadragon.Point} size
         * @param {Element} element
         */

        let options;
        if ($.isPlainObject(element)) {
            options = element;
        } else {
            options = {
                element: element,
                location: location,
                placement: placement
            };
        }

        this.elementWrapper = document.createElement('div');
        this.element = options.element;
        this.elementWrapper.appendChild(this.element);

        if (this.element.id) {
            this.elementWrapper.id = "overlay-wrapper-" + this.element.id; // Unique ID if element has one
        }

        // Always add a class for styling & selection
        this.elementWrapper.classList.add("openseadragon-overlay-wrapper");

        this.style = this.elementWrapper.style;
        this._init(options);
    };

    /** @lends OpenSeadragon.Overlay.prototype */
    $.Overlay.prototype = {

        // private
        _init: function(options) {
            this.location = options.location;
            this.placement = options.placement === undefined ?
                $.Placement.TOP_LEFT : options.placement;
            this.onDraw = options.onDraw;
            this.checkResize = options.checkResize === undefined ?
                true : options.checkResize;

            // When this.width is not null, the overlay get scaled horizontally
            this.width = options.width === undefined ? null : options.width;

            // When this.height is not null, the overlay get scaled vertically
            this.height = options.height === undefined ? null : options.height;

            this.rotationMode = options.rotationMode || $.OverlayRotationMode.EXACT;

            // Having a rect as location is a syntactic sugar
            if (this.location instanceof $.Rect) {
                this.width = this.location.width;
                this.height = this.location.height;
                this.location = this.location.getTopLeft();
                this.placement = $.Placement.TOP_LEFT;
            }

            // Deprecated properties kept for backward compatibility.
            this.scales = this.width !== null && this.height !== null;
            this.bounds = new $.Rect(
                this.location.x, this.location.y, this.width, this.height);
            this.position = this.location;
        },

        /**
         * Internal function to adjust the position of an overlay
         * depending on it size and placement.
         * @function
         * @param {OpenSeadragon.Point} position
         * @param {OpenSeadragon.Point} size
         */
        adjust: function(position, size) {
            const properties = $.Placement.properties[this.placement];
            if (!properties) {
                return;
            }
            if (properties.isHorizontallyCentered) {
                position.x -= size.x / 2;
            } else if (properties.isRight) {
                position.x -= size.x;
            }
            if (properties.isVerticallyCentered) {
                position.y -= size.y / 2;
            } else if (properties.isBottom) {
                position.y -= size.y;
            }
        },

        /**
         * @function
         */
        destroy: function() {
            const element = this.elementWrapper;
            const style = this.style;

            if (element.parentNode) {
                element.parentNode.removeChild(element);
                //this should allow us to preserve overlays when required between
                //pages
                if (element.prevElementParent) {
                    style.display = 'none';
                    //element.prevElementParent.insertBefore(
                    //    element,
                    //    element.prevNextSibling
                    //);
                    document.body.appendChild(element);
                }
            }

            // clear the onDraw callback
            this.onDraw = null;

            style.top = "";
            style.left = "";
            style.position = "";

            if (this.width !== null) {
                style.width = "";
            }
            if (this.height !== null) {
                style.height = "";
            }
            const transformOriginProp = $.getCssPropertyWithVendorPrefix(
                'transformOrigin');
            const transformProp = $.getCssPropertyWithVendorPrefix(
                'transform');
            if (transformOriginProp && transformProp) {
                style[transformOriginProp] = "";
                style[transformProp] = "";
            }
        },

        /**
         * @function
         * @param {Element} container
         */
        drawHTML: function(container, viewport) {
            const element = this.elementWrapper;
            if (element.parentNode !== container) {
                //save the source parent for later if we need it
                element.prevElementParent = element.parentNode;
                element.prevNextSibling = element.nextSibling;
                container.appendChild(element);

                // have to set position before calculating size, fix #1116
                this.style.position = "absolute";
                // this.size is used by overlays which don't get scaled in at
                // least one direction when this.checkResize is set to false.
                this.size = $.getElementSize(this.elementWrapper);
            }
            const positionAndSize = this._getOverlayPositionAndSize(viewport);
            const position = positionAndSize.position;
            const size = this.size = positionAndSize.size;
            let outerScale = "";
            if (viewport.overlayPreserveContentDirection) {
                outerScale = viewport.flipped ? " scaleX(-1)" : " scaleX(1)";
            }
            const rotate = viewport.flipped ? -positionAndSize.rotate : positionAndSize.rotate;
            const scale = viewport.flipped ? " scaleX(-1)" : "";
            // call the onDraw callback if it exists to allow one to overwrite
            // the drawing/positioning/sizing of the overlay
            if (this.onDraw) {
                this.onDraw(position, size, this.element);
            } else {
                const style = this.style;
                const innerStyle = this.element.style;
                innerStyle.display = "block";
                style.left = position.x + "px";
                style.top = position.y + "px";
                if (this.width !== null) {
                    innerStyle.width = size.x + "px";
                }
                if (this.height !== null) {
                    innerStyle.height = size.y + "px";
                }
                const transformOriginProp = $.getCssPropertyWithVendorPrefix(
                    'transformOrigin');
                const transformProp = $.getCssPropertyWithVendorPrefix(
                    'transform');
                if (transformOriginProp && transformProp) {
                    if (rotate && !viewport.flipped) {
                        innerStyle[transformProp] = "";
                        style[transformOriginProp] = this._getTransformOrigin();
                        style[transformProp] = "rotate(" + rotate + "deg)";
                    } else if (!rotate && viewport.flipped) {
                        innerStyle[transformProp] = outerScale;
                        style[transformOriginProp] = this._getTransformOrigin();
                        style[transformProp] = scale;
                    } else if (rotate && viewport.flipped){
                        innerStyle[transformProp] = outerScale;
                        style[transformOriginProp] = this._getTransformOrigin();
                        style[transformProp] = "rotate(" + rotate + "deg)" + scale;
                    } else {
                        innerStyle[transformProp] = "";
                        style[transformOriginProp] = "";
                        style[transformProp] = "";
                    }
                }
                style.display = 'flex';
            }
        },

        // private
        _getOverlayPositionAndSize: function(viewport) {
            let position = viewport.pixelFromPoint(this.location, true);
            let size = this._getSizeInPixels(viewport);
            this.adjust(position, size);

            let rotate = 0;
            if (viewport.getRotation(true) &&
                this.rotationMode !== $.OverlayRotationMode.NO_ROTATION) {
                // BOUNDING_BOX is only valid if both directions get scaled.
                // Get replaced by EXACT otherwise.
                if (this.rotationMode === $.OverlayRotationMode.BOUNDING_BOX &&
                    this.width !== null && this.height !== null) {
                    const rect = new $.Rect(position.x, position.y, size.x, size.y);
                    const boundingBox = this._getBoundingBox(rect, viewport.getRotation(true));
                    position = boundingBox.getTopLeft();
                    size = boundingBox.getSize();
                } else {
                    rotate = viewport.getRotation(true);
                }
            }

            if (viewport.flipped) {
                position.x = (viewport.getContainerSize().x - position.x);
            }
            return {
                position: position,
                size: size,
                rotate: rotate
            };
        },

        // private
        _getSizeInPixels: function(viewport) {
            let width = this.size.x;
            let height = this.size.y;
            if (this.width !== null || this.height !== null) {
                const scaledSize = viewport.deltaPixelsFromPointsNoRotate(
                    new $.Point(this.width || 0, this.height || 0), true);
                if (this.width !== null) {
                    width = scaledSize.x;
                }
                if (this.height !== null) {
                    height = scaledSize.y;
                }
            }
            if (this.checkResize &&
                (this.width === null || this.height === null)) {
                const eltSize = this.size = $.getElementSize(this.elementWrapper);
                if (this.width === null) {
                    width = eltSize.x;
                }
                if (this.height === null) {
                    height = eltSize.y;
                }
            }
            return new $.Point(width, height);
        },

        // private
        _getBoundingBox: function(rect, degrees) {
            const refPoint = this._getPlacementPoint(rect);
            return rect.rotate(degrees, refPoint).getBoundingBox();
        },

        // private
        _getPlacementPoint: function(rect) {
            const result = new $.Point(rect.x, rect.y);
            const properties = $.Placement.properties[this.placement];
            if (properties) {
                if (properties.isHorizontallyCentered) {
                    result.x += rect.width / 2;
                } else if (properties.isRight) {
                    result.x += rect.width;
                }
                if (properties.isVerticallyCentered) {
                    result.y += rect.height / 2;
                } else if (properties.isBottom) {
                    result.y += rect.height;
                }
            }
            return result;
        },

        // private
        _getTransformOrigin: function() {
            let result = "";
            const properties = $.Placement.properties[this.placement];
            if (!properties) {
                return result;
            }
            if (properties.isLeft) {
                result = "left";
            } else if (properties.isRight) {
                result = "right";
            }
            if (properties.isTop) {
                result += " top";
            } else if (properties.isBottom) {
                result += " bottom";
            }
            return result;
        },

        /**
         * Changes the overlay settings.
         * @function
         * @param {OpenSeadragon.Point|OpenSeadragon.Rect|Object} location
         * If an object is specified, the options are the same than the constructor
         * except for the element which can not be changed.
         * @param {OpenSeadragon.Placement} placement
         */
        update: function(location, placement) {
            const options = $.isPlainObject(location) ? location : {
                location: location,
                placement: placement
            };
            this._init({
                location: options.location || this.location,
                placement: options.placement !== undefined ?
                    options.placement : this.placement,
                onDraw: options.onDraw || this.onDraw,
                checkResize: options.checkResize || this.checkResize,
                width: options.width !== undefined ? options.width : this.width,
                height: options.height !== undefined ? options.height : this.height,
                rotationMode: options.rotationMode || this.rotationMode
            });
        },

        /**
         * Returns the current bounds of the overlay in viewport coordinates
         * @function
         * @param {OpenSeadragon.Viewport} viewport the viewport
         * @returns {OpenSeadragon.Rect} overlay bounds
         */
        getBounds: function(viewport) {
            $.console.assert(viewport,
                'A viewport must now be passed to Overlay.getBounds.');
            let width = this.width;
            let height = this.height;
            if (width === null || height === null) {
                const size = viewport.deltaPointsFromPixelsNoRotate(this.size, true);
                if (width === null) {
                    width = size.x;
                }
                if (height === null) {
                    height = size.y;
                }
            }
            const location = this.location.clone();
            this.adjust(location, new $.Point(width, height));
            return this._adjustBoundsForRotation(
                viewport, new $.Rect(location.x, location.y, width, height));
        },

        // private
        _adjustBoundsForRotation: function(viewport, bounds) {
            if (!viewport ||
                viewport.getRotation(true) === 0 ||
                this.rotationMode === $.OverlayRotationMode.EXACT) {
                return bounds;
            }
            if (this.rotationMode === $.OverlayRotationMode.BOUNDING_BOX) {
                // If overlay not fully scalable, BOUNDING_BOX falls back to EXACT
                if (this.width === null || this.height === null) {
                    return bounds;
                }
                // It is easier to just compute the position and size and
                // convert to viewport coordinates.
                const positionAndSize = this._getOverlayPositionAndSize(viewport);
                return viewport.viewerElementToViewportRectangle(new $.Rect(
                    positionAndSize.position.x,
                    positionAndSize.position.y,
                    positionAndSize.size.x,
                    positionAndSize.size.y));
            }

            // NO_ROTATION case
            return bounds.rotate(-viewport.getRotation(true),
                this._getPlacementPoint(bounds));
        }
    };

}(OpenSeadragon));

/*
 * OpenSeadragon - DrawerBase
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @typedef OpenSeadragon.BaseDrawerOptions
 * @memberOf OpenSeadragon
 * @property {boolean} [usePrivateCache=false] specify whether the drawer should use
 *   detached (=internal) cache object in case it has to perform custom type conversion atop
 *   what cache performs. In that case, drawer must implement internalCacheCreate() which gets data in one
 *   of formats the drawer declares as supported. This method must return object to be used during drawing.
 *   You should probably implement also internalCacheFree() to provide cleanup logics.
 *
 * @property {boolean} [preloadCache=true] When internalCacheCreate is used, it can be applied offline
 *   (asynchronously) during data processing = preloading, or just in time before rendering (if necessary).
 *   Preloading supports async handlers, and can use promises. If preloadCache=false, no async (e.g. cache conversion)
 *   logics can be used!
 *
 * @property {boolean} [offScreen=false] When true, the drawer is not attached to DOM. This must be false
 *   for all drawers created and used for rendering, particularly the main viewer drawer. However,
 *   if you need to use particular viewer API for rendering an offscreen images for further processing,
 *   you can set this to true.
 *
 * @property {boolean} [broadCastTileInvalidation=true] When true, the drawer will reflect changes done to the viewer's
 *   base drawer instance. For example, navigator will reflect data updates of the main viewport.
 */

const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc
/**
 * @class OpenSeadragon.DrawerBase
 * @classdesc Base class for Drawers that handle rendering of tiles for an {@link OpenSeadragon.Viewer}.
 *   More viewers can be implemented even as plugins if they are attached to the OpenSeadragon namespace.
 *   Then you can employ the newly defined type as you would do with built-in drawers.
 * @param {Object} options - Options for this Drawer.
 * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer.
 * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport.
 * @param {HTMLElement} options.element - Parent element.
 * @abstract
 */

OpenSeadragon.DrawerBase = class DrawerBase {
    constructor(options){
        $.console.assert( options.viewer, "[Drawer] options.viewer is required" );
        $.console.assert( options.viewport, "[Drawer] options.viewport is required" );
        $.console.assert( options.element, "[Drawer] options.element is required" );

        this._id = this.getType() + $.now();
        this.viewer = options.viewer;
        this.viewport = options.viewport;
        this.debugGridColor = typeof options.debugGridColor === 'string' ? [options.debugGridColor] : options.debugGridColor || $.DEFAULT_SETTINGS.debugGridColor;

        /**
         * @memberof OpenSeadragon.DrawerBase#
         * @type OpenSeadragon.BaseDrawerOptions
         */
        this.options = $.extend({
            usePrivateCache: false,
            preloadCache: true,
            offScreen: false,
            broadCastTileInvalidation: true,
        }, this.defaultOptions, options.options);

        this.container  = $.getElement( options.element );

        this._renderingTarget = this._createDrawingElement();

        if (!this.options.offScreen) {
            this.canvas.style.width     = "100%";
            this.canvas.style.height    = "100%";
            this.canvas.style.position  = "absolute";
            // set canvas.style.left = 0 so the canvas is positioned properly in ltr and rtl html
            this.canvas.style.left = "0";
            $.setElementOpacity( this.canvas, this.viewer.opacity, true );

            // Allow pointer events to pass through the canvas element so implicit
            //   pointer capture works on touch devices
            $.setElementPointerEventsNone( this.canvas );
            $.setElementTouchActionNone( this.canvas );

            // explicit left-align
            this.container.style.textAlign = "left";
            this.container.appendChild( this.canvas );

            if (this.options.broadCastTileInvalidation) {
                let parentViewer = this.viewer;
                while (parentViewer.viewer) {
                    parentViewer = parentViewer.viewer;
                }
                this._parentViewer = parentViewer;
                parentViewer._registerDrawer(this);
            } else {
                this.viewer._registerDrawer(this);
                this._parentViewer = this.viewer;
            }
        }

        this._checkInterfaceImplementation();
        this.setInternalCacheNeedsRefresh();  // initializes timestamp
    }

    /**
     * Retrieve default options for the current drawer.
     * The base implementation provides default shared options.
     * Overrides should enumerate all defaults or extend from this implementation.
     *   return $.extend({}, super.options, { ... custom drawer instance options ... });
     * @memberof {OpenSeadragon.DrawerBase}
     * @returns {OpenSeadragon.BaseDrawerOptions} common options
     */
    get defaultOptions() {
        // defaults are defined in constructor to avoid overriding issues
        return {};
    }

    /**
     * @memberof {OpenSeadragon.DrawerBase}
     * @return {Element}
     */
    get canvas(){
        return this._renderingTarget;
    }

    get element(){
        $.console.error('Drawer.element is deprecated. Use Drawer.container instead.');
        return this.container;
    }

    /**
     * Get unique drawer ID
     * @return {string}
     */
    getId() {
        return this._id;
    }

    /**
     * @abstract
     * @memberof {OpenSeadragon.DrawerBase}
     * @returns {String | undefined} What type of drawer this is. Must be overridden by extending classes.
     */
    getType(){
        $.console.error('Drawer.getType must be implemented by child class');
        return undefined;
    }

    /**
     * Retrieve required data formats the data must be converted to.
     * This list MUST BE A VALID SUBSET OF getSupportedDataFormats()
     * @memberof {OpenSeadragon.DrawerBase}
     * @return {string[]}
     */
    getRequiredDataFormats() {
        return this.getSupportedDataFormats();
    }

    /**
     * Retrieve data types
     * @abstract
     * @memberof {OpenSeadragon.DrawerBase}
     * @return {string[]}
     */
    getSupportedDataFormats() {
        throw "Drawer.getSupportedDataFormats must define its supported rendering data types!";
    }

    /**
     * Check a particular cache record is compatible.
     * This function _MUST_ be called: if it returns a falsey
     * value, the rendering _MUST NOT_ proceed. It should
     * await next animation frames and check again for availability.
     * @param {OpenSeadragon.Tile} tile
     * @memberof {OpenSeadragon.DrawerBase}
     * @return {any|undefined} undefined if cache not available, compatible data otherwise.
     */
    getDataToDraw(tile) {
        const cache = tile.getCache(tile.cacheKey);
        if (!cache) {
            $.console.warn("Attempt to draw tile %s when not cached!", tile);
            return undefined;
        }
        const dataCache = cache.getDataForRendering(this, tile);
        return dataCache && dataCache.data;
    }

    /**
     * @abstract
     * @returns {Boolean} Whether the drawer implementation is supported by the browser. Must be overridden by extending classes.
     */
    static isSupported() {
        $.console.error('Drawer.isSupported must be implemented by child class');
    }

    /**
     * @abstract
     * @returns {Element} the element to draw into
     * @private
     */
    _createDrawingElement() {
        $.console.error('Drawer._createDrawingElement must be implemented by child class');
        return null;
    }

    /**
     * @abstract
     * @param {Array} tiledImages - An array of TiledImages that are ready to be drawn.
     * @private
     */
    draw(tiledImages) {
        $.console.error('Drawer.draw must be implemented by child class');
    }

    /**
     * @abstract
     * @returns {Boolean} True if rotation is supported.
     */
    canRotate() {
        $.console.error('Drawer.canRotate must be implemented by child class');
    }

    /**
     * Destroy the drawer. Child classes must call this super class.
     */
    destroy() {
        // how to force child classes to call this?
        // we could force destroy methods to return some unique value that is obtainable only from this method...
        this._parentViewer._unregisterDrawer(this);
    }

    /**
     * Destroy internal cache. Should be called within destroy() when
     * usePrivateCache is set to true. Ensures cleanup of anything created
     * by internalCacheCreate(...).
     */
    destroyInternalCache() {
        this.viewer.tileCache.clearDrawerInternalCache(this);
    }

    /**
     * @param {TiledImage} tiledImage the tiled image that is calling the function
     * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams.
     * @private
     */
    minimumOverlapRequired(tiledImage) {
        return false;
    }

    /**
     * @abstract
     * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is
     * drawn smoothly on the canvas; see imageSmoothingEnabled in
     * {@link OpenSeadragon.Options} for more explanation.
     */
    setImageSmoothingEnabled(imageSmoothingEnabled){
        $.console.error('Drawer.setImageSmoothingEnabled must be implemented by child class');
    }

    /**
     * Optional public API to draw a rectangle (e.g. for debugging purposes)
     * Child classes can override this method if they wish to support this
     * @param {OpenSeadragon.Rect} rect
     */
    drawDebuggingRect(rect) {
        $.console.warn('[drawer].drawDebuggingRect is not implemented by this drawer');
    }

    // Deprecated functions
    clear(){
        $.console.warn('[drawer].clear() is deprecated. The drawer is responsible for clearing itself as needed before drawing tiles.');
    }

    /**
     * If options.usePrivateCache is true, this method MUST RETURN the private cache content
     * @param {OpenSeadragon.CacheRecord} cache
     * @param {OpenSeadragon.Tile} tile
     * @return any
     */
    internalCacheCreate(cache, tile) {}

    /**
     * It is possible to perform any necessary cleanup on internal cache, necessary if you
     * need to clean up some memory (e.g. destroy canvas by setting with & height to 0).
     * @param {*} data object returned by internalCacheCreate(...)
     */
    internalCacheFree(data) {}

    /**
     * Call to invalidate internal cache. It will be rebuilt. With synchronous converions,
     * it will be rebuilt immediatelly. With asynchronous, it will be rebuilt once invalidation
     * routine happens, e.g. you should call also requestInvalidate() if you need to happen
     * it as soon as possible.
     */
    setInternalCacheNeedsRefresh() {
        this._dataNeedsRefresh = $.now();
    }

    /**
     * When a Tiled Image is initialized and ready, this method is called.
     * Unlike with events, here it is guaranteed that all external code has finished
     * processing (under normal circumstances) and the tiled image should not change.
     * @param {OpenSeadragon.TiledImage} tiledImage target image that has been created
     */
    tiledImageCreated(tiledImage) {
        // pass
    }

    // Private functions

    /**
     * Ensures that child classes have provided implementations for public API methods
     * draw, canRotate, destroy, and setImageSmoothinEnabled. Throws an exception if the original
     * placeholder methods are still in place.
     * @private
     *
     */
    _checkInterfaceImplementation(){
        if (this._createDrawingElement === $.DrawerBase.prototype._createDrawingElement) {
            throw(new Error("[drawer]._createDrawingElement must be implemented by child class"));
        }
        if (this.draw === $.DrawerBase.prototype.draw) {
            throw(new Error("[drawer].draw must be implemented by child class"));
        }
        if (this.canRotate === $.DrawerBase.prototype.canRotate) {
            throw(new Error("[drawer].canRotate must be implemented by child class"));
        }
        if (this.destroy === $.DrawerBase.prototype.destroy) {
            throw(new Error("[drawer].destroy must be implemented by child class"));
        }
        if (this.setImageSmoothingEnabled === $.DrawerBase.prototype.setImageSmoothingEnabled) {
            throw(new Error("[drawer].setImageSmoothingEnabled must be implemented by child class"));
        }
    }


    // Utility functions

    /**
     * Scale from OpenSeadragon viewer rectangle to drawer rectangle
     * (ignoring rotation)
     * @param {OpenSeadragon.Rect} rectangle - The rectangle in viewport coordinate system.
     * @returns {OpenSeadragon.Rect} Rectangle in drawer coordinate system.
     */
    viewportToDrawerRectangle(rectangle) {
        const topLeft = this.viewport.pixelFromPointNoRotate(rectangle.getTopLeft(), true);
        const size = this.viewport.deltaPixelsFromPointsNoRotate(rectangle.getSize(), true);

        return new $.Rect(
            topLeft.x * $.pixelDensityRatio,
            topLeft.y * $.pixelDensityRatio,
            size.x * $.pixelDensityRatio,
            size.y * $.pixelDensityRatio
        );
    }

    /**
     * This function converts the given point from to the drawer coordinate by
     * multiplying it with the pixel density.
     * This function does not take rotation into account, thus assuming provided
     * point is at 0 degree.
     * @param {OpenSeadragon.Point} point - the pixel point to convert
     * @returns {OpenSeadragon.Point} Point in drawer coordinate system.
     */
    viewportCoordToDrawerCoord(point) {
        const vpPoint = this.viewport.pixelFromPointNoRotate(point, true);
        return new $.Point(
            vpPoint.x * $.pixelDensityRatio,
            vpPoint.y * $.pixelDensityRatio
        );
    }


    // Internal utility functions

    /**
     * Calculate width and height of the canvas based on viewport dimensions
     * and pixelDensityRatio
     * @private
     * @returns {OpenSeadragon.Point} {x, y} size of the canvas
     */
    _calculateCanvasSize() {
        const pixelDensityRatio = $.pixelDensityRatio;
        const viewportSize = this.viewport.getContainerSize();
        return new OpenSeadragon.Point( Math.round(viewportSize.x * pixelDensityRatio), Math.round(viewportSize.y * pixelDensityRatio));
    }

    /**
     * Called by implementations to fire the tiled-image-drawn event (used by tests)
     * @private
     */
    _raiseTiledImageDrawnEvent(tiledImage, tiles){
        if(!this.viewer) {
            return;
        }

        /**
        *  Raised when a tiled image is drawn to the canvas. Used internally for testing.
        *  The update-viewport event is preferred if you want to know when a frame has been drawn.
        *
        * @event tiled-image-drawn
        * @memberof OpenSeadragon.Viewer
        * @type {object}
        * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
        * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
        * @property {Array} tiles - An array of Tile objects that were drawn.
        * @property {?Object} userData - Arbitrary subscriber-defined object.
        * @private
        */
        this.viewer.raiseEvent( 'tiled-image-drawn', {
            tiledImage: tiledImage,
            tiles: tiles,
        });
    }

    /**
     * Called by implementations to fire the drawer-error event
     * @private
     */
    _raiseDrawerErrorEvent(tiledImage, errorMessage){
        if(!this.viewer) {
            return;
        }

        /**
        *  Raised when a tiled image is drawn to the canvas. Used internally for testing.
        *  The update-viewport event is preferred if you want to know when a frame has been drawn.
        *
        * @event drawer-error
        * @memberof OpenSeadragon.Viewer
        * @type {object}
        * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
        * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
        * @property {OpenSeadragon.DrawerBase} drawer - The drawer that raised the error.
        * @property {String} error - A message describing the error.
        * @property {?Object} userData - Arbitrary subscriber-defined object.
        * @protected
        */
        this.viewer.raiseEvent( 'drawer-error', {
            tiledImage: tiledImage,
            drawer: this,
            error: errorMessage,
        });
    }


};

}( OpenSeadragon ));

/*
 * OpenSeadragon - HTMLDrawer
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

const OpenSeadragon = $; // alias back for JSDoc

/**
 * @class OpenSeadragon.HTMLDrawer
 * @extends OpenSeadragon.DrawerBase
 * @classdesc HTML-based implementation of DrawerBase for an {@link OpenSeadragon.Viewer}.
 * @param {Object} options - Options for this Drawer.
 * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer.
 * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport.
 * @param {Element} options.element - Parent element.
 * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details.
 */

class HTMLDrawer extends OpenSeadragon.DrawerBase{
    constructor(options){
        super(options);

        /**
         * The HTML element (div) that this drawer uses for drawing
         * @member {Element} canvas
         * @memberof OpenSeadragon.HTMLDrawer#
         */

        /**
         * The parent element of this Drawer instance, passed in when the Drawer was created.
         * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}.
         * @member {Element} container
         * @memberof OpenSeadragon.HTMLDrawer#
         */

        // Reject listening for the tile-drawing event, which this drawer does not fire
        this.viewer.rejectEventHandler("tile-drawing", "The HTMLDrawer does not raise the tile-drawing event");
        // Since the tile-drawn event is fired by this drawer, make sure handlers can be added for it
        this.viewer.allowEventHandler("tile-drawn");

        // works with canvas & image objects
        function _prepareTile(tile, data) {
            const element = $.makeNeutralElement( "div" );
            const imgElement = data.cloneNode();
            imgElement.style.msInterpolationMode = "nearest-neighbor";
            imgElement.style.width = "100%";
            imgElement.style.height = "100%";

            const style = element.style;
            style.position = "absolute";

            return {
                element, imgElement, style, data
            };
        }

        // In theory, HTML drawer should cope well with canvas node type too,
        // but tests fail - if this conversion is used, it outputs uninitialized zeroed data
        // (data manipulation test module).

        // The actual placing logics will not happen at draw event, but when the cache is created:
        // $.converter.learn("context2d", HTMLDrawer.canvasCacheType, (t, d) => _prepareTile(t, d.canvas), 1, 1);
        $.converter.learn("image", HTMLDrawer.imageCacheType, _prepareTile, 1, 1);
        // Also learn how to move back, since these elements can be just used as-is
        // $.converter.learn(HTMLDrawer.canvasCacheType, "context2d", (t, d) => d.data.getContext('2d'), 1, 3);
        $.converter.learn(HTMLDrawer.imageCacheType, "image", (t, d) => d.data, 1, 3);

        function _freeTile(data) {
            if ( data.imgElement && data.imgElement.parentNode ) {
                data.imgElement.parentNode.removeChild( data.imgElement );
            }
            if ( data.element && data.element.parentNode ) {
                data.element.parentNode.removeChild( data.element );
            }
        }

        // $.converter.learnDestroy(HTMLDrawer.canvasCacheType, _freeTile);
        $.converter.learnDestroy(HTMLDrawer.imageCacheType, _freeTile);
    }

    static get imageCacheType() {
        return 'htmlDrawer[image]';
    }

    static get canvasCacheType() {
        return 'htmlDrawer[canvas]';
    }

    /**
     * @returns {Boolean} always true
     */
    static isSupported() {
        return true;
    }

    /**
     *
     * @returns 'html'
     */
    getType(){
        return 'html';
    }

    getSupportedDataFormats() {
        return [HTMLDrawer.imageCacheType, HTMLDrawer.canvasCacheType];
    }

    /**
     * @param {TiledImage} tiledImage the tiled image that is calling the function
     * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams.
     * @private
     */
    minimumOverlapRequired(tiledImage) {
        return true;
    }

    /**
     * create the HTML element (e.g. canvas, div) that the image will be drawn into
     * @returns {Element} the div to draw into
     */
    _createDrawingElement(){
        return $.makeNeutralElement("div");
    }

    /**
     * Draws the TiledImages
     */
    draw(tiledImages) {
        const _this = this;
        this._prepareNewFrame(); // prepare to draw a new frame
        tiledImages.forEach(function(tiledImage){
            if (tiledImage.opacity !== 0) {
                _this._drawTiles(tiledImage);
            }
        });

    }

    /**
     * @returns {Boolean} False - rotation is not supported.
     */
    canRotate() {
        return false;
    }

    /**
     * Destroy the drawer (unload current loaded tiles)
     */
    destroy() {
        super.destroy();
        this.container.removeChild(this.canvas);
    }

    /**
     * This function is ignored by the HTML Drawer. Implementing it is required by DrawerBase.
     * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is
     * drawn smoothly on the canvas; see imageSmoothingEnabled in
     * {@link OpenSeadragon.Options} for more explanation.
     */
    setImageSmoothingEnabled(){
        // noop - HTML Drawer does not deal with this property
    }

    /**
     * Clears the Drawer so it's ready to draw another frame.
     * @private
     *
     */
    _prepareNewFrame() {
        this.canvas.innerHTML = "";
    }

    /**
     * Draws a TiledImage.
     * @private
     *
     */
    _drawTiles( tiledImage ) {
        const lastDrawn = tiledImage.getTilesToDraw().map(info => info.tile);
        if (tiledImage.opacity === 0 || (lastDrawn.length === 0 && !tiledImage.placeholderFillStyle)) {
            return;
        }

        // Iterate over the tiles to draw, and draw them
        for (let i = lastDrawn.length - 1; i >= 0; i--) {
            const tile = lastDrawn[ i ];
            this._drawTile( tile );

            if( this.viewer ){
                /**
                 * Raised when a tile is drawn to the canvas. Only valid for
                 * context2d and html drawers.
                 *
                 * @event tile-drawn
                 * @memberof OpenSeadragon.Viewer
                 * @type {object}
                 * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
                 * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
                 * @property {OpenSeadragon.Tile} tile
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                this.viewer.raiseEvent( 'tile-drawn', {
                    tiledImage: tiledImage,
                    tile: tile
                });
            }
        }

    }

    /**
     * Draws the given tile.
     * @private
     * @param {OpenSeadragon.Tile} tile - The tile to draw.
     * @param {Function} drawingHandler - Method for firing the drawing event if using canvas.
     * drawingHandler({context, tile, rendered})
     */
    _drawTile( tile ) {
        $.console.assert(tile, '[Drawer._drawTile] tile is required');

        let container = this.canvas;

        if ( !tile.loaded ) {
            $.console.warn(
                "Attempting to draw tile %s when it's not yet loaded.",
                tile.toString()
            );
            return;
        }

        //EXPERIMENTAL - trying to figure out how to scale the container
        //               content during animation of the container size.

        const dataObject = this.getDataToDraw(tile);
        if (!dataObject) {
            return;
        }

        if ( dataObject.element.parentNode !== container ) {
            container.appendChild( dataObject.element );
        }
        if ( dataObject.imgElement.parentNode !== dataObject.element ) {
            dataObject.element.appendChild( dataObject.imgElement );
        }

        dataObject.style.top     = tile.position.y + "px";
        dataObject.style.left    = tile.position.x + "px";
        dataObject.style.height  = tile.size.y + "px";
        dataObject.style.width   = tile.size.x + "px";

        if (tile.flipped) {
            dataObject.style.transform = "scaleX(-1)";
        }

        $.setElementOpacity( dataObject.element, tile.opacity );
    }
}

$.HTMLDrawer = HTMLDrawer;


}( OpenSeadragon ));

/*
 * OpenSeadragon - CanvasDrawer
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

    const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc
/**
 * @class OpenSeadragon.CanvasDrawer
 * @extends OpenSeadragon.DrawerBase
 * @classdesc Default implementation of CanvasDrawer for an {@link OpenSeadragon.Viewer}.
 * @param {Object} options - Options for this Drawer.
 * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer.
 * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport.
 * @param {Element} options.element - Parent element.
 * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details.
 */

class CanvasDrawer extends OpenSeadragon.DrawerBase{
    constructor(options) {
        super(options);

        /**
         * The HTML element (canvas) that this drawer uses for drawing
         * @member {Element} canvas
         * @memberof OpenSeadragon.CanvasDrawer#
         */

        /**
         * The parent element of this Drawer instance, passed in when the Drawer was created.
         * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}.
         * @member {Element} container
         * @memberof OpenSeadragon.CanvasDrawer#
         */

        /**
         * 2d drawing context for {@link OpenSeadragon.CanvasDrawer#canvas}.
         * @member {Object} context
         * @memberof OpenSeadragon.CanvasDrawer#
         * @private
         */
        this.context = this.canvas.getContext('2d');

        // Sketch canvas used to temporarily draw tiles which cannot be drawn directly
        // to the main canvas due to opacity. Lazily initialized.
        this.sketchCanvas = null;
        this.sketchContext = null;

        // Image smoothing for canvas rendering (only if canvas is used).
        // Canvas default is "true", so this will only be changed if user specifies "false" in the options or via setImageSmoothinEnabled.
        this._imageSmoothingEnabled = true;

        // Since the tile-drawn and tile-drawing events are fired by this drawer, make sure handlers can be added for them
        this.viewer.allowEventHandler("tile-drawn");
        this.viewer.allowEventHandler("tile-drawing");

    }

    /**
     * @returns {Boolean} true if canvas is supported by the browser, otherwise false
     */
    static isSupported(){
        return $.supportsCanvas;
    }

    getType(){
        return 'canvas';
    }

    getSupportedDataFormats() {
        return ["context2d"];
    }

    /**
     * create the HTML element (e.g. canvas, div) that the image will be drawn into
     * @returns {Element} the canvas to draw into
     */
    _createDrawingElement(){
        const canvas = $.makeNeutralElement("canvas");
        const viewportSize = this._calculateCanvasSize();
        canvas.width = viewportSize.x;
        canvas.height = viewportSize.y;
        return canvas;
    }

    /**
     * Draws the TiledImages
     */
    draw(tiledImages) {
        this._prepareNewFrame(); // prepare to draw a new frame
        if(this.viewer.viewport.getFlip() !== this._viewportFlipped){
            this._flip();
        }
        for(const tiledImage of tiledImages){
            if (tiledImage.opacity !== 0) {
                this._drawTiles(tiledImage);
            }
        }
    }

    /**
     * @returns {Boolean} True - rotation is supported.
     */
    canRotate() {
        return true;
    }

    /**
     * Destroy the drawer (unload current loaded tiles)
     */
    destroy() {
        super.destroy();
        //force unloading of current canvas (1x1 will be gc later, trick not necessarily needed)
        this.canvas.width  = 1;
        this.canvas.height = 1;
        this.sketchCanvas = null;
        this.sketchContext = null;
        this.container.removeChild(this.canvas);
    }

    /**
     * @param {TiledImage} tiledImage the tiled image that is calling the function
     * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams.
     * @private
     */
    minimumOverlapRequired(tiledImage) {
        return true;
    }


    /**
     * Turns image smoothing on or off for this viewer. Note: Ignored in some (especially older) browsers that do not support this property.
     *
     * @function
     * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is
     * drawn smoothly on the canvas; see imageSmoothingEnabled in
     * {@link OpenSeadragon.Options} for more explanation.
     */
    setImageSmoothingEnabled(imageSmoothingEnabled){
        this._imageSmoothingEnabled = !!imageSmoothingEnabled;
        this._updateImageSmoothingEnabled(this.context);
        this.viewer.forceRedraw();
    }

    /**
     * Draw a rectangle onto the canvas
     * @param {OpenSeadragon.Rect} rect
     */
    drawDebuggingRect(rect) {
        const context = this.context;
        context.save();
        context.lineWidth = 2 * $.pixelDensityRatio;
        context.strokeStyle = this.debugGridColor[0];
        context.fillStyle = this.debugGridColor[0];

        context.strokeRect(
            rect.x * $.pixelDensityRatio,
            rect.y * $.pixelDensityRatio,
            rect.width * $.pixelDensityRatio,
            rect.height * $.pixelDensityRatio
        );

        context.restore();
    }

    /**
     * Test whether the current context is flipped or not
     * @private
     */
    get _viewportFlipped(){
        return this.context.getTransform().a < 0;
    }

    /**
     * Fires the tile-drawing event.
     * @private
     */
    _raiseTileDrawingEvent(tiledImage, context, tile, rendered){
        /**
         * This event is fired just before the tile is drawn giving the application a chance to alter the image.
         *
         * NOTE: This event is only fired when the 'canvas' drawer is being used
         *
         * @event tile-drawing
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {OpenSeadragon.Tile} tile - The Tile being drawn.
         * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
         * @property {CanvasRenderingContext2D} context - The HTML canvas context being drawn into.
         * @property {CanvasRenderingContext2D} rendered - The HTML canvas context containing the tile imagery.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.viewer.raiseEvent('tile-drawing', {
            tiledImage: tiledImage,
            context: context,
            tile: tile,
            rendered: rendered
        });
    }

    /**
     * Clears the Drawer so it's ready to draw another frame.
     * @private
     *
     */
    _prepareNewFrame() {
        const viewportSize = this._calculateCanvasSize();
        if( this.canvas.width !== viewportSize.x ||
            this.canvas.height !== viewportSize.y ) {
            this.canvas.width = viewportSize.x;
            this.canvas.height = viewportSize.y;
            this._updateImageSmoothingEnabled(this.context);
            if ( this.sketchCanvas !== null ) {
                const sketchCanvasSize = this._calculateSketchCanvasSize();
                this.sketchCanvas.width = sketchCanvasSize.x;
                this.sketchCanvas.height = sketchCanvasSize.y;
                this._updateImageSmoothingEnabled(this.sketchContext);
            }
        }
        this._clear();
    }

    /**
     * @private
     * @param {Boolean} useSketch Whether to clear sketch canvas or main canvas
     * @param {OpenSeadragon.Rect} [bounds] The rectangle to clear
     */
    _clear(useSketch, bounds){
        const context = this._getContext(useSketch);
        if (bounds) {
            context.clearRect(bounds.x, bounds.y, bounds.width, bounds.height);
        } else {
            const canvas = context.canvas;
            context.clearRect(0, 0, canvas.width, canvas.height);
        }
    }

    /**
     * Draws a TiledImage.
     * @private
     *
     */
    _drawTiles( tiledImage ) {
        const lastDrawn =  tiledImage.getTilesToDraw().map(info => info.tile);
        if (tiledImage.opacity === 0 || (lastDrawn.length === 0 && !tiledImage.placeholderFillStyle)) {
            return;
        }

        let tile = lastDrawn[0];
        let useSketch;

        if (tile) {
            useSketch = tiledImage.opacity < 1 ||
                (tiledImage.compositeOperation && tiledImage.compositeOperation !== 'source-over') ||
                (!tiledImage._isBottomItem() &&
                tiledImage.source.hasTransparency(null, tile.getUrl(), tile.ajaxHeaders, tile.postData));
        }

        let sketchScale;
        let sketchTranslate;

        const zoom = this.viewport.getZoom(true);
        const imageZoom = tiledImage.viewportToImageZoom(zoom);

        if (lastDrawn.length > 1 &&
            imageZoom > tiledImage.smoothTileEdgesMinZoom &&
            !tiledImage.iOSDevice &&
            tiledImage.getRotation(true) % 360 === 0 ){ // TODO: support tile edge smoothing with tiled image rotation.
            // When zoomed in a lot (>100%) the tile edges are visible.
            // So we have to composite them at ~100% and scale them up together.
            // Note: Disabled on iOS devices per default as it causes a native crash
            useSketch = true;

            const context = tile.length && this.getDataToDraw(tile);
            if (context) {
                sketchScale = context.canvas.width / (tile.size.x * $.pixelDensityRatio);
            } else {
                sketchScale = 1;
            }
            sketchTranslate = tile.getTranslationForEdgeSmoothing(sketchScale,
                this._getCanvasSize(false),
                this._getCanvasSize(true));
        }

        let bounds;
        if (useSketch) {
            if (!sketchScale) {
                // Except when edge smoothing, we only clean the part of the
                // sketch canvas we are going to use for performance reasons.
                bounds = this.viewport.viewportToViewerElementRectangle(
                    tiledImage.getClippedBounds(true))
                    .getIntegerBoundingBox();

                bounds = bounds.times($.pixelDensityRatio);
            }
            this._clear(true, bounds);
        }

        // When scaling, we must rotate only when blending the sketch canvas to
        // avoid interpolation
        if (!sketchScale) {
            this._setRotations(tiledImage, useSketch);
        }

        let usedClip = false;
        if ( tiledImage._clip ) {
            this._saveContext(useSketch);

            let box = tiledImage.imageToViewportRectangle(tiledImage._clip, true);
            box = box.rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true));
            let clipRect = this.viewportToDrawerRectangle(box);
            if (sketchScale) {
                clipRect = clipRect.times(sketchScale);
            }
            if (sketchTranslate) {
                clipRect = clipRect.translate(sketchTranslate);
            }
            this._setClip(clipRect, useSketch);

            usedClip = true;
        }

        if (tiledImage._croppingPolygons) {
            const self = this;
            if(!usedClip){
                this._saveContext(useSketch);
            }
            try {
                const polygons = tiledImage._croppingPolygons.map(function (polygon) {
                    return polygon.map(function (coord) {
                        const point = tiledImage
                            .imageToViewportCoordinates(coord.x, coord.y, true)
                            .rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true));
                        let clipPoint = self.viewportCoordToDrawerCoord(point);
                        if (sketchScale) {
                            clipPoint = clipPoint.times(sketchScale);
                        }
                        if (sketchTranslate) { // mostly fixes #2312
                            clipPoint = clipPoint.plus(sketchTranslate);
                        }
                        return clipPoint;
                    });
                });
                this._clipWithPolygons(polygons, useSketch);
            } catch (e) {
                $.console.error(e);
            }
            usedClip = true;
        }
        tiledImage._hasOpaqueTile = false;
        if ( tiledImage.placeholderFillStyle && tiledImage._hasOpaqueTile === false ) {
            let placeholderRect = this.viewportToDrawerRectangle(tiledImage.getBoundsNoRotate(true));
            if (sketchScale) {
                placeholderRect = placeholderRect.times(sketchScale);
            }
            if (sketchTranslate) {
                placeholderRect = placeholderRect.translate(sketchTranslate);
            }

            let fillStyle = null;
            if ( typeof tiledImage.placeholderFillStyle === "function" ) {
                fillStyle = tiledImage.placeholderFillStyle(tiledImage, this.context);
            }
            else {
                fillStyle = tiledImage.placeholderFillStyle;
            }

            this._drawRectangle(placeholderRect, fillStyle, useSketch);
        }

        const subPixelRoundingRule = determineSubPixelRoundingRule(tiledImage.subPixelRoundingForTransparency);

        let shouldRoundPositionAndSize = false;

        if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS) {
            shouldRoundPositionAndSize = true;
        } else if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST) {
            shouldRoundPositionAndSize = !(this.viewer && this.viewer.isAnimating());
        }

        // Iterate over the tiles to draw, and draw them
        for (let i = 0; i < lastDrawn.length; i++) {
            tile = lastDrawn[ i ];
            this._drawTile( tile, tiledImage, useSketch, sketchScale,
                sketchTranslate, shouldRoundPositionAndSize, tiledImage.source );

            if( this.viewer ){
                /**
                 * Raised when a tile is drawn to the canvas. Only valid for
                 * context2d and html drawers.
                 *
                 * @event tile-drawn
                 * @memberof OpenSeadragon.Viewer
                 * @type {object}
                 * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
                 * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
                 * @property {OpenSeadragon.Tile} tile
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                this.viewer.raiseEvent( 'tile-drawn', {
                    tiledImage: tiledImage,
                    tile: tile
                });
            }
        }

        if ( usedClip ) {
            this._restoreContext( useSketch );
        }

        if (!sketchScale) {
            if (tiledImage.getRotation(true) % 360 !== 0) {
                this._restoreRotationChanges(useSketch);
            }
            if (this.viewport.getRotation(true) % 360 !== 0) {
                this._restoreRotationChanges(useSketch);
            }
        }

        if (useSketch) {
            if (sketchScale) {
                this._setRotations(tiledImage);
            }
            this.blendSketch({
                opacity: tiledImage.opacity,
                scale: sketchScale,
                translate: sketchTranslate,
                compositeOperation: tiledImage.compositeOperation,
                bounds: bounds
            });
            if (sketchScale) {
                if (tiledImage.getRotation(true) % 360 !== 0) {
                    this._restoreRotationChanges(false);
                }
                if (this.viewport.getRotation(true) % 360 !== 0) {
                    this._restoreRotationChanges(false);
                }
            }
        }

        this._drawDebugInfo( tiledImage, lastDrawn );

        // Fire tiled-image-drawn event.
        this._raiseTiledImageDrawnEvent(tiledImage, lastDrawn);
    }

    /**
     * Draws special debug information for a TiledImage if in debug mode.
     * @private
     * @param {OpenSeadragon.Tile[]} lastDrawn - An unordered list of Tiles drawn last frame.
     */
    _drawDebugInfo( tiledImage, lastDrawn ) {
        if( tiledImage.debugMode ) {
            for ( let i = lastDrawn.length - 1; i >= 0; i-- ) {
                const tile = lastDrawn[ i ];
                try {
                    this._drawDebugInfoOnTile(tile, lastDrawn.length, i, tiledImage);
                } catch(e) {
                    $.console.error(e);
                }
            }
        }
    }

    /**
     * This function will create multiple polygon paths on the drawing context by provided polygons,
     * then clip the context to the paths.
     * @private
     * @param {OpenSeadragon.Point[][]} polygons - an array of polygons. A polygon is an array of OpenSeadragon.Point
     * @param {Boolean} useSketch - Whether to use the sketch canvas or not.
     */
    _clipWithPolygons (polygons, useSketch) {
        const context = this._getContext(useSketch);
        context.beginPath();
        for(const polygon of polygons){
            for(const [i, coord] of polygon.entries() ){
                context[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y);
            }
        }

        context.clip();
    }

    /**
     * Draws the given tile.
     * @private
     * @param {OpenSeadragon.Tile} tile - The tile to draw.
     * @param {OpenSeadragon.TiledImage} tiledImage - The tiled image being drawn.
     * @param {Boolean} useSketch - Whether to use the sketch canvas or not.
     * where <code>rendered</code> is the context with the pre-drawn image.
     * @param {Float} [scale=1] - Apply a scale to tile position and size. Defaults to 1.
     * @param {OpenSeadragon.Point} [translate] A translation vector to offset tile position
     * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round
     * position and size of tiles supporting alpha channel in non-transparency
     * context.
     * @param {OpenSeadragon.TileSource} source - The source specification of the tile.
     */
    _drawTile( tile, tiledImage, useSketch, scale, translate, shouldRoundPositionAndSize, source) {
        $.console.assert(tile, '[Drawer._drawTile] tile is required');
        $.console.assert(tiledImage, '[Drawer._drawTile] drawingHandler is required');

        if ( !tile.loaded ){
            $.console.warn(
                "Attempting to draw tile %s when it's not yet loaded.",
                tile.toString()
            );
            return;
        }

        const rendered = this.getDataToDraw(tile);
        if (!rendered) {
            return;
        }

        const context = this._getContext(useSketch);
        scale = scale || 1;

        let position = tile.position.times($.pixelDensityRatio),
            size     = tile.size.times($.pixelDensityRatio);

        context.save();

        if (typeof scale === 'number' && scale !== 1) {
            // draw tile at a different scale
            position = position.times(scale);
            size = size.times(scale);
        }

        if (translate instanceof $.Point) {
            // shift tile position slightly
            position = position.plus(translate);
        }

        //if we are supposed to be rendering fully opaque rectangle,
        //ie its done fading or fading is turned off, and if we are drawing
        //an image with an alpha channel, then the only way
        //to avoid seeing the tile underneath is to clear the rectangle
        if (context.globalAlpha === 1 && tile.hasTransparency) {
            if (shouldRoundPositionAndSize) {
                // Round to the nearest whole pixel so we don't get seams from overlap.
                position.x = Math.round(position.x);
                position.y = Math.round(position.y);
                size.x = Math.round(size.x);
                size.y = Math.round(size.y);
            }

            //clearing only the inside of the rectangle occupied
            //by the png prevents edge flikering
            context.clearRect(
                position.x,
                position.y,
                size.x,
                size.y
            );
        }

        this._raiseTileDrawingEvent(tiledImage, context, tile, rendered);

        let sourceWidth, sourceHeight;
        if (tile.sourceBounds) {
            sourceWidth = Math.min(tile.sourceBounds.width, rendered.canvas.width);
            sourceHeight = Math.min(tile.sourceBounds.height, rendered.canvas.height);
        } else {
            sourceWidth = rendered.canvas.width;
            sourceHeight = rendered.canvas.height;
        }

        context.translate(position.x + size.x / 2, 0);
        if (tile.flipped) {
            context.scale(-1, 1);
        }
        context.drawImage(
            rendered.canvas,
            0,
            0,
            sourceWidth,
            sourceHeight,
            -size.x / 2,
            position.y,
            size.x,
            size.y
        );

        context.restore();
    }



    /**
     * Get the context of the main or sketch canvas
     * @private
     * @param {Boolean} useSketch
     * @returns {CanvasRenderingContext2D}
     */
    _getContext( useSketch ) {
        let context = this.context;
        if ( useSketch ) {
            if (this.sketchCanvas === null) {
                this.sketchCanvas = document.createElement( "canvas" );
                const sketchCanvasSize = this._calculateSketchCanvasSize();
                this.sketchCanvas.width = sketchCanvasSize.x;
                this.sketchCanvas.height = sketchCanvasSize.y;
                this.sketchContext = this.sketchCanvas.getContext( "2d" );

                // If the viewport is not currently rotated, the sketchCanvas
                // will have the same size as the main canvas. However, if
                // the viewport get rotated later on, we will need to resize it.
                if (this.viewport.getRotation() === 0) {
                    const self = this;
                    this.viewer.addHandler('rotate', function resizeSketchCanvas() {
                        if (self.viewport.getRotation() === 0) {
                            return;
                        }
                        self.viewer.removeHandler('rotate', resizeSketchCanvas);
                        const sketchCanvasSize = self._calculateSketchCanvasSize();
                        self.sketchCanvas.width = sketchCanvasSize.x;
                        self.sketchCanvas.height = sketchCanvasSize.y;
                    });
                }
                this._updateImageSmoothingEnabled(this.sketchContext);
            }
            context = this.sketchContext;
        }
        return context;
    }

    /**
     * Save the context of the main or sketch canvas
     * @private
     * @param {Boolean} useSketch
     */
    _saveContext( useSketch ) {
        this._getContext( useSketch ).save();
    }

    /**
     * Restore the context of the main or sketch canvas
     * @private
     * @param {Boolean} useSketch
     */
    _restoreContext( useSketch ) {
        this._getContext( useSketch ).restore();
    }

    // private
    _setClip(rect, useSketch) {
        const context = this._getContext( useSketch );
        context.beginPath();
        context.rect(rect.x, rect.y, rect.width, rect.height);
        context.clip();
    }

    // private
    // used to draw a placeholder rectangle
    _drawRectangle(rect, fillStyle, useSketch) {
        const context = this._getContext( useSketch );
        context.save();
        context.fillStyle = fillStyle;
        context.fillRect(rect.x, rect.y, rect.width, rect.height);
        context.restore();
    }

    /**
     * Blends the sketch canvas in the main canvas.
     * @param {Object} options The options
     * @param {Float} options.opacity The opacity of the blending.
     * @param {Float} [options.scale=1] The scale at which tiles were drawn on
     * the sketch. Default is 1.
     * Use scale to draw at a lower scale and then enlarge onto the main canvas.
     * @param {OpenSeadragon.Point} [options.translate] A translation vector
     * that was used to draw the tiles
     * @param {String} [options.compositeOperation] - How the image is
     * composited onto other images; see compositeOperation in
     * {@link OpenSeadragon.Options} for possible values.
     * @param {OpenSeadragon.Rect} [options.bounds] The part of the sketch
     * canvas to blend in the main canvas. If specified, options.scale and
     * options.translate get ignored.
     */
    blendSketch(opacity, scale, translate, compositeOperation) {
        let options = opacity;
        if (!$.isPlainObject(options)) {
            options = {
                opacity: opacity,
                scale: scale,
                translate: translate,
                compositeOperation: compositeOperation
            };
        }

        opacity = options.opacity;
        compositeOperation = options.compositeOperation;
        const bounds = options.bounds;

        this.context.save();
        this.context.globalAlpha = opacity;
        if (compositeOperation) {
            this.context.globalCompositeOperation = compositeOperation;
        }
        if (bounds) {
            // Internet Explorer, Microsoft Edge, and Safari have problems
            // when you call context.drawImage with negative x or y
            // or x + width or y + height greater than the canvas width or height respectively.
            if (bounds.x < 0) {
                bounds.width += bounds.x;
                bounds.x = 0;
            }
            if (bounds.x + bounds.width > this.canvas.width) {
                bounds.width = this.canvas.width - bounds.x;
            }
            if (bounds.y < 0) {
                bounds.height += bounds.y;
                bounds.y = 0;
            }
            if (bounds.y + bounds.height > this.canvas.height) {
                bounds.height = this.canvas.height - bounds.y;
            }

            this.context.drawImage(
                this.sketchCanvas,
                bounds.x,
                bounds.y,
                bounds.width,
                bounds.height,
                bounds.x,
                bounds.y,
                bounds.width,
                bounds.height
            );
        } else {
            scale = options.scale || 1;
            translate = options.translate;
            const position = translate instanceof $.Point ?
                translate : new $.Point(0, 0);

            let widthExt = 0;
            let heightExt = 0;
            if (translate) {
                const widthDiff = this.sketchCanvas.width - this.canvas.width;
                const heightDiff = this.sketchCanvas.height - this.canvas.height;
                widthExt = Math.round(widthDiff / 2);
                heightExt = Math.round(heightDiff / 2);
            }
            this.context.drawImage(
                this.sketchCanvas,
                position.x - widthExt * scale,
                position.y - heightExt * scale,
                (this.canvas.width + 2 * widthExt) * scale,
                (this.canvas.height + 2 * heightExt) * scale,
                -widthExt,
                -heightExt,
                this.canvas.width + 2 * widthExt,
                this.canvas.height + 2 * heightExt
            );
        }
        this.context.restore();
    }

    // private
    _drawDebugInfoOnTile(tile, count, i, tiledImage) {

        const colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length;
        const context = this.context;
        context.save();
        context.lineWidth = 2 * $.pixelDensityRatio;
        context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial';
        context.strokeStyle = this.debugGridColor[colorIndex];
        context.fillStyle = this.debugGridColor[colorIndex];

        this._setRotations(tiledImage);

        if(this._viewportFlipped){
            this._flip({point: tile.position.plus(tile.size.divide(2))});
        }

        context.strokeRect(
            tile.position.x * $.pixelDensityRatio,
            tile.position.y * $.pixelDensityRatio,
            tile.size.x * $.pixelDensityRatio,
            tile.size.y * $.pixelDensityRatio
        );

        const tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio;
        const tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio;

        // Rotate the text the right way around.
        context.translate( tileCenterX, tileCenterY );
        const angleInDegrees = this.viewport.getRotation(true);
        context.rotate( Math.PI / 180 * -angleInDegrees );
        context.translate( -tileCenterX, -tileCenterY );

        if( tile.x === 0 && tile.y === 0 ){
            context.fillText(
                "Zoom: " + this.viewport.getZoom(),
                tile.position.x * $.pixelDensityRatio,
                (tile.position.y - 30) * $.pixelDensityRatio
            );
            context.fillText(
                "Pan: " + this.viewport.getBounds().toString(),
                tile.position.x * $.pixelDensityRatio,
                (tile.position.y - 20) * $.pixelDensityRatio
            );
        }
        context.fillText(
            "Level: " + tile.level,
            (tile.position.x + 10) * $.pixelDensityRatio,
            (tile.position.y + 20) * $.pixelDensityRatio
        );
        context.fillText(
            "Column: " + tile.x,
            (tile.position.x + 10) * $.pixelDensityRatio,
            (tile.position.y + 30) * $.pixelDensityRatio
        );
        context.fillText(
            "Row: " + tile.y,
            (tile.position.x + 10) * $.pixelDensityRatio,
            (tile.position.y + 40) * $.pixelDensityRatio
        );
        context.fillText(
            "Order: " + i + " of " + count,
            (tile.position.x + 10) * $.pixelDensityRatio,
            (tile.position.y + 50) * $.pixelDensityRatio
        );
        context.fillText(
            "Size: " + tile.size.toString(),
            (tile.position.x + 10) * $.pixelDensityRatio,
            (tile.position.y + 60) * $.pixelDensityRatio
        );
        context.fillText(
            "Position: " + tile.position.toString(),
            (tile.position.x + 10) * $.pixelDensityRatio,
            (tile.position.y + 70) * $.pixelDensityRatio
        );

        if (this.viewport.getRotation(true) % 360 !== 0 ) {
            this._restoreRotationChanges();
        }
        if (tiledImage.getRotation(true) % 360 !== 0) {
            this._restoreRotationChanges();
        }

        context.restore();
    }

    // private
    _updateImageSmoothingEnabled(context){
        context.msImageSmoothingEnabled = this._imageSmoothingEnabled;
        context.imageSmoothingEnabled = this._imageSmoothingEnabled;
    }

    /**
     * Get the canvas size
     * @private
     * @param {Boolean} sketch If set to true return the size of the sketch canvas
     * @returns {OpenSeadragon.Point} The size of the canvas
     */
    _getCanvasSize(sketch) {
        const canvas = this._getContext(sketch).canvas;
        return new $.Point(canvas.width, canvas.height);
    }

    /**
     * Get the canvas center
     * @private
     * @param {Boolean} sketch If set to true return the center point of the sketch canvas
     * @returns {OpenSeadragon.Point} The center point of the canvas
     */
    _getCanvasCenter() {
        return new $.Point(this.canvas.width / 2, this.canvas.height / 2);
    }

    /**
     * Set rotations for viewport & tiledImage
     * @private
     * @param {OpenSeadragon.TiledImage} tiledImage
     * @param {Boolean} [useSketch=false]
     */
    _setRotations(tiledImage, useSketch = false) {
        let saveContext = false;
        if (this.viewport.getRotation(true) % 360 !== 0) {
            this._offsetForRotation({
                degrees: this.viewport.getRotation(true),
                useSketch: useSketch,
                saveContext: saveContext
            });
            saveContext = false;
        }
        if (tiledImage.getRotation(true) % 360 !== 0) {
            this._offsetForRotation({
                degrees: tiledImage.getRotation(true),
                point: this.viewport.pixelFromPointNoRotate(
                    tiledImage._getRotationPoint(true), true),
                useSketch: useSketch,
                saveContext: saveContext
            });
        }
    }

    // private
    _offsetForRotation(options) {
        const point = options.point ?
            options.point.times($.pixelDensityRatio) :
            this._getCanvasCenter();

        const context = this._getContext(options.useSketch);
        context.save();

        context.translate(point.x, point.y);
        context.rotate(Math.PI / 180 * options.degrees);
        context.translate(-point.x, -point.y);
    }

    // private
    _flip(options) {
        options = options || {};
        const point = options.point ?
        options.point.times($.pixelDensityRatio) :
        this._getCanvasCenter();
        const context = this._getContext(options.useSketch);

        context.translate(point.x, 0);
        context.scale(-1, 1);
        context.translate(-point.x, 0);
    }

    // private
    _restoreRotationChanges(useSketch) {
        const context = this._getContext(useSketch);
        context.restore();
    }

    // private
    _calculateCanvasSize() {
        const pixelDensityRatio = $.pixelDensityRatio;
        const viewportSize = this.viewport.getContainerSize();
        return {
            // canvas width and height are integers
            x: Math.round(viewportSize.x * pixelDensityRatio),
            y: Math.round(viewportSize.y * pixelDensityRatio)
        };
    }

    // private
    _calculateSketchCanvasSize() {
        const canvasSize = this._calculateCanvasSize();
        if (this.viewport.getRotation() === 0) {
            return canvasSize;
        }
        // If the viewport is rotated, we need a larger sketch canvas in order
        // to support edge smoothing.
        const sketchCanvasSize = Math.ceil(Math.sqrt(
            canvasSize.x * canvasSize.x +
            canvasSize.y * canvasSize.y));
        return {
            x: sketchCanvasSize,
            y: sketchCanvasSize
        };
    }
}
$.CanvasDrawer = CanvasDrawer;


/**
 * Defines the value for subpixel rounding to fallback to in case of missing or
 * invalid value.
 * @private
 */
const DEFAULT_SUBPIXEL_ROUNDING_RULE = $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER;

/**
 * Checks whether the input value is an invalid subpixel rounding enum value.
 * @private
 *
 * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to check.
 * @returns {Boolean} Returns true if the input value is none of the expected
 * {@link SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS}, {@link SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST} or {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} value.
 */
function isSubPixelRoundingRuleUnknown(value) {
    return value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS &&
        value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST &&
        value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER;
}

/**
 * Ensures the returned value is always a valid subpixel rounding enum value,
 * defaulting to {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} if input is missing or invalid.
 * @private
 * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to normalize.
 * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns a valid subpixel rounding enum value.
 */
function normalizeSubPixelRoundingRule(value) {
    if (isSubPixelRoundingRuleUnknown(value)) {
        return DEFAULT_SUBPIXEL_ROUNDING_RULE;
    }
    return value;
}

/**
 * Ensures the returned value is always a valid subpixel rounding enum value,
 * defaulting to 'NEVER' if input is missing or invalid.
 * @private
 *
 * @param {Object} subPixelRoundingRules - A subpixel rounding enum values dictionary [{@link BROWSERS}] --> {@link SUBPIXEL_ROUNDING_OCCURRENCES}.
 * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns the determined subpixel rounding enum value for the
 * current browser.
 */
function determineSubPixelRoundingRule(subPixelRoundingRules) {
    if (typeof subPixelRoundingRules === 'number') {
        return normalizeSubPixelRoundingRule(subPixelRoundingRules);
    }

    if (!subPixelRoundingRules || !$.Browser) {
        return DEFAULT_SUBPIXEL_ROUNDING_RULE;
    }

    let subPixelRoundingRule = subPixelRoundingRules[$.Browser.vendor];

    if (isSubPixelRoundingRuleUnknown(subPixelRoundingRule)) {
        subPixelRoundingRule = subPixelRoundingRules['*'];
    }

    return normalizeSubPixelRoundingRule(subPixelRoundingRule);
}

}( OpenSeadragon ));


/*
 * OpenSeadragon - WebGLDrawer
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

    const OpenSeadragon = $; // alias for JSDoc

    /**
     * @class WebglContextManager
     * @classdesc Handles the webgl context, isolating it from the rest of the DrawerBase API.
     * Manages WebGL context lifecycle, shaders, textures, framebuffers, and other WebGL resources.
     * @param {Object} options - Options for the context manager
     * @param {HTMLCanvasElement} options.renderingCanvas - The canvas element to use for WebGL context
     * @param {Boolean} [options.unpackWithPremultipliedAlpha=false] - Whether to enable gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL
     * @param {Boolean} [options.imageSmoothingEnabled=true] - Whether image smoothing is enabled
     */
    class WebglContextManager {
        constructor(options) {
            this._renderingCanvas = options.renderingCanvas;
            this._unpackWithPremultipliedAlpha = !!options.unpackWithPremultipliedAlpha;
            this._imageSmoothingEnabled = options.imageSmoothingEnabled !== undefined ? options.imageSmoothingEnabled : true;
            this._initShaderProgram = options.initShaderProgram;

            this._gl = null;
            this._isWebGL2 = false;
            this._extTextureFilterAnisotropic = null;
            this._maxAnisotropy = 0;

            this._firstPass = null;
            this._secondPass = null;
            this._glFrameBuffer = null;
            this._renderToTexture = null;
            this._glNumTextures = 0;
            this._unitQuad = null;

            this._destroyed = false;

            // Create WebGL context
            this._gl = this._renderingCanvas.getContext('webgl2');
            if (this._gl) {
                this._isWebGL2 = true;
                this._setupWebGLExtensions();
            } else {
                this._gl = this._renderingCanvas.getContext('webgl');
                this._isWebGL2 = false;
                if (this._gl) {
                    this._setupWebGLExtensions();
                }
            }

            if (this._gl) {
                this._gl.pixelStorei(this._gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, this._unpackWithPremultipliedAlpha);
            }
        }

        /**
         * Get the WebGL context
         * @returns {WebGLRenderingContext|WebGL2RenderingContext|null} The WebGL context
         */
        getContext() {
            return this._gl;
        }

        /**
         * Check if using WebGL2
         * @returns {Boolean} true if WebGL2, false if WebGL1
         */
        isWebGL2() {
            return this._isWebGL2;
        }

        /**
         * Get the maximum number of texture units
         * @returns {Number} MAX_TEXTURE_IMAGE_UNITS value
         */
        getMaxTextures() {
            if (!this._gl) {
                return 0;
            }
            return this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS);
        }

        /**
         * Get the rendering canvas element
         * @returns {HTMLCanvasElement} The canvas element
         */
        getRenderingCanvas() {
            return this._renderingCanvas;
        }

        /**
         * Get the first pass shader program and resources
         * @returns {Object|null} The first pass object with shader program, buffers, and uniforms
         */
        getFirstPass() {
            return this._firstPass;
        }

        /**
         * Get the second pass shader program and resources
         * @returns {Object|null} The second pass object with shader program, buffers, and uniforms
         */
        getSecondPass() {
            return this._secondPass;
        }

        /**
         * Get the render-to-texture framebuffer
         * @returns {WebGLFramebuffer|null} The framebuffer
         */
        getFrameBuffer() {
            return this._glFrameBuffer;
        }

        /**
         * Get the render-to-texture texture
         * @returns {WebGLTexture|null} The texture
         */
        getRenderToTexture() {
            return this._renderToTexture;
        }

        /**
         * Get the unit quad vertex buffer
         * @returns {Float32Array} The unit quad buffer
         */
        getUnitQuad() {
            return this._unitQuad;
        }

        /**
         * Set up WebGL extensions (works for both WebGL1 and WebGL2)
         * @private
         */
        _setupWebGLExtensions() {
            const gl = this._gl;

            // Anisotropic filtering extension (available in both WebGL1 and WebGL2)
            this._extTextureFilterAnisotropic =
                gl.getExtension('EXT_texture_filter_anisotropic') ||
                gl.getExtension('WEBKIT_EXT_texture_filter_anisotropic') ||
                gl.getExtension('MOZ_EXT_texture_filter_anisotropic');

            if (this._extTextureFilterAnisotropic) {
                this._maxAnisotropy = gl.getParameter(
                    this._extTextureFilterAnisotropic.MAX_TEXTURE_MAX_ANISOTROPY_EXT
                );
            }
        }

        /**
         * Get the texture filter constant (LINEAR or NEAREST)
         * @returns {Number} gl.LINEAR or gl.NEAREST
         */
        getTextureFilter() {
            const gl = this._gl;
            return this._imageSmoothingEnabled ? gl.LINEAR : gl.NEAREST;
        }

        /**
         * Apply anisotropic filtering to the currently bound texture if available
         * @private
         */
        _applyAnisotropy() {
            if (!this._imageSmoothingEnabled || !this._extTextureFilterAnisotropic || this._maxAnisotropy <= 0) {
                return;
            }
            const gl = this._gl;
            gl.texParameterf(
                gl.TEXTURE_2D,
                this._extTextureFilterAnisotropic.TEXTURE_MAX_ANISOTROPY_EXT,
                Math.min(4, this._maxAnisotropy)
            );
        }

        /**
         * Set up the renderer: create shaders, textures, and framebuffers
         * @param {Number} width - Canvas width
         * @param {Number} height - Canvas height
         */
        setupRenderer(width, height) {
            const gl = this._gl;
            if (!gl) {
                $.console.error('WebGL context not available for setupRenderer');
                return;
            }

            // Create unit quad once
            this._unitQuad = this.makeQuadVertexBuffer(0, 1, 0, 1);

            this._makeFirstPassShaderProgram();
            this._makeSecondPassShaderProgram();

            // set up the texture to render to in the first pass, and which will be used for rendering the second pass
            this._renderToTexture = gl.createTexture();
            gl.activeTexture(gl.TEXTURE0);
            gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture);
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.getTextureFilter());
            this._applyAnisotropy();
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

            // set up the framebuffer for render-to-texture
            this._glFrameBuffer = gl.createFramebuffer();
            gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer);
            gl.framebufferTexture2D(
                gl.FRAMEBUFFER,
                gl.COLOR_ATTACHMENT0,
                gl.TEXTURE_2D,
                this._renderToTexture,
                0
            );

            gl.enable(gl.BLEND);
            gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
        }

        /**
         * Resize the render-to-texture when canvas size changes
         * @param {Number} width - New canvas width
         * @param {Number} height - New canvas height
         */
        resizeRenderer(width, height) {
            const gl = this._gl;
            if (!gl) {
                return;
            }
            gl.viewport(0, 0, width, height);

            //release the old texture
            gl.deleteTexture(this._renderToTexture);
            //create a new texture and set it up
            this._renderToTexture = gl.createTexture();
            gl.activeTexture(gl.TEXTURE0);
            gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture);
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.getTextureFilter());
            this._applyAnisotropy();
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

            //bind the frame buffer to the new texture
            gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer);
            gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._renderToTexture, 0);
        }

        /**
         * Create and upload a texture for a tile
         * @param {HTMLImageElement|HTMLCanvasElement|ImageData} data - Image data to upload
         * @param {Object} options - Texture options
         * @param {Boolean} [options.unpackWithPremultipliedAlpha] - Override default unpack setting
         * @returns {WebGLTexture|null} The created texture, or null on error
         */
        createTexture(data, options = {}) {
            const gl = this._gl;
            if (!gl) {
                return null;
            }

            const texture = gl.createTexture();
            gl.activeTexture(gl.TEXTURE0);
            gl.bindTexture(gl.TEXTURE_2D, texture);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.getTextureFilter());
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this.getTextureFilter());
            this._applyAnisotropy();

            try {
                const unpackPremultipliedAlpha = options.unpackWithPremultipliedAlpha !== undefined ?
                    options.unpackWithPremultipliedAlpha :
                    this._unpackWithPremultipliedAlpha;
                gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, unpackPremultipliedAlpha);
                gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);
                return texture;
            } catch (e) {
                gl.deleteTexture(texture);
                return null;
            }
        }

        /**
         * Delete a texture
         * @param {WebGLTexture} texture - The texture to delete
         */
        deleteTexture(texture) {
            if (this._gl && texture) {
                this._gl.deleteTexture(texture);
            }
        }

        /**
         * Set image smoothing enabled state
         * @param {Boolean} enabled - Whether image smoothing is enabled
         */
        setImageSmoothingEnabled(enabled) {
            this._imageSmoothingEnabled = !!enabled;
        }

        /**
         * Set unpack with premultiplied alpha state
         * @param {Boolean} enabled - Whether to use premultiplied alpha
         */
        setUnpackWithPremultipliedAlpha(enabled) {
            this._unpackWithPremultipliedAlpha = !!enabled;
            if (this._gl) {
                this._gl.pixelStorei(this._gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, this._unpackWithPremultipliedAlpha);
            }
        }

        /**
         * Make a quad vertex buffer
         * @param {Number} left - Left coordinate
         * @param {Number} right - Right coordinate
         * @param {Number} top - Top coordinate
         * @param {Number} bottom - Bottom coordinate
         * @returns {Float32Array} Vertex buffer
         */
        makeQuadVertexBuffer(left, right, top, bottom) {
            return new Float32Array([
                left, bottom,
                right, bottom,
                left, top,
                left, top,
                right, bottom,
                right, top]);
        }

        /**
         * Create the first pass shader program
         * @private
         */
        _makeFirstPassShaderProgram() {
            const numTextures = this._glNumTextures = this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS);
            const makeMatrixUniforms = () => {
                return [...Array(numTextures).keys()].map(index => `uniform mat3 u_matrix_${index};`).join('\n');
            };
            const makeConditionals = () => {
                return [...Array(numTextures).keys()].map(index => `${index > 0 ? 'else ' : ''}if(int(a_index) == ${index}) { transform_matrix = u_matrix_${index}; }`).join('\n');
            };

            const vertexShaderProgram = `
            attribute vec2 a_output_position;
            attribute vec2 a_texture_position;
            attribute float a_index;

            ${makeMatrixUniforms()} // create a uniform mat3 for each potential tile to draw

            varying vec2 v_texture_position;
            varying float v_image_index;

            void main() {

                mat3 transform_matrix; // value will be set by the if/elses in makeConditional()

                ${makeConditionals()}

                gl_Position = vec4(transform_matrix * vec3(a_output_position, 1), 1);

                v_texture_position = a_texture_position;
                v_image_index = a_index;
            }
            `;

            const fragmentShaderProgram = `
            precision mediump float;

            // our textures
            uniform sampler2D u_images[${numTextures}];
            // our opacities
            uniform float u_opacities[${numTextures}];

            // the varyings passed in from the vertex shader.
            varying vec2 v_texture_position;
            varying float v_image_index;

            void main() {
                // can't index directly with a variable, need to use a loop iterator hack
                for(int i = 0; i < ${numTextures}; ++i){
                    if(i == int(v_image_index)){
                        gl_FragColor = texture2D(u_images[i], v_texture_position) * u_opacities[i];
                    }
                }
            }
            `;

            const gl = this._gl;

            const program = this._initShaderProgram(gl, vertexShaderProgram, fragmentShaderProgram);
            gl.useProgram(program);

            // get locations of attributes and uniforms, and create buffers for each attribute
            this._firstPass = {
                shaderProgram: program,
                aOutputPosition: gl.getAttribLocation(program, 'a_output_position'),
                aTexturePosition: gl.getAttribLocation(program, 'a_texture_position'),
                aIndex: gl.getAttribLocation(program, 'a_index'),
                uTransformMatrices: [...Array(this._glNumTextures).keys()].map(i=>gl.getUniformLocation(program, `u_matrix_${i}`)),
                uImages: gl.getUniformLocation(program, 'u_images'),
                uOpacities: gl.getUniformLocation(program, 'u_opacities'),
                bufferOutputPosition: gl.createBuffer(),
                bufferTexturePosition: gl.createBuffer(),
                bufferIndex: gl.createBuffer(),
            };

            gl.uniform1iv(this._firstPass.uImages, [...Array(numTextures).keys()]);

            // provide coordinates for the rectangle in output space, i.e. a unit quad for each one.
            const outputQuads = new Float32Array(numTextures * 12);
            for(let i = 0; i < numTextures; ++i){
                outputQuads.set(Float32Array.from(this._unitQuad), i * 12);
            }
            gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferOutputPosition);
            gl.bufferData(gl.ARRAY_BUFFER, outputQuads, gl.STATIC_DRAW); // bind data statically here, since it's unchanging
            gl.enableVertexAttribArray(this._firstPass.aOutputPosition);

            // provide texture coordinates for the rectangle in image (texture) space. Data will be set later.
            gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferTexturePosition);
            gl.enableVertexAttribArray(this._firstPass.aTexturePosition);

            // for each vertex, provide an index into the array of textures/matrices to use for the correct tile
            gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferIndex);
            const indices = [...Array(this._glNumTextures).keys()].map(i => Array(6).fill(i)).flat(); // repeat each index 6 times, for the 6 vertices per tile (2 triangles)
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(indices), gl.STATIC_DRAW); // bind data statically here, since it's unchanging
            gl.enableVertexAttribArray(this._firstPass.aIndex);
        }

        /**
         * Create the second pass shader program
         * @private
         */
        _makeSecondPassShaderProgram() {
            const vertexShaderProgram = `
            attribute vec2 a_output_position;
            attribute vec2 a_texture_position;

            varying vec2 v_texture_position;

            void main() {
                // Transform to clip space (0:1 --> -1:1)
                gl_Position = vec4(vec3(a_output_position * 2.0 - 1.0, 1), 1);

                v_texture_position = a_texture_position;
            }
            `;

            const fragmentShaderProgram = `
            precision mediump float;

            // our texture
            uniform sampler2D u_image;

            // the texCoords passed in from the vertex shader.
            varying vec2 v_texture_position;

            // the opacity multiplier for the image
            uniform float u_opacity_multiplier;

            void main() {
                gl_FragColor = texture2D(u_image, v_texture_position);
                gl_FragColor *= u_opacity_multiplier;
            }
            `;

            const gl = this._gl;

            const program = this._initShaderProgram(gl, vertexShaderProgram, fragmentShaderProgram);
            gl.useProgram(program);

            // get locations of attributes and uniforms, and create buffers for each attribute
            this._secondPass = {
                shaderProgram: program,
                aOutputPosition: gl.getAttribLocation(program, 'a_output_position'),
                aTexturePosition: gl.getAttribLocation(program, 'a_texture_position'),
                uImage: gl.getUniformLocation(program, 'u_image'),
                uOpacityMultiplier: gl.getUniformLocation(program, 'u_opacity_multiplier'),
                bufferOutputPosition: gl.createBuffer(),
                bufferTexturePosition: gl.createBuffer(),
            };

            // provide coordinates for the rectangle in output space, i.e. a unit quad for each one.
            gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferOutputPosition);
            gl.bufferData(gl.ARRAY_BUFFER, this._unitQuad, gl.STATIC_DRAW); // bind data statically here since it's unchanging
            gl.enableVertexAttribArray(this._secondPass.aOutputPosition);

            // provide texture coordinates for the rectangle in image (texture) space.
            gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferTexturePosition);
            gl.bufferData(gl.ARRAY_BUFFER, this._unitQuad, gl.DYNAMIC_DRAW); // bind data statically here since it's unchanging
            gl.enableVertexAttribArray(this._secondPass.aTexturePosition);
        }

        /**
         * Destroy the WebGL context and all resources
         */
        destroy() {
            if (this._destroyed) {
                return;
            }
            this._destroyed = true;

            const gl = this._gl;
            if (gl) {
                try {
                    // adapted from https://stackoverflow.com/a/23606581/1214731
                    const numTextureUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS);
                    if (numTextureUnits && numTextureUnits > 0) {
                        for (let unit = 0; unit < numTextureUnits; ++unit) {
                            gl.activeTexture(gl.TEXTURE0 + unit);
                            gl.bindTexture(gl.TEXTURE_2D, null);
                            gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
                        }
                    }
                    gl.bindBuffer(gl.ARRAY_BUFFER, null);
                    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
                    gl.bindRenderbuffer(gl.RENDERBUFFER, null);
                    gl.bindFramebuffer(gl.FRAMEBUFFER, null);

                    // Delete all our created resources
                    if (this._secondPass && this._secondPass.bufferOutputPosition) {
                        gl.deleteBuffer(this._secondPass.bufferOutputPosition);
                    }
                    if (this._glFrameBuffer) {
                        gl.deleteFramebuffer(this._glFrameBuffer);
                    }
                } catch (e) {
                    // Context may already be lost, continue with cleanup
                    $.console.warn('Error during WebGL cleanup in WebglContextManager.destroy():', e);
                }

                const ext = gl.getExtension('WEBGL_lose_context');
                if (ext) {
                    ext.loseContext();
                }
            }

            // Clean up references
            this._gl = null;
            this._firstPass = null;
            this._secondPass = null;
            this._glFrameBuffer = null;
            this._renderToTexture = null;
            this._unitQuad = null;
        }

        /**
         * Check if this context manager has been destroyed
         * @returns {Boolean} true if destroyed, false otherwise
         */
        isDestroyed() {
            return this._destroyed;
        }
    }

   /**
    * @class OpenSeadragon.WebGLDrawer
    * @classdesc Default implementation of WebGLDrawer for an {@link OpenSeadragon.Viewer}. The WebGLDrawer
    * defines its own data type that ensures textures are correctly loaded to and deleted from the GPU memory.
    * The drawer utilizes a context-dependent two pass drawing pipeline. For the first pass, tile composition
    * for a given TiledImage is always done using a canvas with a WebGL context. This allows tiles to be stitched
    * together without seams or artifacts, without requiring a tile source with overlap. If overlap is present,
    * overlapping pixels are discarded. The second pass copies all pixel data from the WebGL context onto an output
    * canvas with a Context2d context. This allows applications to have access to pixel data and other functionality
    * provided by Context2d, regardless of whether the CanvasDrawer or the WebGLDrawer is used. Certain options,
    * including compositeOperation, clip, croppingPolygons, and debugMode are implemented using Context2d operations;
    * in these scenarios, each TiledImage is drawn onto the output canvas immediately after the tile composition step
    * (pass 1). Otherwise, for efficiency, all TiledImages are copied over to the output canvas at once, after all
    * tiles have been composited for all images.
    * @param {Object} options - Options for this Drawer.
    * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer.
    * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport.
    * @param {Element} options.element - Parent element.
    * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details.
    * @param {Boolean} [options.unpackWithPremultipliedAlpha=false] - Whether to enable gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL when uploading textures.
    */
    OpenSeadragon.WebGLDrawer = class WebGLDrawer extends OpenSeadragon.DrawerBase{
        constructor(options){
           super(options);

            /**
             * The HTML element (canvas) that this drawer uses for drawing
             * @member {Element} canvas
             * @memberof OpenSeadragon.WebGLDrawer#
             */

            /**
             * The parent element of this Drawer instance, passed in when the Drawer was created.
             * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}.
             * @member {Element} container
             * @memberof OpenSeadragon.WebGLDrawer#
             */

            // private members
            this._destroyed = false;
            /**
             * WebGL context manager instance
             * @member {WebglContextManager} _glContext
             * @memberof OpenSeadragon.WebGLDrawer#
             * @private
             */
            this._glContext = null;
            /**
             * Flag to enable/disable automatic WebGL context re-initialization on context loss.
             * When enabled, the drawer will attempt to recover from context exhaustion errors.
             * @member {Boolean} _enableContextRecovery
             * @memberof OpenSeadragon.WebGLDrawer#
             * @private
             */
            this._enableContextRecovery = true;
            this._outputCanvas = null;
            this._outputContext = null;
            this._clippingCanvas = null;
            this._clippingContext = null;
            this._renderingCanvas = null;
            this._backupCanvasDrawer = null;
            this._canvasFallbackAllowed = this.viewer.drawerCandidates && this.viewer.drawerCandidates.includes('canvas');

            this._imageSmoothingEnabled = true; // will be updated by setImageSmoothingEnabled
            this._unpackWithPremultipliedAlpha = !!this.options.unpackWithPremultipliedAlpha;

            // Reject listening for the tile-drawing and tile-drawn events, which this drawer does not fire
            this.viewer.rejectEventHandler("tile-drawn", "The WebGLDrawer does not raise the tile-drawn event");
            this.viewer.rejectEventHandler("tile-drawing", "The WebGLDrawer does not raise the tile-drawing event");

            // this.viewer and this.canvas are part of the public DrawerBase API
            // and are defined by the parent DrawerBase class. Additional setup is done by
            // the private _setupCanvases and _setupRenderer functions.
            this._setupCanvases();
            this._setupRenderer();

            this._supportedFormats = ["context2d", "image"];
            this.context = this._outputContext; // API required by tests
        }

        get defaultOptions() {
            return {
                // use detached cache: our type conversion will not collide (and does not have to preserve CPU data ref)
                usePrivateCache: true,
                preloadCache: false,
                unpackWithPremultipliedAlpha: false,
            };
        }

        getSupportedDataFormats() {
            return this._supportedFormats;
        }

        // Public API required by all Drawer implementations
        /**
        * Clean up the renderer, removing all resources
        */
        destroy(){
            if(this._destroyed){
                return;
            }
            super.destroy();
            // Remove the resize handler to prevent memory leaks
            if (this._resizeHandler) {
                this.viewer.removeHandler("resize", this._resizeHandler);
                this._resizeHandler = null;
            }

            // Destroy WebGL context manager
            if (this._glContext) {
                this._glContext.destroy();
                this._glContext = null;
            }

            // make canvases 1 x 1 px and delete references
            if (this._renderingCanvas) {
                this._renderingCanvas.width = this._renderingCanvas.height = 1;
            }
            if (this._clippingCanvas) {
                this._clippingCanvas.width = this._clippingCanvas.height = 1;
            }
            if (this._outputCanvas) {
                this._outputCanvas.width = this._outputCanvas.height = 1;
            }
            this._renderingCanvas = null;
            this._clippingCanvas = this._clippingContext = null;
            this._outputCanvas = this._outputContext = null;

            if(this._backupCanvasDrawer){
                this._backupCanvasDrawer.destroy();
                this._backupCanvasDrawer = null;
            }

            this.container.removeChild(this.canvas);
            if(this.viewer.drawer === this){
                this.viewer.drawer = null;
            }

            this.destroyInternalCache();

            // set our destroyed flag to true
            this._destroyed = true;
        }

        // Public API required by all Drawer implementations
        /**
        *
        * @returns {Boolean} true
        */
        canRotate(){
            return true;
        }

        // Public API required by all Drawer implementations
        /**
         * Functional test: true if WebGL is supported and the real first-pass shader pipeline
         * can render (same shaders/context path used at runtime). Uses a temp context and
         * WebglContextManager, draws known non-black pixels to an FBO, then readPixels.
         * @returns {Boolean} true if WebGL is supported and the pipeline renders successfully
         */
        static isSupported(){
            let contextManager = null;
            let testTexture = null;
            let gl = null;
            try {
                const size = 4;
                const canvas = document.createElement('canvas');
                canvas.width = size;
                canvas.height = size;
                if (!$.isFunction(canvas.getContext)) {
                    return false;
                }
                gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
                if (!gl) {
                    return false;
                }
                contextManager = new WebglContextManager({
                    renderingCanvas: canvas,
                    unpackWithPremultipliedAlpha: false,
                    imageSmoothingEnabled: true,
                    initShaderProgram: WebGLDrawer.initShaderProgram
                });
                if (!contextManager.getContext()) {
                    return false;
                }
                contextManager.setupRenderer(size, size);

                const firstPass = contextManager.getFirstPass();
                const glFrameBuffer = contextManager.getFrameBuffer();
                if (!firstPass || !glFrameBuffer) {
                    return false;
                }

                const maxTextures = contextManager.getMaxTextures();
                if (!maxTextures || maxTextures <= 0) {
                    return false;
                }

                const imageData = new ImageData(size, size);
                imageData.data[0] = 255;
                imageData.data[1] = 0;
                imageData.data[2] = 0;
                imageData.data[3] = 255;
                testTexture = contextManager.createTexture(imageData);
                if (!testTexture) {
                    return false;
                }

                const unitQuad = contextManager.makeQuadVertexBuffer(0, 1, 0, 1);
                gl.viewport(0, 0, size, size);
                gl.bindFramebuffer(gl.FRAMEBUFFER, glFrameBuffer);
                gl.clearColor(0, 0, 0, 0);
                gl.clear(gl.COLOR_BUFFER_BIT);
                gl.useProgram(firstPass.shaderProgram);

                gl.activeTexture(gl.TEXTURE0);
                gl.bindTexture(gl.TEXTURE_2D, testTexture);
                gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferTexturePosition);
                gl.bufferData(gl.ARRAY_BUFFER, unitQuad, gl.DYNAMIC_DRAW);
                const ndcMatrix = new Float32Array([2, 0, 0, 0, 2, 0, -1, -1, 1]);
                gl.uniformMatrix3fv(firstPass.uTransformMatrices[0], false, ndcMatrix);
                gl.uniform1fv(firstPass.uOpacities, new Float32Array([1]));

                gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferOutputPosition);
                gl.vertexAttribPointer(firstPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0);
                gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferTexturePosition);
                gl.vertexAttribPointer(firstPass.aTexturePosition, 2, gl.FLOAT, false, 0, 0);
                gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferIndex);
                gl.vertexAttribPointer(firstPass.aIndex, 1, gl.FLOAT, false, 0, 0);

                gl.drawArrays(gl.TRIANGLES, 0, 6);

                const pixels = new Uint8Array(size * size * 4);
                gl.readPixels(0, 0, size, size, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
                const hasNonZero = pixels.some(v => v !== 0);
                if (!hasNonZero) {
                    $.console.warn('[WebGLDrawer.isSupported] Functional test failed: no non-zero pixels read back.');
                    return false;
                }
                return true;
            } catch (e) {
                $.console.warn('[WebGLDrawer.isSupported] Functional test failed:', e && e.message ? e.message : e);
                return false;
            } finally {
                try {
                    if (testTexture && contextManager) {
                        contextManager.deleteTexture(testTexture);
                    }
                    if (contextManager) {
                        contextManager.destroy();
                    } else if (gl) {
                        const ext = gl.getExtension('WEBGL_lose_context');
                        if (ext) {
                            ext.loseContext();
                        }
                    }
                } catch (cleanupErr) {
                    // ignore cleanup errors so we preserve the test result
                }
            }
        }

        /**
         *
         * @returns {string} 'webgl'
         */
        getType(){
            return 'webgl';
        }

        /**
         * Check if the drawer is using WebGL2
         * @returns {Boolean} true if WebGL2 is being used, false if WebGL1
         */
        isWebGL2(){
            return this._glContext ? this._glContext.isWebGL2() : false;
        }

        /**
         * Enable or disable automatic WebGL context re-initialization on context loss.
         * When enabled, the drawer will attempt to recover from context exhaustion errors
         * by re-initializing the WebGL context.
         * @param {Boolean} enabled - true to enable recovery, false to disable
         */
        setContextRecoveryEnabled(enabled) {
            this._enableContextRecovery = !!enabled;
        }

        /**
         * Check if context recovery is enabled
         * @returns {Boolean} true if recovery is enabled, false otherwise
         */
        isContextRecoveryEnabled() {
            return this._enableContextRecovery;
        }

        /**
         * @param {TiledImage} tiledImage the tiled image that is calling the function
         * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams.
         * @private
         */
        minimumOverlapRequired(tiledImage) {
            // return true if we cannot render with webgl, since the backup canvas drawer will be used.
            return tiledImage.hasIssue('webgl');
        }

        /**
        * create the HTML element (canvas in this case) that the image will be drawn into
        * @private
        * @returns {Element} the canvas to draw into
        */
        _createDrawingElement(){
            const canvas = $.makeNeutralElement("canvas");
            const viewportSize = this._calculateCanvasSize();
            canvas.width = viewportSize.x;
            canvas.height = viewportSize.y;
            return canvas;
        }

        /**
         * Get the backup renderer (CanvasDrawer) to use if data cannot be used by webgl
         * Lazy loaded
         * @private
         * @returns {CanvasDrawer}
         */
        _getBackupCanvasDrawer(){
            if(!this._backupCanvasDrawer){
                this._backupCanvasDrawer = this.viewer.requestDrawer('canvas', {mainDrawer: false});
                this._backupCanvasDrawer.canvas.style.setProperty('visibility', 'hidden');
                this._backupCanvasDrawer.getSupportedDataFormats = () => this._supportedFormats;
                this._backupCanvasDrawer.getDataToDraw = this.getDataToDraw.bind(this);
            }

            return this._backupCanvasDrawer;
        }

        //
        /**
         * Internal draw method, wrapped in a try/catch within draw()
         * @param {Array} tiledImages Array of TiledImage objects to draw
         * @param {Boolean} [isRetry=false] Internal flag to prevent infinite retry loops
         * @private
         */
        _draw(tiledImages, isRetry = false){
            const gl = this._glContext ? this._glContext.getContext() : null;
            if (!gl) {
                return;
            }
            const firstPass = this._glContext.getFirstPass();
            const secondPass = this._glContext.getSecondPass();
            const glFrameBuffer = this._glContext.getFrameBuffer();
            const renderToTexture = this._glContext.getRenderToTexture();
            const bounds = this.viewport.getBoundsNoRotateWithMargins(true);
            const view = {
                bounds: bounds,
                center: new OpenSeadragon.Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2),
                rotation: this.viewport.getRotation(true) * Math.PI / 180
            };

            const flipMultiplier = this.viewport.flipped ? -1 : 1;
            // calculate view matrix for viewer
            const posMatrix = $.Mat3.makeTranslation(-view.center.x, -view.center.y);
            const scaleMatrix = $.Mat3.makeScaling(2 / view.bounds.width * flipMultiplier, -2 / view.bounds.height);
            const rotMatrix = $.Mat3.makeRotation(-view.rotation);
            const viewMatrix = scaleMatrix.multiply(rotMatrix).multiply(posMatrix);

            gl.bindFramebuffer(gl.FRAMEBUFFER, null);
            gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer

            // clear the output canvas
            this._outputContext.clearRect(0, 0, this._outputCanvas.width, this._outputCanvas.height);


            let renderingBufferHasImageData = false;

            //iterate over tiled images and draw each one using a two-pass rendering pipeline if needed
            tiledImages.forEach( (tiledImage, tiledImageIndex) => {

            if(tiledImage.getIssue('webgl')){
                // first, draw any data left in the rendering buffer onto the output canvas
                if(renderingBufferHasImageData){
                    this._outputContext.drawImage(this._renderingCanvas, 0, 0);
                    // clear the buffer
                    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
                    gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer
                    renderingBufferHasImageData = false;
                }

                // next, use the backup canvas drawer to draw the tiled image (if allowed)
                if(this._canvasFallbackAllowed){
                    const canvasDrawer = this._getBackupCanvasDrawer();
                    canvasDrawer.draw([tiledImage]);
                    this._outputContext.drawImage(canvasDrawer.canvas, 0, 0);
                }

            } else {
                const tilesToDraw = tiledImage.getTilesToDraw();

                if ( tiledImage.placeholderFillStyle && tiledImage._hasOpaqueTile === false ) {
                    this._drawPlaceholder(tiledImage);
                }

                if(tilesToDraw.length === 0 || tiledImage.getOpacity() === 0){
                    return;
                }
                const firstTile = tilesToDraw[0];

                const useContext2dPipeline = ( tiledImage.compositeOperation ||
                    this.viewer.compositeOperation ||
                    tiledImage._clip ||
                    tiledImage._croppingPolygons ||
                    tiledImage.debugMode
                );

                const useTwoPassRendering = useContext2dPipeline || (tiledImage.opacity < 1) || firstTile.tile.hasTransparency;

                // using the context2d pipeline requires a clean rendering (back) buffer to start
                if(useContext2dPipeline){
                    // if the rendering buffer has image data currently, write it to the output canvas now and clear it

                    if(renderingBufferHasImageData){
                        this._outputContext.drawImage(this._renderingCanvas, 0, 0);
                    }

                    // clear the buffer
                    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
                    gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer
                }

                // First rendering pass: compose tiles that make up this tiledImage
                gl.useProgram(firstPass.shaderProgram);

                // bind to the framebuffer for render-to-texture if using two-pass rendering, otherwise back buffer (null)
                if(useTwoPassRendering){
                    gl.bindFramebuffer(gl.FRAMEBUFFER, glFrameBuffer);
                    // clear the buffer to draw a new image
                    gl.clear(gl.COLOR_BUFFER_BIT);
                } else {
                    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
                    // no need to clear, just draw on top of the existing pixels
                }

                let overallMatrix = viewMatrix;

                const imageRotation = tiledImage.getRotation(true);
                // if needed, handle the tiledImage being rotated
                if( imageRotation % 360 !== 0){
                    const imageRotationMatrix = $.Mat3.makeRotation(-imageRotation * Math.PI / 180);
                    const imageCenter = tiledImage.getBoundsNoRotate(true).getCenter();
                    const t1 = $.Mat3.makeTranslation(imageCenter.x, imageCenter.y);
                    const t2 = $.Mat3.makeTranslation(-imageCenter.x, -imageCenter.y);

                    // update the view matrix to account for this image's rotation
                    const localMatrix = t1.multiply(imageRotationMatrix).multiply(t2);
                    overallMatrix = viewMatrix.multiply(localMatrix);
                }

                // Check MAX_TEXTURE_IMAGE_UNITS - throw error if invalid (will be caught by outer try-catch)
                const maxTextures = this._glContext.getMaxTextures();
                if(maxTextures <= 0 || maxTextures === null || maxTextures === undefined){
                    // This can apparently happen on some systems if too many WebGL contexts have been created
                    // in which case maxTextures can be null, leading to out of bounds errors with the array.
                    // For example, when viewers were created and not destroyed in the test suite, this error
                    // occurred in the TravisCI tests, though it did not happen when testing locally either in
                    // a browser or on the command line via grunt test.

                    throw new Error(`WebGL error: bad value for gl parameter MAX_TEXTURE_IMAGE_UNITS (${maxTextures}). This could happen
                    if too many contexts have been created and not released, or there is another problem with the graphics card.`);
                }

                const texturePositionArray = new Float32Array(maxTextures * 12); // 6 vertices (2 triangles) x 2 coordinates per vertex
                const textureDataArray = new Array(maxTextures);
                const matrixArray = new Array(maxTextures);
                const opacityArray = new Array(maxTextures);

                // iterate over tiles and add data for each one to the buffers
                for(let tileIndex = 0; tileIndex < tilesToDraw.length; tileIndex++){
                    const tile = tilesToDraw[tileIndex].tile;
                    const indexInDrawArray = tileIndex % maxTextures;
                    const numTilesToDraw =  indexInDrawArray + 1;
                    const textureInfo = this.getDataToDraw(tile);

                    if (textureInfo && textureInfo.texture) {
                        this._getTileData(tile, tiledImage, textureInfo, overallMatrix, indexInDrawArray, texturePositionArray, textureDataArray, matrixArray, opacityArray);
                    }
                    // else {
                    //   If the texture info is not available, we cannot draw this tile. This is either because
                    //   the tile data is still being processed, or the data was not correct - in that case,
                    //   internalCacheCreate(..) already logged an error.
                    // }

                    if( (numTilesToDraw === maxTextures) || (tileIndex === tilesToDraw.length - 1)){
                        // We've filled up the buffers: time to draw this set of tiles

                        // bind each tile's texture to the appropriate gl.TEXTURE#
                        for(let i = 0; i < numTilesToDraw; i++){
                            gl.activeTexture(gl.TEXTURE0 + i);
                            gl.bindTexture(gl.TEXTURE_2D, textureDataArray[i]);
                        }

                        // set the buffer data for the texture coordinates to use for each tile
                        gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferTexturePosition);
                        gl.bufferData(gl.ARRAY_BUFFER, texturePositionArray, gl.DYNAMIC_DRAW);

                        // set the transform matrix uniform for each tile
                        matrixArray.forEach( (matrix, index) => {
                            gl.uniformMatrix3fv(firstPass.uTransformMatrices[index], false, matrix);
                        });
                        // set the opacity uniform for each tile
                        gl.uniform1fv(firstPass.uOpacities, new Float32Array(opacityArray));

                        // bind vertex buffers and (re)set attributes before calling gl.drawArrays()
                        gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferOutputPosition);
                        gl.vertexAttribPointer(firstPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0);

                        gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferTexturePosition);
                        gl.vertexAttribPointer(firstPass.aTexturePosition, 2, gl.FLOAT, false, 0, 0);

                        gl.bindBuffer(gl.ARRAY_BUFFER, firstPass.bufferIndex);
                        gl.vertexAttribPointer(firstPass.aIndex, 1, gl.FLOAT, false, 0, 0);

                        // Draw! 6 vertices per tile (2 triangles per rectangle)
                        gl.drawArrays(gl.TRIANGLES, 0, 6 * numTilesToDraw );
                    }
                }

                if(useTwoPassRendering){
                    // Second rendering pass: Render the tiled image from the framebuffer into the back buffer
                    gl.useProgram(secondPass.shaderProgram);

                    // set the rendering target to the back buffer (null)
                    gl.bindFramebuffer(gl.FRAMEBUFFER, null);

                    // bind the rendered texture from the first pass to use during this second pass
                    gl.activeTexture(gl.TEXTURE0);
                    gl.bindTexture(gl.TEXTURE_2D, renderToTexture);

                    // set opacity to the value for the current tiledImage
                    gl.uniform1f(secondPass.uOpacityMultiplier, tiledImage.opacity);

                    // bind buffers and set attributes before calling gl.drawArrays
                    gl.bindBuffer(gl.ARRAY_BUFFER, secondPass.bufferTexturePosition);
                    gl.vertexAttribPointer(secondPass.aTexturePosition, 2, gl.FLOAT, false, 0, 0);
                    gl.bindBuffer(gl.ARRAY_BUFFER, secondPass.bufferOutputPosition);
                    gl.vertexAttribPointer(secondPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0);

                    // Draw the quad (two triangles)
                    gl.drawArrays(gl.TRIANGLES, 0, 6);

                }

                renderingBufferHasImageData = true;

                if(useContext2dPipeline){
                    // draw from the rendering canvas onto the output canvas, clipping/cropping if needed.
                    this._applyContext2dPipeline(tiledImage, tilesToDraw, tiledImageIndex);
                    renderingBufferHasImageData = false;
                    // clear the buffer
                    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
                    gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer
                }

                // after drawing the first TiledImage, fire the tiled-image-drawn event (for testing)
                if(tiledImageIndex === 0){
                    this._raiseTiledImageDrawnEvent(tiledImage, tilesToDraw.map(info=>info.tile));
                }
            }

            });

            if(renderingBufferHasImageData){
                this._outputContext.drawImage(this._renderingCanvas, 0, 0);
            }
        }
        /**
        *
        * @param {Array} tiledImages Array of TiledImage objects to draw
        * @param {Boolean} [isRetry=false] Internal flag to prevent infinite retry loops
        */
        draw(tiledImages, isRetry = false){
            try {
                this._draw(tiledImages, isRetry);
            } catch (error) {
                // Handle WebGL context errors that occur at any point during the draw operation
                if (this._isWebGLContextError(error)) {
                    // Try recovery if enabled and not a retry
                    if (this._enableContextRecovery && !isRetry) {
                        $.console.warn('WebGL context error detected during draw operation, attempting to recreate context...', error);
                        const recreatedDrawer = this._recreateContext();
                        if (recreatedDrawer) {
                            $.console.info('WebGL context recreated successfully, retrying draw operation');
                            // Raise event for successful recovery
                            if (this.viewer) {
                                /**
                                 * Raised when the WebGL drawer successfully recovers from a context loss.
                                 *
                                 * @event webgl-context-recovered
                                 * @memberof OpenSeadragon.Viewer
                                 * @type {object}
                                 * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
                                 * @property {OpenSeadragon.WebGLDrawer} drawer - The drawer instance (same instance, context recreated).
                                 * @property {Error} error - The original error that triggered the recovery.
                                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                                 */
                                this.viewer.raiseEvent('webgl-context-recovered', {
                                    drawer: this,
                                    error: error
                                });
                            }
                            // Retry draw on same instance
                            this.draw(tiledImages, true);
                        } else {
                            // Recovery attempted but failed - fall back to canvas drawer (if allowed)
                            this._fallbackToCanvasDrawer(error, tiledImages);
                        }
                    } else {
                        // Recovery disabled or retry - fall back only when recovery was enabled (retry case)
                        if (this._enableContextRecovery) {
                            this._fallbackToCanvasDrawer(error, tiledImages); // will only happen if canvas fallback is allowed
                        } else {
                            throw error;
                        }
                    }
                } else {
                    // Not a WebGL context error - re-throw
                    throw error;
                }
            }
        }

        // Public API required by all Drawer implementations
        /**
        * Sets whether image smoothing is enabled or disabled
        * @param {Boolean} enabled If true, uses gl.LINEAR as the TEXTURE_MIN_FILTER and TEXTURE_MAX_FILTER, otherwise gl.NEAREST.
        */
        setImageSmoothingEnabled(enabled){
            if( this._imageSmoothingEnabled !== enabled ){
                this._imageSmoothingEnabled = enabled;
                if (this._glContext) {
                    this._glContext.setImageSmoothingEnabled(enabled);
                }
                this.setInternalCacheNeedsRefresh();
                this.viewer.forceRedraw();
            }
        }

        /**
        * Sets whether textures are unpacked with premultiplied alpha
        * @param {Boolean} enabled If true, sets gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL to true.
        */
        setUnpackWithPremultipliedAlpha(enabled){
            if (this._unpackWithPremultipliedAlpha !== enabled){
                this._unpackWithPremultipliedAlpha = enabled;
                if (this._glContext) {
                    this._glContext.setUnpackWithPremultipliedAlpha(enabled);
                }
                this.setInternalCacheNeedsRefresh();
                this.viewer.forceRedraw();
            }
        }

        /**
        * Draw a rect onto the output canvas for debugging purposes
        * @param {OpenSeadragon.Rect} rect
        */
        drawDebuggingRect(rect){
            const context = this._outputContext;
            context.save();
            context.lineWidth = 2 * $.pixelDensityRatio;
            context.strokeStyle = this.debugGridColor[0];
            context.fillStyle = this.debugGridColor[0];

            context.strokeRect(
                rect.x * $.pixelDensityRatio,
                rect.y * $.pixelDensityRatio,
                rect.width * $.pixelDensityRatio,
                rect.height * $.pixelDensityRatio
            );

            context.restore();
        }

        /**
        * Draw data from the rendering canvas onto the output canvas, with clipping,
        * cropping and/or debug info as requested.
        * @private
        * @param {OpenSeadragon.TiledImage} tiledImage - the tiledImage to draw
        * @param {Array} tilesToDraw - array of objects containing tiles that were drawn
        */
        _applyContext2dPipeline(tiledImage, tilesToDraw, tiledImageIndex){
            // composite onto the output canvas, clipping if necessary
            this._outputContext.save();

            // set composite operation; ignore for first image drawn
            this._outputContext.globalCompositeOperation = tiledImageIndex === 0 ? null : tiledImage.compositeOperation || this.viewer.compositeOperation;
            if(tiledImage._croppingPolygons || tiledImage._clip){
                this._renderToClippingCanvas(tiledImage);
                this._outputContext.drawImage(this._clippingCanvas, 0, 0);

            } else {
                this._outputContext.drawImage(this._renderingCanvas, 0, 0);
            }
            this._outputContext.restore();
            if(tiledImage.debugMode){
                const flipped = this.viewer.viewport.getFlip();
                if(flipped){
                    this._flip();
                }
                this._drawDebugInfo(tilesToDraw, tiledImage, flipped);
                if(flipped){
                    this._flip();
                }
            }


        }

        // private
        _getTileData(tile, tiledImage, textureInfo, viewMatrix, index, texturePositionArray, textureDataArray, matrixArray, opacityArray){

            const texture = textureInfo.texture;
            const textureQuad = textureInfo.position;
            const overlapFraction = textureInfo.overlapFraction;

            // set the position of this texture
            texturePositionArray.set(textureQuad, index * 12);

            // compute offsets that account for tile overlap; needed for calculating the transform matrix appropriately
            const xOffset = tile.positionedBounds.width * overlapFraction.x;
            const yOffset = tile.positionedBounds.height * overlapFraction.y;
            const x = tile.positionedBounds.x + (tile.x === 0 ? 0 : xOffset);
            const y = tile.positionedBounds.y + (tile.y === 0 ? 0 : yOffset);
            const right = tile.positionedBounds.x + tile.positionedBounds.width - (tile.isRightMost ? 0 : xOffset);
            const bottom = tile.positionedBounds.y + tile.positionedBounds.height - (tile.isBottomMost ? 0 : yOffset);

            const model = new $.Mat3([
                right - x, 0, 0, // right - x = width
                0, bottom - y, 0, // bottom - y = height
                x, y, 1
            ]);

            if (tile.flipped) {
                // For documentation:
                // // flip the tile around the center of the unit quad
                // let t1 = $.Mat3.makeTranslation(0.5, 0);
                // let t2 = $.Mat3.makeTranslation(-0.5, 0);
                //
                // // update the view matrix to account for this image's rotation
                // let localMatrix = t1.multiply($.Mat3.makeScaling(-1, 1)).multiply(t2);
                // matrix = matrix.multiply(localMatrix);

                //Optimized: this works since matrix only contains main diagonal values & translation
                model.scaleAndTranslateSelf(-1, 1, 1, 0);
            }

            model.scaleAndTranslateOtherSetSelf(viewMatrix);
            opacityArray[index] = tile.opacity;
            textureDataArray[index] = texture;
            matrixArray[index] = model.values;
        }


        // private
        _setupRenderer(){
            if(!this._glContext || !this._glContext.getContext()){
                $.console.error('_setupCanvases must be called before _setupRenderer');
                return;
            }
            this._glContext.setupRenderer(this._renderingCanvas.width, this._renderingCanvas.height);
        }


        // private
        _resizeRenderer(){
            if(!this._glContext){
                return;
            }
            this._glContext.resizeRenderer(this._renderingCanvas.width, this._renderingCanvas.height);
        }

        // private
        _setupCanvases(){
            const _this = this;

            this._outputCanvas = this.canvas; //output canvas
            this._outputContext = this._outputCanvas.getContext('2d');

            this._renderingCanvas = document.createElement('canvas');

            this._clippingCanvas = document.createElement('canvas');
            this._clippingContext = this._clippingCanvas.getContext('2d');
            this._renderingCanvas.width = this._clippingCanvas.width = this._outputCanvas.width;
            this._renderingCanvas.height = this._clippingCanvas.height = this._outputCanvas.height;

            // Create WebGL context manager
            this._glContext = new WebglContextManager({
                renderingCanvas: this._renderingCanvas,
                unpackWithPremultipliedAlpha: this._unpackWithPremultipliedAlpha,
                imageSmoothingEnabled: this._imageSmoothingEnabled,
                initShaderProgram: this.constructor.initShaderProgram
            });

            this._resizeHandler = function(){

                if(_this._outputCanvas !== _this.viewer.drawer.canvas){
                    _this._outputCanvas.style.width = _this.viewer.drawer.canvas.clientWidth + 'px';
                    _this._outputCanvas.style.height = _this.viewer.drawer.canvas.clientHeight + 'px';
                }

                const viewportSize = _this._calculateCanvasSize();
                if( _this._outputCanvas.width !== viewportSize.x ||
                    _this._outputCanvas.height !== viewportSize.y ) {
                    _this._outputCanvas.width = viewportSize.x;
                    _this._outputCanvas.height = viewportSize.y;
                }

                _this._renderingCanvas.style.width = _this._outputCanvas.clientWidth + 'px';
                _this._renderingCanvas.style.height = _this._outputCanvas.clientHeight + 'px';
                _this._renderingCanvas.width = _this._clippingCanvas.width = _this._outputCanvas.width;
                _this._renderingCanvas.height = _this._clippingCanvas.height = _this._outputCanvas.height;

                // important - update the size of the rendering viewport!
                _this._resizeRenderer();
            };

            //make the additional canvas elements mirror size changes to the output canvas
            this.viewer.addHandler("resize", this._resizeHandler);
        }


        /**
         * Check if an error is related to WebGL context issues.
         * @param {Error} error - The error to check
         * @returns {Boolean} true if the error is a WebGL context error, false otherwise
         * @private
         */
        _isWebGLContextError(error) {
            if (!error || !error.message) {
                return false;
            }
            const message = error.message.toLowerCase();
            return message.includes('max_texture_image_units') ||
                   (message.includes('webgl') && ((message.includes('context') || message.includes('lost') || message.includes('invalid'))));
        }

        /**
         * Recreate the WebGL context when it has been lost or exhausted.
         * This method recreates only the WebglContextManager, preserving the drawer instance
         * and all drawer state (canvases, options, cache, etc.).
         * @returns {OpenSeadragon.WebGLDrawer|null} The same drawer instance if successful, null otherwise
         * @private
         */
        _recreateContext() {
            if (this._destroyed) {
                return null;
            }

            try {
                // Store old canvas properties
                const oldCanvas = this._renderingCanvas;
                const oldWidth = oldCanvas.width;
                const oldHeight = oldCanvas.height;
                const oldStyleWidth = oldCanvas.style.width;
                const oldStyleHeight = oldCanvas.style.height;

                // Destroy internal cache FIRST (while old context still exists)
                // This ensures textures are freed using the old context before it's destroyed
                this.destroyInternalCache();

                // Destroy old context manager
                if (this._glContext) {
                    this._glContext.destroy();
                    this._glContext = null;
                }

                // Note: destroyInternalCache() above already properly cleaned up all texture
                // and glContext references via internalCacheFree() callbacks

                // Create new rendering canvas element
                this._renderingCanvas = document.createElement('canvas');
                this._renderingCanvas.width = oldWidth;
                this._renderingCanvas.height = oldHeight;
                if (oldStyleWidth) {
                    this._renderingCanvas.style.width = oldStyleWidth;
                }
                if (oldStyleHeight) {
                    this._renderingCanvas.style.height = oldStyleHeight;
                }

                // Create new context manager with new canvas
                this._glContext = new WebglContextManager({
                    renderingCanvas: this._renderingCanvas,
                    unpackWithPremultipliedAlpha: this._unpackWithPremultipliedAlpha,
                    imageSmoothingEnabled: this._imageSmoothingEnabled,
                    initShaderProgram: this.constructor.initShaderProgram
                });

                // Verify context is valid
                if (!this._glContext.getContext()) {
                    $.console.error('Failed to recreate WebGL context: no GL context');
                    return null;
                }

                // Check if the new context has valid MAX_TEXTURE_IMAGE_UNITS
                try {
                    const maxTextures = this._glContext.getMaxTextures();
                    if (!maxTextures || maxTextures <= 0) {
                        $.console.error('Failed to recreate WebGL context: invalid MAX_TEXTURE_IMAGE_UNITS');
                        return null;
                    }
                } catch (e) {
                    $.console.error('Failed to verify new WebGL context:', e);
                    return null;
                }

                // Reinitialize renderer (shaders, framebuffers)
                this._setupRenderer();

                // Mark cache as needing refresh for future entries
                // (Old entries were already freed above)
                this.setInternalCacheNeedsRefresh();

                return this; // Return same drawer instance
            } catch (e) {
                $.console.error('Failed to recreate WebGL context:', e);
                return null;
            }
        }

        /**
         * Fall back to canvas drawer when WebGL fails (requires viewer.drawerCandidates to include 'canvas').
         * If allowed, switches the viewer to use the canvas drawer, raises the webgl-context-recovery-failed event
         * with the canvas drawer, and draws the current frame.
         * Otherwise, raise the event with canvasDrawer: null and rethrow the error.
         *
         * @param {Error} error - The error that triggered the fallback
         * @param {Array} tiledImages - Array of TiledImage objects to draw with the new drawer
         * @throws {Error} Re-throws the error if canvas is not an allowed fallback or if canvas drawer creation fails
         * @private
         */
        _fallbackToCanvasDrawer(error, tiledImages) {
            const oldWebGLDrawer = this;
            if (!this._canvasFallbackAllowed) {
                oldWebGLDrawer._raiseContextRecoveryFailedEvent(error, null);
                throw error;
            }
            const canvasDrawer = this.viewer.requestDrawer('canvas', {
                mainDrawer: true,
                redrawImmediately: false
            });

            if (canvasDrawer) {
                $.console.error('Failed to recreate WebGL context, switching to canvas drawer');
                oldWebGLDrawer._raiseContextRecoveryFailedEvent(error, canvasDrawer);
                this.viewer.world.requestInvalidate(true);
            } else {
                $.console.error('Failed to create canvas drawer as fallback');
                oldWebGLDrawer._raiseContextRecoveryFailedEvent(error, null);
                throw error;
            }
        }

        /**
         * Raise the webgl-context-recovery-failed event.
         * @param {Error} error - The error that triggered the recovery failure
         * @param {OpenSeadragon.CanvasDrawer} [canvasDrawer=null] - The canvas drawer that was created as a fallback, or null if canvas was not an allowed fallback or creation failed
         * @private
         */
        _raiseContextRecoveryFailedEvent(error, canvasDrawer = null) {
            if (!this.viewer) {
                return;
            }
            /**
             * Raised when the WebGL drawer fails to recover from a context loss. The drawer may fall back to
             * canvas drawer only when canvas is in the viewer's drawer list; otherwise canvasDrawer is null and no switch occurs.
             *
             * @event webgl-context-recovery-failed
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
             * @property {OpenSeadragon.WebGLDrawer} drawer - The WebGL drawer instance that failed to recover (may be destroyed).
             * @property {OpenSeadragon.CanvasDrawer} canvasDrawer - The canvas drawer that was created as a fallback, or null if canvas was not an allowed fallback or creation failed.
             * @property {Error} error - The original error that triggered the recovery attempt.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent('webgl-context-recovery-failed', {
                drawer: this,
                canvasDrawer: canvasDrawer,
                error: error
            });
        }

        internalCacheCreate(cache, tile) {
            const tiledImage = tile.tiledImage;
            const gl = this._glContext ? this._glContext.getContext() : null;
            if (!gl) {
                $.console.error('WebGL context not available in internalCacheCreate');
                return {};
            }
            let texture;
            let position;

            let data = cache.data;
            let isCanvas = false;
            if (data instanceof CanvasRenderingContext2D) {
                data = data.canvas;
                isCanvas = true;
            }

            if (!tiledImage.getIssue('webgl')) {
                if (isCanvas && $.isCanvasTainted(data)){
                    tiledImage.setIssue('webgl', 'WebGL cannot be used to draw this TiledImage because it has tainted data. Does crossOriginPolicy need to be set?');
                    this._raiseDrawerErrorEvent(tiledImage, this._canvasFallbackAllowed ?
                        'Tainted data cannot be used by the WebGLDrawer. Falling back to CanvasDrawer for this TiledImage.' :
                        'Tainted data cannot be used by the WebGLDrawer, and canvas fallback is not enabled.');
                    this.setInternalCacheNeedsRefresh();
                } else {
                    let sourceWidthFraction, sourceHeightFraction;
                    if (tile.sourceBounds) {
                        sourceWidthFraction = Math.min(tile.sourceBounds.width, data.width) / data.width;
                        sourceHeightFraction = Math.min(tile.sourceBounds.height, data.height) / data.height;
                    } else {
                        sourceWidthFraction = 1;
                        sourceHeightFraction = 1;
                    }

                    const overlap = tiledImage.source.tileOverlap;
                    const overlapFraction = this._calculateOverlapFraction(tile, tiledImage);
                    if( overlap > 0){
                        // calculate the normalized position of the rect to actually draw
                        // discarding overlap.
                        const left = (tile.x === 0 ? 0 : overlapFraction.x) * sourceWidthFraction;
                        const top = (tile.y === 0 ? 0 : overlapFraction.y) * sourceHeightFraction;
                        const right = (tile.isRightMost ? 1 : 1 - overlapFraction.x) * sourceWidthFraction;
                        const bottom = (tile.isBottomMost ? 1 : 1 - overlapFraction.y) * sourceHeightFraction;
                        position = this._glContext.makeQuadVertexBuffer(left, right, top, bottom);
                    } else if (sourceWidthFraction === 1 && sourceHeightFraction === 1) {
                        // no overlap and no padding: this texture can use the unit quad as its position data
                        position = this._glContext.getUnitQuad();
                    } else {
                        position = this._glContext.makeQuadVertexBuffer(0, sourceWidthFraction, 0, sourceHeightFraction);
                    }

                    // create a gl Texture for this tile using the manager
                    texture = this._glContext.createTexture(data, {
                        unpackWithPremultipliedAlpha: this._unpackWithPremultipliedAlpha
                    });

                    if (!texture) {
                        tiledImage.setIssue('webgl', 'Error creating texture in WebGL.');
                        const canvasAllowed = this._canvasFallbackAllowed;
                        this._raiseDrawerErrorEvent(tiledImage, canvasAllowed ?
                            'Unknown error when creating texture. Falling back to CanvasDrawer for this TiledImage.' :
                            'Cannot use WebGL for this TiledImage; canvas fallback is not enabled.');
                        this.setInternalCacheNeedsRefresh();
                    } else {
                        // TextureInfo stored in the cache
                        // Store reference to the context that created this texture
                        return {
                            texture: texture,
                            position: position,
                            overlapFraction: overlapFraction,
                            glContext: this._glContext  // Store context reference for safe deletion
                        };
                    }
                }
            }
            if (data instanceof Image) {
                const canvas = document.createElement( 'canvas' );
                canvas.width = data.width;
                canvas.height = data.height;
                const context = canvas.getContext('2d', { willReadFrequently: true });
                context.drawImage( data, 0, 0 );
                data = context;
            }
            if (data instanceof CanvasRenderingContext2D) {
                return data;
            }
            $.console.error("Unsupported data used for WebGL Drawer - probably a bug!");
            return {};
        }

        internalCacheFree(data) {
            if (data && data.texture) {
                // Use the stored context reference if available, otherwise fall back to current context
                const glContext = data.glContext || this._glContext;

                if (glContext && !glContext.isDestroyed()) {
                    try {
                        glContext.deleteTexture(data.texture);
                    } catch (e) {
                        // Context may have been destroyed between check and deletion - safe to ignore
                    }
                }

                // Always nullify references
                data.texture = null;
                data.glContext = null;
            }
        }


        // private
        _calculateOverlapFraction(tile, tiledImage){
            const overlap = tiledImage.source.tileOverlap;
            const nativeWidth = tile.sourceBounds.width; // in pixels
            const nativeHeight = tile.sourceBounds.height; // in pixels
            const overlapWidth  = (tile.x === 0 ? 0 : overlap) + (tile.isRightMost ? 0 : overlap); // in pixels
            const overlapHeight = (tile.y === 0 ? 0 : overlap) + (tile.isBottomMost ? 0 : overlap); // in pixels
            const widthOverlapFraction = overlap / (nativeWidth + overlapWidth); // as a fraction of image including overlap
            const heightOverlapFraction = overlap / (nativeHeight + overlapHeight); // as a fraction of image including overlap
            return {
                x: widthOverlapFraction,
                y: heightOverlapFraction
            };
        }

        _setClip(){
            // no-op: called by _renderToClippingCanvas when tiledImage._clip is truthy
            // so that tests will pass.
        }

        // private
        _renderToClippingCanvas(item){

            this._clippingContext.clearRect(0, 0, this._clippingCanvas.width, this._clippingCanvas.height);
            this._clippingContext.save();
            if(this.viewer.viewport.getFlip()){
                const point = new $.Point(this.canvas.width / 2, this.canvas.height / 2);
                this._clippingContext.translate(point.x, 0);
                this._clippingContext.scale(-1, 1);
                this._clippingContext.translate(-point.x, 0);
            }

            if(item._clip){
                const polygon = [
                    {x: item._clip.x, y: item._clip.y},
                    {x: item._clip.x + item._clip.width, y: item._clip.y},
                    {x: item._clip.x + item._clip.width, y: item._clip.y + item._clip.height},
                    {x: item._clip.x, y: item._clip.y + item._clip.height},
                ];
                const clipPoints = polygon.map(coord => {
                    const point = item.imageToViewportCoordinates(coord.x, coord.y, true)
                        .rotate(this.viewer.viewport.getRotation(true), this.viewer.viewport.getCenter(true));
                    const clipPoint = this.viewportCoordToDrawerCoord(point);
                    return clipPoint;
                });
                this._clippingContext.beginPath();
                clipPoints.forEach( (coord, i) => {
                    this._clippingContext[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y);
                });
                this._clippingContext.clip();
                this._setClip();
            }
            if(item._croppingPolygons){
                const polygons = item._croppingPolygons.map(polygon => {
                    return polygon.map(coord => {
                        const point = item.imageToViewportCoordinates(coord.x, coord.y, true)
                            .rotate(this.viewer.viewport.getRotation(true), this.viewer.viewport.getCenter(true));
                        const clipPoint = this.viewportCoordToDrawerCoord(point);
                        return clipPoint;
                    });
                });
                this._clippingContext.beginPath();
                polygons.forEach((polygon) => {
                    polygon.forEach( (coord, i) => {
                        this._clippingContext[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y);
                    });
                });
                this._clippingContext.clip();
            }

            if(this.viewer.viewport.getFlip()){
                const point = new $.Point(this.canvas.width / 2, this.canvas.height / 2);
                this._clippingContext.translate(point.x, 0);
                this._clippingContext.scale(-1, 1);
                this._clippingContext.translate(-point.x, 0);
            }

            this._clippingContext.drawImage(this._renderingCanvas, 0, 0);

            this._clippingContext.restore();
        }

        /**
         * Set rotations for viewport & tiledImage
         * @private
         * @param {OpenSeadragon.TiledImage} tiledImage
         */
        _setRotations(tiledImage) {
            let saveContext = false;
            if (this.viewport.getRotation(true) % 360 !== 0) {
                this._offsetForRotation({
                    degrees: this.viewport.getRotation(true),
                    saveContext: saveContext
                });
                saveContext = false;
            }
            if (tiledImage.getRotation(true) % 360 !== 0) {
                this._offsetForRotation({
                    degrees: tiledImage.getRotation(true),
                    point: this.viewport.pixelFromPointNoRotate(
                        tiledImage._getRotationPoint(true), true),
                    saveContext: saveContext
                });
            }
        }

        // private
        _offsetForRotation(options) {
            const point = options.point ?
                options.point.times($.pixelDensityRatio) :
                this._getCanvasCenter();

            const context = this._outputContext;
            context.save();

            context.translate(point.x, point.y);
            context.rotate(Math.PI / 180 * options.degrees);
            context.translate(-point.x, -point.y);
        }

        // private
        _flip(options) {
            options = options || {};
            const point = options.point ?
            options.point.times($.pixelDensityRatio) :
            this._getCanvasCenter();
            const context = this._outputContext;

            context.translate(point.x, 0);
            context.scale(-1, 1);
            context.translate(-point.x, 0);
        }

        // private
        _drawDebugInfo( tilesToDraw, tiledImage, flipped ) {

            for ( let i = tilesToDraw.length - 1; i >= 0; i-- ) {
                const tile = tilesToDraw[ i ].tile;
                try {
                    this._drawDebugInfoOnTile(tile, tilesToDraw.length, i, tiledImage, flipped);
                } catch(e) {
                    $.console.error(e);
                }
            }
        }

        // private
        _drawDebugInfoOnTile(tile, count, i, tiledImage, flipped) {

            const colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length;
            const context = this.context;
            context.save();
            context.lineWidth = 2 * $.pixelDensityRatio;
            context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial';
            context.strokeStyle = this.debugGridColor[colorIndex];
            context.fillStyle = this.debugGridColor[colorIndex];

            this._setRotations(tiledImage);

            if(flipped){
                this._flip({point: tile.position.plus(tile.size.divide(2))});
            }

            context.strokeRect(
                tile.position.x * $.pixelDensityRatio,
                tile.position.y * $.pixelDensityRatio,
                tile.size.x * $.pixelDensityRatio,
                tile.size.y * $.pixelDensityRatio
            );

            const tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio;
            const tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio;

            // Rotate the text the right way around.
            context.translate( tileCenterX, tileCenterY );
            const angleInDegrees = this.viewport.getRotation(true);
            context.rotate( Math.PI / 180 * -angleInDegrees );
            context.translate( -tileCenterX, -tileCenterY );

            if( tile.x === 0 && tile.y === 0 ){
                context.fillText(
                    "Zoom: " + this.viewport.getZoom(),
                    tile.position.x * $.pixelDensityRatio,
                    (tile.position.y - 30) * $.pixelDensityRatio
                );
                context.fillText(
                    "Pan: " + this.viewport.getBounds().toString(),
                    tile.position.x * $.pixelDensityRatio,
                    (tile.position.y - 20) * $.pixelDensityRatio
                );
            }
            context.fillText(
                "Level: " + tile.level,
                (tile.position.x + 10) * $.pixelDensityRatio,
                (tile.position.y + 20) * $.pixelDensityRatio
            );
            context.fillText(
                "Column: " + tile.x,
                (tile.position.x + 10) * $.pixelDensityRatio,
                (tile.position.y + 30) * $.pixelDensityRatio
            );
            context.fillText(
                "Row: " + tile.y,
                (tile.position.x + 10) * $.pixelDensityRatio,
                (tile.position.y + 40) * $.pixelDensityRatio
            );
            context.fillText(
                "Order: " + i + " of " + count,
                (tile.position.x + 10) * $.pixelDensityRatio,
                (tile.position.y + 50) * $.pixelDensityRatio
            );
            context.fillText(
                "Size: " + tile.size.toString(),
                (tile.position.x + 10) * $.pixelDensityRatio,
                (tile.position.y + 60) * $.pixelDensityRatio
            );
            context.fillText(
                "Position: " + tile.position.toString(),
                (tile.position.x + 10) * $.pixelDensityRatio,
                (tile.position.y + 70) * $.pixelDensityRatio
            );

            if (this.viewport.getRotation(true) % 360 !== 0 ) {
                this._restoreRotationChanges();
            }
            if (tiledImage.getRotation(true) % 360 !== 0) {
                this._restoreRotationChanges();
            }

            context.restore();
        }

        _drawPlaceholder(tiledImage){

            const bounds = tiledImage.getBounds(true);
            const rect = this.viewportToDrawerRectangle(tiledImage.getBounds(true));
            const context = this._outputContext;

            let fillStyle;
            if ( typeof tiledImage.placeholderFillStyle === "function" ) {
                fillStyle = tiledImage.placeholderFillStyle(tiledImage, context);
            }
            else {
                fillStyle = tiledImage.placeholderFillStyle;
            }

            this._offsetForRotation({degrees: this.viewer.viewport.getRotation(true)});
            context.fillStyle = fillStyle;
            context.translate(rect.x, rect.y);
            context.rotate(Math.PI / 180 * bounds.degrees);
            context.translate(-rect.x, -rect.y);
            context.fillRect(rect.x, rect.y, rect.width, rect.height);
            this._restoreRotationChanges();

        }

        /**
         * Get the canvas center
         * @private
         * @returns {OpenSeadragon.Point} The center point of the canvas
         */
        _getCanvasCenter() {
            return new $.Point(this.canvas.width / 2, this.canvas.height / 2);
        }

        // private
        _restoreRotationChanges() {
            const context = this._outputContext;
            context.restore();
        }

        // modified from https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Tutorial/Adding_2D_content_to_a_WebGL_context
        static initShaderProgram(gl, vsSource, fsSource) {

            function loadShader(gl, type, source) {
                const shader = gl.createShader(type);

                // Send the source to the shader object

                gl.shaderSource(shader, source);

                // Compile the shader program

                gl.compileShader(shader);

                // See if it compiled successfully

                if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
                    $.console.error(
                        `An error occurred compiling the shaders: ${gl.getShaderInfoLog(shader)}`
                    );
                    gl.deleteShader(shader);
                    return null;
                }

                return shader;
            }

            const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
            const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);

            // Create the shader program

            const shaderProgram = gl.createProgram();
            gl.attachShader(shaderProgram, vertexShader);
            gl.attachShader(shaderProgram, fragmentShader);
            gl.linkProgram(shaderProgram);

            // If creating the shader program failed, alert

            if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
            $.console.error(
                `Unable to initialize the shader program: ${gl.getProgramInfoLog(
                shaderProgram
                )}`
            );
            return null;
            }

            return shaderProgram;
        }
    };


}( OpenSeadragon ));

/*
 * OpenSeadragon - Viewport
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){


/**
 * @class Viewport
 * @memberof OpenSeadragon
 * @classdesc Handles coordinate-related functionality (zoom, pan, rotation, etc.)
 * for an {@link OpenSeadragon.Viewer}.
 * @param {Object} options - Options for this Viewport.
 * @param {Object} [options.margins] - See viewportMargins in {@link OpenSeadragon.Options}.
 * @param {Number} [options.springStiffness] - See springStiffness in {@link OpenSeadragon.Options}.
 * @param {Number} [options.animationTime] - See animationTime in {@link OpenSeadragon.Options}.
 * @param {Number} [options.minZoomImageRatio] - See minZoomImageRatio in {@link OpenSeadragon.Options}.
 * @param {Number} [options.maxZoomPixelRatio] - See maxZoomPixelRatio in {@link OpenSeadragon.Options}.
 * @param {Number} [options.visibilityRatio] - See visibilityRatio in {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.wrapHorizontal] - See wrapHorizontal in {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.wrapVertical] - See wrapVertical in {@link OpenSeadragon.Options}.
 * @param {Number} [options.defaultZoomLevel] - See defaultZoomLevel in {@link OpenSeadragon.Options}.
 * @param {Number} [options.minZoomLevel] - See minZoomLevel in {@link OpenSeadragon.Options}.
 * @param {Number} [options.maxZoomLevel] - See maxZoomLevel in {@link OpenSeadragon.Options}.
 * @param {Number} [options.degrees] - See degrees in {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.homeFillsViewer] - See homeFillsViewer in {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.silenceMultiImageWarnings] - See silenceMultiImageWarnings in {@link OpenSeadragon.Options}.
 */
$.Viewport = function( options ) {

    //backward compatibility for positional args while preferring more
    //idiomatic javascript options object as the only argument
    const args = arguments;
    if (args.length && args[0] instanceof $.Point) {
        options = {
            containerSize:  args[0],
            contentSize:    args[1],
            config:         args[2]
        };
    }

    //options.config and the general config argument are deprecated
    //in favor of the more direct specification of optional settings
    //being passed directly on the options object
    if ( options.config ){
        $.extend( true, options, options.config );
        delete options.config;
    }

    this._margins = $.extend({
        left: 0,
        top: 0,
        right: 0,
        bottom: 0
    }, options.margins || {});

    delete options.margins;

    options.initialDegrees = options.degrees;
    delete options.degrees;

    $.extend( true, this, {

        //required settings
        containerSize:      null,
        contentSize:        null,

        //internal state properties
        zoomPoint:          null,
        rotationPivot:      null,
        viewer:             null,

        //configurable options
        springStiffness:            $.DEFAULT_SETTINGS.springStiffness,
        animationTime:              $.DEFAULT_SETTINGS.animationTime,
        minZoomImageRatio:          $.DEFAULT_SETTINGS.minZoomImageRatio,
        maxZoomPixelRatio:          $.DEFAULT_SETTINGS.maxZoomPixelRatio,
        visibilityRatio:            $.DEFAULT_SETTINGS.visibilityRatio,
        wrapHorizontal:             $.DEFAULT_SETTINGS.wrapHorizontal,
        wrapVertical:               $.DEFAULT_SETTINGS.wrapVertical,
        defaultZoomLevel:           $.DEFAULT_SETTINGS.defaultZoomLevel,
        minZoomLevel:               $.DEFAULT_SETTINGS.minZoomLevel,
        maxZoomLevel:               $.DEFAULT_SETTINGS.maxZoomLevel,
        initialDegrees:             $.DEFAULT_SETTINGS.degrees,
        flipped:                    $.DEFAULT_SETTINGS.flipped,
        homeFillsViewer:            $.DEFAULT_SETTINGS.homeFillsViewer,
        silenceMultiImageWarnings:  $.DEFAULT_SETTINGS.silenceMultiImageWarnings

    }, options );

    this._updateContainerInnerSize();

    this.centerSpringX = new $.Spring({
        initial: 0,
        springStiffness: this.springStiffness,
        animationTime:   this.animationTime
    });
    this.centerSpringY = new $.Spring({
        initial: 0,
        springStiffness: this.springStiffness,
        animationTime:   this.animationTime
    });
    this.zoomSpring    = new $.Spring({
        exponential: true,
        initial: 1,
        springStiffness: this.springStiffness,
        animationTime:   this.animationTime
    });

    this.degreesSpring = new $.Spring({
        initial: options.initialDegrees,
        springStiffness: this.springStiffness,
        animationTime: this.animationTime
    });

    this._oldCenterX = this.centerSpringX.current.value;
    this._oldCenterY = this.centerSpringY.current.value;
    this._oldZoom    = this.zoomSpring.current.value;
    this._oldDegrees = this.degreesSpring.current.value;

    this._sizeChanged = false;

    this._setContentBounds(new $.Rect(0, 0, 1, 1), 1);

    this.goHome(true);
    this.update();
};

/** @lends OpenSeadragon.Viewport.prototype */
$.Viewport.prototype = {

    // deprecated
    get degrees () {
        $.console.warn('Accessing [Viewport.degrees] is deprecated. Use viewport.getRotation instead.');
        return this.getRotation();
    },

    // deprecated
    set degrees (degrees) {
        $.console.warn('Setting [Viewport.degrees] is deprecated. Use viewport.rotateTo, viewport.rotateBy, or viewport.setRotation instead.');
        this.rotateTo(degrees);
    },

    /**
     * Updates the viewport's home bounds and constraints for the given content size.
     * @function
     * @param {OpenSeadragon.Point} contentSize - size of the content in content units
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:reset-size
     */
    resetContentSize: function(contentSize) {
        $.console.assert(contentSize, "[Viewport.resetContentSize] contentSize is required");
        $.console.assert(contentSize instanceof $.Point, "[Viewport.resetContentSize] contentSize must be an OpenSeadragon.Point");
        $.console.assert(contentSize.x > 0, "[Viewport.resetContentSize] contentSize.x must be greater than 0");
        $.console.assert(contentSize.y > 0, "[Viewport.resetContentSize] contentSize.y must be greater than 0");

        this._setContentBounds(new $.Rect(0, 0, 1, contentSize.y / contentSize.x), contentSize.x);
        return this;
    },

    // deprecated
    setHomeBounds: function(bounds, contentFactor) {
        $.console.error("[Viewport.setHomeBounds] this function is deprecated; The content bounds should not be set manually.");
        this._setContentBounds(bounds, contentFactor);
    },

    // Set the viewport's content bounds
    // @param {OpenSeadragon.Rect} bounds - the new bounds in viewport coordinates
    // without rotation
    // @param {Number} contentFactor - how many content units per viewport unit
    // @fires OpenSeadragon.Viewer.event:reset-size
    // @private
    _setContentBounds: function(bounds, contentFactor) {
        $.console.assert(bounds, "[Viewport._setContentBounds] bounds is required");
        $.console.assert(bounds instanceof $.Rect, "[Viewport._setContentBounds] bounds must be an OpenSeadragon.Rect");
        $.console.assert(bounds.width > 0, "[Viewport._setContentBounds] bounds.width must be greater than 0");
        $.console.assert(bounds.height > 0, "[Viewport._setContentBounds] bounds.height must be greater than 0");

        this._contentBoundsNoRotate = bounds.clone();
        this._contentSizeNoRotate = this._contentBoundsNoRotate.getSize().times(
            contentFactor);

        this._contentBounds = bounds.rotate(this.getRotation()).getBoundingBox();
        this._contentSize = this._contentBounds.getSize().times(contentFactor);
        this._contentAspectRatio = this._contentSize.x / this._contentSize.y;

        if (this.viewer) {
            /**
             * Raised when the viewer's content size or home bounds are reset
             * (see {@link OpenSeadragon.Viewport#resetContentSize}).
             *
             * @event reset-size
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {OpenSeadragon.Point} contentSize
             * @property {OpenSeadragon.Rect} contentBounds - Content bounds.
             * @property {OpenSeadragon.Rect} homeBounds - Content bounds.
             * Deprecated use contentBounds instead.
             * @property {Number} contentFactor
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent('reset-size', {
                contentSize: this._contentSizeNoRotate.clone(),
                contentFactor: contentFactor,
                homeBounds: this._contentBoundsNoRotate.clone(),
                contentBounds: this._contentBounds.clone()
            });
        }
    },

    /**
     * Returns the home zoom in "viewport zoom" value.
     * @function
     * @returns {Number} The home zoom in "viewport zoom".
     */
    getHomeZoom: function() {
        if (this.defaultZoomLevel) {
            return this.defaultZoomLevel;
        }

        const aspectFactor = this._contentAspectRatio / this.getAspectRatio();
        let output;
        if (this.homeFillsViewer) { // fill the viewer and clip the image
            output = aspectFactor >= 1 ? aspectFactor : 1;
        } else {
            output = aspectFactor >= 1 ? 1 : aspectFactor;
        }

        return output / this._contentBounds.width;
    },

    /**
     * Returns the home bounds in viewport coordinates.
     * @function
     * @returns {OpenSeadragon.Rect} The home bounds in vewport coordinates.
     */
    getHomeBounds: function() {
        return this.getHomeBoundsNoRotate().rotate(-this.getRotation());
    },

    /**
     * Returns the home bounds in viewport coordinates.
     * This method ignores the viewport rotation. Use
     * {@link OpenSeadragon.Viewport#getHomeBounds} to take it into account.
     * @function
     * @returns {OpenSeadragon.Rect} The home bounds in vewport coordinates.
     */
    getHomeBoundsNoRotate: function() {
        const center = this._contentBounds.getCenter();
        const width  = 1.0 / this.getHomeZoom();
        const height = width / this.getAspectRatio();

        return new $.Rect(
            center.x - (width / 2.0),
            center.y - (height / 2.0),
            width,
            height
        );
    },

    /**
     * @function
     * @param {Boolean} immediately
     * @fires OpenSeadragon.Viewer.event:home
     */
    goHome: function(immediately) {
        if (this.viewer) {
            /**
             * Raised when the "home" operation occurs (see {@link OpenSeadragon.Viewport#goHome}).
             *
             * @event home
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {Boolean} immediately
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent('home', {
                immediately: immediately
            });
        }
        return this.fitBounds(this.getHomeBounds(), immediately);
    },

    /**
     * @function
     */
    getMinZoom: function() {
        const homeZoom = this.getHomeZoom();
        const zoom = this.minZoomLevel ?
            this.minZoomLevel :
                this.minZoomImageRatio * homeZoom;

        return zoom;
    },

    /**
     * @function
     */
    getMaxZoom: function() {
        let zoom = this.maxZoomLevel;
        if (!zoom) {
            zoom = this._contentSize.x * this.maxZoomPixelRatio / this._containerInnerSize.x;
            zoom /= this._contentBounds.width;
        }

        return Math.max( zoom, this.getHomeZoom() );
    },

    /**
     * @function
     */
    getAspectRatio: function() {
        return this._containerInnerSize.x / this._containerInnerSize.y;
    },

    /**
     * @function
     * @returns {OpenSeadragon.Point} The size of the container, in screen coordinates.
     */
    getContainerSize: function() {
        return new $.Point(
            this.containerSize.x,
            this.containerSize.y
        );
    },

    /**
     * The margins push the "home" region in from the sides by the specified amounts.
     * @function
     * @returns {Object} Properties (Numbers, in screen coordinates): left, top, right, bottom.
     */
    getMargins: function() {
        return $.extend({}, this._margins); // Make a copy so we are not returning our original
    },

    /**
     * The margins push the "home" region in from the sides by the specified amounts.
     * @function
     * @param {Object} margins - Properties (Numbers, in screen coordinates): left, top, right, bottom.
     */
    setMargins: function(margins) {
        $.console.assert($.type(margins) === 'object', '[Viewport.setMargins] margins must be an object');

        this._margins = $.extend({
            left: 0,
            top: 0,
            right: 0,
            bottom: 0
        }, margins);

        this._updateContainerInnerSize();
        if (this.viewer) {
            this.viewer.forceRedraw();
        }
    },

    /**
     * Returns the bounds of the visible area in viewport coordinates.
     * @function
     * @param {Boolean} current - Pass true for the current location; defaults to false (target location).
     * @returns {OpenSeadragon.Rect} The location you are zoomed/panned to, in viewport coordinates.
     */
    getBounds: function(current) {
        return this.getBoundsNoRotate(current).rotate(-this.getRotation(current));
    },

    /**
     * Returns the bounds of the visible area in viewport coordinates.
     * This method ignores the viewport rotation. Use
     * {@link OpenSeadragon.Viewport#getBounds} to take it into account.
     * @function
     * @param {Boolean} current - Pass true for the current location; defaults to false (target location).
     * @returns {OpenSeadragon.Rect} The location you are zoomed/panned to, in viewport coordinates.
     */
    getBoundsNoRotate: function(current) {
        const center = this.getCenter(current);
        const width  = 1.0 / this.getZoom(current);
        const height = width / this.getAspectRatio();

        return new $.Rect(
            center.x - (width / 2.0),
            center.y - (height / 2.0),
            width,
            height
        );
    },

    /**
     * @function
     * @param {Boolean} current - Pass true for the current location; defaults to false (target location).
     * @returns {OpenSeadragon.Rect} The location you are zoomed/panned to,
     * including the space taken by margins, in viewport coordinates.
     */
    getBoundsWithMargins: function(current) {
        return this.getBoundsNoRotateWithMargins(current).rotate(
            -this.getRotation(current), this.getCenter(current));
    },

    /**
     * @function
     * @param {Boolean} current - Pass true for the current location; defaults to false (target location).
     * @returns {OpenSeadragon.Rect} The location you are zoomed/panned to,
     * including the space taken by margins, in viewport coordinates.
     */
    getBoundsNoRotateWithMargins: function(current) {
        const bounds = this.getBoundsNoRotate(current);
        const factor = this._containerInnerSize.x * this.getZoom(current);
        bounds.x -= this._margins.left / factor;
        bounds.y -= this._margins.top / factor;
        bounds.width += (this._margins.left + this._margins.right) / factor;
        bounds.height += (this._margins.top + this._margins.bottom) / factor;
        return bounds;
    },

    /**
     * @function
     * @param {Boolean} current - Pass true for the current location; defaults to false (target location).
     */
    getCenter: function( current ) {
        const centerCurrent = new $.Point(
                this.centerSpringX.current.value,
                this.centerSpringY.current.value
            );
        const centerTarget = new $.Point(
                this.centerSpringX.target.value,
                this.centerSpringY.target.value
            );

        if ( current ) {
            return centerCurrent;
        } else if ( !this.zoomPoint ) {
            return centerTarget;
        }

        const oldZoomPixel = this.pixelFromPoint(this.zoomPoint, true);

        const zoom    = this.getZoom();
        const width   = 1.0 / zoom;
        const height  = width / this.getAspectRatio();
        const bounds  = new $.Rect(
            centerCurrent.x - width / 2.0,
            centerCurrent.y - height / 2.0,
            width,
            height
        );

        const newZoomPixel = this._pixelFromPoint(this.zoomPoint, bounds);
        const deltaZoomPixels = newZoomPixel.minus( oldZoomPixel ).rotate(-this.getRotation(true));
        const deltaZoomPoints = deltaZoomPixels.divide( this._containerInnerSize.x * zoom );

        return centerTarget.plus( deltaZoomPoints );
    },

    /**
     * @function
     * @param {Boolean} current - Pass true for the current location; defaults to false (target location).
     */
    getZoom: function( current ) {
        if ( current ) {
            return this.zoomSpring.current.value;
        } else {
            return this.zoomSpring.target.value;
        }
    },

    // private
    _applyZoomConstraints: function(zoom) {
        return Math.max(
            Math.min(zoom, this.getMaxZoom()),
            this.getMinZoom());
    },

    /**
     * @function
     * @private
     * @param {OpenSeadragon.Rect} bounds
     * @returns {OpenSeadragon.Rect} constrained bounds.
     */
    _applyBoundaryConstraints: function(bounds) {
        const newBounds = this.viewportToViewerElementRectangle(bounds).getBoundingBox();
        const cb = this.viewportToViewerElementRectangle(this._contentBoundsNoRotate).getBoundingBox();

        let xConstrained = false;
        let yConstrained = false;

        if (this.wrapHorizontal) {
            //do nothing
        } else {
            const boundsRight = newBounds.x + newBounds.width;
            const contentRight = cb.x + cb.width;

            let horizontalThreshold, leftDx, rightDx;
            if (newBounds.width > cb.width) {
                horizontalThreshold = this.visibilityRatio * cb.width;
            } else {
                horizontalThreshold = this.visibilityRatio * newBounds.width;
            }

            leftDx = cb.x - boundsRight + horizontalThreshold;
            rightDx = contentRight - newBounds.x - horizontalThreshold;
            if (horizontalThreshold > cb.width) {
                newBounds.x += (leftDx + rightDx) / 2;
                xConstrained = true;
            } else if (rightDx < 0) {
                newBounds.x += rightDx;
                xConstrained = true;
            } else if (leftDx > 0) {
                newBounds.x += leftDx;
                xConstrained = true;
            }

        }

        if (this.wrapVertical) {
            //do nothing
        } else {
            const boundsBottom = newBounds.y + newBounds.height;
            const contentBottom = cb.y + cb.height;

            let verticalThreshold, topDy, bottomDy;
            if (newBounds.height > cb.height) {
                verticalThreshold = this.visibilityRatio * cb.height;
            } else{
                verticalThreshold = this.visibilityRatio * newBounds.height;
            }

            topDy = cb.y - boundsBottom + verticalThreshold;
            bottomDy = contentBottom - newBounds.y - verticalThreshold;
            if (verticalThreshold > cb.height) {
                newBounds.y += (topDy + bottomDy) / 2;
                yConstrained = true;
            } else if (bottomDy < 0) {
                newBounds.y += bottomDy;
                yConstrained = true;
            } else if (topDy > 0) {
                newBounds.y += topDy;
                yConstrained = true;
            }

        }

        const constraintApplied = xConstrained || yConstrained;
        const newViewportBounds = constraintApplied ? this.viewerElementToViewportRectangle(newBounds) : bounds.clone();
        newViewportBounds.xConstrained = xConstrained;
        newViewportBounds.yConstrained = yConstrained;
        newViewportBounds.constraintApplied = constraintApplied;

        return newViewportBounds;
    },

    /**
     * @function
     * @private
     * @param {Boolean} [immediately=false] - whether the function that triggered this event was
     * called with the "immediately" flag
     */
    _raiseConstraintsEvent: function(immediately) {
        if (this.viewer) {
            /**
             * Raised when the viewport constraints are applied (see {@link OpenSeadragon.Viewport#applyConstraints}).
             *
             * @event constrain
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {Boolean} immediately - whether the function that triggered this event was
             * called with the "immediately" flag
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent( 'constrain', {
                immediately: immediately
            });
        }
    },

    /**
     * Enforces the minZoom, maxZoom and visibilityRatio constraints by
     * zooming and panning to the closest acceptable zoom and location.
     * @function
     * @param {Boolean} [immediately=false]
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:constrain if constraints were applied
     */
    applyConstraints: function(immediately) {
        const actualZoom = this.getZoom();
        const constrainedZoom = this._applyZoomConstraints(actualZoom);

        if (actualZoom !== constrainedZoom) {
            this.zoomTo(constrainedZoom, this.zoomPoint, immediately);
        }

        const constrainedBounds = this.getConstrainedBounds(false);

        if(constrainedBounds.constraintApplied){
            this.fitBounds(constrainedBounds, immediately);
            this._raiseConstraintsEvent(immediately);
        }

        return this;
    },

    /**
     * Equivalent to {@link OpenSeadragon.Viewport#applyConstraints}
     * @function
     * @param {Boolean} [immediately=false]
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:constrain
     */
    ensureVisible: function(immediately) {
        return this.applyConstraints(immediately);
    },

    /**
     * @function
     * @private
     * @param {OpenSeadragon.Rect} bounds
     * @param {Object} options (immediately=false, constraints=false)
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    _fitBounds: function(bounds, options) {
        options = options || {};
        const immediately = options.immediately || false;
        const constraints = options.constraints || false;

        const aspect = this.getAspectRatio();
        const center = bounds.getCenter();

        // Compute width and height of bounding box.
        const newBounds = new $.Rect(
            bounds.x,
            bounds.y,
            bounds.width,
            bounds.height,
            bounds.degrees + this.getRotation())
            .getBoundingBox();

        if (newBounds.getAspectRatio() >= aspect) {
            newBounds.height = newBounds.width / aspect;
        } else {
            newBounds.width = newBounds.height * aspect;
        }

        // Compute x and y from width, height and center position
        newBounds.x = center.x - newBounds.width / 2;
        newBounds.y = center.y - newBounds.height / 2;
        let newZoom = 1.0 / newBounds.width;

        if (immediately) {
            this.panTo(center, true);
            this.zoomTo(newZoom, null, true);
            if(constraints){
                this.applyConstraints(true);
            }
            return this;
        }

        const currentCenter = this.getCenter(true);
        const currentZoom = this.getZoom(true);
        this.panTo(currentCenter, true);
        this.zoomTo(currentZoom, null, true);

        const oldBounds = this.getBounds();
        const oldZoom   = this.getZoom();

        if (oldZoom === 0 || Math.abs(newZoom / oldZoom - 1) < 0.00000001) {
            this.zoomTo(newZoom, null, true);
            this.panTo(center, immediately);
            if(constraints){
                this.applyConstraints(false);
            }
            return this;
        }

        if(constraints){
            this.panTo(center, false);

            newZoom = this._applyZoomConstraints(newZoom);
            this.zoomTo(newZoom, null, false);

            const constrainedBounds = this.getConstrainedBounds();

            this.panTo(currentCenter, true);
            this.zoomTo(currentZoom, null, true);

            this.fitBounds(constrainedBounds);
        } else {
            const rotatedNewBounds = newBounds.rotate(-this.getRotation());
            const referencePoint = rotatedNewBounds.getTopLeft().times(newZoom)
                .minus(oldBounds.getTopLeft().times(oldZoom))
                .divide(newZoom - oldZoom);

            this.zoomTo(newZoom, referencePoint, immediately);
        }
        return this;
    },

    /**
     * Makes the viewport zoom and pan so that the specified bounds take
     * as much space as possible in the viewport.
     * Note: this method ignores the constraints (minZoom, maxZoom and
     * visibilityRatio).
     * Use {@link OpenSeadragon.Viewport#fitBoundsWithConstraints} to enforce
     * them.
     * @function
     * @param {OpenSeadragon.Rect} bounds
     * @param {Boolean} [immediately=false]
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    fitBounds: function(bounds, immediately) {
        return this._fitBounds(bounds, {
            immediately: immediately,
            constraints: false
        });
    },

    /**
     * Makes the viewport zoom and pan so that the specified bounds take
     * as much space as possible in the viewport while enforcing the constraints
     * (minZoom, maxZoom and visibilityRatio).
     * Note: because this method enforces the constraints, part of the
     * provided bounds may end up outside of the viewport.
     * Use {@link OpenSeadragon.Viewport#fitBounds} to ignore them.
     * @function
     * @param {OpenSeadragon.Rect} bounds
     * @param {Boolean} [immediately=false]
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    fitBoundsWithConstraints: function(bounds, immediately) {
        return this._fitBounds(bounds, {
            immediately: immediately,
            constraints: true
        });
    },

    /**
     * Zooms so the image just fills the viewer vertically.
     * @param {Boolean} immediately
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    fitVertically: function(immediately) {
        const box = new $.Rect(
            this._contentBounds.x + (this._contentBounds.width / 2),
            this._contentBounds.y,
            0,
            this._contentBounds.height);
        return this.fitBounds(box, immediately);
    },

    /**
     * Zooms so the image just fills the viewer horizontally.
     * @param {Boolean} immediately
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    fitHorizontally: function(immediately) {
        const box = new $.Rect(
            this._contentBounds.x,
            this._contentBounds.y + (this._contentBounds.height / 2),
            this._contentBounds.width,
            0);
        return this.fitBounds(box, immediately);
    },


    /**
     * Returns bounds taking constraints into account
     * Added to improve constrained panning
     * @param {Boolean} current - Pass true for the current location; defaults to false (target location).
     * @returns {OpenSeadragon.Rect} The bounds in viewport coordinates after applying constraints. The returned $.Rect
     *                               contains additional properties constraintsApplied, xConstrained and yConstrained.
     *                               These flags indicate whether the viewport bounds were modified by the constraints
     *                               of the viewer rectangle, and in which dimension(s).
     */
    getConstrainedBounds: function(current) {
        const bounds = this.getBounds(current);
        const constrainedBounds = this._applyBoundaryConstraints(bounds);

        return constrainedBounds;
    },

    /**
     * @function
     * @param {OpenSeadragon.Point} delta
     * @param {Boolean} immediately
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:pan
     */
    panBy: function( delta, immediately ) {
        const center = new $.Point();
        if (immediately) {
            center.x = this.centerSpringX.current.value;
            center.y = this.centerSpringY.current.value;
        } else {
            center.x = this.centerSpringX.target.value;
            center.y = this.centerSpringY.target.value;
        }
        return this.panTo( center.plus( delta ), immediately );
    },

    /**
     * @function
     * @param {OpenSeadragon.Point} center
     * @param {Boolean} immediately
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:pan
     */
    panTo: function( center, immediately ) {
        if ( immediately ) {
            this.centerSpringX.resetTo( center.x );
            this.centerSpringY.resetTo( center.y );
        } else {
            this.centerSpringX.springTo( center.x );
            this.centerSpringY.springTo( center.y );
        }

        if( this.viewer ){
            /**
             * Raised when the viewport is panned (see {@link OpenSeadragon.Viewport#panBy} and {@link OpenSeadragon.Viewport#panTo}).
             *
             * @event pan
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {OpenSeadragon.Point} center
             * @property {Boolean} immediately
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent( 'pan', {
                center: center,
                immediately: immediately
            });
        }

        return this;
    },

    /**
     * @function
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:zoom
     */
    zoomBy: function(factor, refPoint, immediately) {
        return this.zoomTo(
            this.zoomSpring.target.value * factor, refPoint, immediately);
    },

    /**
     * Zooms to the specified zoom level
     * @function
     * @param {Number} zoom The zoom level to zoom to.
     * @param {OpenSeadragon.Point} [refPoint] The point which will stay at
     * the same screen location. Defaults to the viewport center.
     * @param {Boolean} [immediately=false]
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:zoom
     */
    zoomTo: function(zoom, refPoint, immediately) {
        const _this = this;

        this.zoomPoint = refPoint instanceof $.Point &&
            !isNaN(refPoint.x) &&
            !isNaN(refPoint.y) ?
            refPoint :
            null;

        if (immediately) {
            this._adjustCenterSpringsForZoomPoint(function() {
                _this.zoomSpring.resetTo(zoom);
            });
        } else {
            this.zoomSpring.springTo(zoom);
        }

        if (this.viewer) {
            /**
             * Raised when the viewport zoom level changes (see {@link OpenSeadragon.Viewport#zoomBy} and {@link OpenSeadragon.Viewport#zoomTo}).
             *
             * @event zoom
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {Number} zoom
             * @property {OpenSeadragon.Point} refPoint
             * @property {Boolean} immediately
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent('zoom', {
                zoom: zoom,
                refPoint: refPoint,
                immediately: immediately
            });
        }

        return this;
    },

    /**
     * Rotates this viewport to the angle specified.
     * @function
     * @param {Number} degrees The degrees to set the rotation to.
     * @param {Boolean} [immediately=false] Whether to animate to the new angle
     * or rotate immediately.
     * * @returns {OpenSeadragon.Viewport} Chainable.
     */
    setRotation: function(degrees, immediately) {
        return this.rotateTo(degrees, null, immediately);
    },

    /**
     * Gets the current rotation in degrees.
     * @function
     * @param {Boolean} [current=false] True for current rotation, false for target.
     * @returns {Number} The current rotation in degrees.
     */
    getRotation: function(current) {
        return current ?
            this.degreesSpring.current.value :
            this.degreesSpring.target.value;
    },

    /**
     * Rotates this viewport to the angle specified around a pivot point. Alias for rotateTo.
     * @function
     * @param {Number} degrees The degrees to set the rotation to.
     * @param {OpenSeadragon.Point} [pivot] (Optional) point in viewport coordinates
     * around which the rotation should be performed. Defaults to the center of the viewport.
     * @param {Boolean} [immediately=false] Whether to animate to the new angle
     * or rotate immediately.
     * * @returns {OpenSeadragon.Viewport} Chainable.
     */
    setRotationWithPivot: function(degrees, pivot, immediately) {
        return this.rotateTo(degrees, pivot, immediately);
    },

    /**
     * Rotates this viewport to the angle specified.
     * @function
     * @param {Number} degrees The degrees to set the rotation to.
     * @param {OpenSeadragon.Point} [pivot] (Optional) point in viewport coordinates
     * around which the rotation should be performed. Defaults to the center of the viewport.
     * @param {Boolean} [immediately=false] Whether to animate to the new angle
     * or rotate immediately.
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    rotateTo: function(degrees, pivot, immediately){
        if (!this.viewer || !this.viewer.drawer.canRotate()) {
            return this;
        }

        if (this.degreesSpring.target.value === degrees &&
            this.degreesSpring.isAtTargetValue()) {
            return this;
        }
        this.rotationPivot = pivot instanceof $.Point &&
            !isNaN(pivot.x) &&
            !isNaN(pivot.y) ?
            pivot :
            null;
        if (immediately) {
            if(this.rotationPivot){
                const changeInDegrees = degrees - this._oldDegrees;
                if(!changeInDegrees){
                    this.rotationPivot = null;
                    return this;
                }
                this._rotateAboutPivot(degrees);
            } else{
                this.degreesSpring.resetTo(degrees);
            }
        } else {
            const normalizedFrom = $.positiveModulo(this.degreesSpring.current.value, 360);
            let normalizedTo = $.positiveModulo(degrees, 360);
            const diff = normalizedTo - normalizedFrom;
            if (diff > 180) {
                normalizedTo -= 360;
            } else if (diff < -180) {
                normalizedTo += 360;
            }

            const reverseDiff = normalizedFrom - normalizedTo;
            this.degreesSpring.resetTo(degrees + reverseDiff);
            this.degreesSpring.springTo(degrees);
        }

        this._setContentBounds(
            this.viewer.world.getHomeBounds(),
            this.viewer.world.getContentFactor());
        this.viewer.forceRedraw();

        /**
         * Raised when rotation has been changed.
         *
         * @event rotate
         * @memberof OpenSeadragon.Viewer
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
         * @property {Number} degrees - The number of degrees the rotation was set to.
         * @property {Boolean} immediately - Whether the rotation happened immediately or was animated
         * @property {OpenSeadragon.Point} pivot - The point in viewport coordinates around which the rotation (if any) happened
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.viewer.raiseEvent('rotate', {degrees: degrees, immediately: !!immediately, pivot: this.rotationPivot || this.getCenter()});
        return this;
    },

    /**
     * Rotates this viewport by the angle specified.
     * @function
     * @param {Number} degrees The degrees by which to rotate the viewport.
     * @param {OpenSeadragon.Point} [pivot] (Optional) point in viewport coordinates
     * around which the rotation should be performed. Defaults to the center of the viewport.
     * * @param {Boolean} [immediately=false] Whether to animate to the new angle
     * or rotate immediately.
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    rotateBy: function(degrees, pivot, immediately){
        return this.rotateTo(this.degreesSpring.target.value + degrees, pivot, immediately);
    },

    /**
     * @function
     * @returns {OpenSeadragon.Viewport} Chainable.
     * @fires OpenSeadragon.Viewer.event:resize
     */
    resize: function( newContainerSize, maintain ) {
        const oldBounds = this.getBoundsNoRotate();
        const newBounds = oldBounds;
        let widthDeltaFactor;
        this._sizeChanged = !this.containerSize.equals(newContainerSize);

        this.containerSize.x = newContainerSize.x;
        this.containerSize.y = newContainerSize.y;

        this._updateContainerInnerSize();

        if ( maintain ) {
            // TODO: widthDeltaFactor will always be 1; probably not what's intended
            widthDeltaFactor = newContainerSize.x / this.containerSize.x;
            newBounds.width  = oldBounds.width * widthDeltaFactor;
            newBounds.height = newBounds.width / this.getAspectRatio();
        }

        if( this.viewer ){
            /**
             * Raised when a viewer resize operation is initiated (see {@link OpenSeadragon.Viewport#resize}).
             * This event happens before the viewport bounds have been updated.
             * See also {@link OpenSeadragon.Viewer#after-resize} which reflects
             * the new viewport bounds following the resize action.
             *
             * @event resize
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {OpenSeadragon.Point} newContainerSize
             * @property {Boolean} maintain
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent( 'resize', {
                newContainerSize: newContainerSize,
                maintain: maintain
            });
        }

        const output = this.fitBounds( newBounds, true );

        if( this.viewer ){
            /**
             * Raised after the viewer is resized (see {@link OpenSeadragon.Viewport#resize}).
             * See also {@link OpenSeadragon.Viewer#resize} event which happens
             * before the new bounds have been calculated and applied.
             *
             * @event after-resize
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised this event.
             * @property {OpenSeadragon.Point} newContainerSize
             * @property {Boolean} maintain
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent( 'after-resize', {
                newContainerSize: newContainerSize,
                maintain: maintain
            });
        }

        return output;
    },

    // private
    _updateContainerInnerSize: function() {
        this._containerInnerSize = new $.Point(
            Math.max(1, this.containerSize.x - (this._margins.left + this._margins.right)),
            Math.max(1, this.containerSize.y - (this._margins.top + this._margins.bottom))
        );
    },

    /**
     * Update the zoom, degrees, and center (X and Y) springs.
     * @function
     * @returns {Boolean} True if the viewport is still animating, false otherwise.
     */
    update: function() {
        const _this = this;
        this._adjustCenterSpringsForZoomPoint(function() {
            _this.zoomSpring.update();
        });
        if(this.degreesSpring.isAtTargetValue()){
            this.rotationPivot = null;
        }
        this.centerSpringX.update();
        this.centerSpringY.update();

        if(this.rotationPivot){
            this._rotateAboutPivot(true);
        }
        else{
            this.degreesSpring.update();
        }


        const changed = this.centerSpringX.current.value !== this._oldCenterX ||
            this.centerSpringY.current.value !== this._oldCenterY ||
            this.zoomSpring.current.value !== this._oldZoom ||
            this.degreesSpring.current.value !== this._oldDegrees ||
            this._sizeChanged;

        this._sizeChanged = false;


        this._oldCenterX = this.centerSpringX.current.value;
        this._oldCenterY = this.centerSpringY.current.value;
        this._oldZoom    = this.zoomSpring.current.value;
        this._oldDegrees = this.degreesSpring.current.value;

        const isAnimating = changed ||
                          !this.zoomSpring.isAtTargetValue() ||
                          !this.centerSpringX.isAtTargetValue() ||
                          !this.centerSpringY.isAtTargetValue() ||
                          !this.degreesSpring.isAtTargetValue();

        return isAnimating;
    },

    // private - pass true to use spring, or a number for degrees for immediate rotation
    _rotateAboutPivot: function(degreesOrUseSpring){
        const useSpring = degreesOrUseSpring === true;

        const delta = this.rotationPivot.minus(this.getCenter());
        this.centerSpringX.shiftBy(delta.x);
        this.centerSpringY.shiftBy(delta.y);

        if(useSpring){
            this.degreesSpring.update();
        } else {
            this.degreesSpring.resetTo(degreesOrUseSpring);
        }

        const changeInDegrees = this.degreesSpring.current.value - this._oldDegrees;
        const rdelta = delta.rotate(changeInDegrees * -1).times(-1);
        this.centerSpringX.shiftBy(rdelta.x);
        this.centerSpringY.shiftBy(rdelta.y);
    },

    // private
    _adjustCenterSpringsForZoomPoint: function(zoomSpringHandler) {
        if (this.zoomPoint) {
            const oldZoomPixel = this.pixelFromPoint(this.zoomPoint, true);
            zoomSpringHandler();
            const newZoomPixel = this.pixelFromPoint(this.zoomPoint, true);

            const deltaZoomPixels = newZoomPixel.minus(oldZoomPixel);
            const deltaZoomPoints = this.deltaPointsFromPixels(
                deltaZoomPixels, true);

            this.centerSpringX.shiftBy(deltaZoomPoints.x);
            this.centerSpringY.shiftBy(deltaZoomPoints.y);

            if (this.zoomSpring.isAtTargetValue()) {
                this.zoomPoint = null;
            }
        } else {
            zoomSpringHandler();
        }
    },

    /**
     * Convert a delta (translation vector) from viewport coordinates to pixels
     * coordinates. This method does not take rotation into account.
     * Consider using deltaPixelsFromPoints if you need to account for rotation.
     * @param {OpenSeadragon.Point} deltaPoints - The translation vector to convert.
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    deltaPixelsFromPointsNoRotate: function(deltaPoints, current) {
        return deltaPoints.times(
            this._containerInnerSize.x * this.getZoom(current)
        );
    },

    /**
     * Convert a delta (translation vector) from viewport coordinates to pixels
     * coordinates.
     * @param {OpenSeadragon.Point} deltaPoints - The translation vector to convert.
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    deltaPixelsFromPoints: function(deltaPoints, current) {
        return this.deltaPixelsFromPointsNoRotate(
            deltaPoints.rotate(this.getRotation(current)),
            current);
    },

    /**
     * Convert a delta (translation vector) from pixels coordinates to viewport
     * coordinates. This method does not take rotation into account.
     * Consider using deltaPointsFromPixels if you need to account for rotation.
     * @param {OpenSeadragon.Point} deltaPixels - The translation vector to convert.
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    deltaPointsFromPixelsNoRotate: function(deltaPixels, current) {
        return deltaPixels.divide(
            this._containerInnerSize.x * this.getZoom(current)
        );
    },

    /**
     * Convert a delta (translation vector) from pixels coordinates to viewport
     * coordinates.
     * @param {OpenSeadragon.Point} deltaPixels - The translation vector to convert.
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    deltaPointsFromPixels: function(deltaPixels, current) {
        return this.deltaPointsFromPixelsNoRotate(deltaPixels, current)
            .rotate(-this.getRotation(current));
    },

    /**
     * Convert viewport coordinates to pixels coordinates.
     * This method does not take rotation into account.
     * Consider using pixelFromPoint if you need to account for rotation.
     * @param {OpenSeadragon.Point} point the viewport coordinates
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    pixelFromPointNoRotate: function(point, current) {
        return this._pixelFromPointNoRotate(
            point, this.getBoundsNoRotate(current));
    },

    /**
     * Convert viewport coordinates to pixel coordinates.
     * @param {OpenSeadragon.Point} point the viewport coordinates
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    pixelFromPoint: function(point, current) {
        return this._pixelFromPoint(point, this.getBoundsNoRotate(current));
    },

    // private
    _pixelFromPointNoRotate: function(point, bounds) {
        return point.minus(
            bounds.getTopLeft()
        ).times(
            this._containerInnerSize.x / bounds.width
        ).plus(
            new $.Point(this._margins.left, this._margins.top)
        );
    },

    // private
    _pixelFromPoint: function(point, bounds) {
        return this._pixelFromPointNoRotate(
            point.rotate(this.getRotation(true), this.getCenter(true)),
            bounds);
    },

    /**
     * Convert pixel coordinates to viewport coordinates.
     * This method does not take rotation into account.
     * Consider using pointFromPixel if you need to account for rotation.
     * @param {OpenSeadragon.Point} pixel Pixel coordinates
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    pointFromPixelNoRotate: function(pixel, current) {
        const bounds = this.getBoundsNoRotate(current);
        return pixel.minus(
            new $.Point(this._margins.left, this._margins.top)
        ).divide(
            this._containerInnerSize.x / bounds.width
        ).plus(
            bounds.getTopLeft()
        );
    },

    /**
     * Convert pixel coordinates to viewport coordinates.
     * @param {OpenSeadragon.Point} pixel Pixel coordinates
     * @param {Boolean} [current=false] - Pass true for the current location;
     * defaults to false (target location).
     * @returns {OpenSeadragon.Point}
     */
    pointFromPixel: function(pixel, current) {
        return this.pointFromPixelNoRotate(pixel, current).rotate(
            -this.getRotation(current),
            this.getCenter(current)
        );
    },

    // private
    _viewportToImageDelta: function( viewerX, viewerY ) {
        const scale = this._contentBoundsNoRotate.width;
        return new $.Point(
            viewerX * this._contentSizeNoRotate.x / scale,
            viewerY * this._contentSizeNoRotate.x / scale);
    },

    /**
     * Translates from OpenSeadragon viewer coordinate system to image coordinate system.
     * This method can be called either by passing X,Y coordinates or an
     * OpenSeadragon.Point
     * Note: not accurate with multi-image; use TiledImage.viewportToImageCoordinates instead.
     * @function
     * @param {(OpenSeadragon.Point|Number)} viewerX either a point or the X
     * coordinate in viewport coordinate system.
     * @param {Number} [viewerY] Y coordinate in viewport coordinate system.
     * @returns {OpenSeadragon.Point} a point representing the coordinates in the image.
     */
    viewportToImageCoordinates: function(viewerX, viewerY) {
        if (viewerX instanceof $.Point) {
            //they passed a point instead of individual components
            return this.viewportToImageCoordinates(viewerX.x, viewerX.y);
        }

        if (this.viewer) {
            const count = this.viewer.world.getItemCount();
            if (count > 1) {
                if (!this.silenceMultiImageWarnings) {
                    $.console.error('[Viewport.viewportToImageCoordinates] is not accurate ' +
                        'with multi-image; use TiledImage.viewportToImageCoordinates instead.');
                }
            } else if (count === 1) {
                // It is better to use TiledImage.viewportToImageCoordinates
                // because this._contentBoundsNoRotate can not be relied on
                // with clipping.
                const item = this.viewer.world.getItemAt(0);
                return item.viewportToImageCoordinates(viewerX, viewerY, true);
            }
        }

        return this._viewportToImageDelta(
            viewerX - this._contentBoundsNoRotate.x,
            viewerY - this._contentBoundsNoRotate.y);
    },

    // private
    _imageToViewportDelta: function( imageX, imageY ) {
        const scale = this._contentBoundsNoRotate.width;
        return new $.Point(
            imageX / this._contentSizeNoRotate.x * scale,
            imageY / this._contentSizeNoRotate.x * scale);
    },

    /**
     * Translates from image coordinate system to OpenSeadragon viewer coordinate system
     * This method can be called either by passing X,Y coordinates or an
     * OpenSeadragon.Point
     * Note: not accurate with multi-image; use TiledImage.imageToViewportCoordinates instead.
     * @function
     * @param {(OpenSeadragon.Point | Number)} imageX the point or the
     * X coordinate in image coordinate system.
     * @param {Number} [imageY] Y coordinate in image coordinate system.
     * @returns {OpenSeadragon.Point} a point representing the coordinates in the viewport.
     */
    imageToViewportCoordinates: function(imageX, imageY) {
        if (imageX instanceof $.Point) {
            //they passed a point instead of individual components
            return this.imageToViewportCoordinates(imageX.x, imageX.y);
        }

        if (this.viewer) {
            const count = this.viewer.world.getItemCount();
            if (count > 1) {
                if (!this.silenceMultiImageWarnings) {
                    $.console.error('[Viewport.imageToViewportCoordinates] is not accurate ' +
                        'with multi-image; use TiledImage.imageToViewportCoordinates instead.');
                }
            } else if (count === 1) {
                // It is better to use TiledImage.viewportToImageCoordinates
                // because this._contentBoundsNoRotate can not be relied on
                // with clipping.
                const item = this.viewer.world.getItemAt(0);
                return item.imageToViewportCoordinates(imageX, imageY, true);
            }
        }

        const point = this._imageToViewportDelta(imageX, imageY);
        point.x += this._contentBoundsNoRotate.x;
        point.y += this._contentBoundsNoRotate.y;
        return point;
    },

    /**
     * Translates from a rectangle which describes a portion of the image in
     * pixel coordinates to OpenSeadragon viewport rectangle coordinates.
     * This method can be called either by passing X,Y,width,height or an
     * OpenSeadragon.Rect
     * Note: not accurate with multi-image; use TiledImage.imageToViewportRectangle instead.
     * @function
     * @param {(OpenSeadragon.Rect | Number)} imageX the rectangle or the X
     * coordinate of the top left corner of the rectangle in image coordinate system.
     * @param {Number} [imageY] the Y coordinate of the top left corner of the rectangle
     * in image coordinate system.
     * @param {Number} [pixelWidth] the width in pixel of the rectangle.
     * @param {Number} [pixelHeight] the height in pixel of the rectangle.
     * @returns {OpenSeadragon.Rect} This image's bounds in viewport coordinates
     */
    imageToViewportRectangle: function(imageX, imageY, pixelWidth, pixelHeight) {
        let rect = imageX;
        if (!(rect instanceof $.Rect)) {
            //they passed individual components instead of a rectangle
            rect = new $.Rect(imageX, imageY, pixelWidth, pixelHeight);
        }

        if (this.viewer) {
            const count = this.viewer.world.getItemCount();
            if (count > 1) {
                if (!this.silenceMultiImageWarnings) {
                    $.console.error('[Viewport.imageToViewportRectangle] is not accurate ' +
                       'with multi-image; use TiledImage.imageToViewportRectangle instead.');
                }
            } else if (count === 1) {
                // It is better to use TiledImage.imageToViewportRectangle
                // because this._contentBoundsNoRotate can not be relied on
                // with clipping.
                const item = this.viewer.world.getItemAt(0);
                return item.imageToViewportRectangle(
                    imageX, imageY, pixelWidth, pixelHeight, true);
            }
        }

        const coordA = this.imageToViewportCoordinates(rect.x, rect.y);
        const coordB = this._imageToViewportDelta(rect.width, rect.height);
        return new $.Rect(
            coordA.x,
            coordA.y,
            coordB.x,
            coordB.y,
            rect.degrees
        );
    },

    /**
     * Translates from a rectangle which describes a portion of
     * the viewport in point coordinates to image rectangle coordinates.
     * This method can be called either by passing X,Y,width,height or an
     * OpenSeadragon.Rect
     * Note: not accurate with multi-image; use TiledImage.viewportToImageRectangle instead.
     * @function
     * @param {(OpenSeadragon.Rect | Number)} viewerX either a rectangle or
     * the X coordinate of the top left corner of the rectangle in viewport
     * coordinate system.
     * @param {Number} [viewerY] the Y coordinate of the top left corner of the rectangle
     * in viewport coordinate system.
     * @param {Number} [pointWidth] the width of the rectangle in viewport coordinate system.
     * @param {Number} [pointHeight] the height of the rectangle in viewport coordinate system.
     */
    viewportToImageRectangle: function(viewerX, viewerY, pointWidth, pointHeight) {
        let rect = viewerX;
        if (!(rect instanceof $.Rect)) {
            //they passed individual components instead of a rectangle
            rect = new $.Rect(viewerX, viewerY, pointWidth, pointHeight);
        }

        if (this.viewer) {
            const count = this.viewer.world.getItemCount();
            if (count > 1) {
                if (!this.silenceMultiImageWarnings) {
                    $.console.error('[Viewport.viewportToImageRectangle] is not accurate ' +
                        'with multi-image; use TiledImage.viewportToImageRectangle instead.');
                }
            } else if (count === 1) {
                // It is better to use TiledImage.viewportToImageCoordinates
                // because this._contentBoundsNoRotate can not be relied on
                // with clipping.
                const item = this.viewer.world.getItemAt(0);
                return item.viewportToImageRectangle(
                    viewerX, viewerY, pointWidth, pointHeight, true);
            }
        }

        const coordA = this.viewportToImageCoordinates(rect.x, rect.y);
        const coordB = this._viewportToImageDelta(rect.width, rect.height);
        return new $.Rect(
            coordA.x,
            coordA.y,
            coordB.x,
            coordB.y,
            rect.degrees
        );
    },

    /**
     * Convert pixel coordinates relative to the viewer element to image
     * coordinates.
     * Note: not accurate with multi-image.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    viewerElementToImageCoordinates: function( pixel ) {
        const point = this.pointFromPixel( pixel, true );
        return this.viewportToImageCoordinates( point );
    },

    /**
     * Convert pixel coordinates relative to the image to
     * viewer element coordinates.
     * Note: not accurate with multi-image.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    imageToViewerElementCoordinates: function( pixel ) {
        const point = this.imageToViewportCoordinates( pixel );
        return this.pixelFromPoint( point, true );
    },

    /**
     * Convert pixel coordinates relative to the window to image coordinates.
     * Note: not accurate with multi-image.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    windowToImageCoordinates: function(pixel) {
        $.console.assert(this.viewer,
            "[Viewport.windowToImageCoordinates] the viewport must have a viewer.");
        const viewerCoordinates = pixel.minus(
                $.getElementPosition(this.viewer.container));
        return this.viewerElementToImageCoordinates(viewerCoordinates);
    },

    /**
     * Convert image coordinates to pixel coordinates relative to the window.
     * Note: not accurate with multi-image.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    imageToWindowCoordinates: function(pixel) {
        $.console.assert(this.viewer,
            "[Viewport.imageToWindowCoordinates] the viewport must have a viewer.");
        const viewerCoordinates = this.imageToViewerElementCoordinates(pixel);
        return viewerCoordinates.plus(
                $.getElementPosition(this.viewer.container));
    },

    /**
     * Convert pixel coordinates relative to the viewer element to viewport
     * coordinates.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    viewerElementToViewportCoordinates: function( pixel ) {
        return this.pointFromPixel( pixel, true );
    },

    /**
     * Convert viewport coordinates to pixel coordinates relative to the
     * viewer element.
     * @param {OpenSeadragon.Point} point
     * @returns {OpenSeadragon.Point}
     */
    viewportToViewerElementCoordinates: function( point ) {
        return this.pixelFromPoint( point, true );
    },

    /**
     * Convert a rectangle in pixel coordinates relative to the viewer element
     * to viewport coordinates.
     * @param {OpenSeadragon.Rect} rectangle the rectangle to convert
     * @returns {OpenSeadragon.Rect} the converted rectangle
     */
    viewerElementToViewportRectangle: function(rectangle) {
        return $.Rect.fromSummits(
            this.pointFromPixel(rectangle.getTopLeft(), true),
            this.pointFromPixel(rectangle.getTopRight(), true),
            this.pointFromPixel(rectangle.getBottomLeft(), true)
        );
    },

    /**
     * Convert a rectangle in viewport coordinates to pixel coordinates relative
     * to the viewer element.
     * @param {OpenSeadragon.Rect} rectangle the rectangle to convert
     * @returns {OpenSeadragon.Rect} the converted rectangle
     */
    viewportToViewerElementRectangle: function(rectangle) {
        return $.Rect.fromSummits(
            this.pixelFromPoint(rectangle.getTopLeft(), true),
            this.pixelFromPoint(rectangle.getTopRight(), true),
            this.pixelFromPoint(rectangle.getBottomLeft(), true)
        );
    },

    /**
     * Convert pixel coordinates relative to the window to viewport coordinates.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    windowToViewportCoordinates: function(pixel) {
        $.console.assert(this.viewer,
            "[Viewport.windowToViewportCoordinates] the viewport must have a viewer.");
        const viewerCoordinates = pixel.minus(
                $.getElementPosition(this.viewer.container));
        return this.viewerElementToViewportCoordinates(viewerCoordinates);
    },

    /**
     * Convert viewport coordinates to pixel coordinates relative to the window.
     * @param {OpenSeadragon.Point} point
     * @returns {OpenSeadragon.Point}
     */
    viewportToWindowCoordinates: function(point) {
        $.console.assert(this.viewer,
            "[Viewport.viewportToWindowCoordinates] the viewport must have a viewer.");
        const viewerCoordinates = this.viewportToViewerElementCoordinates(point);
        return viewerCoordinates.plus(
                $.getElementPosition(this.viewer.container));
    },

    /**
     * Convert a viewport zoom to an image zoom.
     * Image zoom: ratio of the original image size to displayed image size.
     * 1 means original image size, 0.5 half size...
     * Viewport zoom: ratio of the displayed image's width to viewport's width.
     * 1 means identical width, 2 means image's width is twice the viewport's width...
     * Note: not accurate with multi-image.
     * @function
     * @param {Number} viewportZoom The viewport zoom
     * target zoom.
     * @returns {Number} imageZoom The image zoom
     */
    viewportToImageZoom: function(viewportZoom) {
        if (this.viewer) {
            const count = this.viewer.world.getItemCount();
            if (count > 1) {
                if (!this.silenceMultiImageWarnings) {
                    $.console.error('[Viewport.viewportToImageZoom] is not ' +
                        'accurate with multi-image.');
                }
            } else if (count === 1) {
                // It is better to use TiledImage.viewportToImageZoom
                // because this._contentBoundsNoRotate can not be relied on
                // with clipping.
                const item = this.viewer.world.getItemAt(0);
                return item.viewportToImageZoom(viewportZoom);
            }
        }

        const imageWidth = this._contentSizeNoRotate.x;
        const containerWidth = this._containerInnerSize.x;
        const scale = this._contentBoundsNoRotate.width;
        const viewportToImageZoomRatio = (containerWidth / imageWidth) * scale;
        return viewportZoom * viewportToImageZoomRatio;
    },

    /**
     * Convert an image zoom to a viewport zoom.
     * Image zoom: ratio of the original image size to displayed image size.
     * 1 means original image size, 0.5 half size...
     * Viewport zoom: ratio of the displayed image's width to viewport's width.
     * 1 means identical width, 2 means image's width is twice the viewport's width...
     * Note: not accurate with multi-image; use [TiledImage.imageToViewportZoom] for the specific image of interest.
     * @function
     * @param {Number} imageZoom The image zoom
     * target zoom.
     * @returns {Number} viewportZoom The viewport zoom
     */
    imageToViewportZoom: function(imageZoom) {
        if (this.viewer) {
            const count = this.viewer.world.getItemCount();
            if (count > 1) {
                if (!this.silenceMultiImageWarnings) {
                    $.console.error('[Viewport.imageToViewportZoom] is not accurate ' +
                        'with multi-image. Instead, use [TiledImage.imageToViewportZoom] for the specific image of interest');
                }
            } else if (count === 1) {
                // It is better to use TiledImage.imageToViewportZoom
                // because this._contentBoundsNoRotate can not be relied on
                // with clipping.
                const item = this.viewer.world.getItemAt(0);
                return item.imageToViewportZoom(imageZoom);
            }
        }

        const imageWidth = this._contentSizeNoRotate.x;
        const containerWidth = this._containerInnerSize.x;
        const scale = this._contentBoundsNoRotate.width;
        const viewportToImageZoomRatio = (imageWidth / containerWidth) / scale;
        return imageZoom * viewportToImageZoomRatio;
    },

    /**
     * Toggles flip state and demands a new drawing on navigator and viewer objects.
     * @function
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    toggleFlip: function() {
      this.setFlip(!this.getFlip());
      return this;
    },

    /**
     * Get flip state stored on viewport.
     * @function
     * @returns {Boolean} Flip state.
     */
    getFlip: function() {
      return this.flipped;
    },

    /**
     * Sets flip state according to the state input argument.
     * @function
     * @param {Boolean} state - Flip state to set.
     * @returns {OpenSeadragon.Viewport} Chainable.
     */
    setFlip: function( state ) {
      if ( this.flipped === state ) {
        return this;
      }

      this.flipped = state;
      if(this.viewer.navigator){
        this.viewer.navigator.setFlip(this.getFlip());
      }
      this.viewer.forceRedraw();

      /**
       * Raised when flip state has been changed.
       *
       * @event flip
       * @memberof OpenSeadragon.Viewer
       * @type {object}
       * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
       * @property {Number} flipped - The flip state after this change.
       * @property {?Object} userData - Arbitrary subscriber-defined object.
       */
      this.viewer.raiseEvent('flip', {flipped: state});
      return this;
    },

    /**
     * Gets current max zoom pixel ratio
     * @function
     * @returns {Number} Max zoom pixel ratio
     */
    getMaxZoomPixelRatio: function() {
        return this.maxZoomPixelRatio;
    },

    /**
     * Sets max zoom pixel ratio
     * @function
     * @param {Number} ratio - Max zoom pixel ratio
     * @param {Boolean} [applyConstraints=true] - Apply constraints after setting ratio;
     * Takes effect only if current zoom is greater than set max zoom pixel ratio
     * @param {Boolean} [immediately=false] - Whether to animate to new zoom
     */
    setMaxZoomPixelRatio: function(ratio, applyConstraints = true, immediately = false) {

        $.console.assert(!isNaN(ratio), "[Viewport.setMaxZoomPixelRatio] ratio must be a number");

        if (isNaN(ratio)) {
            return;
        }

        this.maxZoomPixelRatio = ratio;

        if (applyConstraints) {
            if (this.getZoom() > this.getMaxZoom()) {
                this.applyConstraints(immediately);
            }
        }
    },

};

}( OpenSeadragon ));

/*
 * OpenSeadragon - TiledImage
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

(function( $ ){

/**
 * Object that keeps ready-to-draw tile state information. These properties might differ in time
 * dynamically, e.g. when blending/animating.
 * TODO: info.level is probably info.tile.level - remove?
 * @typedef {Object} OpenSeadragon.TiledImage.DrawTileInfo
 * @property {Number} level
 * @property {Number} levelOpacity
 * @property {Number} currentTime
 * @property {OpenSeadragon.Tile} tile
 */

/**
 * Issues enum that records issues for the target image
 * @typedef {('webgl')} OpenSeadragon.TiledImage.Issue
 */

/**
 * You shouldn't have to create a TiledImage instance directly; get it asynchronously by
 * using {@link OpenSeadragon.Viewer#open} or {@link OpenSeadragon.Viewer#addTiledImage} instead.
 * @class TiledImage
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.EventSource
 * @classdesc Handles rendering of tiles for an {@link OpenSeadragon.Viewer}.
 * A new instance is created for each TileSource opened.
 * @param {Object} options - Configuration for this TiledImage.
 * @param {OpenSeadragon.TileSource} options.source - The TileSource that defines this TiledImage.
 * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this TiledImage.
 * @param {OpenSeadragon.TileCache} options.tileCache - The TileCache for this TiledImage to use.
 * @param {OpenSeadragon.Drawer} options.drawer - The Drawer for this TiledImage to draw onto.
 * @param {OpenSeadragon.ImageLoader} options.imageLoader - The ImageLoader for this TiledImage to use.
 * @param {Number} [options.x=0] - Left position, in viewport coordinates.
 * @param {Number} [options.y=0] - Top position, in viewport coordinates.
 * @param {Number} [options.width=1] - Width, in viewport coordinates.
 * @param {Number} [options.height] - Height, in viewport coordinates.
 * @param {OpenSeadragon.Rect} [options.fitBounds] The bounds in viewport coordinates
 * to fit the image into. If specified, x, y, width and height get ignored.
 * @param {OpenSeadragon.Placement} [options.fitBoundsPlacement=OpenSeadragon.Placement.CENTER]
 * How to anchor the image in the bounds if options.fitBounds is set.
 * @param {OpenSeadragon.Rect} [options.clip] - An area, in image pixels, to clip to
 * (portions of the image outside of this area will not be visible). Only works on
 * browsers that support the HTML5 canvas.
 * @param {Number} [options.springStiffness] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.animationTime] - See {@link OpenSeadragon.Options}.
 * @param {Number} [options.minZoomImageRatio] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.wrapHorizontal] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.wrapVertical] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.immediateRender] - See {@link OpenSeadragon.Options}.
 * @param {Number} [options.blendTime] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.alwaysBlend] - See {@link OpenSeadragon.Options}.
 * @param {Number} [options.minPixelRatio] - See {@link OpenSeadragon.Options}.
 * @param {Number} [options.smoothTileEdgesMinZoom] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.iOSDevice] - See {@link OpenSeadragon.Options}.
 * @param {Number} [options.opacity=1] - Set to draw at proportional opacity. If zero, images will not draw.
 * @param {Boolean} [options.preload=false] - Set true to load even when the image is hidden by zero opacity.
 * @param {String} [options.compositeOperation] - How the image is composited onto other images;
 * see compositeOperation in {@link OpenSeadragon.Options} for possible values.
 * @param {Boolean} [options.debugMode] - See {@link OpenSeadragon.Options}.
 * @param {String|CanvasGradient|CanvasPattern|Function} [options.placeholderFillStyle] - See {@link OpenSeadragon.Options}.
 * @param {String|Boolean} [options.crossOriginPolicy] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.ajaxWithCredentials] - See {@link OpenSeadragon.Options}.
 * @param {Boolean} [options.loadTilesWithAjax]
 *      Whether to load tile data using AJAX requests.
 *      Defaults to the setting in {@link OpenSeadragon.Options}.
 * @param {Object} [options.ajaxHeaders={}]
 *      A set of headers to include when making tile AJAX requests.
 * @param {string|string[]} [options.originalDataType=undefined]
 *      A default format to convert tiles to at the beginning. The format is the base tile format,
 *      and this can optimize rendering or processing logics, for example, in case a plugin always requires a certain
 *      format to convert to.
 */
$.TiledImage = function( options ) {
    this._initialized = false;
    /**
     * The {@link OpenSeadragon.TileSource} that defines this TiledImage.
     * @member {OpenSeadragon.TileSource} source
     * @memberof OpenSeadragon.TiledImage#
     */
    $.console.assert( options.tileCache, "[TiledImage] options.tileCache is required" );
    $.console.assert( options.drawer, "[TiledImage] options.drawer is required" );
    $.console.assert( options.viewer, "[TiledImage] options.viewer is required" );
    $.console.assert( options.imageLoader, "[TiledImage] options.imageLoader is required" );
    $.console.assert( options.source, "[TiledImage] options.source is required" );
    $.console.assert(!options.clip || options.clip instanceof $.Rect,
        "[TiledImage] options.clip must be an OpenSeadragon.Rect if present");

    $.EventSource.call( this );
    // Asynchronously loaded items remember where users wanted them
    this._optimalWorldIndex = undefined;

    this._tileCache = options.tileCache;
    delete options.tileCache;

    this._drawer = options.drawer;
    delete options.drawer;

    this._imageLoader = options.imageLoader;
    delete options.imageLoader;

    if (options.clip instanceof $.Rect) {
        this._clip = options.clip.clone();
    }

    delete options.clip;

    const x = options.x || 0;
    delete options.x;
    const y = options.y || 0;
    delete options.y;

    // Ratio of zoomable image height to width.
    this.normHeight = options.source.dimensions.y / options.source.dimensions.x;
    this.contentAspectX = options.source.dimensions.x / options.source.dimensions.y;

    let scale = 1;
    if ( options.width ) {
        scale = options.width;
        delete options.width;

        if ( options.height ) {
            $.console.error( "specifying both width and height to a tiledImage is not supported" );
            delete options.height;
        }
    } else if ( options.height ) {
        scale = options.height / this.normHeight;
        delete options.height;
    }

    const fitBounds = options.fitBounds;
    delete options.fitBounds;
    const fitBoundsPlacement = options.fitBoundsPlacement || OpenSeadragon.Placement.CENTER;
    delete options.fitBoundsPlacement;

    const degrees = options.degrees || 0;
    delete options.degrees;

    const ajaxHeaders = options.ajaxHeaders;
    delete options.ajaxHeaders;

    // Setter ensures lowercase
    this.crossOriginPolicy = options.crossOriginPolicy;
    delete options.crossOriginPolicy;

    $.extend( true, this, {

        //internal state properties
        viewer:         null,
        tilesMatrix:    {},    // A '3d' dictionary [level][x][y] --> Tile.
        coverage:       {},    // A '3d' dictionary [level][x][y] --> Boolean; shows what areas have been drawn.
        loadingCoverage: {},   // A '3d' dictionary [level][x][y] --> Boolean; shows what areas are loaded or are being loaded/blended.
        lastResetTime:  0,     // Last time for which the tiledImage was reset.
        _needsDraw:     true,  // Does the tiledImage need to be drawn again?
        _needsUpdate:   true,  // Does the tiledImage need to update the viewport again?
        _hasOpaqueTile: false,  // Do we have even one fully opaque tile?
        _tilesLoading:  0,     // The number of pending tile requests.
        _zombieCache:   false, // Allow cache to stay in memory upon deletion.
        _tilesToDraw:   [],    // info about the tiles currently in the viewport, two deep: array[level][tile]
        _lastDrawn:     [],    // array of tiles that were last fetched by the drawer
        _arrayCacheMap: [],    // array cache to avoid constant re-creation and GC overload
        _isBlending:    false, // Are any tiles still being blended?
        _wasBlending:   false, // Were any tiles blending before the last draw?
        _issues:        {},    // An issue flag map - image was marked as problematic by some entity (usually a drawer)?
        //configurable settings
        springStiffness:                   $.DEFAULT_SETTINGS.springStiffness,
        animationTime:                     $.DEFAULT_SETTINGS.animationTime,
        minZoomImageRatio:                 $.DEFAULT_SETTINGS.minZoomImageRatio,
        wrapHorizontal:                    $.DEFAULT_SETTINGS.wrapHorizontal,
        wrapVertical:                      $.DEFAULT_SETTINGS.wrapVertical,
        immediateRender:                   $.DEFAULT_SETTINGS.immediateRender,
        loadDestinationTilesOnAnimation:   $.DEFAULT_SETTINGS.loadDestinationTilesOnAnimation,
        blendTime:                         $.DEFAULT_SETTINGS.blendTime,
        alwaysBlend:                       $.DEFAULT_SETTINGS.alwaysBlend,
        minPixelRatio:                     $.DEFAULT_SETTINGS.minPixelRatio,
        smoothTileEdgesMinZoom:            $.DEFAULT_SETTINGS.smoothTileEdgesMinZoom,
        iOSDevice:                         $.DEFAULT_SETTINGS.iOSDevice,
        debugMode:                         $.DEFAULT_SETTINGS.debugMode,
        ajaxWithCredentials:               $.DEFAULT_SETTINGS.ajaxWithCredentials,
        placeholderFillStyle:              $.DEFAULT_SETTINGS.placeholderFillStyle,
        opacity:                           $.DEFAULT_SETTINGS.opacity,
        preload:                           $.DEFAULT_SETTINGS.preload,
        compositeOperation:                $.DEFAULT_SETTINGS.compositeOperation,
        subPixelRoundingForTransparency:   $.DEFAULT_SETTINGS.subPixelRoundingForTransparency,
        maxTilesPerFrame:                  $.DEFAULT_SETTINGS.maxTilesPerFrame,
        originalDataType:                  undefined,
        _currentMaxTilesPerFrame:          (options.maxTilesPerFrame || $.DEFAULT_SETTINGS.maxTilesPerFrame) * 10
    }, options );

    this._preload = this.preload;
    delete this.preload;

    this._fullyLoaded = false;

    this._xSpring = new $.Spring({
        initial: x,
        springStiffness: this.springStiffness,
        animationTime: this.animationTime
    });

    this._ySpring = new $.Spring({
        initial: y,
        springStiffness: this.springStiffness,
        animationTime: this.animationTime
    });

    this._scaleSpring = new $.Spring({
        initial: scale,
        springStiffness: this.springStiffness,
        animationTime: this.animationTime
    });

    this._degreesSpring = new $.Spring({
        initial: degrees,
        springStiffness: this.springStiffness,
        animationTime: this.animationTime
    });

    this._updateForScale();

    if (fitBounds) {
        this.fitBounds(fitBounds, fitBoundsPlacement, true);
    }

    this._ownAjaxHeaders = {};
    this.setAjaxHeaders(ajaxHeaders, false);
    this._initialized = true;
    // this.invalidatedAt = 0;
};

$.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.TiledImage.prototype */{
    /**
     * @returns {Boolean} Whether the TiledImage needs to be drawn.
     */
    needsDraw: function() {
        return this._needsDraw;
    },

    /**
     * Mark the tiled image as needing to be (re)drawn
     */
    redraw: function() {
        this._needsDraw = true;
    },

    /**
     * @returns {Boolean} Whether all tiles necessary for this TiledImage to draw at the current view have been loaded.
     */
    getFullyLoaded: function() {
        return this._fullyLoaded;
    },

    /**
     * Executes the provided callback when the TiledImage is fully loaded. If already loaded,
     * schedules the callback asynchronously. Otherwise, attaches a one-time event listener
     * for the 'fully-loaded-change' event.
     * @param {Function} callback - Function to execute when loading completes
     */
    whenFullyLoaded: function(callback) {
        if (this.getFullyLoaded()) {
            setTimeout(callback, 1); // Asynchronous execution
        } else {
            this.addOnceHandler('fully-loaded-change', function() {
                callback(); // Maintain context
            });
        }
    },

    // private
    _setFullyLoaded: function(flag) {
        if (flag === this._fullyLoaded) {
            return;
        }

        this._fullyLoaded = flag;

        /**
         * Fired when the TiledImage's "fully loaded" flag (whether all tiles necessary for this TiledImage
         * to draw at the current view have been loaded) changes.
         *
         * @event fully-loaded-change
         * @memberof OpenSeadragon.TiledImage
         * @type {object}
         * @property {Boolean} fullyLoaded - The new "fully loaded" value.
         * @property {OpenSeadragon.TiledImage} eventSource - A reference to the TiledImage which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent('fully-loaded-change', {
            fullyLoaded: this._fullyLoaded
        });
    },

    /**
     * Forces the system consider all tiles in this tiled image
     * as outdated, and fire tile update event on relevant tiles
     * Detailed description is available within the 'tile-invalidated'
     * event.
     * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
     * @param {boolean} [viewportOnly=false] optionally invalidate only viewport-visible tiles if true
     * @param {number} [tStamp=OpenSeadragon.now()] optionally provide tStamp of the update event
     * @return {OpenSeadragon.Promise}
     */
    requestInvalidate: function (restoreTiles = true, viewportOnly = false, tStamp = $.now()) {
        const tiles = viewportOnly ? this._lastDrawn.map(x => x.tile) : this._tileCache.getLoadedTilesFor(this);
        return this.viewer.world.requestTileInvalidateEvent(tiles, tStamp, restoreTiles);
    },

    /**
     * Clears all tiles and triggers an update on the next call to
     * {@link OpenSeadragon.TiledImage#update}.
     */
    reset: function() {
        this._tileCache.clearTilesFor(this);
        this._currentMaxTilesPerFrame = this.maxTilesPerFrame * 10;
        this.lastResetTime = $.now();
        this._needsDraw = true;
        this._fullyLoaded = false;
    },

    /**
     * Updates the TiledImage's bounds, animating if needed. Based on the new
     * bounds, updates the levels and tiles to be drawn into the viewport.
     * @param viewportChanged Whether the viewport changed meaning tiles need to be updated.
     * @returns {Boolean} Whether the TiledImage needs to be drawn.
     */
    update: function(viewportChanged) {
        const xUpdated = this._xSpring.update();
        const yUpdated = this._ySpring.update();
        const scaleUpdated = this._scaleSpring.update();
        const degreesUpdated = this._degreesSpring.update();

        const updated = (xUpdated || yUpdated || scaleUpdated || degreesUpdated || this._needsUpdate);

        if (updated || viewportChanged || !this._fullyLoaded){
            const fullyLoadedFlag = this._updateLevelsForViewport();
            this._setFullyLoaded(fullyLoadedFlag);
        }

        this._needsUpdate = false;

        if (updated) {
            this._updateForScale();
            this._raiseBoundsChange();
            this._needsDraw = true;
            return true;
        }

        return false;
    },

    /**
     * Mark this TiledImage as having been drawn, so that it will only be drawn
     * again if something changes about the image. If the image is still blending,
     * this will have no effect.
     * @returns {Boolean} whether the item still needs to be drawn due to blending
     */
    setDrawn: function(){
        this._needsDraw = this._isBlending || this._wasBlending ||
            (this.opacity > 0 && this._lastDrawn.length < 1);
        return this._needsDraw;
    },

    get crossOriginPolicy(){
        return this._crossOriginPolicy;
    },

    set crossOriginPolicy(crossOriginPolicy) {
        if (typeof crossOriginPolicy === 'string') {
            this._crossOriginPolicy = crossOriginPolicy.toLowerCase();
        } else {
            this._crossOriginPolicy = $.DEFAULT_SETTINGS.crossOriginPolicy;
        }
    },

    /**
     * Set the internal issue flag for this TiledImage. Lazy loaded - not
     * checked each time a Tile is loaded, but can be set if a consumer of the
     * tiles (e.g. a Drawer) discovers a Tile to have certain data issue so that further
     * checks are not needed and alternative rendering strategies can be used.
     * @param {OpenSeadragon.TiledImage.Issue} issueType
     * @param {string} description
     * @param {Error|any} error
     * @private
     */
    setIssue(issueType, description = undefined, error = undefined){
        const errorText = error ? (error.message || error) : '';
        this._issues[issueType] = (description || `TiledImage is ${issueType}}`) + errorText;
        $.console.warn(this._issues[issueType], error);
    },

    /**
     * @param {OpenSeadragon.TiledImage.Issue} issueType
     * @returns {string} issue details or undefined if the issue does not apply
     */
    getIssue(issueType) {
        return this._issues[issueType];
    },

    /**
     * @param {OpenSeadragon.TiledImage.Issue} issueType
     * @returns {Boolean} whether the TiledImage has been marked with a given issue
     */
    hasIssue(issueType){
        return !!this.getIssue(issueType);
    },

    /**
     * Destroy the TiledImage (unload current loaded tiles).
     */
    destroy: function() {
        this.reset();
        this.source.destroy(this.viewer);
    },

    /**
     * Get this TiledImage's bounds in viewport coordinates.
     * @param {Boolean} [current=false] - Pass true for the current location;
     * false for target location.
     * @returns {OpenSeadragon.Rect} This TiledImage's bounds in viewport coordinates.
     */
    getBounds: function(current) {
        return this.getBoundsNoRotate(current)
            .rotate(this.getRotation(current), this._getRotationPoint(current));
    },

    /**
     * Get this TiledImage's bounds in viewport coordinates without taking
     * rotation into account.
     * @param {Boolean} [current=false] - Pass true for the current location;
     * false for target location.
     * @returns {OpenSeadragon.Rect} This TiledImage's bounds in viewport coordinates.
     */
    getBoundsNoRotate: function(current) {
        return current ?
            new $.Rect(
                this._xSpring.current.value,
                this._ySpring.current.value,
                this._worldWidthCurrent,
                this._worldHeightCurrent) :
            new $.Rect(
                this._xSpring.target.value,
                this._ySpring.target.value,
                this._worldWidthTarget,
                this._worldHeightTarget);
    },

    // deprecated
    getWorldBounds: function() {
        $.console.error('[TiledImage.getWorldBounds] is deprecated; use TiledImage.getBounds instead');
        return this.getBounds();
    },

    /**
     * Get the bounds of the displayed part of the tiled image.
     * @param {Boolean} [current=false] Pass true for the current location,
     * false for the target location.
     * @returns {$.Rect} The clipped bounds in viewport coordinates.
     */
    getClippedBounds: function(current) {
        let bounds = this.getBoundsNoRotate(current);
        if (this._clip) {
            const worldWidth = current ?
                this._worldWidthCurrent : this._worldWidthTarget;
            const ratio = worldWidth / this.source.dimensions.x;
            const clip = this._clip.times(ratio);
            bounds = new $.Rect(
                bounds.x + clip.x,
                bounds.y + clip.y,
                clip.width,
                clip.height);
        }
        return bounds.rotate(this.getRotation(current), this._getRotationPoint(current));
    },

    /**
     * @function
     * @param {Number} level
     * @param {Number} x
     * @param {Number} y
     * @returns {OpenSeadragon.Rect} Where this tile fits (in normalized coordinates).
     */
    getTileBounds: function( level, x, y ) {
        const numTiles = this.source.getNumTiles(level);
        const xMod    = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
        const yMod    = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
        const bounds = this.source.getTileBounds(level, xMod, yMod);
        if (this.getFlip()) {
            bounds.x = Math.max(0, 1 - bounds.x - bounds.width);
        }
        bounds.x += (x - xMod) / numTiles.x;
        bounds.y += (this._worldHeightCurrent / this._worldWidthCurrent) * ((y - yMod) / numTiles.y);
        return bounds;
    },

    /**
     * @returns {OpenSeadragon.Point} This TiledImage's content size, in original pixels.
     */
    getContentSize: function() {
        return new $.Point(this.source.dimensions.x, this.source.dimensions.y);
    },

    /**
     * @returns {OpenSeadragon.Point} The TiledImage's content size, in window coordinates.
     */
    getSizeInWindowCoordinates: function() {
        const topLeft = this.imageToWindowCoordinates(new $.Point(0, 0));
        const bottomRight = this.imageToWindowCoordinates(this.getContentSize());
        return new $.Point(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
    },

    /**
     * Get tile list that was used to draw the viewport current or last frame.
     * @return {OpenSeadragon.OpenSeadragon.TiledImage.DrawTileInfo[]}
     */
    get lastDrawn() {
        return this._lastDrawn;
    },

    /**
     * Get drawer instance used to draw this tiled image. Normally,
     * it is the drawer of the base viewer that owns the tiled image.
     * However, if the image is instantiated manually and used for example
     * in offscreen rendering, you might need to change the reference drawer.
     * @returns {OpenSeadragon.DrawerBase} The drawer instance used to draw this tiled image.
     */
    getDrawer: function () {
        return this.viewer.drawer;
    },

    // private
    _viewportToImageDelta: function( viewerX, viewerY, current ) {
        const scale = (current ? this._scaleSpring.current.value : this._scaleSpring.target.value);
        return new $.Point(viewerX * (this.source.dimensions.x / scale),
            viewerY * ((this.source.dimensions.y * this.contentAspectX) / scale));
    },

    /**
     * Translates from OpenSeadragon viewer coordinate system to image coordinate system.
     * This method can be called either by passing X,Y coordinates or an {@link OpenSeadragon.Point}.
     * @param {Number|OpenSeadragon.Point} viewerX - The X coordinate or point in viewport coordinate system.
     * @param {Number} [viewerY] - The Y coordinate in viewport coordinate system.
     * @param {Boolean} [current=false] - Pass true to use the current location; false for target location.
     * @returns {OpenSeadragon.Point} A point representing the coordinates in the image.
     */
    viewportToImageCoordinates: function(viewerX, viewerY, current) {
        let point;
        if (viewerX instanceof $.Point) {
            //they passed a point instead of individual components
            current = viewerY;
            point = viewerX;
        } else {
            point = new $.Point(viewerX, viewerY);
        }

        point = point.rotate(-this.getRotation(current), this._getRotationPoint(current));
        return current ?
            this._viewportToImageDelta(
                point.x - this._xSpring.current.value,
                point.y - this._ySpring.current.value) :
            this._viewportToImageDelta(
                point.x - this._xSpring.target.value,
                point.y - this._ySpring.target.value);
    },

    // private
    _imageToViewportDelta: function( imageX, imageY, current ) {
        const scale = (current ? this._scaleSpring.current.value : this._scaleSpring.target.value);
        return new $.Point((imageX / this.source.dimensions.x) * scale,
            (imageY / this.source.dimensions.y / this.contentAspectX) * scale);
    },

    /**
     * Translates from image coordinate system to OpenSeadragon viewer coordinate system
     * This method can be called either by passing X,Y coordinates or an {@link OpenSeadragon.Point}.
     * @param {Number|OpenSeadragon.Point} imageX - The X coordinate or point in image coordinate system.
     * @param {Number} [imageY] - The Y coordinate in image coordinate system.
     * @param {Boolean} [current=false] - Pass true to use the current location; false for target location.
     * @returns {OpenSeadragon.Point} A point representing the coordinates in the viewport.
     */
    imageToViewportCoordinates: function(imageX, imageY, current) {
        if (imageX instanceof $.Point) {
            //they passed a point instead of individual components
            current = imageY;
            imageY = imageX.y;
            imageX = imageX.x;
        }

        const point = this._imageToViewportDelta(imageX, imageY, current);
        if (current) {
            point.x += this._xSpring.current.value;
            point.y += this._ySpring.current.value;
        } else {
            point.x += this._xSpring.target.value;
            point.y += this._ySpring.target.value;
        }

        return point.rotate(this.getRotation(current), this._getRotationPoint(current));
    },

    /**
     * Translates from a rectangle which describes a portion of the image in
     * pixel coordinates to OpenSeadragon viewport rectangle coordinates.
     * This method can be called either by passing X,Y,width,height or an {@link OpenSeadragon.Rect}.
     * @param {Number|OpenSeadragon.Rect} imageX - The left coordinate or rectangle in image coordinate system.
     * @param {Number} [imageY] - The top coordinate in image coordinate system.
     * @param {Number} [pixelWidth] - The width in pixel of the rectangle.
     * @param {Number} [pixelHeight] - The height in pixel of the rectangle.
     * @param {Boolean} [current=false] - Pass true to use the current location; false for target location.
     * @returns {OpenSeadragon.Rect} A rect representing the coordinates in the viewport.
     */
    imageToViewportRectangle: function(imageX, imageY, pixelWidth, pixelHeight, current) {
        let rect = imageX;
        if (rect instanceof $.Rect) {
            //they passed a rect instead of individual components
            current = imageY;
        } else {
            rect = new $.Rect(imageX, imageY, pixelWidth, pixelHeight);
        }

        const coordA = this.imageToViewportCoordinates(rect.getTopLeft(), current);
        const coordB = this._imageToViewportDelta(rect.width, rect.height, current);

        return new $.Rect(
            coordA.x,
            coordA.y,
            coordB.x,
            coordB.y,
            rect.degrees + this.getRotation(current)
        );
    },

    /**
     * Translates from a rectangle which describes a portion of
     * the viewport in point coordinates to image rectangle coordinates.
     * This method can be called either by passing X,Y,width,height or an {@link OpenSeadragon.Rect}.
     * @param {Number|OpenSeadragon.Rect} viewerX - The left coordinate or rectangle in viewport coordinate system.
     * @param {Number} [viewerY] - The top coordinate in viewport coordinate system.
     * @param {Number} [pointWidth] - The width in viewport coordinate system.
     * @param {Number} [pointHeight] - The height in viewport coordinate system.
     * @param {Boolean} [current=false] - Pass true to use the current location; false for target location.
     * @returns {OpenSeadragon.Rect} A rect representing the coordinates in the image.
     */
    viewportToImageRectangle: function( viewerX, viewerY, pointWidth, pointHeight, current ) {
        let rect = viewerX;
        if (viewerX instanceof $.Rect) {
            //they passed a rect instead of individual components
            current = viewerY;
        } else {
            rect = new $.Rect(viewerX, viewerY, pointWidth, pointHeight);
        }

        const coordA = this.viewportToImageCoordinates(rect.getTopLeft(), current);
        const coordB = this._viewportToImageDelta(rect.width, rect.height, current);

        return new $.Rect(
            coordA.x,
            coordA.y,
            coordB.x,
            coordB.y,
            rect.degrees - this.getRotation(current)
        );
    },

    /**
     * Convert pixel coordinates relative to the viewer element to image
     * coordinates.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    viewerElementToImageCoordinates: function( pixel ) {
        const point = this.viewport.pointFromPixel( pixel, true );
        return this.viewportToImageCoordinates( point );
    },

    /**
     * Convert pixel coordinates relative to the image to
     * viewer element coordinates.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    imageToViewerElementCoordinates: function( pixel ) {
        const point = this.imageToViewportCoordinates( pixel );
        return this.viewport.pixelFromPoint( point, true );
    },

    /**
     * Convert pixel coordinates relative to the window to image coordinates.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    windowToImageCoordinates: function( pixel ) {
        const viewerCoordinates = pixel.minus(
            OpenSeadragon.getElementPosition( this.viewer.element ));
        return this.viewerElementToImageCoordinates( viewerCoordinates );
    },

    /**
     * Convert image coordinates to pixel coordinates relative to the window.
     * @param {OpenSeadragon.Point} pixel
     * @returns {OpenSeadragon.Point}
     */
    imageToWindowCoordinates: function( pixel ) {
        const viewerCoordinates = this.imageToViewerElementCoordinates( pixel );
        return viewerCoordinates.plus(
            OpenSeadragon.getElementPosition( this.viewer.element ));
    },

    // private
    // Convert rectangle in viewport coordinates to this tiled image point
    // coordinates (x in [0, 1] and y in [0, aspectRatio])
    _viewportToTiledImageRectangle: function(rect) {
        const scale = this._scaleSpring.current.value;
        rect = rect.rotate(-this.getRotation(true), this._getRotationPoint(true));
        return new $.Rect(
            (rect.x - this._xSpring.current.value) / scale,
            (rect.y - this._ySpring.current.value) / scale,
            rect.width / scale,
            rect.height / scale,
            rect.degrees);
    },

    /**
     * Convert a viewport zoom to an image zoom.
     * Image zoom: ratio of the original image size to displayed image size.
     * 1 means original image size, 0.5 half size...
     * Viewport zoom: ratio of the displayed image's width to viewport's width.
     * 1 means identical width, 2 means image's width is twice the viewport's width...
     * @function
     * @param {Number} viewportZoom The viewport zoom
     * @returns {Number} imageZoom The image zoom
     */
    viewportToImageZoom: function( viewportZoom ) {
        const ratio = this._scaleSpring.current.value *
            this.viewport._containerInnerSize.x / this.source.dimensions.x;
        return ratio * viewportZoom;
    },

    /**
     * Convert an image zoom to a viewport zoom.
     * Image zoom: ratio of the original image size to displayed image size.
     * 1 means original image size, 0.5 half size...
     * Viewport zoom: ratio of the displayed image's width to viewport's width.
     * 1 means identical width, 2 means image's width is twice the viewport's width...
     * @function
     * @param {Number} imageZoom The image zoom
     * @returns {Number} viewportZoom The viewport zoom
     */
    imageToViewportZoom: function( imageZoom ) {
        const ratio = this._scaleSpring.current.value *
            this.viewport._containerInnerSize.x / this.source.dimensions.x;
        return imageZoom / ratio;
    },

    /**
     * Sets the TiledImage's position in the world.
     * @param {OpenSeadragon.Point} position - The new position, in viewport coordinates.
     * @param {Boolean} [immediately=false] - Whether to animate to the new position or snap immediately.
     * @fires OpenSeadragon.TiledImage.event:bounds-change
     */
    setPosition: function(position, immediately) {
        const sameTarget = (this._xSpring.target.value === position.x &&
            this._ySpring.target.value === position.y);

        if (immediately) {
            if (sameTarget && this._xSpring.current.value === position.x &&
                this._ySpring.current.value === position.y) {
                return;
            }

            this._xSpring.resetTo(position.x);
            this._ySpring.resetTo(position.y);
            this._needsDraw = true;
            this._needsUpdate = true;
        } else {
            if (sameTarget) {
                return;
            }

            this._xSpring.springTo(position.x);
            this._ySpring.springTo(position.y);
            this._needsDraw = true;
            this._needsUpdate = true;
        }

        if (!sameTarget) {
            this._raiseBoundsChange();
        }
    },

    /**
     * Sets the TiledImage's width in the world, adjusting the height to match based on aspect ratio.
     * @param {Number} width - The new width, in viewport coordinates.
     * @param {Boolean} [immediately=false] - Whether to animate to the new size or snap immediately.
     * @fires OpenSeadragon.TiledImage.event:bounds-change
     */
    setWidth: function(width, immediately) {
        this._setScale(width, immediately);
    },

    /**
     * Sets the TiledImage's height in the world, adjusting the width to match based on aspect ratio.
     * @param {Number} height - The new height, in viewport coordinates.
     * @param {Boolean} [immediately=false] - Whether to animate to the new size or snap immediately.
     * @fires OpenSeadragon.TiledImage.event:bounds-change
     */
    setHeight: function(height, immediately) {
        this._setScale(height / this.normHeight, immediately);
    },

    /**
     * Sets an array of polygons to crop the TiledImage during draw tiles.
     * The render function will use the default non-zero winding rule.
     * @param {OpenSeadragon.Point[][]} polygons - represented in an array of point object in image coordinates.
     * Example format: [
     *  [{x: 197, y:172}, {x: 226, y:172}, {x: 226, y:198}, {x: 197, y:198}], // First polygon
     *  [{x: 328, y:200}, {x: 330, y:199}, {x: 332, y:201}, {x: 329, y:202}]  // Second polygon
     *  [{x: 321, y:201}, {x: 356, y:205}, {x: 341, y:250}] // Third polygon
     * ]
     */
    setCroppingPolygons: function( polygons ) {
        const isXYObject = function(obj) {
            return obj instanceof $.Point || (typeof obj.x === 'number' && typeof obj.y === 'number');
        };

        const objectToSimpleXYObject = function(objs) {
            return objs.map(function(obj) {
                try {
                    if (isXYObject(obj)) {
                        return { x: obj.x, y: obj.y };
                    } else {
                        throw new Error();
                    }
                } catch(e) {
                    throw new Error('A Provided cropping polygon point is not supported');
                }
            });
        };

        try {
            if (!$.isArray(polygons)) {
                throw new Error('Provided cropping polygon is not an array');
            }
            this._croppingPolygons = polygons.map(function(polygon){
                return objectToSimpleXYObject(polygon);
            });
            this._needsDraw = true;
        } catch (e) {
            $.console.error('[TiledImage.setCroppingPolygons] Cropping polygon format not supported');
            $.console.error(e);
            this.resetCroppingPolygons();
        }
    },

    /**
     * Resets the cropping polygons, thus next render will remove all cropping
     * polygon effects.
     */
    resetCroppingPolygons: function() {
        this._croppingPolygons = null;
        this._needsDraw = true;
    },

    /**
     * Positions and scales the TiledImage to fit in the specified bounds.
     * Note: this method fires OpenSeadragon.TiledImage.event:bounds-change
     * twice
     * @param {OpenSeadragon.Rect} bounds The bounds to fit the image into.
     * @param {OpenSeadragon.Placement} [anchor=OpenSeadragon.Placement.CENTER]
     * How to anchor the image in the bounds.
     * @param {Boolean} [immediately=false] Whether to animate to the new size
     * or snap immediately.
     * @fires OpenSeadragon.TiledImage.event:bounds-change
     */
    fitBounds: function(bounds, anchor, immediately) {
        anchor = anchor || $.Placement.CENTER;
        const anchorProperties = $.Placement.properties[anchor];
        let aspectRatio = this.contentAspectX;
        let xOffset = 0;
        let yOffset = 0;
        let displayedWidthRatio = 1;
        let displayedHeightRatio = 1;

        if (this._clip) {
            aspectRatio = this._clip.getAspectRatio();
            displayedWidthRatio = this._clip.width / this.source.dimensions.x;
            displayedHeightRatio = this._clip.height / this.source.dimensions.y;
            if (bounds.getAspectRatio() > aspectRatio) {
                xOffset = this._clip.x / this._clip.height * bounds.height;
                yOffset = this._clip.y / this._clip.height * bounds.height;
            } else {
                xOffset = this._clip.x / this._clip.width * bounds.width;
                yOffset = this._clip.y / this._clip.width * bounds.width;
            }
        }

        if (bounds.getAspectRatio() > aspectRatio) {
            // We will have margins on the X axis
            const height = bounds.height / displayedHeightRatio;
            let marginLeft = 0;
            if (anchorProperties.isHorizontallyCentered) {
                marginLeft = (bounds.width - bounds.height * aspectRatio) / 2;
            } else if (anchorProperties.isRight) {
                marginLeft = bounds.width - bounds.height * aspectRatio;
            }
            this.setPosition(
                new $.Point(bounds.x - xOffset + marginLeft, bounds.y - yOffset),
                immediately);
            this.setHeight(height, immediately);
        } else {
            // We will have margins on the Y axis
            const width = bounds.width / displayedWidthRatio;
            let marginTop = 0;
            if (anchorProperties.isVerticallyCentered) {
                marginTop = (bounds.height - bounds.width / aspectRatio) / 2;
            } else if (anchorProperties.isBottom) {
                marginTop = bounds.height - bounds.width / aspectRatio;
            }
            this.setPosition(
                new $.Point(bounds.x - xOffset, bounds.y - yOffset + marginTop),
                immediately);
            this.setWidth(width, immediately);
        }
    },

    /**
     * @returns {OpenSeadragon.Rect|null} The TiledImage's current clip rectangle,
     * in image pixels, or null if none.
     */
    getClip: function() {
        if (this._clip) {
            return this._clip.clone();
        }

        return null;
    },

    /**
     * @param {OpenSeadragon.Rect|null} newClip - An area, in image pixels, to clip to
     * (portions of the image outside of this area will not be visible). Only works on
     * browsers that support the HTML5 canvas.
     * @fires OpenSeadragon.TiledImage.event:clip-change
     */
    setClip: function(newClip) {
        $.console.assert(!newClip || newClip instanceof $.Rect,
            "[TiledImage.setClip] newClip must be an OpenSeadragon.Rect or null");

        if (newClip instanceof $.Rect) {
            this._clip = newClip.clone();
        } else {
            this._clip = null;
        }

        this._needsUpdate = true;
        this._needsDraw = true;
        /**
         * Raised when the TiledImage's clip is changed.
         * @event clip-change
         * @memberOf OpenSeadragon.TiledImage
         * @type {object}
         * @property {OpenSeadragon.TiledImage} eventSource - A reference to the
         * TiledImage which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent('clip-change');
    },

    /**
     * @returns {Boolean} Whether the TiledImage should be flipped before rendering.
     */
    getFlip: function() {
        return this.flipped;
    },

    /**
     * @param {Boolean} flip Whether the TiledImage should be flipped before rendering.
     * @fires OpenSeadragon.TiledImage.event:bounds-change
     */
    setFlip: function(flip) {
        this.flipped = flip;
    },

    get flipped() {
        return this._flipped;
    },
    set flipped(flipped) {
        const changed = this._flipped !== !!flipped;
        this._flipped = !!flipped;
        if (changed && this._initialized) {
            this.update(true);
            this._needsDraw = true;
            this._raiseBoundsChange();
        }
    },

    get wrapHorizontal(){
        return this._wrapHorizontal;
    },
    set wrapHorizontal(wrap){
        const changed = this._wrapHorizontal !== !!wrap;
        this._wrapHorizontal = !!wrap;
        if(this._initialized && changed){
            this.update(true);
            this._needsDraw = true;
            // this._raiseBoundsChange();
        }
    },

    get wrapVertical(){
        return this._wrapVertical;
    },
    set wrapVertical(wrap){
        const changed = this._wrapVertical !== !!wrap;
        this._wrapVertical = !!wrap;
        if(this._initialized && changed){
            this.update(true);
            this._needsDraw = true;
            // this._raiseBoundsChange();
        }
    },

    get debugMode(){
        return this._debugMode;
    },
    set debugMode(debug){
        this._debugMode = !!debug;
        this._needsDraw = true;
    },

    /**
     * @returns {Number} The TiledImage's current opacity.
     */
    getOpacity: function() {
        return this.opacity;
    },

    /**
     * @param {Number} opacity Opacity the tiled image should be drawn at.
     * @fires OpenSeadragon.TiledImage.event:opacity-change
     */
    setOpacity: function(opacity) {
        this.opacity = opacity;
    },

    get opacity() {
        return this._opacity;
    },

    set opacity(opacity) {
        if (opacity === this.opacity) {
            return;
        }

        this._opacity = opacity;
        this._needsDraw = true;
        this._needsUpdate = true;
        /**
         * Raised when the TiledImage's opacity is changed.
         * @event opacity-change
         * @memberOf OpenSeadragon.TiledImage
         * @type {object}
         * @property {Number} opacity - The new opacity value.
         * @property {OpenSeadragon.TiledImage} eventSource - A reference to the
         * TiledImage which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent('opacity-change', {
            opacity: this.opacity
        });
    },

    /**
     * @returns {Boolean} whether the tiledImage can load its tiles even when it has zero opacity.
     */
    getPreload: function() {
        return this._preload;
    },

    /**
     * Set true to load even when hidden. Set false to block loading when hidden.
     */
    setPreload: function(preload) {
        this._preload = !!preload;
        this._needsDraw = true;
    },

    /**
     * Get the rotation of this tiled image in degrees.
     * @param {Boolean} [current=false] True for current rotation, false for target.
     * @returns {Number} the rotation of this tiled image in degrees.
     */
    getRotation: function(current) {
        return current ?
            this._degreesSpring.current.value :
            this._degreesSpring.target.value;
    },

    /**
     * Set the current rotation of this tiled image in degrees.
     * @param {Number} degrees the rotation in degrees.
     * @param {Boolean} [immediately=false] Whether to animate to the new angle
     * or rotate immediately.
     * @fires OpenSeadragon.TiledImage.event:bounds-change
     */
    setRotation: function(degrees, immediately) {
        if (this._degreesSpring.target.value === degrees &&
            this._degreesSpring.isAtTargetValue()) {
            return;
        }
        if (immediately) {
            this._degreesSpring.resetTo(degrees);
        } else {
            this._degreesSpring.springTo(degrees);
        }
        this._needsDraw = true;
        this._needsUpdate = true;
        this._raiseBoundsChange();
    },

    /**
     * Get the region of this tiled image that falls within the viewport.
     * @returns {OpenSeadragon.Rect} the region of this tiled image that falls within the viewport.
     * Returns false for images with opacity==0 unless preload==true
     */
    getDrawArea: function(){

        if( this._opacity === 0 && !this._preload){
            return false;
        }

        let drawArea = this._viewportToTiledImageRectangle(
            this.viewport.getBoundsWithMargins(true));

        if (!this.wrapHorizontal && !this.wrapVertical) {
            const tiledImageBounds = this._viewportToTiledImageRectangle(
                this.getClippedBounds(true));
            drawArea = drawArea.intersection(tiledImageBounds);
        }

        return drawArea;
    },

    getLoadArea: function() {
        let loadArea = this._viewportToTiledImageRectangle(
            this.viewport.getBoundsWithMargins(false));

        if (!this.wrapHorizontal && !this.wrapVertical) {
            const tiledImageBounds = this._viewportToTiledImageRectangle(
                this.getClippedBounds(false));
            loadArea = loadArea.intersection(tiledImageBounds);
        }

        return loadArea;
    },

    /**
     * Get tiles that should be drawn at the current position of tiled image.
     * Note: this method should be called only once per frame.
     * @returns {OpenSeadragon.TiledImage.DrawTileInfo[]} Array of Tiles that make up the current view
     */
    getTilesToDraw: function(){
        // start with all the tiles added to this._tilesToDraw during the most recent
        // call to this.update. Then update them so the blending and coverage properties
        // are updated based on the current time

        // reuse last-drawn array to avoid allocations
        const lastDrawn = this._lastDrawn;

        let insertionIndex = 0;
        for (const maybeNested of this._tilesToDraw) {
            if (Array.isArray(maybeNested)) {
                for (const item of maybeNested) {
                    lastDrawn[insertionIndex++] = item;
                }
            } else if (maybeNested) {
                lastDrawn[insertionIndex++] = maybeNested;
            }
        }
        lastDrawn.length = insertionIndex;
        // update all tiles, which can change the coverage provided
        this._updateTilesInViewport(lastDrawn);

        // _tilesToDraw might have been updated by the update; refresh it
        // mark the tiles as being drawn, so that they won't be discarded from
        // the tileCache
        insertionIndex = 0;
        for (const maybeNested of this._tilesToDraw) {
            if (Array.isArray(maybeNested)) {
                for (const item of maybeNested) {
                    if (item.tile.loaded) {
                        item.tile.beingDrawn = true;
                        lastDrawn[insertionIndex++] = item;
                    }
                }
            } else if (maybeNested) {
                if (maybeNested.tile.loaded) {
                    maybeNested.tile.beingDrawn = true;
                    lastDrawn[insertionIndex++] = maybeNested;
                }
            }
        }
        lastDrawn.length = insertionIndex;
        return lastDrawn;
    },

    /**
     * Get the point around which this tiled image is rotated
     * @private
     * @param {Boolean} current True for current rotation point, false for target.
     * @returns {OpenSeadragon.Point}
     */
    _getRotationPoint: function(current) {
        return this.getBoundsNoRotate(current).getCenter();
    },

    get compositeOperation(){
        return this._compositeOperation;
    },

    set compositeOperation(compositeOperation){

        if (compositeOperation === this._compositeOperation) {
            return;
        }
        this._compositeOperation = compositeOperation;
        this._needsDraw = true;
        /**
         * Raised when the TiledImage's opacity is changed.
         * @event composite-operation-change
         * @memberOf OpenSeadragon.TiledImage
         * @type {object}
         * @property {String} compositeOperation - The new compositeOperation value.
         * @property {OpenSeadragon.TiledImage} eventSource - A reference to the
         * TiledImage which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent('composite-operation-change', {
            compositeOperation: this._compositeOperation
        });

    },

    /**
     * @returns {String} The TiledImage's current compositeOperation.
     */
    getCompositeOperation: function() {
        return this._compositeOperation;
    },

    /**
     * @param {String} compositeOperation the tiled image should be drawn with this globalCompositeOperation.
     * @fires OpenSeadragon.TiledImage.event:composite-operation-change
     */
    setCompositeOperation: function(compositeOperation) {
        this.compositeOperation = compositeOperation; //invokes setter
    },

    /**
     * Update headers to include when making AJAX requests.
     *
     * Unless `propagate` is set to false (which is likely only useful in rare circumstances),
     * the updated headers are propagated to all tiles and queued image loader jobs.
     *
     * Note that the rules for merging headers still apply, i.e. headers returned by
     * {@link OpenSeadragon.TileSource#getTileAjaxHeaders} take precedence over
     * the headers here in the tiled image (`TiledImage.ajaxHeaders`).
     *
     * @function
     * @param {Object} ajaxHeaders Updated AJAX headers, which will be merged over any headers specified in {@link OpenSeadragon.Options}.
     * @param {Boolean} [propagate=true] Whether to propagate updated headers to existing tiles and queued image loader jobs.
     */
    setAjaxHeaders: function(ajaxHeaders, propagate) {
        if (ajaxHeaders === null) {
            ajaxHeaders = {};
        }
        if (!$.isPlainObject(ajaxHeaders)) {
            $.console.error('[TiledImage.setAjaxHeaders] Ignoring invalid headers, must be a plain object');
            return;
        }

        this._ownAjaxHeaders = ajaxHeaders;
        this._updateAjaxHeaders(propagate);
    },

    /**
     * Update headers to include when making AJAX requests.
     *
     * This function has the same effect as calling {@link OpenSeadragon.TiledImage#setAjaxHeaders},
     * except that the headers for this tiled image do not change. This is especially useful
     * for propagating updated headers from {@link OpenSeadragon.TileSource#getTileAjaxHeaders}
     * to existing tiles.
     *
     * @private
     * @function
     * @param {Boolean} [propagate=true] Whether to propagate updated headers to existing tiles and queued image loader jobs.
     */
    _updateAjaxHeaders: function(propagate) {
        if (propagate === undefined) {
            propagate = true;
        }

        // merge with viewer's headers
        if ($.isPlainObject(this.viewer.ajaxHeaders)) {
            this.ajaxHeaders = $.extend({}, this.viewer.ajaxHeaders, this._ownAjaxHeaders);
        } else {
            this.ajaxHeaders = this._ownAjaxHeaders;
        }

        // propagate header updates to all tiles and queued image loader jobs
        if (propagate) {
            let numTiles, xMod, yMod, tile;

            for (const level in this.tilesMatrix) {
                numTiles = this.source.getNumTiles(level);
                const matrixLevel = this.tilesMatrix[level];

                for (const x in matrixLevel) {
                    xMod = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;

                    for (const y in matrixLevel[x]) {
                        yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
                        tile = matrixLevel[x][y];

                        tile.loadWithAjax = this.loadTilesWithAjax;
                        if (tile.loadWithAjax) {
                            const tileAjaxHeaders = this.source.getTileAjaxHeaders( level, xMod, yMod );
                            tile.ajaxHeaders = $.extend({}, this.ajaxHeaders, tileAjaxHeaders);
                        } else {
                            tile.ajaxHeaders = null;
                        }
                    }
                }
            }

            for (let i = 0; i < this._imageLoader.jobQueue.length; i++) {
                const job = this._imageLoader.jobQueue[i];
                job.loadWithAjax = job.tile.loadWithAjax;
                job.ajaxHeaders = job.tile.loadWithAjax ? job.tile.ajaxHeaders : null;
            }
        }
    },

    /**
     * Enable cache preservation even without this tile image,
     * by default disabled. It means that upon removing,
     * the tile cache does not get immediately erased but
     * stays in the memory to be potentially re-used by other
     * TiledImages.
     * @param {boolean} allow
     */
    allowZombieCache: function(allow) {
        this._zombieCache = allow;
    },

    // private
    _setScale: function(scale, immediately) {
        const sameTarget = (this._scaleSpring.target.value === scale);
        if (immediately) {
            if (sameTarget && this._scaleSpring.current.value === scale) {
                return;
            }

            this._scaleSpring.resetTo(scale);
            this._updateForScale();
            this._needsDraw = true;
            this._needsUpdate = true;
        } else {
            if (sameTarget) {
                return;
            }

            this._scaleSpring.springTo(scale);
            this._updateForScale();
            this._needsDraw = true;
            this._needsUpdate = true;
        }

        if (!sameTarget) {
            this._raiseBoundsChange();
        }
    },

    // private
    _updateForScale: function() {
        this._worldWidthTarget = this._scaleSpring.target.value;
        this._worldHeightTarget = this.normHeight * this._scaleSpring.target.value;
        this._worldWidthCurrent = this._scaleSpring.current.value;
        this._worldHeightCurrent = this.normHeight * this._scaleSpring.current.value;
    },

    // private
    _raiseBoundsChange: function() {
        /**
         * Raised when the TiledImage's bounds are changed.
         * Note that this event is triggered only when the animation target is changed;
         * not for every frame of animation.
         * @event bounds-change
         * @memberOf OpenSeadragon.TiledImage
         * @type {object}
         * @property {OpenSeadragon.TiledImage} eventSource - A reference to the
         * TiledImage which raised the event.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent('bounds-change');
    },

    // private
    _isBottomItem: function() {
        return this.viewer.world.getItemAt(0) === this;
    },

    // private
    _getLevelsInterval: function() {
        let lowestLevel = Math.max(
            this.source.minLevel,
            Math.floor(Math.log(this.minZoomImageRatio) / Math.log(2))
        );
        const currentZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
                this.source.getPixelRatio(0), true).x *
            this._scaleSpring.current.value;
        let highestLevel = Math.min(
            Math.abs(this.source.maxLevel),
            Math.abs(Math.floor(
                Math.log(currentZeroRatio / this.minPixelRatio) / Math.log(2)
            ))
        );

        // Calculations for the interval of levels to draw
        // can return invalid intervals; fix that here if necessary
        highestLevel = Math.max(highestLevel, this.source.minLevel || 0);
        lowestLevel = Math.min(lowestLevel, highestLevel);
        return {
            lowestLevel: lowestLevel,
            highestLevel: highestLevel
        };
    },

    // returns boolean flag of whether the image should be marked as fully loaded
    _updateLevelsForViewport: function(){
        const levelsInterval = this._getLevelsInterval();
        const lowestLevel = levelsInterval.lowestLevel; // the lowest level we should draw at our current zoom
        const highestLevel = levelsInterval.highestLevel; // the highest level we should draw at our current zoom
        const drawArea = this.getDrawArea();

        let loadArea = drawArea;
        let bestLoadTileCandidates = this._getCachedArray('bestLoadTileCandidates', 0);

        if (this.loadDestinationTilesOnAnimation) {
            loadArea = this.getLoadArea();
        }
        const currentTime = $.now();

        // reset each tile's beingDrawn flag
        for (const tileInfo of this._lastDrawn) {
            tileInfo.tile.beingDrawn = false;
        }
        // clear the list of tiles to draw
        this._tilesToDraw.length = 0;
        this._tilesLoading = 0;
        this.loadingCoverage = {};

        if (!drawArea){
            this._needsDraw = false;
            return this._fullyLoaded;
        }

        // make a list of levels to use for the current zoom level
        const levelList = this._getCachedArray('levelList', highestLevel - lowestLevel + 1);
        // go from highest to lowest resolution
        for (let i = 0, level = highestLevel; level >= lowestLevel; level--, i++) {
            levelList[i] = level;
        }

        // if a single-tile level is loaded, add that to the end of the list
        // as a fallback to use during zooming out, until a lower-res tile is
        // loaded
        for (let level = highestLevel + 1; level <= this.source.maxLevel; level++) {
            const tile = (
                this.tilesMatrix[level] &&
                this.tilesMatrix[level][0] &&
                this.tilesMatrix[level][0][0]
            );
            if (tile && tile.isBottomMost && tile.isRightMost && tile.loaded) {
                levelList.push(level);
                break;
            }
        }


        // Update any level that will be drawn.
        // We are iterating from highest resolution to lowest resolution
        // Once a level fully covers the viewport the loop is halted and
        // lower-resolution levels are skipped
        let useLevel = false;
        for (let i = 0; i < levelList.length; i++) {
            const level = levelList[i];

            const currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
                this.source.getPixelRatio(level),
                true
            ).x * this._scaleSpring.current.value;

            // make sure we skip levels until currentRenderPixelRatio becomes >= minPixelRatio
            // but always use the last level in the list so we draw something
            if (i === levelList.length - 1 || currentRenderPixelRatio >= this.minPixelRatio ) {
                useLevel = true;
            } else if (!useLevel) {
                continue;
            }

            const targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
                this.source.getPixelRatio(level),
                false
            ).x * this._scaleSpring.current.value;

            const targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
                this.source.getPixelRatio(
                    Math.max(
                        this.source.getClosestLevel(),
                        0
                    )
                ),
                false
            ).x * this._scaleSpring.current.value;

            const optimalRatio = this.immediateRender ? 1 : targetZeroRatio;
            const levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5);
            const levelVisibility = optimalRatio / Math.abs(
                optimalRatio - targetRenderPixelRatio
            );

            // Update the level and keep track of 'best' tiles to load
            const result = this._updateLevel(
                level,
                levelOpacity,
                levelVisibility,
                drawArea,
                loadArea,
                currentTime,
                bestLoadTileCandidates
            );

            this.viewer.world.ensureTilesUpToDate(result.tilesToDraw);

            bestLoadTileCandidates = result.bestLoadTileCandidates;
            this._tilesToDraw[level] = result.tilesToDraw;

            // Stop the loop if lower-res tiles would all be covered by
            // already drawn tiles
            if (this._providesCoverage(this.coverage, level)) {
                break;
            }
        }


        // Load the new 'best' n tiles
        if (bestLoadTileCandidates && bestLoadTileCandidates.length > 0) {
            // We need to set loading state immediatelly, if we need setTimeout() here,
            // we should immediatelly set loading=true to all tiles
            for (const tile of bestLoadTileCandidates) {
                if (tile) {
                    this._loadTile(tile, currentTime);
                }
            }
            this._needsDraw = true;
            return false;
        } else {
            return this._tilesLoading === 0;
        }
    },

    /**
     * Update all tiles that contribute to the current view
     * @private
     *
     */
    _updateTilesInViewport: function(tiles) {
        const currentTime = $.now();
        const _this = this;
        this._tilesLoading = 0;
        this._wasBlending = this._isBlending;
        this._isBlending = false;
        this.loadingCoverage = {};
        const lowestLevel = tiles.length ? tiles[0].level : 0;

        const drawArea = this.getDrawArea();
        if(!drawArea){
            return;
        }

        // Update each tile in the list of tiles. As the tiles are updated,
        // the coverage provided is also updated. If a level provides coverage
        // as part of this process, discard tiles from lower levels
        let level = 0;
        for (const info of tiles) {
            const tile = info.tile;
            if (tile && tile.loaded) {
                const tileIsBlending = _this._blendTile(
                    tile,
                    tile.x,
                    tile.y,
                    info.level,
                    info.levelOpacity,
                    currentTime,
                    lowestLevel
                );
                _this._isBlending = _this._isBlending || tileIsBlending;
                _this._needsDraw = _this._needsDraw || tileIsBlending || _this._wasBlending;
            }

            if (this._providesCoverage(this.coverage, info.level)) {
                level = Math.max(level, info.level);
            }
        }
        if (level > 0) {
            for (const levelKey in this._tilesToDraw) {
                if (levelKey < level) {
                    this._tilesToDraw[levelKey] = undefined;
                }
            }
        }
    },

    /**
     * Updates the opacity of a tile according to the time it has been on screen
     * to perform a fade-in.
     * Updates coverage once a tile is fully opaque.
     * Returns whether the fade-in has completed.
     * @private
     *
     * @param {OpenSeadragon.Tile} tile
     * @param {Number} x
     * @param {Number} y
     * @param {Number} level
     * @param {Number} levelOpacity
     * @param {Number} currentTime
     * @param {Boolean} lowestLevel
     * @returns {Boolean} true if blending did not yet finish
     */
    _blendTile: function(tile, x, y, level, levelOpacity, currentTime, lowestLevel ){
        let blendTimeMillis = 1000 * this.blendTime,
            deltaTime,
            opacity;

        if ( !tile.blendStart ) {
            tile.blendStart = currentTime;
        }

        deltaTime   = currentTime - tile.blendStart;
        opacity     = blendTimeMillis ? Math.min( 1, deltaTime / ( blendTimeMillis ) ) : 1;

        // if this tile is at the lowest level being drawn, render at opacity=1
        if(level === lowestLevel){
            opacity = 1;
            deltaTime = blendTimeMillis;
        }

        if ( this.alwaysBlend ) {
            opacity *= levelOpacity;
        }
        tile.opacity = opacity;

        if ( opacity === 1 ) {
            this._setCoverage( this.coverage, level, x, y, true );
            this._hasOpaqueTile = true;
        }
        // return true if the tile is still blending
        return deltaTime < blendTimeMillis;
    },

    /**
     * Updates all tiles at a given resolution level.
     * @private
     * @param {Number} level
     * @param {Number} levelOpacity
     * @param {Number} levelVisibility
     * @param {OpenSeadragon.Rect} drawArea
     * @param {OpenSeadragon.Rect} loadArea
     * @param {Number} currentTime
     * @param {OpenSeadragon.Tile[]} bestLoadTileCandidates Array of the current best tiles
     * @returns {Object} Dictionary {
     *    bestLoadTileCandidates: OpenSeadragon.Tile - the current "best" tiles to draw,
     *    tilesToDraw: OpenSeadragon.Tile) - the updated tiles
     * }
     */
    _updateLevel: function(level, levelOpacity,
                            levelVisibility, drawArea, loadArea, currentTime, bestLoadTileCandidates) {

        const drawTopLeftBound = drawArea.getBoundingBox().getTopLeft();
        const drawBottomRightBound = drawArea.getBoundingBox().getBottomRight();
        if (this.viewer) {
            /**
             * <em>- Needs documentation -</em>
             *
             * @event update-level
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
             * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
             * @property {Object} havedrawn - deprecated, always true (kept for backwards compatibility)
             * @property {Object} level
             * @property {Object} opacity
             * @property {Object} visibility
             * @property {OpenSeadragon.Rect} drawArea
             * @property {Object} topleft deprecated, use drawArea instead
             * @property {Object} bottomright deprecated, use drawArea instead
             * @property {Object} currenttime
             * @property {Object[]} best
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.viewer.raiseEvent('update-level', {
                tiledImage: this,
                havedrawn: true, // deprecated, kept for backwards compatibility
                level: level,
                opacity: levelOpacity,
                visibility: levelVisibility,
                drawArea: drawArea,
                topleft: drawTopLeftBound,
                bottomright: drawBottomRightBound,
                currenttime: currentTime,
                // todo misleading name, consider changing
                best: bestLoadTileCandidates
            });
        }

        const numberOfTiles = this.source.getNumTiles(level);
        const viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter());
        this._resetCoverage(this.coverage, level);
        if (loadArea) {
            this._resetCoverage(this.loadingCoverage, level);
        }

        let tilesToDraw = null;
        let tileIndex = 0;

        // Iterate over tiles and decide, which will be loaded and drawn
        this._visitTiles(level, drawArea, (x, y, total) => {
            const tile = this._getTile(
                x, y,
                level,
                currentTime,
                numberOfTiles
            );

            if (!tilesToDraw) {
                tilesToDraw = this._getCachedArray(level, total);
            }

            /////////////////////////////////////////////////////
            // First Part: Decide if tile will be used to draw //
            /////////////////////////////////////////////////////

            if( this.viewer ){
                /**
                 * This event is called before tile is being updated: its position, coverage and other properties.
                 * Note that this does not mean the tile will be loaded, it might be just updated greedily to avoid
                 * visible load animation (happens if position is updated lazily when tile.loaded / loading is true).
                 *
                 * @event update-tile
                 * @memberof OpenSeadragon.Viewer
                 * @type {object}
                 * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event.
                 * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
                 * @property {OpenSeadragon.Tile} tile
                 * @property {?Object} userData - Arbitrary subscriber-defined object.
                 */
                this.viewer.raiseEvent( 'update-tile', {
                    tiledImage: this,
                    tile: tile
                });
            }

            this._setCoverage( this.coverage, level, x, y, false );

            if (tile.exists) {
                if (tile.loaded) {
                    if (tile.opacity === 1) {
                        this._setCoverage( this.coverage, level, x, y, true );
                    }

                    // Tiles are carried in info objects
                    tilesToDraw[tileIndex++] = {
                        tile: tile,
                        level: level,
                        levelOpacity: levelOpacity,
                        currentTime: currentTime
                    };
                    this._setCoverage(this.loadingCoverage, level, x, y, true);
                }

                this._positionTile(
                    tile,
                    this.source.tileOverlap,
                    this.viewport,
                    viewportCenter,
                    levelVisibility
                );
            }

            /////////////////////////////////////////////////////
            // Second Part: Decide if tile will be loaded      //
            /////////////////////////////////////////////////////

            if (loadArea && !tile.loaded) {
                let loadingCoverage = tile.loading || this._isCovered(this.loadingCoverage, level, x, y);
                this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage);

                if ( !tile.exists ) {
                    return;
                }

                // Try-find will populate tile with data if equal tile exists in system
                if (!tile.loading && this._tryFindTileCacheRecord(tile)) {
                    loadingCoverage = true;
                }

                if (tile.loading) {
                    // the tile is already in the download queue or being processed
                    this._tilesLoading++;
                } else if (!loadingCoverage) {
                    // add tile to best tiles to load only when not loaded already
                    bestLoadTileCandidates = this._compareTiles( bestLoadTileCandidates, tile, this._currentMaxTilesPerFrame);
                }
            }
        });

        // _currentMaxTilesPerFrame can be temporarily boosted, bring it down after each usage if necessary
        if (this._currentMaxTilesPerFrame > this.maxTilesPerFrame) {
            this._currentMaxTilesPerFrame = Math.max(Math.ceil(this._currentMaxTilesPerFrame / 2), this.maxTilesPerFrame);
        }

        if (tilesToDraw) {
            tilesToDraw.length = tileIndex;
        }

        return {
            bestLoadTileCandidates: bestLoadTileCandidates,
            tilesToDraw: tilesToDraw || []
        };
    },

    /**
     * Visit all tiles in an a given area on a given level.
     * @private
     * @param {Number} level
     * @param {OpenSeadragon.Rect} area
     * @param {Function} callback - x, y, total - tile x, y position and total number of tiles
     */
    _visitTiles: function(level, area, callback) {
        const bbox = area.getBoundingBox();
        const drawCornerTiles = this._getCornerTiles(level, bbox.getTopLeft(), bbox.getBottomRight());
        const drawTopLeftTile = drawCornerTiles.topLeft;
        const drawBottomRightTile = drawCornerTiles.bottomRight;

        const numberOfTiles  = this.source.getNumTiles(level);

        if (this.getFlip()) {
            // The right-most tile can be narrower than the others. When flipped,
            // this tile is now on the left. Because it is narrower than the normal
            // left-most tile, the subsequent tiles may not be wide enough to completely
            // fill the viewport. Fix this by rendering an extra column of tiles. If we
            // are not wrapping, make sure we never render more than the number of tiles
            // in the image.
            drawBottomRightTile.x += 1;
            if (!this.wrapHorizontal) {
                drawBottomRightTile.x  = Math.min(drawBottomRightTile.x, numberOfTiles.x - 1);
            }
        }
        const numTiles = Math.max(0, (drawBottomRightTile.x - drawTopLeftTile.x) * (drawBottomRightTile.y - drawTopLeftTile.y));

        for (let x = drawTopLeftTile.x; x <= drawBottomRightTile.x; x++) {
            for (let y = drawTopLeftTile.y; y <= drawBottomRightTile.y; y++) {

                let flippedX;
                if (this.getFlip()) {
                    const xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x;
                    flippedX = x + numberOfTiles.x - xMod - xMod - 1;
                } else {
                    flippedX = x;
                }

                if (area.intersection(this.getTileBounds(level, flippedX, y)) === null) {
                    // This tile is not in the draw area
                    continue;
                }

                callback(flippedX, y, numTiles);
            }
        }
    },

    /**
     * @private
     * @param {OpenSeadragon.Tile} tile
     * @param {Boolean} overlap
     * @param {OpenSeadragon.Viewport} viewport
     * @param {OpenSeadragon.Point} viewportCenter
     * @param {Number} levelVisibility
     */
    _positionTile: function( tile, overlap, viewport, viewportCenter, levelVisibility ){
        const boundsTL = tile.bounds.getTopLeft();

        boundsTL.x *= this._scaleSpring.current.value;
        boundsTL.y *= this._scaleSpring.current.value;
        boundsTL.x += this._xSpring.current.value;
        boundsTL.y += this._ySpring.current.value;

        const boundsSize   = tile.bounds.getSize();

        boundsSize.x *= this._scaleSpring.current.value;
        boundsSize.y *= this._scaleSpring.current.value;

        tile.positionedBounds.x = boundsTL.x;
        tile.positionedBounds.y = boundsTL.y;
        tile.positionedBounds.width = boundsSize.x;
        tile.positionedBounds.height = boundsSize.y;

        const positionC = viewport.pixelFromPointNoRotate(boundsTL, true);
        const positionT = viewport.pixelFromPointNoRotate(boundsTL, false);
        let sizeC = viewport.deltaPixelsFromPointsNoRotate(boundsSize, true);
        const sizeT = viewport.deltaPixelsFromPointsNoRotate(boundsSize, false);
        const tileCenter = positionT.plus( sizeT.divide( 2 ) );
        const tileSquaredDistance = viewportCenter.squaredDistanceTo( tileCenter );

        if(this.getDrawer().minimumOverlapRequired(this)){
            if ( !overlap ) {
                sizeC = sizeC.plus( new $.Point(1, 1));
            }

            if (tile.isRightMost && this.wrapHorizontal) {
                sizeC.x += 0.75; // Otherwise Firefox and Safari show seams
            }

            if (tile.isBottomMost && this.wrapVertical) {
                sizeC.y += 0.75; // Otherwise Firefox and Safari show seams
            }
        }

        tile.position   = positionC;
        tile.size       = sizeC;
        tile.squaredDistance   = tileSquaredDistance;
        tile.visibility = levelVisibility;
    },

    // private
    _getCornerTiles: function(level, topLeftBound, bottomRightBound) {
        let leftX;
        let rightX;
        if (this.wrapHorizontal) {
            leftX = $.positiveModulo(topLeftBound.x, 1);
            rightX = $.positiveModulo(bottomRightBound.x, 1);
        } else {
            leftX = Math.max(0, topLeftBound.x);
            rightX = Math.min(1, bottomRightBound.x);
        }
        let topY;
        let bottomY;
        const aspectRatio = 1 / this.source.aspectRatio;
        if (this.wrapVertical) {
            topY = $.positiveModulo(topLeftBound.y, aspectRatio);
            bottomY = $.positiveModulo(bottomRightBound.y, aspectRatio);
        } else {
            topY = Math.max(0, topLeftBound.y);
            bottomY = Math.min(aspectRatio, bottomRightBound.y);
        }

        const topLeftTile = this.source.getTileAtPoint(level, new $.Point(leftX, topY));
        const bottomRightTile = this.source.getTileAtPoint(level, new $.Point(rightX, bottomY));
        const numTiles  = this.source.getNumTiles(level);

        if (this.wrapHorizontal) {
            topLeftTile.x += numTiles.x * Math.floor(topLeftBound.x);
            bottomRightTile.x += numTiles.x * Math.floor(bottomRightBound.x);
        }
        if (this.wrapVertical) {
            topLeftTile.y += numTiles.y * Math.floor(topLeftBound.y / aspectRatio);
            bottomRightTile.y += numTiles.y * Math.floor(bottomRightBound.y / aspectRatio);
        }

        return {
            topLeft: topLeftTile,
            bottomRight: bottomRightTile,
        };
    },

    /**
     * @private
     * @inner
     * Try to find existing cache of the tile
     * @param {OpenSeadragon.Tile} tile
     */
    _tryFindTileCacheRecord: function(tile) {
        const record = this._tileCache.getCacheRecord(tile.originalCacheKey);

        if (!record) {
            return false;
        }
        tile.loading = true;
        this._setTileLoaded(tile, record.data, null, null, record.type);
        return true;
    },

    /**
     * @private
     * @inner
     * Obtains a tile at the given location.
     * @private
     * @param {Number} x
     * @param {Number} y
     * @param {Number} level
     * @param {Number} time
     * @param {Number} numTiles
     * @returns {OpenSeadragon.Tile}
     */
    _getTile: function(
        x, y,
        level,
        time,
        numTiles
    ) {
        let xMod,
            yMod,
            bounds,
            sourceBounds,
            exists,
            urlOrGetter,
            post,
            ajaxHeaders,
            tile,
            tilesMatrix = this.tilesMatrix,
            tileSource = this.source;

        let matrixLevel = tilesMatrix[ level ];
        if ( !matrixLevel ) {
            tilesMatrix[ level ] = matrixLevel = {};
        }
        if ( !matrixLevel[ x ] ) {
            matrixLevel[ x ] = {};
        }

        if ( !matrixLevel[ x ][ y ] || !matrixLevel[ x ][ y ].flipped !== !this.flipped ) {
            xMod    = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
            yMod    = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
            bounds  = this.getTileBounds( level, x, y );
            sourceBounds = tileSource.getTileBounds( level, xMod, yMod, true );
            exists  = tileSource.tileExists( level, xMod, yMod );
            urlOrGetter     = tileSource.getTileUrl( level, xMod, yMod );
            post    = tileSource.getTilePostData( level, xMod, yMod );

            // Headers are only applicable if loadTilesWithAjax is set
            if (this.loadTilesWithAjax) {
                ajaxHeaders = tileSource.getTileAjaxHeaders( level, xMod, yMod );
                // Combine tile AJAX headers with tiled image AJAX headers (if applicable)
                if ($.isPlainObject(this.ajaxHeaders)) {
                    ajaxHeaders = $.extend({}, this.ajaxHeaders, ajaxHeaders);
                }
            } else {
                ajaxHeaders = null;
            }

            tile = new $.Tile(
                level,
                x,
                y,
                bounds,
                exists,
                urlOrGetter,
                undefined,
                this.loadTilesWithAjax,
                ajaxHeaders,
                sourceBounds,
                post,
                tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post)
            );

            if (this.getFlip()) {
                if (xMod === 0) {
                    tile.isRightMost = true;
                }
            } else {
                if (xMod === numTiles.x - 1) {
                    tile.isRightMost = true;
                }
            }

            if (yMod === numTiles.y - 1) {
                tile.isBottomMost = true;
            }

            tile.flipped = this.flipped;

            matrixLevel[ x ][ y ] = tile;
        } else {
            tile = matrixLevel[ x ][ y ];
        }
        tile.lastTouchTime = time;

        return tile;
    },

    /**
     * Dispatch a job to the ImageLoader to load the Image for a Tile.
     * @private
     * @param {OpenSeadragon.Tile} tile
     * @param {Number} time
     */
    _loadTile: function(tile, time ) {
        const _this = this;
        tile.loading = true;
        tile.tiledImage = this;
        if (!this._imageLoader.addJob({
            src: tile.getUrl(),
            tile: tile,
            source: this.source,
            postData: tile.postData,
            loadWithAjax: tile.loadWithAjax,
            ajaxHeaders: tile.ajaxHeaders,
            crossOriginPolicy: this.crossOriginPolicy,
            ajaxWithCredentials: this.ajaxWithCredentials,
            callback: function( data, errorMsg, tileRequest, dataType, tries ){
                _this._onTileLoad( tile, time, data, errorMsg, tileRequest, dataType, tries );
            },
            abort: function() {
                tile.loading = false;
            }
        })) {
            /**
             * Triggered if tile load job was added to a full queue.
             * This allows to react upon e.g. network not being able to serve the tiles fast enough.
             * @event job-queue-full
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Tile} tile - The tile that failed to load.
             * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image the tile belongs to.
             * @property {number} time - The time in milliseconds when the tile load began.
             */
            this.viewer.raiseEvent("job-queue-full", {
                tile: tile,
                tiledImage: this,
                time: time,
            });
        }
    },

    /**
     * Callback fired when a Tile's Image finished downloading.
     * @private
     * @param {OpenSeadragon.Tile} tile
     * @param {Number} time
     * @param {*} data image data
     * @param {String} errorMsg
     * @param {XMLHttpRequest} tileRequest
     * @param {String} [dataType=undefined] data type, derived automatically if not set
     * @param {number} tries - The number of times the tile has been retried.
     */
    _onTileLoad: function( tile, time, data, errorMsg, tileRequest, dataType, tries ) {
        //data is set to null on error by image loader, allow custom falsey values (e.g. 0)
        if ( data === null || data === undefined ) {
            $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg );
            /**
             * Triggered when a tile fails to load.
             *
             * @event tile-load-failed
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.Tile} tile - The tile that failed to load.
             * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image the tile belongs to.
             * @property {number} time - The time in milliseconds when the tile load began.
             * @property {string} message - The error message.
             * @property {number} tries - The number of times the tile has been retried.
             * @property {boolean} maxReached - Whether the maximum number of retries has been reached.
             * @property {XMLHttpRequest} tileRequest - The XMLHttpRequest used to load the tile if available.
             */
            this.viewer.raiseEvent("tile-load-failed", {
                tile: tile,
                tiledImage: this,
                time: time,
                message: errorMsg,
                tileRequest: tileRequest,
                tries: tries,
                maxReached: this.viewer.tileRetryMax === 0 ? true : tries >= this.viewer.tileRetryMax
            });
            tile.loading = false;
            tile.exists = false;
            return;
        } else {
            tile.exists = true;
        }

        if ( time < this.lastResetTime ) {
            $.console.warn( "Ignoring tile %s loaded before reset: %s", tile, tile.getUrl() );
            tile.loading = false;
            return;
        }

        if (this.originalDataType) {
            // First fetch conversion path to ensure safe conversion and to deduce target type it chooses as optimal one
            const conversion = $.converter.getConversionPath(dataType, this.originalDataType);
            if (conversion) {
                const desiredType = $.converter.getConversionPathFinalType(conversion);
                $.converter.convert(tile, data, dataType, desiredType).then(newData => {
                    this._setTileLoaded(tile, newData, null, tileRequest, desiredType);
                }).catch(e => {
                    $.console.warn("Failed to satisfy original type [%s] %s from %s: %s", desiredType, tile, dataType, e);
                    this._setTileLoaded(tile, data, null, tileRequest, dataType);
                });
            } else {
                $.console.warn( "Ignoring default base tile data type %s: no conversion possible from %s", this.originalDataType, dataType);
                this._setTileLoaded(tile, data, null, tileRequest, dataType);
            }
        } else {
            this._setTileLoaded(tile, data, null, tileRequest, dataType);
        }
    },

    /**
     * @private
     * @param {OpenSeadragon.Tile} tile
     * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object,
     *   can be null: in that case, cache is assigned to a tile without further processing
     * @param {?Number} cutoff ignored, @deprecated
     * @param {?XMLHttpRequest} tileRequest
     * @param {?String} [dataType=undefined] data type, derived automatically if not set
     */
    _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) {
        tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
        // does nothing if tile.cacheKey already present

        $.console.assert(dataType !== undefined, "TileSource::downloadTileStart must return a dataType.");

        let tileCacheCreated = false;
        tile.addCache(tile.cacheKey, () => {
            tileCacheCreated = true;
            return data;
        }, dataType, false, false);

        let resolver = null,
            increment = 0,
            eventFinished = false;
        const _this = this;

        function completionCallback() {
            increment--;
            if (increment > 0) {
                return;
            }
            eventFinished = true;

            //do not override true if set (false is default)
            tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency(
                undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData
            );
            tile.loading = false;
            tile.loaded = true;
            _this.redraw();
            resolver(tile);
        }

        function getCompletionCallback() {
            if (eventFinished) {
                $.console.error("Event 'tile-loaded' argument getCompletionCallback must be called synchronously. " +
                    "Its return value should be called asynchronously.");
            }
            increment++;
            return completionCallback;
        }

        function markTileAsReady() {
            const fallbackCompletion = getCompletionCallback();

            /**
             * Triggered when a tile has just been loaded in memory. That means that the
             * image has been downloaded and can be modified before being drawn to the canvas.
             * This event is _awaiting_, it supports asynchronous functions or functions that return a promise.
             *
             * @event tile-loaded
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {Image|*} image - The image (data) of the tile. Deprecated.
             * @property {*} data image data, the data sent to ImageJob.prototype.finish(),
             *   by default an Image object. Deprecated
             * @property {String} dataType type of the data
             * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile.
             * @property {OpenSeadragon.Tile} tile - The tile which has been loaded.
             * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable).
             * @property {OpenSeadragon.Promise} - Promise resolved when the tile gets fully loaded.
             *   NOTE: DO NOT await the promise in the handler: you will create a deadlock!
             * @property {function} getCompletionCallback - deprecated
             */
            _this.viewer.raiseEventAwaiting("tile-loaded", {
                tile: tile,
                tiledImage: _this,
                tileRequest: tileRequest,
                promise: new $.Promise(resolve => {
                    resolver = resolve;
                }),
                get image() {
                    $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'tile-invalidated' event to modify data instead.");
                    return data;
                },
                get data() {
                    $.console.error("[tile-loaded] event 'data' has been deprecated. Use 'tile-invalidated' event to modify data instead.");
                    return data;
                },
                getCompletionCallback: function () {
                    $.console.error("[tile-loaded] getCompletionCallback is deprecated: it introduces race conditions: " +
                        "use async event handlers instead, execution order is deducted by addHandler(...) priority argument.");
                    return getCompletionCallback();
                },
            }).catch(() => {
                $.console.error("[tile-loaded] event finished with failure: there might be a problem with a plugin you are using.");
            }).then(fallbackCompletion);
        }


        if (tileCacheCreated) {
            // setting invalidation tstamp to 1 makes sure any update gets applied later on
            this.viewer.world.requestTileInvalidateEvent([tile], undefined, false, true, true).then(markTileAsReady).catch(markTileAsReady);
        } else {
            const origCache = tile.getCache(tile.originalCacheKey);
            // First, ensure we really are ready to draw the tile
            const ensureValidDrawerType = (cache) => {
                if (this.viewer.isDestroyed()) {
                    return $.Promise.resolve();
                }
                const drawer = this.getDrawer();
                if (!cache.isUsableForDrawer(drawer)) {
                    return cache.prepareForRendering(drawer);
                }
                return $.Promise.resolve();
            };

            // Tile-invalidated not called on each tile, but only on tiles with new data! Verify we share the main cache
            for (const t of origCache._tiles) {

                // To keep consistent, if we find main cache tile that differs from original cache key, we inherit also main cache
                if (t.cacheKey !== tile.cacheKey) {

                    // add reference also to the main cache, no matter what the other tile state has
                    // completion of the invaldate event should take care of all such tiles
                    const targetMainCache = t.getCache();
                    ensureValidDrawerType(targetMainCache).then(
                        () => tile.setCache(t.cacheKey, targetMainCache, true, false)
                    ).then(markTileAsReady);
                    return;
                }

                if (t.processing) {
                    // Or, if there is a processing promise, we wait for it to complete and then update the tile state.
                    t.processingPromise.then(t => {
                        const targetMainCache = t.getCache();
                        ensureValidDrawerType(targetMainCache).then(() => {
                            tile.setCache(t.cacheKey, targetMainCache, true, false);
                            if (!targetMainCache.loaded) {
                                return targetMainCache.await();
                            }
                            return null;
                        }).then(markTileAsReady);
                    });
                    return;
                }
            }
            ensureValidDrawerType(origCache).then(markTileAsReady);
        }
    },


    /**
     * Determines the 'best tiles' from the given 'last best' tiles and the
     * tile in question. Keeps the best tiles sorted according to visibility and distance.
     * @private
     *
     * @param {OpenSeadragon.Tile[]} previousBest The best tiles so far. Must be sorted. If not,
     *    call previousBest.sort(this._sortTilesComparator) first.
     * @param {OpenSeadragon.Tile} tile The new tile to consider.
     * @param {Number} maxNTiles The max number of best tiles.
     * @returns {OpenSeadragon.Tile[]} The new best tiles.
     */
    _compareTiles: function( previousBest, tile, maxNTiles ) {
        if ( !previousBest ) {
            return [tile];
        }

        let inserted = false;
        for (let tileIndex = 0; tileIndex < previousBest.length; tileIndex++) {
            const nextTile = previousBest[tileIndex];
            if (this._sortTilesComparator(nextTile, tile) > 0) {
                previousBest.splice(tileIndex, 0, tile);
                inserted = true;
                break;
            }
        }

        if (!inserted) {
            previousBest.push(tile);
        }

        if (previousBest.length > maxNTiles) {
            previousBest.pop();
        }
        return previousBest;
    },

    /**
     * Comparator for tile sorting according to visibility and distance.
     * @private
     *
     * @param {OpenSeadragon.Tile} a The tile a.
     * @param {OpenSeadragon.Tile} b The tile b.
     */
    _sortTilesComparator: function(a, b) {
        if (a === null) {
            return 1;
        }
        if (b === null) {
            return -1;
        }
        if (a.visibility === b.visibility) {
            // sort by smallest squared distance
            return a.squaredDistance - b.squaredDistance;
        }
        // sort by largest visibility value
        return b.visibility - a.visibility;
    },

    /**
     * To avoid repeatedly creating arrays for each frame, we cache them.
     * @param {any} key
     * @param {Number} [length=undefined]
     * @private
     */
    _getCachedArray: function(key, length = undefined) {
        let arr = this._arrayCacheMap[key];
        if (!arr) {
            if (length !== undefined) {
                arr = this._arrayCacheMap[key] = new Array(length);
            } else {
                arr = this._arrayCacheMap[key] = [];
            }
        } else if (length !== undefined) {
            arr.length = length;
        }
        return arr;
    },

    /**
     * Returns true if the given tile provides coverage to lower-level tiles of
     * lower resolution representing the same content. If neither x nor y is
     * given, returns true if the entire visible level provides coverage.
     *
     * Note that out-of-bounds tiles provide coverage in this sense, since
     * there's no content that they would need to cover. Tiles at non-existent
     * levels that are within the image bounds, however, do not.
     * @private
     *
     * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean.
     * @param {Number} level - The resolution level of the tile.
     * @param {Number} x - The X position of the tile.
     * @param {Number} y - The Y position of the tile.
     * @returns {Boolean}
     */
    _providesCoverage: function( coverage, level, x, y ) {
        let rows;
        let cols;
        let i, j;

        if ( !coverage[ level ] ) {
            return false;
        }

        if ( x === undefined || y === undefined ) {
            rows = coverage[ level ];
            for ( i in rows ) {
                if ( Object.prototype.hasOwnProperty.call( rows, i ) ) {
                    cols = rows[ i ];
                    for ( j in cols ) {
                        if ( Object.prototype.hasOwnProperty.call( cols, j ) && !cols[ j ] ) {
                            return false;
                        }
                    }
                }
            }

            return true;
        }

        return (
            coverage[ level ][ x] === undefined ||
            coverage[ level ][ x ][ y ] === undefined ||
            coverage[ level ][ x ][ y ] === true
        );
    },

    /**
     * Returns true if the given tile is completely covered by higher-level
     * tiles of higher resolution representing the same content. If neither x
     * nor y is given, returns true if the entire visible level is covered.
     * @private
     *
     * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean.
     * @param {Number} level - The resolution level of the tile.
     * @param {Number} x - The X position of the tile.
     * @param {Number} y - The Y position of the tile.
     * @returns {Boolean}
     */
    _isCovered: function( coverage, level, x, y ) {
        if ( x === undefined || y === undefined ) {
            return this._providesCoverage( coverage, level + 1 );
        } else {
            return (
                this._providesCoverage( coverage, level + 1, 2 * x, 2 * y ) &&
                this._providesCoverage( coverage, level + 1, 2 * x, 2 * y + 1 ) &&
                this._providesCoverage( coverage, level + 1, 2 * x + 1, 2 * y ) &&
                this._providesCoverage( coverage, level + 1, 2 * x + 1, 2 * y + 1 )
            );
        }
    },

    /**
     * Sets whether the given tile provides coverage or not.
     * @private
     *
     * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean.
     * @param {Number} level - The resolution level of the tile.
     * @param {Number} x - The X position of the tile.
     * @param {Number} y - The Y position of the tile.
     * @param {Boolean} covers - Whether the tile provides coverage.
     */
    _setCoverage: function( coverage, level, x, y, covers ) {
        if ( !coverage[ level ] ) {
            $.console.warn(
                "Setting coverage for a tile before its level's coverage has been reset: %s",
                level
            );
            return;
        }

        if ( !coverage[ level ][ x ] ) {
            coverage[ level ][ x ] = {};
        }

        coverage[ level ][ x ][ y ] = covers;
    },

    /**
     * Resets coverage information for the given level. This should be called
     * after every draw routine. Note that at the beginning of the next draw
     * routine, coverage for every visible tile should be explicitly set.
     * @private
     *
     * @param {Object} coverage - A '3d' dictionary [level][x][y] --> Boolean.
     * @param {Number} level - The resolution level of tiles to completely reset.
     */
    _resetCoverage: function( coverage, level ) {
        coverage[ level ] = {};
    }
});



}( OpenSeadragon ));

/*
 * OpenSeadragon - TileCache
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

    const OpenSeadragon = $; // alias for JSDoc

    const DRAWER_INTERNAL_CACHE = Symbol("DRAWER_INTERNAL_CACHE");

    /**
     * @class OpenSeadragon.CacheRecord
     * @memberof OpenSeadragon
     * @classdesc Cached Data Record, the cache object. Keeps only latest object type required.
     *
     * This class acts like the Maybe type:
     *  - it has 'loaded' flag indicating whether the tile data is ready
     *  - it has 'data' property that has value if loaded=true
     *
     * Furthermore, it has a 'getData' function that returns a promise resolving
     * with the value on the desired type passed to the function.
     */
    OpenSeadragon.CacheRecord = class CacheRecord {
        constructor() {
            this.revive();
        }

        /**
         * Access the cache record data directly. Preferred way of data access.
         * Might be undefined if this.loaded = false.
         * You can access the data in synchronous way, but the data might not be available.
         * If you want to access the data indirectly (await), use this.transformTo or this.getDataAs
         * @returns {any}
         */
        get data() {
            return this._data;
        }

        /**
         * Read the cache type. The type can dynamically change, but should be consistent at
         * one point in the time. For available types see the OpenSeadragon.Converter, or the tutorials.
         * @returns {string}
         */
        get type() {
            return this._type;
        }

        /**
         * Await ongoing process so that we get cache ready on callback.
         * @returns {OpenSeadragon.Promise<?>}
         */
        await() {
            if (!this._promise) { //if not cache loaded, do not fail
                return $.Promise.resolve(this._data);
            }
            return this._promise;
        }

        getImage() {
            $.console.error("[CacheRecord.getImage] options.image is deprecated. Moreover, it might not work" +
                " correctly as the cache system performs conversion asynchronously in case the type needs to be converted.");
            this.transformTo("image");
            return this.data;
        }

        getRenderedContext() {
            $.console.error("[CacheRecord.getRenderedContext] options.getRenderedContext  is deprecated. Moreover, it might not work" +
                " correctly as the cache system performs conversion asynchronously in case the type needs to be converted.");
            this.transformTo("context2d");
            return this.data;
        }

        /**
         * Set the cache data. Asynchronous.
         * @param {any} data
         * @param {string} type
         * @returns {OpenSeadragon.Promise<?>} the old cache data that has been overwritten
         */
        setDataAs(data, type) {
            //allow set data with destroyed state, destroys the data if necessary
            $.console.assert(data !== undefined && data !== null, "[CacheRecord.setDataAs] needs valid data to set!");
            if (this._conversionJobQueue) {
                //delay saving if ongiong conversion, these were registered first
                let resolver = null;
                const promise = new $.Promise((resolve, reject) => {
                    resolver = resolve;
                });
                this._conversionJobQueue.push(() => resolver(this._overwriteData(data, type)));
                return promise;
            }
            return this._overwriteData(data, type);
        }

        /**
         * Access the cache record data indirectly. Preferred way of data access. Asynchronous.
         * @param {string} [type=undefined]
         * @param {boolean} [copy=true] if false and same type is retrieved as the cache type,
         *  copy is not performed: note that this is potentially dangerous as it might
         *  introduce race conditions (you get a cache data direct reference you modify).
         * @returns {OpenSeadragon.Promise<?>} desired data type in promise, undefined if the cache was destroyed
         */
        getDataAs(type = undefined, copy = true) {
            if (this.loaded) {
                if (type === this._type) {
                    return copy ? $.converter.copy(this._tRef, this._data, type || this._type) : this._promise;
                }
                return this._transformDataIfNeeded(this._tRef, this._data, type || this._type, copy) || this._promise;
            }
            return this._promise.then(data => this._transformDataIfNeeded(this._tRef, data, type || this._type, copy) || data);
        }

        _transformDataIfNeeded(referenceTile, data, type, copy) {
            //might get destroyed in meanwhile
            if (this._destroyed) {
                return $.Promise.resolve();
            }

            let result;
            if (type !== this._type) {
                result = $.converter.convert(referenceTile, data, this._type, type);
            } else if (copy) { //convert does not copy data if same type, do explicitly
                result = $.converter.copy(referenceTile, data, type);
            }
            if (result) {
                return result.then(finalData => {
                    if (this._destroyed) {
                        $.converter.destroy(finalData, type);
                        return undefined;
                    }
                    return finalData;
                }).catch(e => {
                    this._handleConversionError(e);
                    return undefined;
                });
            }
            return false; // no conversion needed, parent function returns item as-is
        }

        /**
         * Access of the data by drawers, synchronous function. Should always access a valid main cache.
         * This is ensured by invalidation routine that executes data modification on a copy record, and
         * then synchronously swaps records (main caches) to the new data between render calls.
         *
         * If a drawer decides to have internal cache with synchronous behavior, it is (if necessary)
         * performed during this phase.
         *
         * @param {OpenSeadragon.DrawerBase} drawer drawer reference which requests the data: the drawer
         *   defines the supported formats this cache should return **synchronously**
         * @param {OpenSeadragon.Tile} tileToDraw reference to the tile that is in the process of drawing and
         *   for which we request the data; if we attempt to draw such tile while main cache target is destroyed,
         *   attempt to reset the tile state to force system to re-download it again
         * @returns {OpenSeadragon.CacheRecord|OpenSeadragon.InternalCacheRecord|undefined} desired data if available,
         *   wrapped in the cache container. This data is guaranteed to be loaded & in the type supported by the drawer.
         *   Returns undefined if the data is not ready for rendering.
         * @private
         */
        getDataForRendering(drawer, tileToDraw) {
            // Test cache state
            if (this._destroyed) {
                $.console.error(`Attempt to draw tile with destroyed main cache ${this}!`);
                tileToDraw._unload();
                return undefined;
            }
            if (!this.loaded) {
                // If a conversion/load is in progress, it is normal that the cache is temporarily not loaded.
                // Avoid spamming errors; just skip drawing this tile this frame.
                if (this._promise) {
                    return undefined;
                }
                $.console.error(`Attempt to draw cache ${this} when not loaded!`);
                return undefined;
            }
            if (this._destroyed) {
                $.console.error(`Attempt to draw tile with destroyed main cache ${this}!`);
                tileToDraw._unload();  // try to restore the state so that the tile is later on fetched again
                return undefined;
            }

            // Ensure cache in a format suitable for the current drawer. If not it is an error, prepareForRendering
            // should be called at the end of invalidation routine instead. Since the processing is async, we are
            // unable to provide the rendering data immediatelly - return.
            const supportedTypes = drawer.getSupportedDataFormats();
            if (!supportedTypes.includes(this.type)) {
                $.console.error(`Attempt to draw tile cache ${this} with unsupported type '${this.type}' for the target drawer!`);
                this.prepareForRendering(drawer);
                return undefined;
            }

            // If we support internal cache
            if (drawer.options.usePrivateCache) {
                // let sync preparation handle data if no preloading desired
                if (!drawer.options.preloadCache) {
                    return this.prepareInternalCacheSync(drawer);
                }
                // or check internal cache state before returning
                const internalCache = this._getInternalCacheRef(drawer);
                if (!internalCache || !internalCache.loaded) {
                    $.console.error(`Attempt to draw tile cache ${this} with internal cache non-ready state!`);
                    return undefined;
                }
                return internalCache;
            }

            // else just return self reference
            return this;
        }

        /**
         * Check whether the cache is usable for the given drawer. The cache is considered
         * usable if it is in a format supported by the drawer and, if the drawer uses internal cache,
         * the internal cache was created (it might not be loaded yet though).
         * @param {OpenSeadragon.DrawerBase} drawer
         * @return {boolean}
         */
        isUsableForDrawer(drawer) {
            const supportedTypes = drawer.getSupportedDataFormats();
            if (!supportedTypes.includes(this.type)) {
                return false;
            }
            if (drawer.options.usePrivateCache) {
                const internalCache = this._getInternalCacheRef(drawer);
                if (!internalCache) {
                    return false;
                }
            }
            return true;
        }

        /**
         * Preparation for rendering ensures the CacheRecord is in a format supported by the current
         * drawer. Furthermore, if internal cache is to be used by a drawer with preloading enabled,
         * it happens in this step.
         *
         * Note: Should not be called if cache type is already among supported types.
         * @private
         * @param {OpenSeadragon.DrawerBase} drawer
         * @return {OpenSeadragon.Promise<*>} reference to the data,
         *  or null if not data yet loaded/ready (usually due to error)
         */
        prepareForRendering(drawer) {
            const supportedTypes = drawer.getRequiredDataFormats();

            // If not loaded, await until ready and try again
            if (!this.loaded) {
                return this.await().then(_ => this.prepareForRendering(drawer));
            }

            let selfPromise;
            // If not in one of required types, transform
            if (!supportedTypes.includes(this.type)) {
                selfPromise = this.transformTo(supportedTypes);
            } else {
                selfPromise = this.await();
            }

            const swallow = (p) => p.catch(e => {
                this._handleConversionError(e);
                return null;
            });

            // If internal cache wanted and preloading enabled, convert now
            if (drawer.options.usePrivateCache && drawer.options.preloadCache) {
                return swallow(selfPromise.then(_ => this.prepareInternalCacheAsync(drawer)));
            }
            return swallow(selfPromise);
        }

        /**
         * Internal cache is defined by a Drawer. Async preparation happens as the last step in the
         * invalidation routine.
         * Must not be called if drawer.options.usePrivateCache == false. Called inside prepareForRenderine
         * by cache itself if preloadCache == true (supports async behavior).
         *
         * @private
         * @param {OpenSeadragon.DrawerBase} drawer
         * @return {OpenSeadragon.Promise<*>} reference to the data wrapped in a promise,
         *  or null if not data yet loaded/ready (usually due to error)
         */
        prepareInternalCacheAsync(drawer) {
            let internalCache = this._getInternalCacheRef(drawer);
            if (this._checkInternalCacheUpToDate(internalCache, drawer)) {
                return internalCache.await();
            }

            // Force reset
            if (internalCache && !internalCache.loaded) {
                internalCache.await().then(() => internalCache.destroy());
            }

            $.console.assert(this._tRef, "Data Create called from invalidation routine needs tile reference!");
            const transformedData = drawer.internalCacheCreate(this, this._tRef);
            $.console.assert(transformedData !== undefined, "[DrawerBase.internalCacheCreate] must return a value if usePrivateCache is enabled!");
            const drawerID = drawer.getId();
            internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData,
                drawerID, (data) => drawer.internalCacheFree(data));
            return internalCache.await();
        }

        /**
         * Internal cache is defined by a Drawer. Sync preparation happens directly before rendering.
         * Must not be called if drawer.options.usePrivateCache == false. Called inside getDataForRendering
         * by cache itself if preloadCache == false (without support for async behavior).
         * @private
         * @param {OpenSeadragon.DrawerBase} drawer
         * @return {OpenSeadragon.InternalCacheRecord} reference to the cache
         */
        prepareInternalCacheSync(drawer) {
            let internalCache = this._getInternalCacheRef(drawer);
            if (this._checkInternalCacheUpToDate(internalCache, drawer)) {
                return internalCache;
            }

            // Force reset
            if (internalCache) {
                internalCache.destroy();
            }

            $.console.assert(this._tRef, "Data Create called from drawing loop needs tile reference!");
            const transformedData = drawer.internalCacheCreate(this, this._tRef);
            $.console.assert(transformedData !== undefined, "[DrawerBase.internalCacheCreate] must return a value if usePrivateCache is enabled!");

            const drawerID = drawer.getId();
            internalCache = this[DRAWER_INTERNAL_CACHE][drawerID] = new $.InternalCacheRecord(transformedData,
                drawerID, (data) => drawer.internalCacheFree(data));
            return internalCache;
        }

        /**
         * Get an internal cache reference for given drawer
         * @param {OpenSeadragon.DrawerBase} drawer
         * @return {OpenSeadragon.InternalCacheRecord|undefined}
         * @private
         */
        _getInternalCacheRef(drawer) {
            const options = drawer.options;
            if (!options.usePrivateCache) {
                $.console.error("[CacheRecord.prepareInternalCacheSync] must not be called when usePrivateCache is false.");
                return undefined;
            }

            // we can get here only if we want to render incompatible type
            let internalCache = this[DRAWER_INTERNAL_CACHE];
            if (!internalCache) {
                internalCache = this[DRAWER_INTERNAL_CACHE] = {};
            }
            return internalCache[drawer.getId()];
        }

        /**
         * Check if internal cache is up to date. Might be in loading state.
         * @param {OpenSeadragon.InternalCacheRecord} internalCache
         * @param {OpenSeadragon.DrawerBase} drawer
         * @return {boolean} false if the internal cache is outdated
         * @private
         */
        _checkInternalCacheUpToDate(internalCache, drawer) {
            // We respect existing records, unless they are outdated. Invalidation routine by its nature
            // destroys internal cache, therefore we do not need to check if internal cache is consistent with its parent.
            return internalCache && internalCache.tstamp >= drawer._dataNeedsRefresh;
        }

        /**
         * Transform cache to desired type and get the data after conversion.
         * Does nothing if the type equals to the current type. Asynchronous.
         * Transformation is LAZY, meaning conversions are performed only to
         * match the last conversion request target type.
         * @param {string|string[]} type if array provided, the system will
         *   try to optimize for the best type to convert to.
         * @return {OpenSeadragon.Promise<?>}
         */
        transformTo(type = this._type) {
            if (!this.loaded) {
                this._conversionJobQueue = this._conversionJobQueue || [];
                let resolver = null;
                const promise = new $.Promise((resolve, reject) => {
                    resolver = resolve;
                });

                // Todo consider submitting only single tranform job to queue: any other transform calls will have
                //  no effect, the last one decides the target format
                this._conversionJobQueue.push(() => {
                    if (this._destroyed) {
                        return;
                    }
                    //must re-check types since we perform in a queue of conversion requests
                    if ((typeof type === "string" && type !== this._type) || (Array.isArray(type) && !type.includes(this._type))) {
                        //ensures queue gets executed after finish
                        this._convert(this._type, type);
                        this._promise.then(data => resolver(data));
                    } else {
                        //must ensure manually, but after current promise finished, we won't wait for the following job
                        this._promise.then(data => {
                            this._checkAwaitsConvert();
                            return resolver(data);
                        });
                    }
                });
                return promise;
            }

            if ((typeof type === "string" && type !== this._type) || (Array.isArray(type) && !type.includes(this._type))) {
                this._convert(this._type, type);
            }
            return this._promise;
        }

        /**
         * If cache ceases to be the primary one, free data
         * @param {string} drawerId if undefined, all caches are freed, else only target one
         * @private
         */
        destroyInternalCache(drawerId = undefined) {
            const internal = this[DRAWER_INTERNAL_CACHE];
            if (internal) {
                if (drawerId) {
                    const cache = internal[drawerId];
                    if (cache) {
                        cache.destroy();
                        delete internal[drawerId];
                    }
                } else {
                    for (const iCache in internal) {
                        internal[iCache].destroy();
                    }
                    delete this[DRAWER_INTERNAL_CACHE];
                }
            }
        }

        /**
         * Conversion requires tile references:
         * keep the most 'up to date' ref here. It is called and managed automatically.
         * @param {OpenSeadragon.Tile} ref
         * @return {OpenSeadragon.CacheRecord} self reference for builder pattern
         * @private
         */
        withTileReference(ref) {
            this._tRef = ref;
            return this;
        }

        /**
         * Get cache description. Used for system messages and errors.
         * @return {string}
         */
        toString() {
            const tile = this._tRef || (this._tiles.length && this._tiles[0]);
            return tile ? `Cache ${this.type} [used e.g. by ${tile.toString()}]` : `Orphan cache!`;
        }

        /**
         * Set initial state, prepare for usage.
         * Must not be called on active cache, e.g. first call destroy().
         */
        revive() {
            $.console.assert(!this.loaded && !this._type, "[CacheRecord::revive] must not be called when loaded!");
            this._tiles = [];
            this._data = null;
            this._type = null;
            this.loaded = false;
            this._promise = null;
            this._destroyed = false;

            // Optional ownership metadata (set by TileCache for managed records).
            // Working caches created during invalidation are intentionally left without an owner.
            this._ownerTileCache = null;
            this.cacheKey = null;
        }

        /**
         * Free all the data and call data destructors if defined.
         */
        destroy() {
            if (!this._destroyed) {
                delete this._conversionJobQueue;
                this._destroyed = true;

                // make sure this gets destroyed even if loaded=false
                if (this.loaded) {
                    this._destroySelfUnsafe(this._data, this._type);
                } else if (this._promise) {
                    const oldType = this._type;
                    this._promise.then(x => this._destroySelfUnsafe(x, oldType)).catch($.console.error);
                }
            }

        }

        _destroySelfUnsafe(data, type) {
            // ensure old data destroyed
            $.converter.destroy(data, type);
            this.destroyInternalCache();
            // might've got revived in meanwhile if async ...
            if (!this._destroyed) {
                return;
            }
            this.loaded = false;
            this._tiles = null;
            this._data = null;
            this._type = null;
            this._tRef = null;
            this._promise = null;
        }

        /**
         * Add tile dependency on this record
         * @param tile
         * @param data can be null|undefined => optimization, will skip data initialization and just adds tile reference
         * @param type
         */
        addTile(tile, data, type) {
            if (this._destroyed) {
                return;
            }
            $.console.assert(tile, '[CacheRecord.addTile] tile is required');

            // first come first served, data for existing tiles is NOT overridden
            if (data !== undefined && data !== null && this._tiles.length < 1) {
                // Since we IGNORE new data if already initialized, we support 'data getter'
                if (typeof data === 'function') {
                    data = data();
                }

                // in case we attempt to write to existing data object
                if (this.type && this._promise) {
                    if (data instanceof $.Promise) {
                        this._promise = data.then(d => {
                            this._overwriteData(d, type);
                        });
                    } else {
                        this._overwriteData(data, type);
                    }
                } else {
                    // If we receive async callback, we consume the async state
                    if (data instanceof $.Promise) {
                        this._promise = data.then(data => {
                            if (this._destroyed) {
                                try {
                                    $.converter.destroy(data, this._type);
                                } catch (e) {
                                    // no-op
                                }
                                return undefined;
                            }
                            this.loaded = true;
                            this._data = data;
                            return data;
                        }).catch(e => {
                            this._handleConversionError(e);
                            return undefined;
                        });
                        this._data = null;
                    } else {
                        this._promise = $.Promise.resolve(data);
                        this._data = data;
                        this.loaded = true;
                    }

                    this._type = type;
                }
                this._tiles.push(tile);
            } else {
                const tileExists = this._tiles.includes(tile);
                if (!tileExists && this.type && this._promise) {
                    // here really check we are loaded, since if optimization allows sending no data and we add tile without
                    // proper initialization it is a bug
                    this._tiles.push(tile);
                } else if (!tileExists) {
                    $.console.warn("Tile %s caching attempt without data argument on uninitialized cache entry!", tile);
                }
            }
        }

        /**
         * Remove tile dependency on this record.
         * @param tile
         * @returns {Boolean} true if record removed
         */
        removeTile(tile) {
            if (this._destroyed) {
                return false;
            }
            for (let i = 0; i < this._tiles.length; i++) {
                if (this._tiles[i] === tile) {
                    this._tiles.splice(i, 1);
                    if (this._tRef === tile) {
                        // keep fresh ref
                        this._tRef = this._tiles[i - 1];
                    }
                    return true;
                }
            }
            $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile);
            return false;
        }

        /**
         * Get the amount of tiles sharing this record.
         * @return {number}
         */
        getTileCount() {
            return this._tiles ? this._tiles.length : 0;
        }

        /**
         * Private conversion that makes sure collided requests are
         * processed eventually
         * @private
         */
        _checkAwaitsConvert() {
            if (!this._conversionJobQueue || this._destroyed) {
                return;
            }
            //let other code finish first
            setTimeout(() => {
                //check again, meanwhile things might've changed
                if (!this._conversionJobQueue || this._destroyed) {
                    return;
                }
                const job = this._conversionJobQueue[0];
                this._conversionJobQueue.splice(0, 1);
                if (this._conversionJobQueue.length === 0) {
                    delete this._conversionJobQueue;
                }
                job();
            });
        }

        _triggerNeedsDraw() {
            if (this._tiles.length > 0) {
                this._tiles[0].tiledImage.viewer.forceRedraw();
            }
        }

        /**
         * Safely overwrite the cache data and return the old data
         * @private
         */
        _overwriteData(data, type) {
            if (this._destroyed) {
                //we have received the ownership of the data, destroy it too since we are destroyed
                $.converter.destroy(data, type);
                return $.Promise.resolve();
            }
            if (this.loaded) {
                // No-op if attempt to replace with the same object
                if (this._data === data && this._type === type) {
                    return this._promise;
                }
                $.converter.destroy(this._data, this._type);
                this._type = type;
                this._data = data;
                this._promise = $.Promise.resolve(data);
                const internal = this[DRAWER_INTERNAL_CACHE];
                if (internal) {
                    for (const iCache in internal) {
                        internal[iCache].setDataAs(data, type);
                    }
                }
                this._triggerNeedsDraw();
                return this._promise;
            }
            return this._promise.then(() => {
                // No-op if attempt to replace with the same object
                if (this._data === data && this._type === type) {
                    return this._data;
                }
                $.converter.destroy(this._data, this._type);
                this._type = type;
                this._data = data;
                this._promise = $.Promise.resolve(data);
                const internal = this[DRAWER_INTERNAL_CACHE];
                if (internal) {
                    for (const iCache in internal) {
                        internal[iCache].setDataAs(data, type);
                    }
                }
                this._triggerNeedsDraw();
                return this._data;
            });
        }

        /**
         * Private conversion that makes sure the cache knows its data is ready
         * @param to array or a string - allowed types
         * @param from string - type origin
         * @private
         */
        _convert(from, to) {
            const converter = $.converter,
                conversionPath = converter.getConversionPath(from, to);
            if (!conversionPath) {
                $.console.error(`[CacheRecord._convert] Conversion ${from} ---> ${to} cannot be done!`);
                return; //no-op
            }

            const originalData = this._data;
            const stepCount = conversionPath.length;
            const _this = this;
            const convert = (x, i) => {
                if (i >= stepCount) {
                    _this._data = x;
                    _this.loaded = true;
                    _this._checkAwaitsConvert();
                    return $.Promise.resolve(x);
                }
                const edge = conversionPath[i];
                let y;
                try {
                    y = edge.transform(_this._tRef, x);
                } catch (err) {
                    converter.destroy(x, edge.origin.value); // prevent leak
                    return $.Promise.reject(`[CacheRecord._convert] sync failure (while converting using ${edge.target.value}, ${edge.origin.value})`);
                }
                if (y === undefined) {
                    _this.loaded = false;
                    converter.destroy(x, edge.origin.value); // prevent leak
                    return $.Promise.reject(`[CacheRecord._convert] data mid result undefined value (while converting using ${edge.target.value}, ${edge.origin.value})`);
                }
                converter.destroy(x, edge.origin.value);
                const result = $.type(y) === "promise" ? y : $.Promise.resolve(y);
                return result.then(res => convert(res, i + 1));
            };

            this.loaded = false;
            this._data = undefined;
            // Read target type from the conversion path: [edge.target] = Vertex, its value=type
            this._type = conversionPath[stepCount - 1].target.value;

            // IMPORTANT: conversion failures must not poison the cache record with a permanently
            // rejected promise (methods rely on being able to await() without throwing).
            this._promise = convert(originalData, 0).catch(e => {
                this._handleConversionError(e);
                return undefined;
            });
        }

        /**
         * Handle conversion error by cleaning up and unloading affected tiles
         * @param {Error} e
         * @private
         */
        _handleConversionError(e) {
            $.console.error("[CacheRecord] Conversion/preparation error:", e);

            this._destroyed = true;
            this.loaded = false;
            this._data = null;

            // WORKING CACHE: do not escalate to TileCache, do not unload tiles.
            // A working cache is not registered (no cacheKey and/or no owner).
            if (!this.cacheKey || !this._ownerTileCache) {
                this._promise = $.Promise.resolve(undefined);
                this._tiles = [];
                this._tRef = null;
                return;
            }

            // MANAGED CACHE: notify TileCache to remove record and possibly mark tile missing.
            this._ownerTileCache._handleBrokenCacheRecord(this);
        }
    };

    /**
     * @class OpenSeadragon.InternalCacheRecord
     * @memberof OpenSeadragon
     * @classdesc Simple cache record without robust support for async access. Meant for internal use only.
     *
     * This class acts like the Maybe type:
     *  - it has 'loaded' flag indicating whether the tile data is ready
     *  - it has 'data' property that has value if loaded=true
     *
     * This class supposes synchronous access, no collision of transform calls.
     * It also does not record tiles nor allows cache/tile sharing.
     * @private
     */
    OpenSeadragon.InternalCacheRecord = class InternalCacheRecord {
        constructor(data, type, onDestroy) {
            this.tstamp = $.now();
            this._ondestroy = onDestroy;
            this._type = type;

            if (data instanceof $.Promise) {
                this._promise = data;
                data.then(data => {
                    this.loaded = true;
                    this._data = data;
                });
            } else {
                this._promise = null;
                this.loaded = true;
                this._data = data;
            }
        }

        /**
         * Sync access to the data
         * @returns {any}
         */
        get data() {
            return this._data;
        }

        /**
         * Sync access to the current type
         * @returns {string}
         */
        get type() {
            return this._type;
        }

        /**
         * Await ongoing process so that we get cache ready on callback.
         * @returns {OpenSeadragon.Promise<?>}
         */
        await() {
            if (!this._promise) { //if not cache loaded, do not fail
                return $.Promise.resolve(this._data);
            }
            return this._promise;
        }

        /**
         * Must be called before transformTo or setDataAs. To keep
         * compatible api with CacheRecord where tile refs are known.
         * @param {OpenSeadragon.Tile} referenceTile reference tile for conversion
         * @return {OpenSeadragon.InternalCacheRecord} self reference for builder pattern
         */
        withTileReference(referenceTile) {
            this._temporaryTileRef = referenceTile;
            return this;
        }

        /**
         * Free all the data and call data destructors if defined.
         */
        destroy() {
            if (this.loaded) {
                if (this._ondestroy) {
                    this._ondestroy(this._data);
                }
                this._data = null;
                this.loaded = false;
            }
        }
    };


    /**
     * @class OpenSeadragon.TileCache
     * @memberof OpenSeadragon
     * @classdesc Stores all the tiles displayed in a {@link OpenSeadragon.Viewer}.
     * You generally won't have to interact with the TileCache directly.
     * @param {Object} options - Configuration for this TileCache.
     * @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in
     * {@link OpenSeadragon.Options} for details.
     */
    OpenSeadragon.TileCache = class TileCache {
        constructor( options ) {

            options = options || {};

            this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount;
            // requestInvalidate() touches this private property due to performance reasons
            this._tilesLoaded = [];
            this._zombiesLoaded = [];
            this._zombiesLoadedCount = 0;
            this._cachesLoaded = [];
            this._cachesLoadedCount = 0;
        }

        /**
         * @returns {Number} The total number of tiles that have been loaded by
         * this TileCache. Note that the tile might be recorded here mutliple times,
         * once for each cache it uses.
         */
        numTilesLoaded() {
            return this._tilesLoaded.length;
        }

        /**
         * @returns {Number} The total number of cached objects (+ zombies)
         */
        numCachesLoaded() {
            return this._zombiesLoadedCount + this._cachesLoadedCount;
        }

        /**
         * Caches the specified tile, removing an old tile if necessary to stay under the
         * maxImageCacheCount specified on construction. Note that if multiple tiles reference
         * the same image, there may be more tiles than maxImageCacheCount; the goal is to keep
         * the number of images below that number. Note, as well, that even the number of images
         * may temporarily surpass that number, but should eventually come back down to the max specified.
         * @private
         * @param {Object} options - Cache creation parameters.
         * @param {OpenSeadragon.Tile} options.tile - The tile to cache.
         * @param {?String} [options.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey
         * @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache.
         *   Used if options.cacheKey not set.
         * @param {Image} options.image - The image of the tile to cache. Deprecated.
         * @param {*} options.data - The data of the tile to cache. If `typeof data === 'function'` holds,
         *   the data is called to obtain the data item: this is an optimization to load data only when necessary.
         * @param {string} [options.dataType] - The data type of the tile to cache. Required.
         * @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this
         *   function will release an old tile. The cutoff option specifies a tile level at or below which
         *   tiles will not be released.
         * @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to.
         */
        cacheTile(options) {
            $.console.assert(options, "[TileCache.cacheTile] options is required");
            const theTile = options.tile;
            $.console.assert(theTile, "[TileCache.cacheTile] options.tile is required");
            $.console.assert(theTile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required");

            if (options.image instanceof Image) {
                $.console.warn("[TileCache.cacheTile] options.image is deprecated!");
                options.data = options.image;
                options.dataType = "image";
            }

            const cacheKey = options.cacheKey || theTile.cacheKey;

            let cacheRecord = this._cachesLoaded[cacheKey];
            if (!cacheRecord) {
                if (options.data === undefined) {
                    $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " +
                        "has been deprecated and will be removed in the future.");
                    options.data = options.image;
                }

                cacheRecord = this._zombiesLoaded[cacheKey];
                if (cacheRecord) {
                    // zombies should not be (yet) destroyed, but if we encounter one...
                    if (cacheRecord._destroyed) {
                        // if destroyed, invalidation routine will get triggered for us automatically
                        cacheRecord.revive();
                    } else {
                        // if zombie ready, do not overwrite its data, in that case try to call
                        // we need to trigger invalidation routine, data was not part of the system!
                        if (typeof options.data === 'function') {
                            options.data();
                        }
                        delete options.data;
                    }
                    delete this._zombiesLoaded[cacheKey];
                    this._zombiesLoadedCount--;
                    this._cachesLoaded[cacheKey] = cacheRecord;
                    this._cachesLoadedCount++;
                } else {
                    //allow anything but undefined, null, false (other values mean the data was set, for example '0')
                    const validData = options.data !== undefined && options.data !== null && options.data !== false;
                    $.console.assert(validData, "[TileCache.cacheTile] options.data is required to create an CacheRecord");
                    cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord();
                    this._cachesLoadedCount++;
                }
            }

            if (!options.dataType) {
                $.console.error("[TileCache.cacheTile] options.dataType is newly required. " +
                    "For easier use of the cache system, use the tile instance API.");

                // We need to force data acquisition now to guess the type
                if (typeof options.data === 'function') {
                    $.console.error("[TileCache.cacheTile] options.dataType is mandatory " +
                        " when data item is a callback!");
                }
                options.dataType = $.converter.guessType(options.data);
            }

            cacheRecord._ownerTileCache = this;
            cacheRecord.cacheKey = cacheKey;
            cacheRecord.addTile(theTile, options.data, options.dataType);
            this._freeOldRecordRoutine(theTile, options.cutoff || 0);
            return cacheRecord;
        }

        /**
         * Changes cache key
         * @private
         * @param {Object} options - Cache creation parameters.
         * @param {String} options.oldCacheKey - Current key
         * @param {String} options.newCacheKey - New key to set
         * @return {OpenSeadragon.CacheRecord | null}
         */
        renameCache(options) {
            const newKey = options.newCacheKey,
                oldKey = options.oldCacheKey;
            let originalCache = this._cachesLoaded[oldKey];

            if (!originalCache) {
                originalCache = this._zombiesLoaded[oldKey];
                $.console.assert(originalCache, "[TileCache.renameCache] oldCacheKey must reference existing cache!");
                if (this._zombiesLoaded[newKey]) {
                    $.console.error("Cannot rename zombie cache %s to %s: the target cache is occupied!",
                        oldKey, newKey);
                    return null;
                }
                this._zombiesLoaded[newKey] = originalCache;
                delete this._zombiesLoaded[oldKey];
            } else if (this._cachesLoaded[newKey]) {
                $.console.error("Cannot rename cache %s to %s: the target cache is occupied!",
                    oldKey, newKey);
                return null; // do not remove, we perform additional fixes on caches later on when swap occurred
            } else {
                this._cachesLoaded[newKey] = originalCache;
                delete this._cachesLoaded[oldKey];
            }

            originalCache._ownerTileCache = this;
            originalCache.cacheKey = newKey;
            for (const tile of originalCache._tiles) {
                tile.reflectCacheRenamed(oldKey, newKey);
            }

            // do not call free old record routine, we did not increase cache size
            return originalCache;
        }

        /**
         * Reads a cache if it exists and creates a new copy of a target, different cache if it does not
         * @param {Object} options
         * @param {OpenSeadragon.Tile} options.tile - The tile to own ot add record for the cache.
         * @param {String} options.copyTargetKey - The unique key used to identify this tile in the cache.
         * @param {String} options.newCacheKey - The unique key the copy will be created for.
         * @param {String} [options.desiredType=undefined] - For optimization purposes, the desired type. Can
         *   be ignored.
         * @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this
         *   function will release an old tile. The cutoff option specifies a tile level at or below which
         *   tiles will not be released.
         * @returns {OpenSeadragon.Promise<OpenSeadragon.CacheRecord>} - New record.
         * @private
         */
        cloneCache(options) {
            const theTile = options.tile;
            const cacheKey = options.copyTargetKey;
            const cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey];
            $.console.assert(cacheRecord, "[TileCache.cloneCache] attempt to clone non-existent cache %s!", cacheKey);
            $.console.assert(!this._cachesLoaded[options.newCacheKey],
                "[TileCache.cloneCache] attempt to copy clone to existing cache %s!", options.newCacheKey);

            const desiredType = options.desiredType || undefined;
            return cacheRecord.getDataAs(desiredType, true).then(data => {
                const newRecord = this._cachesLoaded[options.newCacheKey] = new $.CacheRecord();
                newRecord.addTile(theTile, data, cacheRecord.type);
                this._cachesLoadedCount++;
                this._freeOldRecordRoutine(theTile, options.cutoff || 0);
                return newRecord;
            });
        }

        /**
         * Inject new cache to the system
         * @param {Object} options
         * @param {OpenSeadragon.Tile} options.tile - Reference tile. All tiles sharing original data will be affected.
         * @param {OpenSeadragon.CacheRecord} options.cache - Cache that will be injected.
         * @param {String} options.targetKey - The target cache key to inhabit. Can replace existing cache.
         * @param {Boolean} options.setAsMainCache - If true, tiles main cache gets updated to consumerKey.
         *   Otherwise, if consumerKey==tile.cacheKey the cache is set as main too.
         * @param {Boolean} options.tileAllowNotLoaded - if true, tile that is not loaded is also processed,
         *   this is internal parameter used in tile-loaded completion routine, as we need to prepare tile but
         *   it is not yet loaded and cannot be marked as so (otherwise the system would think it is ready)
         * @private
         */
        injectCache(options) {
            const targetKey = options.targetKey,
                tile = options.tile;
            if (!options.tileAllowNotLoaded && !tile.loaded && !tile.loading) {
                $.console.warn("Attempt to inject cache on tile in invalid state: this is probably a bug!");
                return;
            }
            const consumer = this._cachesLoaded[targetKey];
            if (consumer) {
                // We need to avoid async execution here: replace consumer instead of overwriting the data.
                const iterateTiles = [...consumer._tiles];  // unloadCacheForTile() will modify the array, use a copy
                for (const tile of iterateTiles) {
                    this.unloadCacheForTile(tile, targetKey, true, false);
                }
            }
            if (this._cachesLoaded[targetKey]) {
                $.console.error("The inject routine should've freed cache!");
            }

            const cache = options.cache;
            this._cachesLoaded[targetKey] = cache;
            cache._ownerTileCache = this;
            cache.cacheKey = targetKey;

            // Update cache: add the new cache, we must add since we removed above with unloadCacheForTile()
            for (const t of tile.getCache(tile.originalCacheKey)._tiles) {  // grab all cache-equal tiles
                t.setCache(targetKey, cache, options.setAsMainCache, false);
            }
        }

        /**
         * Replace cache (and update tile references) by another cache
         * @param {Object} options
         * @param {OpenSeadragon.Tile} options.tile - The tile to own ot add record for the cache.
         * @param {String} options.victimKey - Cache that will be erased. In fact, the victim _replaces_ consumer,
         *   inheriting its tiles and key.
         * @param {String} options.consumerKey - The cache that consumes the victim. In fact, it gets destroyed and
         *   replaced by victim, which inherits all its metadata.
         * @param {Boolean} options.setAsMainCache - If true, tiles main cache gets updated to consumerKey.
         *   Otherwise, if consumerKey==tile.cacheKey the cache is set as main too.
         * @param {Boolean} options.tileAllowNotLoaded - if true, tile that is not loaded is also processed,
         *   this is internal parameter used in tile-loaded completion routine, as we need to prepare tile but
         *   it is not yet loaded and cannot be marked as so (otherwise the system would think it is ready)
         * @private
         */
        replaceCache(options) {
            const victimKey = options.victimKey,
                consumerKey = options.consumerKey,
                victim = this._cachesLoaded[victimKey],
                tile = options.tile;
            if (!victim || (!options.tileAllowNotLoaded && !tile.loaded && !tile.loading)) {
                $.console.warn("Attempt to consume cache on tile in invalid state: this is probably a bug!");
                return;
            }
            const consumer = this._cachesLoaded[consumerKey];
            if (consumer) {
                // We need to avoid async execution here: replace consumer instead of overwriting the data.
                const iterateTiles = [...consumer._tiles];  // unloadCacheForTile() will modify the array, use a copy
                for (const tile of iterateTiles) {
                    this.unloadCacheForTile(tile, consumerKey, true, false);
                }
            }
            if (this._cachesLoaded[consumerKey]) {
                $.console.error("The consume routine should've freed cache!");
            }
            // Just swap victim to become new consumer
            const resultCache = this.renameCache({
                oldCacheKey: victimKey,
                newCacheKey: consumerKey
            });

            if (resultCache) {
                // Only one cache got working item, other caches were idle: update cache: add the new cache
                // we must add since we removed above with unloadCacheForTile()
                for (const t of tile.getCache(tile.originalCacheKey)._tiles) {  // grab all cache-equal tiles
                    t.setCache(consumerKey, resultCache, options.setAsMainCache, false);
                }
            }
        }

        /**
         * This method ensures other tiles are restored if one of the tiles
         * was requested restore().
         * @param tile
         * @param originalCache
         * @param freeIfUnused if true, zombie is not created
         * @private
         */
        restoreTilesThatShareOriginalCache(tile, originalCache, freeIfUnused) {
            for (const t of originalCache._tiles) {
                if (t.cacheKey !== t.originalCacheKey) {
                    this.unloadCacheForTile(t, t.cacheKey, freeIfUnused, true);
                    delete t._caches[t.cacheKey];
                    t.cacheKey = t.originalCacheKey;
                }
            }
        }

        _freeOldRecordRoutine(theTile, cutoff) {
            let insertionIndex = this._tilesLoaded.length,
                worstTileIndex = -1;

            // Note that just because we're unloading a tile doesn't necessarily mean
            // we're unloading its cache records. With repeated calls it should sort itself out, though.
            if (this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount) {
                //prefer zombie deletion, faster, better
                if (this._zombiesLoadedCount > 0) {
                    for (const zombie in this._zombiesLoaded) {
                        this._zombiesLoaded[zombie].destroy();
                        delete this._zombiesLoaded[zombie];
                        this._zombiesLoadedCount--;
                        break;
                    }
                } else {
                    let worstTile = null;
                    let prevTile, worstTime, worstLevel, prevTime, prevLevel;

                    for (let i = this._tilesLoaded.length - 1; i >= 0; i--) {
                        prevTile = this._tilesLoaded[i];

                        if (prevTile.level <= cutoff ||
                            prevTile.beingDrawn ||
                            prevTile.loading ||
                            prevTile.processing) {
                            continue;
                        }
                        if (!worstTile) {
                            worstTile = prevTile;
                            worstTileIndex = i;
                            continue;
                        }

                        prevTime = prevTile.lastTouchTime;
                        worstTime = worstTile.lastTouchTime;
                        prevLevel = prevTile.level;
                        worstLevel = worstTile.level;

                        if (prevTime < worstTime ||
                            (prevTime === worstTime && prevLevel > worstLevel)) {
                            worstTile = prevTile;
                            worstTileIndex = i;
                        }
                    }

                    if (worstTile && worstTileIndex >= 0) {
                        this._unloadTile(worstTile, true);
                        insertionIndex = worstTileIndex;
                    }
                }
            }

            if (theTile.getCacheSize() === 0) {
                this._tilesLoaded[insertionIndex] = theTile;
            } else if (worstTileIndex >= 0) {
                //tile is already recorded, do not add tile, but remove the tile at insertion index
                this._tilesLoaded.splice(insertionIndex, 1);
            }
        }

        _handleBrokenCacheRecord(cache) {
            if (!cache) {
                return;
            }

            const key = cache.cacheKey;

            if (key && this._cachesLoaded[key] === cache) {
                delete this._cachesLoaded[key];
                this._cachesLoadedCount--;
            }
            if (key && this._zombiesLoaded[key] === cache) {
                delete this._zombiesLoaded[key];
                this._zombiesLoadedCount--;
            }

            const tiles = cache._tiles ? [...cache._tiles] : [];
            for (const tile of tiles) {
                const isMainCache = tile.getCache && tile.getCache() === cache;
                const isOriginalCache = key && tile.originalCacheKey === key;

                if (isMainCache || isOriginalCache) {
                    tile.exists = false; // prevents the tile from loading (TODO: consider ability to revive!)
                    tile.unload(true);
                } else {
                    if (tile.removeCache && key) {
                        tile.removeCache(key);
                    }
                    cache.removeTile(tile);
                }
            }

            cache._promise = $.Promise.resolve(undefined);
            cache._tiles = [];
            cache._tRef = null;
            cache._ownerTileCache = null;
        }

        /**
         * Clears all tiles associated with the specified tiledImage.
         * @param {OpenSeadragon.TiledImage} tiledImage
         */
        clearTilesFor(tiledImage) {
            $.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required');
            let tile;

            let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount;
            if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) {
                //prefer newer (fresh ;) zombies
                for (const zombie in this._zombiesLoaded) {
                    this._zombiesLoaded[zombie].destroy();
                    delete this._zombiesLoaded[zombie];
                }
                this._zombiesLoadedCount = 0;
                cacheOverflows = this._cachesLoadedCount > this._maxCacheItemCount;
            }
            for (let i = this._tilesLoaded.length - 1; i >= 0; i--) {
                tile = this._tilesLoaded[i];

                if (tile.tiledImage === tiledImage) {
                    if (!tile.loaded) {
                        //iterates from the array end, safe to remove
                        this._tilesLoaded.splice(i, 1);
                    } else if (tile.tiledImage === tiledImage) {
                        this._unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i);
                    }
                }
            }
        }

        /**
         * Delete all data in the cache
         * @param {boolean} withZombies
         */
        clear(withZombies = true) {
            for (const zombie in this._zombiesLoaded) {
                this._zombiesLoaded[zombie].destroy();
            }
            for (const tile in this._tilesLoaded) {
                this._unloadTile(tile, true);
            }
            this._tilesLoaded = [];
            this._zombiesLoaded = [];
            this._zombiesLoadedCount = 0;
            this._cachesLoaded = [];
            this._cachesLoadedCount = 0;
        }

        /**
         * Clean up internal drawer data for a given drawer
         * @param {OpenSeadragon.DrawerBase} drawer
         */
        clearDrawerInternalCache(drawer) {
            const drawerId = drawer.getId();
            for (const zombie of this._zombiesLoaded) {
                if (zombie) {
                    zombie.destroyInternalCache(drawerId);
                }
            }
            for (const cache of this._cachesLoaded) {
                if (cache) {
                    cache.destroyInternalCache(drawerId);
                }
            }
        }

        /**
         * Returns reference to all tiles loaded by a particular
         * tiled image item
         * @param {OpenSeadragon.TiledImage|null} tiledImage if null, gets all tiles, else filters out tiles
         *   that belong to a specific image
         */
        getLoadedTilesFor(tiledImage) {
            if (!tiledImage) {
                return [...this._tilesLoaded];
            }
            return this._tilesLoaded.filter(tile => tile.tiledImage === tiledImage);
        }

        /**
         * Get cache record (might be a unattached record, i.e. a zombie)
         * @param cacheKey
         * @returns {OpenSeadragon.CacheRecord|undefined}
         */
        getCacheRecord(cacheKey) {
            $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required');
            return this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey];
        }

        /**
         * Delete cache safely from the system if it is not needed
         * @param {OpenSeadragon.CacheRecord} cache
         */
        safeUnloadCache(cache) {
            if (cache && !cache._destroyed && cache.getTileCount() < 1) {
                for (const i in this._zombiesLoaded) {
                    const c = this._zombiesLoaded[i];
                    if (c === cache) {
                        delete this._zombiesLoaded[i];
                        c.destroy();
                        return;
                    }
                }
                $.console.error("Attempt to delete an orphan cache that is not in zombie list: this could be a bug!", cache);
                cache.destroy();
            }
        }

        /**
         * Delete cache record for a given til
         * @param {OpenSeadragon.Tile} tile
         * @param {string} key cache key
         * @param {boolean} destroy if true, empty cache is destroyed, else left as a zombie
         * @param {boolean} okIfNotExists sometimes we call destruction just to make sure, if true do not report as error
         * @private
         */
        unloadCacheForTile(tile, key, destroy, okIfNotExists) {
            const cacheRecord = this._cachesLoaded[key];
            //unload record only if relevant - the tile exists in the record
            if (cacheRecord) {
                if (cacheRecord.removeTile(tile)) {
                    if (!cacheRecord.getTileCount()) {
                        if (destroy) {
                            // #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie)
                            cacheRecord.destroy();
                        } else {
                            // #2 Tile is a zombie. Do not delete record, reuse.
                            this._zombiesLoaded[key] = cacheRecord;
                            this._zombiesLoadedCount++;
                        }
                        // Either way clear cache
                        delete this._cachesLoaded[key];
                        this._cachesLoadedCount--;
                    }
                    return true;
                }
                $.console.error("[TileCache.unloadCacheForTile] System tried to delete tile from cache it " +
                    "does not belong to! This could mean a bug in the cache system.");
                return false;
            }
            if (!okIfNotExists) {
                $.console.warn("[TileCache.unloadCacheForTile] Attempting to delete missing cache!");
            }
            return false;
        }

        /**
         * Unload tile: this will free the tile data and mark the tile as unloaded.
         * @param {OpenSeadragon.Tile} tile
         * @param {boolean} destroy if set to true, tile data is not preserved as zombies but deleted immediatelly
         */
        unloadTile(tile, destroy = false) {
            if (!tile.loaded) {
                $.console.warn("Attempt to unload already unloaded tile.");
                return;
            }
            const index = this._tilesLoaded.findIndex(x => x === tile);
            this._unloadTile(tile, destroy, index);
        }

        /**
         * @param {OpenSeadragon.Tile} tile tile to unload
         * @param {boolean} destroy destroy tile cache if the cache tile counts falls to zero
         * @param {Number} [deleteAtIndex=undefined] index to remove the tile record at, will not remove from _tilesLoaded if not set
         * @private
         */
        _unloadTile(tile, destroy, deleteAtIndex = undefined) {
            $.console.assert(tile, '[TileCache._unloadTile] tile is required');

            for (const key in tile._caches) {
                //we are 'ok' to remove tile caches here since we later call destroy on tile, otherwise
                //tile has count of its cache size --> would be inconsistent
                this.unloadCacheForTile(tile, key, destroy, false);
            }
            //delete also the tile record
            if (deleteAtIndex !== undefined) {
                this._tilesLoaded.splice(deleteAtIndex, 1);
            }

            // Possible error: it can happen that unloaded tile gets to this stage. Should it even be allowed to happen?
            if (!tile.loaded) {
                return;
            }

            const tiledImage = tile.tiledImage;
            tile._unload();

            /**
             * Triggered when a tile has just been unloaded from memory.
             @@ -255,12 +668,15 @@ $.TileCache.prototype = {
             * @type {object}
             * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the unloaded tile.
             * @property {OpenSeadragon.Tile} tile - The tile which has been unloaded.
             * @property {boolean} destroyed - False if the tile data was kept in the system.
             */
            tiledImage.viewer.raiseEvent("tile-unloaded", {
                tile: tile,
                tiledImage: tiledImage,
                destroyed: destroy
            });
        }
    };

}(OpenSeadragon));

/*
 * OpenSeadragon - World
 *
 * Copyright (C) 2009 CodePlex Foundation
 * Copyright (C) 2010-2025 OpenSeadragon contributors
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * - Neither the name of CodePlex Foundation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
 * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

(function( $ ){

/**
 * @class World
 * @memberof OpenSeadragon
 * @extends OpenSeadragon.EventSource
 * @classdesc Keeps track of all of the tiled images in the scene.
 * @param {Object} options - World options.
 * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this World.
 **/
$.World = function( options ) {
    const _this = this;

    $.console.assert( options.viewer, "[World] options.viewer is required" );

    $.EventSource.call( this );

    this.viewer = options.viewer;
    this._items = [];
    this._needsDraw = false;
    this.__invalidatedAt = 1;
    this._autoRefigureSizes = true;
    this._needsSizesFigured = false;
    this._delegatedFigureSizes = function(event) {
        if (_this._autoRefigureSizes) {
            _this._figureSizes();
        } else {
            _this._needsSizesFigured = true;
        }
    };

    this._figureSizes();
};

$.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.World.prototype */{
    /**
     * Add the specified item.
     * @param {OpenSeadragon.TiledImage} item - The item to add.
     * @param {Object} options - Options affecting insertion.
     * @param {Number} [options.index] - Index for the item. If not specified, goes at the top.
     * @fires OpenSeadragon.World.event:add-item
     * @fires OpenSeadragon.World.event:metrics-change
     */
    addItem: function( item, options ) {
        $.console.assert(item, "[World.addItem] item is required");
        $.console.assert(item instanceof $.TiledImage, "[World.addItem] only TiledImages supported at this time");

        options = options || {};
        if (options.index !== undefined) {
            const index = Math.max(0, Math.min(this._items.length, options.index));
            this._items.splice(index, 0, item);
        } else {
            this._items.push( item );
        }

        if (this._autoRefigureSizes) {
            this._figureSizes();
        } else {
            this._needsSizesFigured = true;
        }

        this._needsDraw = true;

        item.addHandler('bounds-change', this._delegatedFigureSizes);
        item.addHandler('clip-change', this._delegatedFigureSizes);

        /**
         * Raised when an item is added to the World.
         * @event add-item
         * @memberOf OpenSeadragon.World
         * @type {object}
         * @property {OpenSeadragon.Viewer} eventSource - A reference to the World which raised the event.
         * @property {OpenSeadragon.TiledImage} item - The item that has been added.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'add-item', {
            item: item
        } );
    },

    /**
     * Get the item at the specified index.
     * @param {Number} index - The item's index.
     * @returns {OpenSeadragon.TiledImage} The item at the specified index.
     */
    getItemAt: function( index ) {
        $.console.assert(index !== undefined, "[World.getItemAt] index is required");
        return this._items[ index ];
    },

    /**
     * Get the index of the given item or -1 if not present.
     * @param {OpenSeadragon.TiledImage} item - The item.
     * @returns {Number} The index of the item or -1 if not present.
     */
    getIndexOfItem: function( item ) {
        $.console.assert(item, "[World.getIndexOfItem] item is required");
        return $.indexOf( this._items, item );
    },

    /**
     * @returns {Number} The number of items used.
     */
    getItemCount: function() {
        return this._items.length;
    },

    /**
     * Change the index of a item so that it appears over or under others.
     * @param {OpenSeadragon.TiledImage} item - The item to move.
     * @param {Number} index - The new index.
     * @fires OpenSeadragon.World.event:item-index-change
     */
    setItemIndex: function( item, index ) {
        $.console.assert(item, "[World.setItemIndex] item is required");
        $.console.assert(index !== undefined, "[World.setItemIndex] index is required");

        const oldIndex = this.getIndexOfItem( item );

        if ( index >= this._items.length ) {
            throw new Error( "Index bigger than number of layers." );
        }

        this._items.splice( oldIndex, 1 );
        this._items.splice( index, 0, item );
        this._needsDraw = true;

        /**
         * Raised when the order of the indexes has been changed.
         * @event item-index-change
         * @memberOf OpenSeadragon.World
         * @type {object}
         * @property {OpenSeadragon.World} eventSource - A reference to the World which raised the event.
         * @property {OpenSeadragon.TiledImage} item - The item whose index has
         * been changed
         * @property {Number} previousIndex - The previous index of the item
         * @property {Number} newIndex - The new index of the item
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'item-index-change', {
            item: item,
            previousIndex: oldIndex,
            newIndex: index
        } );
    },

    /**
     * Remove an item.
     * @param {OpenSeadragon.TiledImage} item - The item to remove.
     * @fires OpenSeadragon.World.event:remove-item
     * @fires OpenSeadragon.World.event:metrics-change
     */
    removeItem: function( item ) {
        $.console.assert(item, "[World.removeItem] item is required");

        const index = $.indexOf(this._items, item );
        if ( index === -1 ) {
            return;
        }

        item.removeHandler('bounds-change', this._delegatedFigureSizes);
        item.removeHandler('clip-change', this._delegatedFigureSizes);
        item.destroy();
        this._items.splice( index, 1 );
        this._figureSizes();
        this._needsDraw = true;
        this._raiseRemoveItem(item);
    },

    /**
     * Remove all items.
     * @fires OpenSeadragon.World.event:remove-item
     * @fires OpenSeadragon.World.event:metrics-change
     */
    removeAll: function() {
        // We need to make sure any pending images are canceled so the world items don't get messed up
        this.viewer._cancelPendingImages();
        let item;
        for (let i = 0; i < this._items.length; i++) {
            item = this._items[i];
            item.removeHandler('bounds-change', this._delegatedFigureSizes);
            item.removeHandler('clip-change', this._delegatedFigureSizes);
            item.destroy();
        }

        const removedItems = this._items;
        this._items = [];
        this._figureSizes();
        this._needsDraw = true;

        for (let i = 0; i < removedItems.length; i++) {
            item = removedItems[i];
            this._raiseRemoveItem(item);
        }
    },

    /**
     * Forces the system consider all tiles across all tiled images
     * as outdated, and fire tile update event on relevant tiles
     * Detailed description is available within the 'tile-invalidated'
     * event.
     * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
     * @param {number} [tStamp=OpenSeadragon.now()] optionally provide tStamp of the update event
     * @function
     * @fires OpenSeadragon.Viewer.event:tile-invalidated
     * @return {OpenSeadragon.Promise<?>}
     */
    requestInvalidate: function (restoreTiles = true, tStamp = $.now()) {

        // Note: Getting the async cache + invalidation flow right is VERY tricky.
        //
        // When debugging invalidation flow, instead of this optimized version,
        // uncomment the following snipplet and test in easier setting:
        //
        // this.__invalidatedAt = tStamp;
        // const batch = this.viewer.tileCache.getLoadedTilesFor(null);
        // OpenSeadragon.trace(`Invalidate request ${tStamp} - ${batch.length} tiles`);
        // return this.requestTileInvalidateEvent(batch, tStamp, restoreTiles);
        //
        // This makes the code easier to reason about. Also, recommended is to put logging
        // messages into a buffered logger using OpenSeadragon.trace(..)
        // to avoid change of flow in the async execution with detailed logs.


        this.__invalidatedAt = tStamp;
        let drawnTstamp = Infinity;
        for (const item of this._items) {
            if (item._lastDrawn.length) {
                drawnTstamp = Math.min(drawnTstamp, item._lastDrawn[0].tile.lastTouchTime);
            }
            // Might be nested
            for (const tileSet of item._tilesToDraw) {
                if (Array.isArray(tileSet)) {
                    if (tileSet.length) {
                        drawnTstamp = Math.min(drawnTstamp, tileSet[0].tile.lastTouchTime);
                    }
                } else if (tileSet) {
                    drawnTstamp = Math.min(drawnTstamp, tileSet.tile.lastTouchTime);
                }
            }
        }

        const allTiles = this.viewer.tileCache.getLoadedTilesFor(null);
        const tilesToRestore = new Array(allTiles.length);

        let restoreIndex = 0;
        let deletedTiles = 0;

        const cache = this.viewer.tileCache;
        for (let i = 0; i < allTiles.length; i++) {
            const tile = allTiles[i];
            const isRecentlyTouched = tile.lastTouchTime >= drawnTstamp;
            const isAboveCutoff = tile.level <= (tile.tiledImage.source.getClosestLevel() || 0);

            if (isRecentlyTouched || isAboveCutoff) {
                tilesToRestore[restoreIndex++] = tile;
            } else {
                cache._unloadTile(tile, false, i - deletedTiles);
                deletedTiles++;
            }
        }
        tilesToRestore.length = restoreIndex;
        return this.requestTileInvalidateEvent(tilesToRestore, tStamp, restoreTiles);
    },

    /**
     * Requests tile data update.
     * @function OpenSeadragon.Viewer.prototype._updateSequenceButtons
     * @private
     * @param {OpenSeadragon.Tile[]} tilesToProcess tiles to update
     * @param {Number} tStamp timestamp in milliseconds, if active timestamp of the same value is executing,
     *   changes are added to the cycle, else they await next iteration
     * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
     * @param {Boolean} [_allowTileUnloaded=false] internal flag for calling on tiles that come new to the system
     * @param {Boolean} [_isFromTileLoad=false] internal flag that must not be used manually
     * @fires OpenSeadragon.Viewer.event:tile-invalidated
     * @return {OpenSeadragon.Promise<?>}
     */
    requestTileInvalidateEvent: function(tilesToProcess, tStamp, restoreTiles = true,
                                         _allowTileUnloaded = false, _isFromTileLoad = false) {
        // Calling the event is not considered invalidation, as tile load events finishes with this too.
        if (!this.viewer.isOpen()) {
            return $.Promise.resolve();
        }

        if (tStamp === undefined) {
            tStamp = this.__invalidatedAt;
        }

        const tilesThatNeedReprocessing = [];

        const jobList = tilesToProcess.map(tile => {
            // We allow re-execution on tiles that are in process but have too low processing timestamp,
            // which must be solved by ensuring subsequent data calls in the suddenly outdated processing
            // pipeline take no effect.
            // Note that in the same list we can have tiles that have shared cache and such
            // cache needs to be processed just once.
            if (!tile || (!_allowTileUnloaded && !tile.loaded && !tile.processing)) {
                // OpenSeadragon.trace(`Ignoring tile ${tile ? tile.toString() : 'null'} tstamp ${tStamp}`);
                return Promise.resolve();
            }

            const tiledImage = tile.tiledImage;
            const drawer = tiledImage.getDrawer();
            // We call the event on the parent viewer window no matter what, nested viewers have parent viewer ref.
            //  we use the knowledge that drawerBase keeps track of parent viewer to register into, we use this ref.
            //  We could turn this into API...
            const eventTarget = drawer._parentViewer || this.viewer;
            const originalCache = tile.getCache(tile.originalCacheKey);
            const tileCache = tile.getCache(tile.originalCacheKey);
            if (tileCache.__invStamp && tileCache.__invStamp >= tStamp) {
                // OpenSeadragon.trace(`Ignoring tile - old,  ${tile ? tile.toString() : 'null'} tstamp ${tStamp}`);
                return Promise.resolve();
            }


            let wasOutdatedRun = false;
            if (originalCache.__finishProcessing) {
                // OpenSeadragon.trace(`                 Tile Pre-Finisher,  ${tile ? tile.toString() : 'null'} as Invalid from future ${tStamp}`);
                originalCache.__finishProcessing(true);
            }

            // Keep the original promise alive until the processing finished normally. If the
            // processing was interrupted, the old promise gets reused in the new run - awaited logics
            // will wait for proper invalidation finish.
            let promise;
            if (!originalCache.__resolve) {
                promise = new $.Promise((resolve) => {
                    originalCache.__resolve = resolve;
                });
            }

            originalCache.__finishProcessing = (asInvalidRun) => {
                wasOutdatedRun = wasOutdatedRun || asInvalidRun;
                // OpenSeadragon.trace(`                  Tile Finisher, ${tile ? tile.toString() : 'null'} as Invalid run ${asInvalidRun} with ${tStamp}`);
                tile.processing = false;
                originalCache.__finishProcessing = null;
                // resolve only when finished without interruption
                if (!asInvalidRun) {
                    originalCache.__resolve(tile);
                    originalCache.__resolve = null;
                }
            };

            for (const t of originalCache._tiles) {
                // Mark all related tiles as processing and register callback to unmark later on
                t.processing = tStamp;
                if (promise) {
                    t.processingPromise = promise;
                }
            }
            originalCache.__invStamp = tStamp;
            originalCache.__wasRestored = restoreTiles;


            let workingCache = null;
            const getWorkingCacheData = (type) => {
                if (workingCache) {
                    return workingCache.getDataAs(type, false);
                }

                const targetCopyKey = restoreTiles ? tile.originalCacheKey : tile.cacheKey;
                const origCache = tile.getCache(targetCopyKey);
                if (!origCache) {
                    $.console.error("[Tile::getData] There is no cache available for tile with key %s", targetCopyKey);
                    return $.Promise.reject();
                }
                // Here ensure type is defined, rquired by data callbacks
                type = type || origCache.type;
                workingCache = new $.CacheRecord().withTileReference(tile);
                return origCache.getDataAs(type, true).then(data => {
                    if (data === undefined || data === null) {
                        // Conversion/loading failed upstream; abort invalidation for this tile.
                        return $.Promise.reject(new Error('[World.getData] Working cache source data unavailable'));
                    }
                    workingCache.addTile(tile, data, type);
                    return workingCache.data;
                });
            };
            const setWorkingCacheData = (value, type) => {
                // // OpenSeadragon.trace(`        WORKER tile,  ${tile ? tile.toString() : 'null'} tstamp ${tStamp}`);
                if (!workingCache) {
                    workingCache = new $.CacheRecord().withTileReference(tile);
                    workingCache.addTile(tile, value, type);
                    return $.Promise.resolve();
                }
                return workingCache.setDataAs(value, type);
            };
            const atomicCacheSwap = () => {
                if (workingCache) {
                    const newCacheKey = tile.buildDistinctMainCacheKey();
                    tiledImage._tileCache.injectCache({
                        tile: tile,
                        cache: workingCache,
                        targetKey: newCacheKey,
                        setAsMainCache: true,
                        tileAllowNotLoaded: tile.loading
                    });
                } else if (restoreTiles) {
                    // If we requested restore, perform now
                    tiledImage._tileCache.restoreTilesThatShareOriginalCache(tile, tile.getCache(tile.originalCacheKey), true);
                }
            };

            const outdatedTest = () => wasOutdatedRun ||
                (typeof originalCache.__invStamp === "number" && originalCache.__invStamp < this.__invalidatedAt) ||
                (!tile.loaded && !tile.loading);

            // OpenSeadragon.trace(`   Procesing tile, ${tile ? tile.toString() : 'null'} tstamp ${tStamp}`);
            /**
             * @event tile-invalidated
             * @memberof OpenSeadragon.Viewer
             * @type {object}
             * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn.
             * @property {OpenSeadragon.Tile} tile
             * @property {AsyncNullaryFunction<boolean>} outdated - predicate that evaluates to true if the event
             *   is outdated and should not be longer processed (has no effect)
             * @property {AsyncUnaryFunction<any, string>} getData - get data of desired type (string argument)
             * @property {AsyncBinaryFunction<undefined, any, string>} setData - set data (any)
             *   and the type of the data (string)
             * @property {function} resetData - function that deletes any previous data modification in the current
             *   execution pipeline
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            return eventTarget.raiseEventAwaiting('tile-invalidated', {
                tile: tile,
                tiledImage: tiledImage,
                outdated: outdatedTest,
                getData: getWorkingCacheData,
                setData: setWorkingCacheData,
                resetData: () => {
                    if (workingCache) {
                        workingCache.destroy();
                        workingCache = null;
                    }
                },
                stopPropagation: () => {
                    const result = outdatedTest();
                    // if (result) {
                    //     OpenSeadragon.trace( `              Stop propagation ${tile.toString()}: out: ${wasOutdatedRun} | ${originalCache.__invStamp} ${tile.loaded} ${tile.loading}`);
                    // }
                    return result;
                },
            }).catch(err => {
                // Plugin/invalidation error: keep existing main cache, discard working cache, and finish processing as invalid.
                $.console.error('Update routine error:', err);
                if (workingCache) {
                    try {
                        workingCache.destroy();
                    } catch (e) {
                        //no-op
                    }
                    workingCache = null;
                }
                wasOutdatedRun = true;
                if (originalCache.__finishProcessing) {
                    originalCache.__finishProcessing(true);
                }
                return null;
            }).then(_ => {
                if (this.viewer.isDestroyed()) {
                    if (originalCache.__finishProcessing) {
                        originalCache.__finishProcessing(true);
                    }
                    return null;
                }

                if (wasOutdatedRun) {
                    return null;
                }

                // OpenSeadragon.trace(`           FF Tile, ${tile ? tile.toString() : 'null'}    FINISH   ${tStamp}`);

                // If we do not have the handler, we were already discarded
                if (originalCache.__finishProcessing) {
                    // If we are not in outdated run, we can finish the data processing if the state is valid
                    if (!wasOutdatedRun && (tile.loaded || tile.loading)) {
                        // If we find out that processing was outdated but the system did not find about this yet, request re-processing
                        if (originalCache.__invStamp < this.__invalidatedAt) {
                            // OpenSeadragon.trace(`         Tile,  ${tile ? tile.toString() : 'null'} tstamp ${tStamp} needs reprocessing`);
                            // todo consider some recursion loop prevention
                            tilesThatNeedReprocessing.push(tile);
                            // we will let it fall through to handle later
                        } else if (originalCache.__invStamp === tStamp) {
                            // If we matched the invalidation state, ensure the new working cache (if created) is used
                            if (workingCache) {
                                // OpenSeadragon.trace(`         Tile,  ${tile ? tile.toString() : 'null'} tstamp ${tStamp} finishing normally, working cache exists.`);
                                return workingCache.prepareForRendering(drawer).then(c => {
                                    // OpenSeadragon.trace(`            Tile,  ${tile ? tile.toString() : 'null'} swapping working cache ${tStamp}`);

                                    // Inside async then, we need to again check validity of the state
                                    if (!wasOutdatedRun) {
                                        if (!outdatedTest() && c) {
                                            atomicCacheSwap();
                                        } else {
                                            workingCache.destroy();
                                            workingCache = null;
                                        }
                                        originalCache.__finishProcessing();
                                    } else {
                                        workingCache.destroy();
                                        workingCache = null;
                                    }
                                });
                            }

                            // If we requested restore, restore to originalCacheKey
                            if (restoreTiles) {
                                // OpenSeadragon.trace(`         Tile, ${tile ? tile.toString() : 'null'} tstamp ${tStamp} finishing normally, original data restored.`);

                                const mainCacheRef = tile.getCache();
                                const freshOriginalCacheRef = tile.getCache(tile.originalCacheKey);
                                if (mainCacheRef !== freshOriginalCacheRef) {
                                    return freshOriginalCacheRef.prepareForRendering(drawer).then((c) => {
                                        // OpenSeadragon.trace(`            Tile, ${tile ? tile.toString() : 'null'}  SWAP2 ${tStamp}`);
                                        // Inside async then, we need to again check validity of the state
                                        if (!wasOutdatedRun) {
                                            if (!outdatedTest() && c) {
                                                atomicCacheSwap();
                                            }
                                            originalCache.__finishProcessing();
                                        }
                                    });
                                } else {
                                    // OpenSeadragon.trace(`         Tile, ${tile ? tile.toString() : 'null'} tstamp ${tStamp} finished - no need to swap cache.`);
                                    return null;
                                }
                            }
                            // else we will let it fall through to handle later
                        } else {
                            $.console.error(
                                `Invalidation flow error: tile processing state is invalid. ` +
                                `Tile: ${tile ? tile.toString() : 'null'}, ` +
                                `loaded: ${tile ? tile.loaded : 'n/a'}, loading: ${tile ? tile.loading : 'n/a'}, ` +
                                `originalCache.__invStamp: ${originalCache.__invStamp}, ` +
                                `this.__invalidatedAt: ${this.__invalidatedAt}, ` +
                                `tStamp: ${tStamp}, wasOutdatedRun: ${wasOutdatedRun}`
                            );
                        }

                        // If we did not handle the data, finish here - still a valid run.

                        // If this is also the first run on the tile, ensure the main cache, whatever it is, is ready for render
                        if (_isFromTileLoad) {
                            // OpenSeadragon.trace(`                             Tile, ${tile ? tile.toString() : 'null'} needs render prep as a first run ${tStamp}`);
                            const freshMainCacheRef = tile.getCache();
                            return freshMainCacheRef.prepareForRendering(drawer).then(() => {
                                // Inside async then, we need to again check validity of the state
                                if (!wasOutdatedRun && originalCache.__finishProcessing) {
                                    originalCache.__finishProcessing();
                                }
                                // else: do not destroy, we are the initial base cache, the system will remove
                                // any rendering internal cache on events such as atomic cache swap
                                // OpenSeadragon.trace(`            Tile, ${tile ? tile.toString() : 'null'}  SWAP FIRST LOAD FINISH ${tStamp}`);
                            });
                        }
                        originalCache.__finishProcessing();
                        return null;
                    }
                    // else invalid state, let this fall through
                    // OpenSeadragon.trace(`Tile, ${tile ? tile.toString() : 'null'} tstamp ${tStamp} discarded.`);
                    if (!wasOutdatedRun) {
                        originalCache.__finishProcessing(true);
                    }
                }

                // If this is also the first run on the tile, ensure the main cache, whatever it is, is ready for render
                if (_isFromTileLoad) {
                    // OpenSeadragon.trace(`                             Tile, ${tile ? tile.toString() : 'null'} needs render prep as a first run ${tStamp} - from invalid event!`);
                    const freshMainCacheRef = tile.getCache();
                    return freshMainCacheRef.prepareForRendering(drawer).then(() => {
                        // OpenSeadragon.trace(`            Tile,  ${tile ? tile.toString() : 'null'}  SWAP FIRST LOAD FINISH ${tStamp}`);
                        if (!wasOutdatedRun && originalCache.__finishProcessing) {
                            originalCache.__finishProcessing();
                        }
                        // else: do not destroy, we are the initial base cache, the system will remove
                        // any rendering internal cache on events such as atomic cache swap
                    });
                }

                if (workingCache) {
                    workingCache.destroy();
                    workingCache = null;
                }
                return null;
            }).catch(e => {
                $.console.error("Update routine error:", e);
                if (workingCache) {
                    workingCache.destroy();
                    workingCache = null;
                }
                originalCache.__finishProcessing();
            });
        });

        return $.Promise.all(jobList).then(() => {
            if (tilesThatNeedReprocessing.length) {
                this.requestTileInvalidateEvent(tilesThatNeedReprocessing, undefined, restoreTiles, true);
            }
            if (!_allowTileUnloaded && !this.viewer.isDestroyed()) {
                this.draw();
            }
        });
    },

    /**
     * Check if a tile needs update, update such tiles in the given list
     * @param {OpenSeadragon.Tile[]} tileList
     */
    ensureTilesUpToDate: function(tileList) {
        let updateList;
        // we cannot track this on per-tile level, but at least we try to find last used value
        let wasRestored;
        for (let tile of tileList) {
            tile = tile.tile || tile;  // osd uses objects of draw-spec with nested tile ref
            if (!tile.loaded || tile.processing) {
                continue;
            }

            const originalCache = tile.getCache(tile.originalCacheKey);
            wasRestored = originalCache.__wasRestored;
            if (originalCache.__invStamp < this.__invalidatedAt) {
                if (!updateList) {
                    updateList = [tile];
                } else {
                    updateList.push(tile);
                }
            }
        }
        if (updateList && updateList.length) {
            // OpenSeadragon.trace(`Ensure tiles up to date ${this.__invalidatedAt} - ${updateList.length} tiles`);
            this.requestTileInvalidateEvent(updateList, $.now(), wasRestored, false);
        }
    },

    /**
     * Clears all tiles and triggers updates for all items.
     */
    resetItems: function() {
        for ( let i = 0; i < this._items.length; i++ ) {
            this._items[i].reset();
        }
    },

    /**
     * Updates (i.e. animates bounds of) all items.
     * @function
     * @param viewportChanged Whether the viewport changed, which indicates that
     * all TiledImages need to be updated.
     */
    update: function(viewportChanged) {
        let animated = false;
        for ( let i = 0; i < this._items.length; i++ ) {
            animated = this._items[i].update(viewportChanged) || animated;
        }

        return animated;
    },

    /**
     * Draws all items.
     */
    draw: function() {
        this.viewer.drawer.draw(this._items);
        this._needsDraw = false;
        for (const item of this._items) {
            this._needsDraw = item.setDrawn() || this._needsDraw;
        }
    },

    /**
     * @returns {Boolean} true if any items need updating.
     */
    needsDraw: function() {
        for ( let i = 0; i < this._items.length; i++ ) {
            if ( this._items[i].needsDraw() ) {
                return true;
            }
        }
        return this._needsDraw;
    },

    /**
     * @returns {OpenSeadragon.Rect} The smallest rectangle that encloses all items, in viewport coordinates.
     */
    getHomeBounds: function() {
        return this._homeBounds.clone();
    },

    /**
     * To facilitate zoom constraints, we keep track of the pixel density of the
     * densest item in the World (i.e. the item whose content size to viewport size
     * ratio is the highest) and save it as this "content factor".
     * @returns {Number} the number of content units per viewport unit.
     */
    getContentFactor: function() {
        return this._contentFactor;
    },

    /**
     * As a performance optimization, setting this flag to false allows the bounds-change event handler
     * on tiledImages to skip calculations on the world bounds. If a lot of images are going to be positioned in
     * rapid succession, this is a good idea. When finished, setAutoRefigureSizes should be called with true
     * or the system may behave oddly.
     * @param {Boolean} [value] The value to which to set the flag.
     */
    setAutoRefigureSizes: function(value) {
        this._autoRefigureSizes = value;
        if (value & this._needsSizesFigured) {
            this._figureSizes();
            this._needsSizesFigured = false;
        }
    },

    /**
     * Arranges all of the TiledImages with the specified settings.
     * @param {Object} options - Specifies how to arrange.
     * @param {Boolean} [options.immediately=false] - Whether to animate to the new arrangement.
     * @param {String} [options.layout] - See collectionLayout in {@link OpenSeadragon.Options}.
     * @param {Number} [options.rows] - See collectionRows in {@link OpenSeadragon.Options}.
     * @param {Number} [options.columns] - See collectionColumns in {@link OpenSeadragon.Options}.
     * @param {Number} [options.tileSize] - See collectionTileSize in {@link OpenSeadragon.Options}.
     * @param {Number} [options.tileMargin] - See collectionTileMargin in {@link OpenSeadragon.Options}.
     * @fires OpenSeadragon.World.event:metrics-change
     */
    arrange: function(options) {
        options = options || {};
        const immediately = options.immediately || false;
        const layout = options.layout || $.DEFAULT_SETTINGS.collectionLayout;
        const rows = options.rows || $.DEFAULT_SETTINGS.collectionRows;
        const columns = options.columns || $.DEFAULT_SETTINGS.collectionColumns;
        const tileSize = options.tileSize || $.DEFAULT_SETTINGS.collectionTileSize;
        const tileMargin = options.tileMargin || $.DEFAULT_SETTINGS.collectionTileMargin;
        const increment = tileSize + tileMargin;
        let wrap;
        if (!options.rows && columns) {
            wrap = columns;
        } else {
            wrap = Math.ceil(this._items.length / rows);
        }
        let x = 0;
        let y = 0;
        let item, box, width, height, position;

        this.setAutoRefigureSizes(false);
        for (let i = 0; i < this._items.length; i++) {
            if (i && (i % wrap) === 0) {
                if (layout === 'horizontal') {
                    y += increment;
                    x = 0;
                } else {
                    x += increment;
                    y = 0;
                }
            }

            item = this._items[i];
            box = item.getBoundsNoRotate();
            if (box.width > box.height) {
                width = tileSize;
            } else {
                width = tileSize * (box.width / box.height);
            }

            height = width * (box.height / box.width);
            position = new $.Point(x + ((tileSize - width) / 2),
                y + ((tileSize - height) / 2));

            item.setPosition(position, immediately);
            item.setWidth(width, immediately);

            if (layout === 'horizontal') {
                x += increment;
            } else {
                y += increment;
            }
        }
        this.setAutoRefigureSizes(true);
    },

    // private
    _figureSizes: function() {
        const oldHomeBounds = this._homeBounds ? this._homeBounds.clone() : null;
        const oldContentSize = this._contentSize ? this._contentSize.clone() : null;
        const oldContentFactor = this._contentFactor || 0;

        if (!this._items.length) {
            this._homeBounds = new $.Rect(0, 0, 1, 1);
            this._contentSize = new $.Point(1, 1);
            this._contentFactor = 1;
        } else {
            let item = this._items[0];
            let bounds = item.getBounds();
            this._contentFactor = item.getContentSize().x / bounds.width;
            let clippedBounds = item.getClippedBounds().getBoundingBox();
            let left = clippedBounds.x;
            let top = clippedBounds.y;
            let right = clippedBounds.x + clippedBounds.width;
            let bottom = clippedBounds.y + clippedBounds.height;
            for (let i = 1; i < this._items.length; i++) {
                item = this._items[i];
                bounds = item.getBounds();
                this._contentFactor = Math.max(this._contentFactor,
                    item.getContentSize().x / bounds.width);
                clippedBounds = item.getClippedBounds().getBoundingBox();
                left = Math.min(left, clippedBounds.x);
                top = Math.min(top, clippedBounds.y);
                right = Math.max(right, clippedBounds.x + clippedBounds.width);
                bottom = Math.max(bottom, clippedBounds.y + clippedBounds.height);
            }

            this._homeBounds = new $.Rect(left, top, right - left, bottom - top);
            this._contentSize = new $.Point(
                this._homeBounds.width * this._contentFactor,
                this._homeBounds.height * this._contentFactor);
        }

        if (this._contentFactor !== oldContentFactor ||
            !this._homeBounds.equals(oldHomeBounds) ||
            !this._contentSize.equals(oldContentSize)) {
            /**
             * Raised when the home bounds or content factor change.
             * @event metrics-change
             * @memberOf OpenSeadragon.World
             * @type {object}
             * @property {OpenSeadragon.World} eventSource - A reference to the World which raised the event.
             * @property {?Object} userData - Arbitrary subscriber-defined object.
             */
            this.raiseEvent('metrics-change', {});
        }
    },

    // private
    _raiseRemoveItem: function(item) {
        /**
         * Raised when an item is removed.
         * @event remove-item
         * @memberOf OpenSeadragon.World
         * @type {object}
         * @property {OpenSeadragon.World} eventSource - A reference to the World which raised the event.
         * @property {OpenSeadragon.TiledImage} item - The item's underlying item.
         * @property {?Object} userData - Arbitrary subscriber-defined object.
         */
        this.raiseEvent( 'remove-item', { item: item } );
    }
});

}( OpenSeadragon ));

//# sourceMappingURL=openseadragon.js.map