From 8d66acde530fb7289ab4ff34aea2edaa9d4ceeac Mon Sep 17 00:00:00 2001
From: Ian Gilman <ian@iangilman.com>
Date: Mon, 29 Apr 2024 10:50:35 -0700
Subject: [PATCH 1/8] Changelog for #2521

---
 changelog.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/changelog.txt b/changelog.txt
index d49699cb..fd03a346 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -5,7 +5,7 @@ OPENSEADRAGON CHANGELOG
 
 * BREAKING CHANGE: Dropped support for IE11 (#2300, #2361 @AndrewADev)
 * DEPRECATION: The OpenSeadragon.createCallback function is no longer recommended (#2367 @akansjain)
-* The viewer now uses WebGL when available (#2310, #2462, #2466, #2468, #2469, #2472, #2478, #2488, #2492 @pearcetm, @Aiosa, @thec0keman)
+* The viewer now uses WebGL when available (#2310, #2462, #2466, #2468, #2469, #2472, #2478, #2488, #2492, #2521 @pearcetm, @Aiosa, @thec0keman)
 * Added webp to supported image formats (#2455 @BeebBenjamin)
 * Introduced maxTilesPerFrame option to allow loading more tiles simultaneously (#2387 @jetic83)
 * Now when creating a viewer or navigator, we leave its position style alone if possible (#2393 @VIRAT9358)

From 5be44521b514184a1b170da95fc8c319448b78cd Mon Sep 17 00:00:00 2001
From: Tom <tmpearce@gmail.com>
Date: Tue, 21 May 2024 04:30:17 -0400
Subject: [PATCH 2/8] Fix sorting logic for best tiles to load

---
 src/tiledimage.js | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/tiledimage.js b/src/tiledimage.js
index 20fa2ebf..cf7075a7 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -1651,8 +1651,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
                 }
 
                 var result = this._updateTile(
-                    drawLevel,
                     haveDrawn,
+                    drawLevel,
                     flippedX, y,
                     level,
                     levelVisibility,
@@ -2183,9 +2183,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
                 return -1;
             }
             if (a.visibility === b.visibility) {
+                // sort by smallest squared distance
                 return (a.squaredDistance - b.squaredDistance);
             } else {
-                return (a.visibility - b.visibility);
+                // sort by largest visibility value
+                return (b.visibility - a.visibility);
             }
         });
     },

From 9ef1c5952e1ac6801a5ac358a53e68999eff6968 Mon Sep 17 00:00:00 2001
From: Tom <tmpearce@gmail.com>
Date: Tue, 21 May 2024 16:33:48 -0400
Subject: [PATCH 3/8] revert swapping order of arguments in _updateTile call to
 fix behavior of canvas drawer

---
 src/tiledimage.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/tiledimage.js b/src/tiledimage.js
index cf7075a7..0bb34b69 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -1651,8 +1651,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
                 }
 
                 var result = this._updateTile(
-                    haveDrawn,
                     drawLevel,
+                    haveDrawn,
                     flippedX, y,
                     level,
                     levelVisibility,

From 3f03bd6e20324a3d7772f5655a512d3148eaddee Mon Sep 17 00:00:00 2001
From: Tom <tmpearce@gmail.com>
Date: Wed, 22 May 2024 17:36:16 -0400
Subject: [PATCH 4/8] swap logic of haveDrawn and drawLevel within _updateTile

---
 src/tiledimage.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/tiledimage.js b/src/tiledimage.js
index 0bb34b69..545ec5a1 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -1651,8 +1651,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
                 }
 
                 var result = this._updateTile(
-                    drawLevel,
                     haveDrawn,
+                    drawLevel,
                     flippedX, y,
                     level,
                     levelVisibility,
@@ -1784,15 +1784,15 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         if (tile.loaded && tile.opacity === 1){
             this._setCoverage( this.coverage, level, x, y, true );
         }
-        if ( haveDrawn && !drawTile ) {
+        if ( drawTile && !haveDrawn ) {
             if ( this._isCovered( this.coverage, level, x, y ) ) {
                 this._setCoverage( this.coverage, level, x, y, true );
             } else {
-                drawTile = true;
+                haveDrawn = true;
             }
         }
 
-        if ( !drawTile ) {
+        if ( !haveDrawn ) {
             return {
                 bestTiles: best,
                 tile: tile

From be30b429f8961eb08ee858a698866efbbf487bf7 Mon Sep 17 00:00:00 2001
From: Tom <tmpearce@gmail.com>
Date: Wed, 22 May 2024 18:54:29 -0400
Subject: [PATCH 5/8] remove unused code paths

---
 src/tiledimage.js | 4337 ++++++++++++++++++++++-----------------------
 1 file changed, 2152 insertions(+), 2185 deletions(-)

diff --git a/src/tiledimage.js b/src/tiledimage.js
index 545ec5a1..6a2d7253 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -34,2278 +34,2245 @@
 
 (function( $ ){
 
-/**
- * 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.
- */
-$.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 );
-
-    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;
-
-    var x = options.x || 0;
-    delete options.x;
-    var 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;
-
-    var 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;
-    }
-
-    var fitBounds = options.fitBounds;
-    delete options.fitBounds;
-    var fitBoundsPlacement = options.fitBoundsPlacement || OpenSeadragon.Placement.CENTER;
-    delete options.fitBoundsPlacement;
-
-    var degrees = options.degrees || 0;
-    delete options.degrees;
-
-    var ajaxHeaders = options.ajaxHeaders;
-    delete options.ajaxHeaders;
-
-    $.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.
-        lastDrawn:      [],    // An unordered list of Tiles drawn last frame.
-        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.
-        _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
-        _isBlending:    false, // Are any tiles still being blended?
-        _wasBlending:   false, // Were any tiles blending before the last draw?
-        _isTainted:     false, // Has a Tile been found with tainted data?
-        //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,
-        blendTime:                         $.DEFAULT_SETTINGS.blendTime,
-        alwaysBlend:                       $.DEFAULT_SETTINGS.alwaysBlend,
-        minPixelRatio:                     $.DEFAULT_SETTINGS.minPixelRatio,
-        smoothTileEdgesMinZoom:            $.DEFAULT_SETTINGS.smoothTileEdgesMinZoom,
-        iOSDevice:                         $.DEFAULT_SETTINGS.iOSDevice,
-        debugMode:                         $.DEFAULT_SETTINGS.debugMode,
-        crossOriginPolicy:                 $.DEFAULT_SETTINGS.crossOriginPolicy,
-        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
-    }, 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;
-};
-
-$.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;
-    },
-
-    // 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
-        });
-    },
-
-    /**
-     * Clears all tiles and triggers an update on the next call to
-     * {@link OpenSeadragon.TiledImage#update}.
-     */
-    reset: function() {
-        this._tileCache.clearTilesFor(this);
-        this.lastResetTime = $.now();
-        this._needsDraw = true;
-    },
-
-    /**
-     * 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) {
-        let xUpdated = this._xSpring.update();
-        let yUpdated = this._ySpring.update();
-        let scaleUpdated = this._scaleSpring.update();
-        let degreesUpdated = this._degreesSpring.update();
-
-        let updated = (xUpdated || yUpdated || scaleUpdated || degreesUpdated || this._needsUpdate);
-
-        if (updated || viewportChanged || !this._fullyLoaded){
-            let 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;
-        return this._needsDraw;
-    },
-
-    /**
-     * Set the internal _isTainted 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 tainted data so that further
-     * checks are not needed and alternative rendering strategies can be used.
-     * @private
-     */
-    setTainted(isTainted){
-        this._isTainted = isTainted;
-    },
-
-    /**
-     * @private
-     * @returns {Boolean} whether the TiledImage has been marked as tainted
-     */
-    isTainted(){
-        return this._isTainted;
-    },
-
-    /**
-     * Destroy the TiledImage (unload current loaded tiles).
-     */
-    destroy: function() {
-        this.reset();
-
-        if (this.source.destroy) {
-            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) {
-        var bounds = this.getBoundsNoRotate(current);
-        if (this._clip) {
-            var worldWidth = current ?
-                this._worldWidthCurrent : this._worldWidthTarget;
-            var ratio = worldWidth / this.source.dimensions.x;
-            var 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 ) {
-        var numTiles = this.source.getNumTiles(level);
-        var xMod    = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
-        var yMod    = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
-        var 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() {
-        var topLeft = this.imageToWindowCoordinates(new $.Point(0, 0));
-        var bottomRight = this.imageToWindowCoordinates(this.getContentSize());
-        return new $.Point(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
-    },
-
-    // private
-    _viewportToImageDelta: function( viewerX, viewerY, current ) {
-        var 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) {
-        var 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 ) {
-        var 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;
-        }
-
-        var 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) {
-        var rect = imageX;
-        if (rect instanceof $.Rect) {
-            //they passed a rect instead of individual components
-            current = imageY;
-        } else {
-            rect = new $.Rect(imageX, imageY, pixelWidth, pixelHeight);
-        }
-
-        var coordA = this.imageToViewportCoordinates(rect.getTopLeft(), current);
-        var 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 ) {
-        var rect = viewerX;
-        if (viewerX instanceof $.Rect) {
-            //they passed a rect instead of individual components
-            current = viewerY;
-        } else {
-            rect = new $.Rect(viewerX, viewerY, pointWidth, pointHeight);
-        }
-
-        var coordA = this.viewportToImageCoordinates(rect.getTopLeft(), current);
-        var 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 ) {
-        var 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 ) {
-        var 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 ) {
-        var 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 ) {
-        var 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) {
-        var 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 ) {
-        var 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...
-     * Note: not accurate with multi-image.
-     * @function
-     * @param {Number} imageZoom The image zoom
-     * @returns {Number} viewportZoom The viewport zoom
-     */
-    imageToViewportZoom: function( imageZoom ) {
-        var 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) {
-        var 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 ) {
-        var isXYObject = function(obj) {
-            return obj instanceof $.Point || (typeof obj.x === 'number' && typeof obj.y === 'number');
-        };
-
-        var 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;
-        var anchorProperties = $.Placement.properties[anchor];
-        var aspectRatio = this.contentAspectX;
-        var xOffset = 0;
-        var yOffset = 0;
-        var displayedWidthRatio = 1;
-        var 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
-            var height = bounds.height / displayedHeightRatio;
-            var 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
-            var width = bounds.width / displayedWidthRatio;
-            var 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
+     * 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.
-     * @fires OpenSeadragon.TiledImage.event:clip-change
+     * @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.
      */
-    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._needsDraw = true;
+    $.TiledImage = function( options ) {
+        this._initialized = false;
         /**
-         * 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.
+         * The {@link OpenSeadragon.TileSource} that defines this TiledImage.
+         * @member {OpenSeadragon.TileSource} source
+         * @memberof OpenSeadragon.TiledImage#
          */
-        this.raiseEvent('clip-change');
-    },
+        $.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");
 
-    /**
-     * @returns {Boolean} Whether the TiledImage should be flipped before rendering.
-     */
-    getFlip: function() {
-        return this.flipped;
-    },
+        $.EventSource.call( this );
 
-    /**
-     * @param {Boolean} flip Whether the TiledImage should be flipped before rendering.
-     * @fires OpenSeadragon.TiledImage.event:bounds-change
-     */
-    setFlip: function(flip) {
-        this.flipped = flip;
-    },
+        this._tileCache = options.tileCache;
+        delete options.tileCache;
 
-    get flipped(){
-        return this._flipped;
-    },
-    set flipped(flipped){
-        let changed = this._flipped !== !!flipped;
-        this._flipped = !!flipped;
-        if(changed){
-            this.update(true);
-            this._needsDraw = true;
-            this._raiseBoundsChange();
-        }
-    },
+        this._drawer = options.drawer;
+        delete options.drawer;
 
-    get wrapHorizontal(){
-        return this._wrapHorizontal;
-    },
-    set wrapHorizontal(wrap){
-        let changed = this._wrapHorizontal !== !!wrap;
-        this._wrapHorizontal = !!wrap;
-        if(this._initialized && changed){
-            this.update(true);
-            this._needsDraw = true;
-            // this._raiseBoundsChange();
-        }
-    },
+        this._imageLoader = options.imageLoader;
+        delete options.imageLoader;
 
-    get wrapVertical(){
-        return this._wrapVertical;
-    },
-    set wrapVertical(wrap){
-        let 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;
+        if (options.clip instanceof $.Rect) {
+            this._clip = options.clip.clone();
         }
 
-        this._opacity = opacity;
-        this._needsDraw = 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
+        delete options.clip;
+
+        var x = options.x || 0;
+        delete options.x;
+        var 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;
+
+        var 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;
+        }
+
+        var fitBounds = options.fitBounds;
+        delete options.fitBounds;
+        var fitBoundsPlacement = options.fitBoundsPlacement || OpenSeadragon.Placement.CENTER;
+        delete options.fitBoundsPlacement;
+
+        var degrees = options.degrees || 0;
+        delete options.degrees;
+
+        var ajaxHeaders = options.ajaxHeaders;
+        delete options.ajaxHeaders;
+
+        $.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.
+            lastDrawn:      [],    // An unordered list of Tiles drawn last frame.
+            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.
+            _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
+            _isBlending:    false, // Are any tiles still being blended?
+            _wasBlending:   false, // Were any tiles blending before the last draw?
+            _isTainted:     false, // Has a Tile been found with tainted data?
+            //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,
+            blendTime:                         $.DEFAULT_SETTINGS.blendTime,
+            alwaysBlend:                       $.DEFAULT_SETTINGS.alwaysBlend,
+            minPixelRatio:                     $.DEFAULT_SETTINGS.minPixelRatio,
+            smoothTileEdgesMinZoom:            $.DEFAULT_SETTINGS.smoothTileEdgesMinZoom,
+            iOSDevice:                         $.DEFAULT_SETTINGS.iOSDevice,
+            debugMode:                         $.DEFAULT_SETTINGS.debugMode,
+            crossOriginPolicy:                 $.DEFAULT_SETTINGS.crossOriginPolicy,
+            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
+        }, options );
+
+        this._preload = this.preload;
+        delete this.preload;
+
+        this._fullyLoaded = false;
+
+        this._xSpring = new $.Spring({
+            initial: x,
+            springStiffness: this.springStiffness,
+            animationTime: this.animationTime
         });
-    },
 
-    /**
-     * @returns {Boolean} whether the tiledImage can load its tiles even when it has zero opacity.
-     */
-    getPreload: function() {
-        return this._preload;
-    },
+        this._ySpring = new $.Spring({
+            initial: y,
+            springStiffness: this.springStiffness,
+            animationTime: this.animationTime
+        });
 
-    /**
-     * Set true to load even when hidden. Set false to block loading when hidden.
-     */
-    setPreload: function(preload) {
-        this._preload = !!preload;
-        this._needsDraw = true;
-    },
+        this._scaleSpring = new $.Spring({
+            initial: scale,
+            springStiffness: this.springStiffness,
+            animationTime: this.animationTime
+        });
 
-    /**
-     * 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;
-    },
+        this._degreesSpring = new $.Spring({
+            initial: degrees,
+            springStiffness: this.springStiffness,
+            animationTime: this.animationTime
+        });
 
-    /**
-     * 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;
+        this._updateForScale();
+
+        if (fitBounds) {
+            this.fitBounds(fitBounds, fitBoundsPlacement, true);
         }
-        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(){
+        this._ownAjaxHeaders = {};
+        this.setAjaxHeaders(ajaxHeaders, false);
+        this._initialized = true;
+    };
+
+    $.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;
+        },
+
+        // 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
+            });
+        },
+
+        /**
+         * Clears all tiles and triggers an update on the next call to
+         * {@link OpenSeadragon.TiledImage#update}.
+         */
+        reset: function() {
+            this._tileCache.clearTilesFor(this);
+            this.lastResetTime = $.now();
+            this._needsDraw = true;
+        },
+
+        /**
+         * 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) {
+            let xUpdated = this._xSpring.update();
+            let yUpdated = this._ySpring.update();
+            let scaleUpdated = this._scaleSpring.update();
+            let degreesUpdated = this._degreesSpring.update();
+
+            let updated = (xUpdated || yUpdated || scaleUpdated || degreesUpdated || this._needsUpdate);
+
+            if (updated || viewportChanged || !this._fullyLoaded){
+                let fullyLoadedFlag = this._updateLevelsForViewport();
+                this._setFullyLoaded(fullyLoadedFlag);
+            }
+
+            this._needsUpdate = false;
+
+            if (updated) {
+                this._updateForScale();
+                this._raiseBoundsChange();
+                this._needsDraw = true;
+                return true;
+            }
 
-        if( this._opacity === 0 && !this._preload){
             return false;
-        }
+        },
 
-        var drawArea = this._viewportToTiledImageRectangle(
-            this.viewport.getBoundsWithMargins(true));
-
-        if (!this.wrapHorizontal && !this.wrapVertical) {
-            var tiledImageBounds = this._viewportToTiledImageRectangle(
-                this.getClippedBounds(true));
-            drawArea = drawArea.intersection(tiledImageBounds);
-        }
-
-        return drawArea;
-    },
-
-    /**
-     *
-     * @returns {Array} 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
-        let tileArray = this._tilesToDraw.flat();
-
-        // update all tiles, which can change the coverage provided
-        this._updateTilesInViewport(tileArray);
-
-        // _tilesToDraw might have been updated by the update; refresh it
-        tileArray = this._tilesToDraw.flat();
-
-         // mark the tiles as being drawn, so that they won't be discarded from
-        // the tileCache
-        tileArray.forEach(tileInfo => {
-            tileInfo.tile.beingDrawn = true;
-        });
-        this._lastDrawn = tileArray;
-        return tileArray;
-    },
-
-    /**
-     * 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.
+         * 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
          */
-        this.raiseEvent('composite-operation-change', {
-            compositeOperation: this._compositeOperation
-        });
+        setDrawn: function(){
+            this._needsDraw = this._isBlending || this._wasBlending;
+            return this._needsDraw;
+        },
 
-    },
+        /**
+         * Set the internal _isTainted 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 tainted data so that further
+         * checks are not needed and alternative rendering strategies can be used.
+         * @private
+         */
+        setTainted(isTainted){
+            this._isTainted = isTainted;
+        },
 
-    /**
-     * @returns {String} The TiledImage's current compositeOperation.
-     */
-    getCompositeOperation: function() {
-        return this._compositeOperation;
-    },
+        /**
+         * @private
+         * @returns {Boolean} whether the TiledImage has been marked as tainted
+         */
+        isTainted(){
+            return this._isTainted;
+        },
 
-    /**
-     * @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
-    },
+        /**
+         * Destroy the TiledImage (unload current loaded tiles).
+         */
+        destroy: function() {
+            this.reset();
 
-    /**
-     * 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;
-        }
+            if (this.source.destroy) {
+                this.source.destroy(this.viewer);
+            }
+        },
 
-        this._ownAjaxHeaders = ajaxHeaders;
-        this._updateAjaxHeaders(propagate);
-    },
+        /**
+         * 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));
+        },
 
-    /**
-     * 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;
-        }
+        /**
+         * 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);
+        },
 
-        // merge with viewer's headers
-        if ($.isPlainObject(this.viewer.ajaxHeaders)) {
-            this.ajaxHeaders = $.extend({}, this.viewer.ajaxHeaders, this._ownAjaxHeaders);
-        } else {
-            this.ajaxHeaders = this._ownAjaxHeaders;
-        }
+        // deprecated
+        getWorldBounds: function() {
+            $.console.error('[TiledImage.getWorldBounds] is deprecated; use TiledImage.getBounds instead');
+            return this.getBounds();
+        },
 
-        // propagate header updates to all tiles and queued image loader jobs
-        if (propagate) {
-            var numTiles, xMod, yMod, tile;
+        /**
+         * 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) {
+            var bounds = this.getBoundsNoRotate(current);
+            if (this._clip) {
+                var worldWidth = current ?
+                    this._worldWidthCurrent : this._worldWidthTarget;
+                var ratio = worldWidth / this.source.dimensions.x;
+                var 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));
+        },
 
-            for (var level in this.tilesMatrix) {
-                numTiles = this.source.getNumTiles(level);
+        /**
+         * @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 ) {
+            var numTiles = this.source.getNumTiles(level);
+            var xMod    = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
+            var yMod    = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
+            var 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;
+        },
 
-                for (var x in this.tilesMatrix[level]) {
-                    xMod = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
+        /**
+         * @returns {OpenSeadragon.Point} This TiledImage's content size, in original pixels.
+         */
+        getContentSize: function() {
+            return new $.Point(this.source.dimensions.x, this.source.dimensions.y);
+        },
 
-                    for (var y in this.tilesMatrix[level][x]) {
-                        yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
-                        tile = this.tilesMatrix[level][x][y];
+        /**
+         * @returns {OpenSeadragon.Point} The TiledImage's content size, in window coordinates.
+         */
+        getSizeInWindowCoordinates: function() {
+            var topLeft = this.imageToWindowCoordinates(new $.Point(0, 0));
+            var bottomRight = this.imageToWindowCoordinates(this.getContentSize());
+            return new $.Point(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
+        },
 
-                        tile.loadWithAjax = this.loadTilesWithAjax;
-                        if (tile.loadWithAjax) {
-                            var tileAjaxHeaders = this.source.getTileAjaxHeaders( level, xMod, yMod );
-                            tile.ajaxHeaders = $.extend({}, this.ajaxHeaders, tileAjaxHeaders);
+        // private
+        _viewportToImageDelta: function( viewerX, viewerY, current ) {
+            var 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) {
+            var 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 ) {
+            var 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;
+            }
+
+            var 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) {
+            var rect = imageX;
+            if (rect instanceof $.Rect) {
+                //they passed a rect instead of individual components
+                current = imageY;
+            } else {
+                rect = new $.Rect(imageX, imageY, pixelWidth, pixelHeight);
+            }
+
+            var coordA = this.imageToViewportCoordinates(rect.getTopLeft(), current);
+            var 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 ) {
+            var rect = viewerX;
+            if (viewerX instanceof $.Rect) {
+                //they passed a rect instead of individual components
+                current = viewerY;
+            } else {
+                rect = new $.Rect(viewerX, viewerY, pointWidth, pointHeight);
+            }
+
+            var coordA = this.viewportToImageCoordinates(rect.getTopLeft(), current);
+            var 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 ) {
+            var 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 ) {
+            var 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 ) {
+            var 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 ) {
+            var 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) {
+            var 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 ) {
+            var 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...
+         * Note: not accurate with multi-image.
+         * @function
+         * @param {Number} imageZoom The image zoom
+         * @returns {Number} viewportZoom The viewport zoom
+         */
+        imageToViewportZoom: function( imageZoom ) {
+            var 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) {
+            var 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 ) {
+            var isXYObject = function(obj) {
+                return obj instanceof $.Point || (typeof obj.x === 'number' && typeof obj.y === 'number');
+            };
+
+            var objectToSimpleXYObject = function(objs) {
+                return objs.map(function(obj) {
+                    try {
+                        if (isXYObject(obj)) {
+                            return { x: obj.x, y: obj.y };
                         } else {
-                            tile.ajaxHeaders = null;
+                            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;
+            var anchorProperties = $.Placement.properties[anchor];
+            var aspectRatio = this.contentAspectX;
+            var xOffset = 0;
+            var yOffset = 0;
+            var displayedWidthRatio = 1;
+            var 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
+                var height = bounds.height / displayedHeightRatio;
+                var 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
+                var width = bounds.width / displayedWidthRatio;
+                var 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._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){
+            let changed = this._flipped !== !!flipped;
+            this._flipped = !!flipped;
+            if(changed){
+                this.update(true);
+                this._needsDraw = true;
+                this._raiseBoundsChange();
+            }
+        },
+
+        get wrapHorizontal(){
+            return this._wrapHorizontal;
+        },
+        set wrapHorizontal(wrap){
+            let 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){
+            let 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;
+            /**
+             * 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;
+            }
+
+            var drawArea = this._viewportToTiledImageRectangle(
+                this.viewport.getBoundsWithMargins(true));
+
+            if (!this.wrapHorizontal && !this.wrapVertical) {
+                var tiledImageBounds = this._viewportToTiledImageRectangle(
+                    this.getClippedBounds(true));
+                drawArea = drawArea.intersection(tiledImageBounds);
+            }
+
+            return drawArea;
+        },
+
+        /**
+         *
+         * @returns {Array} 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
+            let tileArray = this._tilesToDraw.flat();
+
+            // update all tiles, which can change the coverage provided
+            this._updateTilesInViewport(tileArray);
+
+            // _tilesToDraw might have been updated by the update; refresh it
+            tileArray = this._tilesToDraw.flat();
+
+             // mark the tiles as being drawn, so that they won't be discarded from
+            // the tileCache
+            tileArray.forEach(tileInfo => {
+                tileInfo.tile.beingDrawn = true;
+            });
+            this._lastDrawn = tileArray;
+            return tileArray;
+        },
+
+        /**
+         * 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) {
+                var numTiles, xMod, yMod, tile;
+
+                for (var level in this.tilesMatrix) {
+                    numTiles = this.source.getNumTiles(level);
+
+                    for (var x in this.tilesMatrix[level]) {
+                        xMod = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
+
+                        for (var y in this.tilesMatrix[level][x]) {
+                            yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
+                            tile = this.tilesMatrix[level][x][y];
+
+                            tile.loadWithAjax = this.loadTilesWithAjax;
+                            if (tile.loadWithAjax) {
+                                var tileAjaxHeaders = this.source.getTileAjaxHeaders( level, xMod, yMod );
+                                tile.ajaxHeaders = $.extend({}, this.ajaxHeaders, tileAjaxHeaders);
+                            } else {
+                                tile.ajaxHeaders = null;
+                            }
+                        }
+                    }
+                }
+
+                for (var i = 0; i < this._imageLoader.jobQueue.length; i++) {
+                    var job = this._imageLoader.jobQueue[i];
+                    job.loadWithAjax = job.tile.loadWithAjax;
+                    job.ajaxHeaders = job.tile.loadWithAjax ? job.tile.ajaxHeaders : null;
+                }
+            }
+        },
+
+        // private
+        _setScale: function(scale, immediately) {
+            var 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() {
+            var lowestLevel = Math.max(
+                this.source.minLevel,
+                Math.floor(Math.log(this.minZoomImageRatio) / Math.log(2))
+            );
+            var currentZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+                this.source.getPixelRatio(0), true).x *
+                this._scaleSpring.current.value;
+            var 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(){
+            var levelsInterval = this._getLevelsInterval();
+            var lowestLevel = levelsInterval.lowestLevel; // the lowest level we should draw at our current zoom
+            var highestLevel = levelsInterval.highestLevel; // the highest level we should draw at our current zoom
+            var bestTiles = [];
+            var drawArea = this.getDrawArea();
+            var currentTime = $.now();
+
+            // reset each tile's beingDrawn flag
+            this._lastDrawn.forEach(tileinfo => {
+                tileinfo.tile.beingDrawn = false;
+            });
+            // clear the list of tiles to draw
+            this._tilesToDraw = [];
+            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
+            var levelList = new Array(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++){
+                var 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
+            for (let i = 0; i < levelList.length; i++) {
+                let level = levelList[i];
+
+                var currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+                    this.source.getPixelRatio(level),
+                    true
+                ).x * this._scaleSpring.current.value;
+
+                var targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+                    this.source.getPixelRatio(level),
+                    false
+                ).x * this._scaleSpring.current.value;
+
+                var targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+                    this.source.getPixelRatio(
+                        Math.max(
+                            this.source.getClosestLevel(),
+                            0
+                        )
+                    ),
+                    false
+                ).x * this._scaleSpring.current.value;
+
+                var optimalRatio = this.immediateRender ? 1 : targetZeroRatio;
+                var levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5);
+                var levelVisibility = optimalRatio / Math.abs(
+                    optimalRatio - targetRenderPixelRatio
+                );
+
+                // Update the level and keep track of 'best' tiles to load
+                var result = this._updateLevel(
+                    level,
+                    levelOpacity,
+                    levelVisibility,
+                    drawArea,
+                    currentTime,
+                    bestTiles
+                );
+
+                bestTiles = result.bestTiles;
+                var tiles = result.updatedTiles.filter(tile => tile.loaded);
+                var makeTileInfoObject = (function(level, levelOpacity, currentTime){
+                    return function(tile){
+                        return {
+                            tile: tile,
+                            level: level,
+                            levelOpacity: levelOpacity,
+                            currentTime: currentTime
+                        };
+                    };
+                })(level, levelOpacity, currentTime);
+
+                this._tilesToDraw[level] = tiles.map(makeTileInfoObject);
+
+                // 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 (bestTiles && bestTiles.length > 0) {
+                bestTiles.forEach(function (tile) {
+                    if (tile && !tile.context2D) {
+                        this._loadTile(tile, currentTime);
+                    }
+                }, this);
+
+                this._needsDraw = true;
+                return false;
+            } else {
+                return this._tilesLoading === 0;
+            }
+
+            // Update
+
+        },
+
+        /**
+         * Update all tiles that contribute to the current view
+         * @private
+         *
+         */
+        _updateTilesInViewport: function(tiles) {
+            let currentTime = $.now();
+            let _this = this;
+            this._tilesLoading = 0;
+            this._wasBlending = this._isBlending;
+            this._isBlending = false;
+            this.loadingCoverage = {};
+            let lowestLevel = tiles.length ? tiles[0].level : 0;
+
+            let drawArea = this.getDrawArea();
+            if(!drawArea){
+                return;
+            }
+
+            function updateTile(info){
+                let tile = info.tile;
+                if(tile && tile.loaded){
+                    let 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;
+                }
+            }
+
+            // 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(let i = 0; i < tiles.length; i++){
+                let tile = tiles[i];
+                updateTile(tile);
+                if(this._providesCoverage(this.coverage, tile.level)){
+                    level = Math.max(level, tile.level);
+                }
+            }
+            if(level > 0){
+                for( let levelKey in this._tilesToDraw ){
+                    if( levelKey < level ){
+                        delete this._tilesToDraw[levelKey];
                     }
                 }
             }
 
-            for (var i = 0; i < this._imageLoader.jobQueue.length; i++) {
-                var job = this._imageLoader.jobQueue[i];
-                job.loadWithAjax = job.tile.loadWithAjax;
-                job.ajaxHeaders = job.tile.loadWithAjax ? job.tile.ajaxHeaders : null;
-            }
-        }
-    },
+        },
 
-    // private
-    _setScale: function(scale, immediately) {
-        var 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.
+         * 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
          */
-        this.raiseEvent('bounds-change');
-    },
+        _blendTile: function(tile, x, y, level, levelOpacity, currentTime, lowestLevel ){
+            let blendTimeMillis = 1000 * this.blendTime,
+                deltaTime,
+                opacity;
 
-    // private
-    _isBottomItem: function() {
-        return this.viewer.world.getItemAt(0) === this;
-    },
-
-    // private
-    _getLevelsInterval: function() {
-        var lowestLevel = Math.max(
-            this.source.minLevel,
-            Math.floor(Math.log(this.minZoomImageRatio) / Math.log(2))
-        );
-        var currentZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-            this.source.getPixelRatio(0), true).x *
-            this._scaleSpring.current.value;
-        var 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(){
-        var levelsInterval = this._getLevelsInterval();
-        var lowestLevel = levelsInterval.lowestLevel;
-        var highestLevel = levelsInterval.highestLevel;
-        var bestTiles = [];
-        var haveDrawn = false;
-        var drawArea = this.getDrawArea();
-        var currentTime = $.now();
-
-        // reset each tile's beingDrawn flag
-        this._lastDrawn.forEach(tileinfo => {
-            tileinfo.tile.beingDrawn = false;
-        });
-        // clear the list of tiles to draw
-        this._tilesToDraw = [];
-        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
-        var levelList = new Array(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++){
-            var tile = (
-                this.tilesMatrix[level] &&
-                this.tilesMatrix[level][0] &&
-                this.tilesMatrix[level][0][0]
-            );
-            if(tile && tile.isBottomMost && tile.isRightMost && tile.loaded){
-                levelList.push(level);
-                levelList.hasHigherResolutionFallback = true;
-                break;
-            }
-        }
-
-
-        // Update any level that will be drawn
-        for (let i = 0; i < levelList.length; i++) {
-            let level = levelList[i];
-            var drawLevel = false;
-
-            //Avoid calculations for draw if we have already drawn this
-            var currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-                this.source.getPixelRatio(level),
-                true
-            ).x * this._scaleSpring.current.value;
-
-            if (i === levelList.length - 1 ||
-                (!haveDrawn && currentRenderPixelRatio >= this.minPixelRatio) ) {
-                drawLevel = true;
-                haveDrawn = true;
-            } else if (!haveDrawn) {
-                continue;
+            if ( !tile.blendStart ) {
+                tile.blendStart = currentTime;
             }
 
-            //Perform calculations for draw if we haven't drawn this
-            var targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-                this.source.getPixelRatio(level),
-                false
-            ).x * this._scaleSpring.current.value;
+            deltaTime   = currentTime - tile.blendStart;
+            opacity     = blendTimeMillis ? Math.min( 1, deltaTime / ( blendTimeMillis ) ) : 1;
 
-            var targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-                this.source.getPixelRatio(
-                    Math.max(
-                        this.source.getClosestLevel(),
-                        0
-                    )
-                ),
-                false
-            ).x * this._scaleSpring.current.value;
-
-            var optimalRatio = this.immediateRender ? 1 : targetZeroRatio;
-            var levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5);
-            var levelVisibility = optimalRatio / Math.abs(
-                optimalRatio - targetRenderPixelRatio
-            );
-
-            // Update the level and keep track of 'best' tiles to load
-            // the bestTiles
-            var result = this._updateLevel(
-                haveDrawn,
-                drawLevel,
-                level,
-                levelOpacity,
-                levelVisibility,
-                drawArea,
-                currentTime,
-                bestTiles
-            );
-
-            bestTiles = result.bestTiles;
-            var tiles = result.updatedTiles.filter(tile => tile.loaded);
-            var makeTileInfoObject = (function(level, levelOpacity, currentTime){
-                return function(tile){
-                    return {
-                        tile: tile,
-                        level: level,
-                        levelOpacity: levelOpacity,
-                        currentTime: currentTime
-                    };
-                };
-            })(level, levelOpacity, currentTime);
-
-            this._tilesToDraw[level] = tiles.map(makeTileInfoObject);
-
-            // 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 (bestTiles && bestTiles.length > 0) {
-            bestTiles.forEach(function (tile) {
-                if (tile && !tile.context2D) {
-                    this._loadTile(tile, currentTime);
-                }
-            }, this);
-
-            this._needsDraw = true;
-            return false;
-        } else {
-            return this._tilesLoading === 0;
-        }
-
-        // Update
-
-    },
-
-    /**
-     * Update all tiles that contribute to the current view
-     * @private
-     *
-     */
-    _updateTilesInViewport: function(tiles) {
-        let currentTime = $.now();
-        let _this = this;
-        this._tilesLoading = 0;
-        this._wasBlending = this._isBlending;
-        this._isBlending = false;
-        this.loadingCoverage = {};
-        let lowestLevel = tiles.length ? tiles[0].level : 0;
-
-        let drawArea = this.getDrawArea();
-        if(!drawArea){
-            return;
-        }
-
-        function updateTile(info){
-            let tile = info.tile;
-            if(tile && tile.loaded){
-                let 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;
-            }
-        }
-
-        // 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(let i = 0; i < tiles.length; i++){
-            let tile = tiles[i];
-            updateTile(tile);
-            if(this._providesCoverage(this.coverage, tile.level)){
-                level = Math.max(level, tile.level);
-            }
-        }
-        if(level > 0){
-            for( let levelKey in this._tilesToDraw ){
-                if( levelKey < level ){
-                    delete this._tilesToDraw[levelKey];
-                }
-            }
-        }
-
-    },
-
-    /**
-     * 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 {Boolean} haveDrawn
-     * @param {Boolean} drawLevel
-     * @param {Number} level
-     * @param {Number} levelOpacity
-     * @param {Number} levelVisibility
-     * @param {OpenSeadragon.Rect} drawArea
-     * @param {Number} currentTime
-     * @param {OpenSeadragon.Tile[]} best Array of the current best tiles
-     * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile - the current "best" tiles to draw, updatedTiles: OpenSeadragon.Tile) - the updated tiles}.
-     */
-    _updateLevel: function(haveDrawn, drawLevel, level, levelOpacity,
-                           levelVisibility, drawArea, currentTime, best) {
-
-        var topLeftBound = drawArea.getBoundingBox().getTopLeft();
-        var bottomRightBound = 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
-             * @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: haveDrawn,
-                level: level,
-                opacity: levelOpacity,
-                visibility: levelVisibility,
-                drawArea: drawArea,
-                topleft: topLeftBound,
-                bottomright: bottomRightBound,
-                currenttime: currentTime,
-                best: best
-            });
-        }
-
-        this._resetCoverage(this.coverage, level);
-        this._resetCoverage(this.loadingCoverage, level);
-
-        //OK, a new drawing so do your calculations
-        var cornerTiles = this._getCornerTiles(level, topLeftBound, bottomRightBound);
-        var topLeftTile = cornerTiles.topLeft;
-        var bottomRightTile = cornerTiles.bottomRight;
-        var numberOfTiles  = this.source.getNumTiles(level);
-
-        var viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter());
-
-        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.
-            bottomRightTile.x += 1;
-            if (!this.wrapHorizontal) {
-                bottomRightTile.x  = Math.min(bottomRightTile.x, numberOfTiles.x - 1);
-            }
-        }
-        var numTiles = Math.max(0, (bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y));
-        var tiles = new Array(numTiles);
-        var tileIndex = 0;
-        for (var x = topLeftTile.x; x <= bottomRightTile.x; x++) {
-            for (var y = topLeftTile.y; y <= bottomRightTile.y; y++) {
-
-                var flippedX;
-                if (this.getFlip()) {
-                    var xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x;
-                    flippedX = x + numberOfTiles.x - xMod - xMod - 1;
-                } else {
-                    flippedX = x;
-                }
-
-                if (drawArea.intersection(this.getTileBounds(level, flippedX, y)) === null) {
-                    // This tile is outside of the viewport, no need to draw it
-                    continue;
-                }
-
-                var result = this._updateTile(
-                    haveDrawn,
-                    drawLevel,
-                    flippedX, y,
-                    level,
-                    levelVisibility,
-                    viewportCenter,
-                    numberOfTiles,
-                    currentTime,
-                    best
-                );
-                best = result.bestTiles;
-                tiles[tileIndex] = result.tile;
-                tileIndex += 1;
-            }
-        }
-
-        return {
-            bestTiles: best,
-            updatedTiles: tiles
-        };
-    },
-
-    /**
-     * @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 ){
-        var 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;
-
-        var 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;
-
-        var positionC = viewport.pixelFromPointNoRotate(boundsTL, true),
-            positionT = viewport.pixelFromPointNoRotate(boundsTL, false),
-            sizeC = viewport.deltaPixelsFromPointsNoRotate(boundsSize, true),
-            sizeT = viewport.deltaPixelsFromPointsNoRotate(boundsSize, false),
-            tileCenter = positionT.plus( sizeT.divide( 2 ) ),
-            tileSquaredDistance = viewportCenter.squaredDistanceTo( tileCenter );
-
-        if(this.viewer.drawer.minimumOverlapRequired()){
-            if ( !overlap ) {
-                sizeC = sizeC.plus( new $.Point(1, 1));
+            // if this tile is at the lowest level being drawn, render at opacity=1
+            if(level === lowestLevel){
+                opacity = 1;
+                deltaTime = blendTimeMillis;
             }
 
-            if (tile.isRightMost && this.wrapHorizontal) {
-                sizeC.x += 0.75; // Otherwise Firefox and Safari show seams
+            if ( this.alwaysBlend ) {
+                opacity *= levelOpacity;
             }
+            tile.opacity = opacity;
 
-            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;
-    },
-
-    /**
-     * Update a single tile at a particular resolution level.
-     * @private
-     * @param {Boolean} haveDrawn
-     * @param {Boolean} drawLevel
-     * @param {Number} x
-     * @param {Number} y
-     * @param {Number} level
-     * @param {Number} levelVisibility
-     * @param {OpenSeadragon.Point} viewportCenter
-     * @param {Number} numberOfTiles
-     * @param {Number} currentTime
-     * @param {OpenSeadragon.Tile} best - The current "best" tile to draw.
-     * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile[] - the current best tiles, tile: OpenSeadragon.Tile the current tile}
-     */
-    _updateTile: function( haveDrawn, drawLevel, x, y, level,
-                           levelVisibility, viewportCenter, numberOfTiles, currentTime, best){
-
-        var tile = this._getTile(
-            x, y,
-            level,
-            currentTime,
-            numberOfTiles
-            ),
-            drawTile = drawLevel;
-
-        if( this.viewer ){
-            /**
-             * <em>- Needs documentation -</em>
-             *
-             * @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 );
-
-        var loadingCoverage = tile.loaded || tile.loading || this._isCovered(this.loadingCoverage, level, x, y);
-        this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage);
-
-        if ( !tile.exists ) {
-            return {
-                bestTiles: best,
-                tile: tile
-            };
-        }
-        if (tile.loaded && tile.opacity === 1){
-            this._setCoverage( this.coverage, level, x, y, true );
-        }
-        if ( drawTile && !haveDrawn ) {
-            if ( this._isCovered( this.coverage, level, x, y ) ) {
+            if ( opacity === 1 ) {
                 this._setCoverage( this.coverage, level, x, y, true );
-            } else {
-                haveDrawn = true;
+                this._hasOpaqueTile = true;
             }
-        }
+            // return true if the tile is still blending
+            return deltaTime < blendTimeMillis;
+        },
 
-        if ( !haveDrawn ) {
-            return {
-                bestTiles: best,
-                tile: tile
-            };
-        }
+        /**
+         * Updates all tiles at a given resolution level.
+         * @private
+         * @param {Number} level
+         * @param {Number} levelOpacity
+         * @param {Number} levelVisibility
+         * @param {OpenSeadragon.Rect} drawArea
+         * @param {Number} currentTime
+         * @param {OpenSeadragon.Tile[]} best Array of the current best tiles
+         * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile - the current "best" tiles to draw, updatedTiles: OpenSeadragon.Tile) - the updated tiles}.
+         */
+        _updateLevel: function(level, levelOpacity,
+                               levelVisibility, drawArea, currentTime, best) {
 
-        this._positionTile(
-            tile,
-            this.source.tileOverlap,
-            this.viewport,
-            viewportCenter,
-            levelVisibility
-        );
+            var topLeftBound = drawArea.getBoundingBox().getTopLeft();
+            var bottomRightBound = drawArea.getBoundingBox().getBottomRight();
 
-        if (!tile.loaded) {
-            if (tile.context2D) {
-                this._setTileLoaded(tile);
-            } else {
-                var imageRecord = this._tileCache.getImageRecord(tile.cacheKey);
-                if (imageRecord) {
-                    this._setTileLoaded(tile, imageRecord.getData());
-                }
-            }
-        }
-
-        if ( tile.loading ) {
-            // the tile is already in the download queue
-            this._tilesLoading++;
-        } else if (!loadingCoverage) {
-            best = this._compareTiles( best, tile, this.maxTilesPerFrame );
-        }
-
-        return {
-            bestTiles: best,
-            tile: tile
-        };
-    },
-
-    // private
-    _getCornerTiles: function(level, topLeftBound, bottomRightBound) {
-        var leftX;
-        var 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);
-        }
-        var topY;
-        var bottomY;
-        var 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);
-        }
-
-        var topLeftTile = this.source.getTileAtPoint(level, new $.Point(leftX, topY));
-        var bottomRightTile = this.source.getTileAtPoint(level, new $.Point(rightX, bottomY));
-        var 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,
-        };
-    },
-
-    /**
-     * 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
-    ) {
-        var xMod,
-            yMod,
-            bounds,
-            sourceBounds,
-            exists,
-            urlOrGetter,
-            post,
-            ajaxHeaders,
-            context2D,
-            tile,
-            tilesMatrix = this.tilesMatrix,
-            tileSource = this.source;
-
-        if ( !tilesMatrix[ level ] ) {
-            tilesMatrix[ level ] = {};
-        }
-        if ( !tilesMatrix[ level ][ x ] ) {
-            tilesMatrix[ level ][ x ] = {};
-        }
-
-        if ( !tilesMatrix[ level ][ x ][ y ] || !tilesMatrix[ level ][ 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;
-            }
-
-            context2D = tileSource.getContext2D ?
-                tileSource.getContext2D(level, xMod, yMod) : undefined;
-
-            tile = new $.Tile(
-                level,
-                x,
-                y,
-                bounds,
-                exists,
-                urlOrGetter,
-                context2D,
-                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;
-
-            tilesMatrix[ level ][ x ][ y ] = tile;
-        }
-
-        tile = tilesMatrix[ level ][ 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 ) {
-        var _this = this;
-        tile.loading = true;
-        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 ){
-                _this._onTileLoad( tile, time, data, errorMsg, tileRequest );
-            },
-            abort: function() {
-                tile.loading = false;
-            }
-        });
-    },
-
-    /**
-     * 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
-     */
-    _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) {
-        if ( !data ) {
-            $.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 {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
-            });
-            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;
-        }
-
-        var _this = this,
-            finish = function() {
-                var ccc = _this.source;
-                var cutoff = ccc.getClosestLevel();
-                _this._setTileLoaded(tile, data, cutoff, tileRequest);
-        };
-
-
-        finish();
-    },
-
-    /**
-     * @private
-     * @param {OpenSeadragon.Tile} tile
-     * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object
-     * @param {Number|undefined} cutoff
-     * @param {XMLHttpRequest|undefined} tileRequest
-     */
-    _setTileLoaded: function(tile, data, cutoff, tileRequest) {
-        var increment = 0,
-            eventFinished = false,
-            _this = this;
-
-        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 completionCallback() {
-            increment--;
-            if (increment === 0) {
-                tile.loading = false;
-                tile.loaded = true;
-                tile.hasTransparency = _this.source.hasTransparency(
-                    tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData
-                );
-                if (!tile.context2D) {
-                    _this._tileCache.cacheTile({
-                        data: data,
-                        tile: tile,
-                        cutoff: cutoff,
-                        tiledImage: _this
-                    });
-                }
+            if (this.viewer) {
                 /**
-                 * Triggered when a tile is loaded and pre-processing is compelete,
-                 * and the tile is ready to draw.
+                 * <em>- Needs documentation -</em>
                  *
-                 * @event tile-ready
+                 * @event update-level
                  * @memberof OpenSeadragon.Viewer
                  * @type {object}
-                 * @property {OpenSeadragon.Tile} tile - The tile which has been loaded.
-                 * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile.
-                 * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable).
+                 * @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("tile-ready", {
-                    tile: tile,
-                    tiledImage: _this,
-                    tileRequest: tileRequest
+                this.viewer.raiseEvent('update-level', {
+                    tiledImage: this,
+                    havedrawn: true, // deprecated, kept for backwards compatibility
+                    level: level,
+                    opacity: levelOpacity,
+                    visibility: levelVisibility,
+                    drawArea: drawArea,
+                    topleft: topLeftBound,
+                    bottomright: bottomRightBound,
+                    currenttime: currentTime,
+                    best: best
                 });
-                _this._needsDraw = true;
             }
-        }
+
+            this._resetCoverage(this.coverage, level);
+            this._resetCoverage(this.loadingCoverage, level);
+
+            //OK, a new drawing so do your calculations
+            var cornerTiles = this._getCornerTiles(level, topLeftBound, bottomRightBound);
+            var topLeftTile = cornerTiles.topLeft;
+            var bottomRightTile = cornerTiles.bottomRight;
+            var numberOfTiles  = this.source.getNumTiles(level);
+
+            var viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter());
+
+            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.
+                bottomRightTile.x += 1;
+                if (!this.wrapHorizontal) {
+                    bottomRightTile.x  = Math.min(bottomRightTile.x, numberOfTiles.x - 1);
+                }
+            }
+            var numTiles = Math.max(0, (bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y));
+            var tiles = new Array(numTiles);
+            var tileIndex = 0;
+            for (var x = topLeftTile.x; x <= bottomRightTile.x; x++) {
+                for (var y = topLeftTile.y; y <= bottomRightTile.y; y++) {
+
+                    var flippedX;
+                    if (this.getFlip()) {
+                        var xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x;
+                        flippedX = x + numberOfTiles.x - xMod - xMod - 1;
+                    } else {
+                        flippedX = x;
+                    }
+
+                    if (drawArea.intersection(this.getTileBounds(level, flippedX, y)) === null) {
+                        // This tile is outside of the viewport, no need to draw it
+                        continue;
+                    }
+
+                    var result = this._updateTile(
+                        flippedX, y,
+                        level,
+                        levelVisibility,
+                        viewportCenter,
+                        numberOfTiles,
+                        currentTime,
+                        best
+                    );
+                    best = result.bestTiles;
+                    tiles[tileIndex] = result.tile;
+                    tileIndex += 1;
+                }
+            }
+
+            return {
+                bestTiles: best,
+                updatedTiles: tiles
+            };
+        },
 
         /**
-         * 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.
-         *
-         * @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
-         * @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 {function} getCompletionCallback - A function giving a callback to call
-         * when the asynchronous processing of the image is done. The image will be
-         * marked as entirely loaded when the callback has been called once for each
-         * call to getCompletionCallback.
+         * @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 ){
+            var boundsTL = tile.bounds.getTopLeft();
 
-        var fallbackCompletion = getCompletionCallback();
-        this.viewer.raiseEvent("tile-loaded", {
-            tile: tile,
-            tiledImage: this,
-            tileRequest: tileRequest,
-            get image() {
-                $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead.");
-                return data;
-            },
-            data: data,
-            getCompletionCallback: getCompletionCallback
-        });
-        eventFinished = true;
-        // In case the completion callback is never called, we at least force it once.
-        fallbackCompletion();
-    },
+            boundsTL.x *= this._scaleSpring.current.value;
+            boundsTL.y *= this._scaleSpring.current.value;
+            boundsTL.x += this._xSpring.current.value;
+            boundsTL.y += this._ySpring.current.value;
 
+            var boundsSize   = tile.bounds.getSize();
 
-    /**
-     * Determines the 'best tiles' from the given 'last best' tiles and the
-     * tile in question.
-     * @private
-     *
-     * @param {OpenSeadragon.Tile[]} previousBest The best tiles so far.
-     * @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];
-        }
-        previousBest.push(tile);
-        this._sortTiles(previousBest);
-        if (previousBest.length > maxNTiles) {
-            previousBest.pop();
-        }
-        return previousBest;
-    },
+            boundsSize.x *= this._scaleSpring.current.value;
+            boundsSize.y *= this._scaleSpring.current.value;
 
-    /**
-     * Sorts tiles in an array according to distance and visibility.
-     * @private
-     *
-     * @param {OpenSeadragon.Tile[]} tiles The tiles.
-     */
-    _sortTiles: function( tiles ) {
-        tiles.sort(function (a, b) {
-            if (a === null) {
-                return 1;
+            tile.positionedBounds.x = boundsTL.x;
+            tile.positionedBounds.y = boundsTL.y;
+            tile.positionedBounds.width = boundsSize.x;
+            tile.positionedBounds.height = boundsSize.y;
+
+            var positionC = viewport.pixelFromPointNoRotate(boundsTL, true),
+                positionT = viewport.pixelFromPointNoRotate(boundsTL, false),
+                sizeC = viewport.deltaPixelsFromPointsNoRotate(boundsSize, true),
+                sizeT = viewport.deltaPixelsFromPointsNoRotate(boundsSize, false),
+                tileCenter = positionT.plus( sizeT.divide( 2 ) ),
+                tileSquaredDistance = viewportCenter.squaredDistanceTo( tileCenter );
+
+            if(this.viewer.drawer.minimumOverlapRequired()){
+                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
+                }
             }
-            if (b === null) {
-                return -1;
+
+            tile.position   = positionC;
+            tile.size       = sizeC;
+            tile.squaredDistance   = tileSquaredDistance;
+            tile.visibility = levelVisibility;
+        },
+
+        /**
+         * Update a single tile at a particular resolution level.
+         * @private
+         * @param {Number} x
+         * @param {Number} y
+         * @param {Number} level
+         * @param {Number} levelVisibility
+         * @param {OpenSeadragon.Point} viewportCenter
+         * @param {Number} numberOfTiles
+         * @param {Number} currentTime
+         * @param {OpenSeadragon.Tile} best - The current "best" tile to draw.
+         * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile[] - the current best tiles, tile: OpenSeadragon.Tile the current tile}
+         */
+        _updateTile: function( x, y, level,
+                               levelVisibility, viewportCenter, numberOfTiles, currentTime, best){
+
+            var tile = this._getTile(
+                x, y,
+                level,
+                currentTime,
+                numberOfTiles
+                );
+
+            if( this.viewer ){
+                /**
+                 * <em>- Needs documentation -</em>
+                 *
+                 * @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
+                });
             }
-            if (a.visibility === b.visibility) {
-                // sort by smallest squared distance
-                return (a.squaredDistance - b.squaredDistance);
-            } else {
-                // sort by largest visibility value
-                return (b.visibility - a.visibility);
+
+            this._setCoverage( this.coverage, level, x, y, false );
+
+            var loadingCoverage = tile.loaded || tile.loading || this._isCovered(this.loadingCoverage, level, x, y);
+            this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage);
+
+            if ( !tile.exists ) {
+                return {
+                    bestTiles: best,
+                    tile: tile
+                };
+            }
+            if (tile.loaded && tile.opacity === 1){
+                this._setCoverage( this.coverage, level, x, y, true );
             }
-        });
-    },
 
+            this._positionTile(
+                tile,
+                this.source.tileOverlap,
+                this.viewport,
+                viewportCenter,
+                levelVisibility
+            );
 
-    /**
-     * 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 ) {
-        var rows,
-            cols,
-            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;
-                        }
+            if (!tile.loaded) {
+                if (tile.context2D) {
+                    this._setTileLoaded(tile);
+                } else {
+                    var imageRecord = this._tileCache.getImageRecord(tile.cacheKey);
+                    if (imageRecord) {
+                        this._setTileLoaded(tile, imageRecord.getData());
                     }
                 }
             }
 
-            return true;
-        }
+            if ( tile.loading ) {
+                // the tile is already in the download queue
+                this._tilesLoading++;
+            } else if (!loadingCoverage) {
+                best = this._compareTiles( best, tile, this.maxTilesPerFrame );
+            }
 
-        return (
-            coverage[ level ][ x] === undefined ||
-            coverage[ level ][ x ][ y ] === undefined ||
-            coverage[ level ][ x ][ y ] === true
-        );
-    },
+            return {
+                bestTiles: best,
+                tile: tile
+            };
+        },
+
+        // private
+        _getCornerTiles: function(level, topLeftBound, bottomRightBound) {
+            var leftX;
+            var 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);
+            }
+            var topY;
+            var bottomY;
+            var 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);
+            }
+
+            var topLeftTile = this.source.getTileAtPoint(level, new $.Point(leftX, topY));
+            var bottomRightTile = this.source.getTileAtPoint(level, new $.Point(rightX, bottomY));
+            var 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,
+            };
+        },
+
+        /**
+         * 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
+        ) {
+            var xMod,
+                yMod,
+                bounds,
+                sourceBounds,
+                exists,
+                urlOrGetter,
+                post,
+                ajaxHeaders,
+                context2D,
+                tile,
+                tilesMatrix = this.tilesMatrix,
+                tileSource = this.source;
+
+            if ( !tilesMatrix[ level ] ) {
+                tilesMatrix[ level ] = {};
+            }
+            if ( !tilesMatrix[ level ][ x ] ) {
+                tilesMatrix[ level ][ x ] = {};
+            }
+
+            if ( !tilesMatrix[ level ][ x ][ y ] || !tilesMatrix[ level ][ 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;
+                }
+
+                context2D = tileSource.getContext2D ?
+                    tileSource.getContext2D(level, xMod, yMod) : undefined;
+
+                tile = new $.Tile(
+                    level,
+                    x,
+                    y,
+                    bounds,
+                    exists,
+                    urlOrGetter,
+                    context2D,
+                    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;
+
+                tilesMatrix[ level ][ x ][ y ] = tile;
+            }
+
+            tile = tilesMatrix[ level ][ 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 ) {
+            var _this = this;
+            tile.loading = true;
+            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 ){
+                    _this._onTileLoad( tile, time, data, errorMsg, tileRequest );
+                },
+                abort: function() {
+                    tile.loading = false;
+                }
+            });
+        },
+
+        /**
+         * 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
+         */
+        _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) {
+            if ( !data ) {
+                $.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 {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
+                });
+                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;
+            }
+
+            var _this = this,
+                finish = function() {
+                    var ccc = _this.source;
+                    var cutoff = ccc.getClosestLevel();
+                    _this._setTileLoaded(tile, data, cutoff, tileRequest);
+            };
+
+
+            finish();
+        },
+
+        /**
+         * @private
+         * @param {OpenSeadragon.Tile} tile
+         * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object
+         * @param {Number|undefined} cutoff
+         * @param {XMLHttpRequest|undefined} tileRequest
+         */
+        _setTileLoaded: function(tile, data, cutoff, tileRequest) {
+            var increment = 0,
+                eventFinished = false,
+                _this = this;
+
+            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 completionCallback() {
+                increment--;
+                if (increment === 0) {
+                    tile.loading = false;
+                    tile.loaded = true;
+                    tile.hasTransparency = _this.source.hasTransparency(
+                        tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData
+                    );
+                    if (!tile.context2D) {
+                        _this._tileCache.cacheTile({
+                            data: data,
+                            tile: tile,
+                            cutoff: cutoff,
+                            tiledImage: _this
+                        });
+                    }
+                    /**
+                     * Triggered when a tile is loaded and pre-processing is compelete,
+                     * and the tile is ready to draw.
+                     *
+                     * @event tile-ready
+                     * @memberof OpenSeadragon.Viewer
+                     * @type {object}
+                     * @property {OpenSeadragon.Tile} tile - The tile which has been loaded.
+                     * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile.
+                     * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable).
+                     */
+                    _this.viewer.raiseEvent("tile-ready", {
+                        tile: tile,
+                        tiledImage: _this,
+                        tileRequest: tileRequest
+                    });
+                    _this._needsDraw = true;
+                }
+            }
+
+            /**
+             * 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.
+             *
+             * @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
+             * @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 {function} getCompletionCallback - A function giving a callback to call
+             * when the asynchronous processing of the image is done. The image will be
+             * marked as entirely loaded when the callback has been called once for each
+             * call to getCompletionCallback.
+             */
+
+            var fallbackCompletion = getCompletionCallback();
+            this.viewer.raiseEvent("tile-loaded", {
+                tile: tile,
+                tiledImage: this,
+                tileRequest: tileRequest,
+                get image() {
+                    $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead.");
+                    return data;
+                },
+                data: data,
+                getCompletionCallback: getCompletionCallback
+            });
+            eventFinished = true;
+            // In case the completion callback is never called, we at least force it once.
+            fallbackCompletion();
+        },
+
+
+        /**
+         * Determines the 'best tiles' from the given 'last best' tiles and the
+         * tile in question.
+         * @private
+         *
+         * @param {OpenSeadragon.Tile[]} previousBest The best tiles so far.
+         * @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];
+            }
+            previousBest.push(tile);
+            this._sortTiles(previousBest);
+            if (previousBest.length > maxNTiles) {
+                previousBest.pop();
+            }
+            return previousBest;
+        },
+
+        /**
+         * Sorts tiles in an array according to distance and visibility.
+         * @private
+         *
+         * @param {OpenSeadragon.Tile[]} tiles The tiles.
+         */
+        _sortTiles: function( tiles ) {
+            tiles.sort(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);
+                } else {
+                    // sort by largest visibility value
+                    return (b.visibility - a.visibility);
+                }
+            });
+        },
+
+
+        /**
+         * 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 ) {
+            var rows,
+                cols,
+                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;
+            }
 
-    /**
-     * 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 )
+                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 ] = {};
         }
-    },
-
-    /**
-     * 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 ));

From f7c12a716bd968b0f0e4c5c8e66759c81c7b7977 Mon Sep 17 00:00:00 2001
From: Tom <tmpearce@gmail.com>
Date: Wed, 22 May 2024 18:59:19 -0400
Subject: [PATCH 6/8] undo extra tabs before ach line introduced automatically
 by copy-and-pasting code

---
 src/tiledimage.js | 4214 ++++++++++++++++++++++-----------------------
 1 file changed, 2107 insertions(+), 2107 deletions(-)

diff --git a/src/tiledimage.js b/src/tiledimage.js
index 6a2d7253..a227a8dd 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -34,2245 +34,2245 @@
 
 (function( $ ){
 
+/**
+ * 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.
+    */
+$.TiledImage = function( options ) {
+    this._initialized = false;
     /**
-     * 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.
+     * The {@link OpenSeadragon.TileSource} that defines this TiledImage.
+     * @member {OpenSeadragon.TileSource} source
+     * @memberof OpenSeadragon.TiledImage#
      */
-    $.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");
+    $.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 );
+    $.EventSource.call( this );
 
-        this._tileCache = options.tileCache;
-        delete options.tileCache;
+    this._tileCache = options.tileCache;
+    delete options.tileCache;
 
-        this._drawer = options.drawer;
-        delete options.drawer;
+    this._drawer = options.drawer;
+    delete options.drawer;
 
-        this._imageLoader = options.imageLoader;
-        delete options.imageLoader;
+    this._imageLoader = options.imageLoader;
+    delete options.imageLoader;
 
-        if (options.clip instanceof $.Rect) {
-            this._clip = options.clip.clone();
-        }
+    if (options.clip instanceof $.Rect) {
+        this._clip = options.clip.clone();
+    }
 
-        delete options.clip;
+    delete options.clip;
 
-        var x = options.x || 0;
-        delete options.x;
-        var y = options.y || 0;
-        delete options.y;
+    var x = options.x || 0;
+    delete options.x;
+    var 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;
+    // 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;
 
-        var scale = 1;
-        if ( options.width ) {
-            scale = options.width;
-            delete options.width;
+    var 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;
+        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;
+    }
 
-        var fitBounds = options.fitBounds;
-        delete options.fitBounds;
-        var fitBoundsPlacement = options.fitBoundsPlacement || OpenSeadragon.Placement.CENTER;
-        delete options.fitBoundsPlacement;
+    var fitBounds = options.fitBounds;
+    delete options.fitBounds;
+    var fitBoundsPlacement = options.fitBoundsPlacement || OpenSeadragon.Placement.CENTER;
+    delete options.fitBoundsPlacement;
 
-        var degrees = options.degrees || 0;
-        delete options.degrees;
+    var degrees = options.degrees || 0;
+    delete options.degrees;
 
-        var ajaxHeaders = options.ajaxHeaders;
-        delete options.ajaxHeaders;
+    var ajaxHeaders = options.ajaxHeaders;
+    delete options.ajaxHeaders;
 
-        $.extend( true, this, {
+    $.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.
-            lastDrawn:      [],    // An unordered list of Tiles drawn last frame.
-            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.
-            _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
-            _isBlending:    false, // Are any tiles still being blended?
-            _wasBlending:   false, // Were any tiles blending before the last draw?
-            _isTainted:     false, // Has a Tile been found with tainted data?
-            //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,
-            blendTime:                         $.DEFAULT_SETTINGS.blendTime,
-            alwaysBlend:                       $.DEFAULT_SETTINGS.alwaysBlend,
-            minPixelRatio:                     $.DEFAULT_SETTINGS.minPixelRatio,
-            smoothTileEdgesMinZoom:            $.DEFAULT_SETTINGS.smoothTileEdgesMinZoom,
-            iOSDevice:                         $.DEFAULT_SETTINGS.iOSDevice,
-            debugMode:                         $.DEFAULT_SETTINGS.debugMode,
-            crossOriginPolicy:                 $.DEFAULT_SETTINGS.crossOriginPolicy,
-            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
-        }, options );
+        //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.
+        lastDrawn:      [],    // An unordered list of Tiles drawn last frame.
+        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.
+        _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
+        _isBlending:    false, // Are any tiles still being blended?
+        _wasBlending:   false, // Were any tiles blending before the last draw?
+        _isTainted:     false, // Has a Tile been found with tainted data?
+        //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,
+        blendTime:                         $.DEFAULT_SETTINGS.blendTime,
+        alwaysBlend:                       $.DEFAULT_SETTINGS.alwaysBlend,
+        minPixelRatio:                     $.DEFAULT_SETTINGS.minPixelRatio,
+        smoothTileEdgesMinZoom:            $.DEFAULT_SETTINGS.smoothTileEdgesMinZoom,
+        iOSDevice:                         $.DEFAULT_SETTINGS.iOSDevice,
+        debugMode:                         $.DEFAULT_SETTINGS.debugMode,
+        crossOriginPolicy:                 $.DEFAULT_SETTINGS.crossOriginPolicy,
+        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
+    }, options );
 
-        this._preload = this.preload;
-        delete this.preload;
+    this._preload = this.preload;
+    delete this.preload;
 
-        this._fullyLoaded = false;
+    this._fullyLoaded = false;
 
-        this._xSpring = new $.Spring({
-            initial: x,
-            springStiffness: this.springStiffness,
-            animationTime: this.animationTime
-        });
+    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._ySpring = new $.Spring({
+        initial: y,
+        springStiffness: this.springStiffness,
+        animationTime: this.animationTime
+    });
 
-        this._scaleSpring = new $.Spring({
-            initial: scale,
-            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._degreesSpring = new $.Spring({
+        initial: degrees,
+        springStiffness: this.springStiffness,
+        animationTime: this.animationTime
+    });
 
-        this._updateForScale();
+    this._updateForScale();
 
-        if (fitBounds) {
-            this.fitBounds(fitBounds, fitBoundsPlacement, true);
+    if (fitBounds) {
+        this.fitBounds(fitBounds, fitBoundsPlacement, true);
+    }
+
+    this._ownAjaxHeaders = {};
+    this.setAjaxHeaders(ajaxHeaders, false);
+    this._initialized = true;
+};
+
+$.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;
+    },
+
+    // private
+    _setFullyLoaded: function(flag) {
+        if (flag === this._fullyLoaded) {
+            return;
         }
 
-        this._ownAjaxHeaders = {};
-        this.setAjaxHeaders(ajaxHeaders, false);
-        this._initialized = true;
-    };
-
-    $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.TiledImage.prototype */{
-        /**
-         * @returns {Boolean} Whether the TiledImage needs to be drawn.
-         */
-        needsDraw: function() {
-            return this._needsDraw;
-        },
+        this._fullyLoaded = flag;
 
         /**
-         * Mark the tiled image as needing to be (re)drawn
+         * 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.
          */
-        redraw: function() {
+        this.raiseEvent('fully-loaded-change', {
+            fullyLoaded: this._fullyLoaded
+        });
+    },
+
+    /**
+     * Clears all tiles and triggers an update on the next call to
+     * {@link OpenSeadragon.TiledImage#update}.
+     */
+    reset: function() {
+        this._tileCache.clearTilesFor(this);
+        this.lastResetTime = $.now();
+        this._needsDraw = true;
+    },
+
+    /**
+     * 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) {
+        let xUpdated = this._xSpring.update();
+        let yUpdated = this._ySpring.update();
+        let scaleUpdated = this._scaleSpring.update();
+        let degreesUpdated = this._degreesSpring.update();
+
+        let updated = (xUpdated || yUpdated || scaleUpdated || degreesUpdated || this._needsUpdate);
+
+        if (updated || viewportChanged || !this._fullyLoaded){
+            let fullyLoadedFlag = this._updateLevelsForViewport();
+            this._setFullyLoaded(fullyLoadedFlag);
+        }
+
+        this._needsUpdate = false;
+
+        if (updated) {
+            this._updateForScale();
+            this._raiseBoundsChange();
             this._needsDraw = true;
-        },
+            return true;
+        }
 
-        /**
-         * @returns {Boolean} Whether all tiles necessary for this TiledImage to draw at the current view have been loaded.
-         */
-        getFullyLoaded: function() {
-            return this._fullyLoaded;
-        },
+        return false;
+    },
 
-        // private
-        _setFullyLoaded: function(flag) {
-            if (flag === this._fullyLoaded) {
+    /**
+     * 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;
+        return this._needsDraw;
+    },
+
+    /**
+     * Set the internal _isTainted 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 tainted data so that further
+     * checks are not needed and alternative rendering strategies can be used.
+     * @private
+     */
+    setTainted(isTainted){
+        this._isTainted = isTainted;
+    },
+
+    /**
+     * @private
+     * @returns {Boolean} whether the TiledImage has been marked as tainted
+     */
+    isTainted(){
+        return this._isTainted;
+    },
+
+    /**
+     * Destroy the TiledImage (unload current loaded tiles).
+     */
+    destroy: function() {
+        this.reset();
+
+        if (this.source.destroy) {
+            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) {
+        var bounds = this.getBoundsNoRotate(current);
+        if (this._clip) {
+            var worldWidth = current ?
+                this._worldWidthCurrent : this._worldWidthTarget;
+            var ratio = worldWidth / this.source.dimensions.x;
+            var 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 ) {
+        var numTiles = this.source.getNumTiles(level);
+        var xMod    = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
+        var yMod    = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
+        var 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() {
+        var topLeft = this.imageToWindowCoordinates(new $.Point(0, 0));
+        var bottomRight = this.imageToWindowCoordinates(this.getContentSize());
+        return new $.Point(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
+    },
+
+    // private
+    _viewportToImageDelta: function( viewerX, viewerY, current ) {
+        var 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) {
+        var 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 ) {
+        var 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;
+        }
+
+        var 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) {
+        var rect = imageX;
+        if (rect instanceof $.Rect) {
+            //they passed a rect instead of individual components
+            current = imageY;
+        } else {
+            rect = new $.Rect(imageX, imageY, pixelWidth, pixelHeight);
+        }
+
+        var coordA = this.imageToViewportCoordinates(rect.getTopLeft(), current);
+        var 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 ) {
+        var rect = viewerX;
+        if (viewerX instanceof $.Rect) {
+            //they passed a rect instead of individual components
+            current = viewerY;
+        } else {
+            rect = new $.Rect(viewerX, viewerY, pointWidth, pointHeight);
+        }
+
+        var coordA = this.viewportToImageCoordinates(rect.getTopLeft(), current);
+        var 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 ) {
+        var 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 ) {
+        var 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 ) {
+        var 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 ) {
+        var 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) {
+        var 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 ) {
+        var 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...
+     * Note: not accurate with multi-image.
+     * @function
+     * @param {Number} imageZoom The image zoom
+     * @returns {Number} viewportZoom The viewport zoom
+     */
+    imageToViewportZoom: function( imageZoom ) {
+        var 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) {
+        var 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._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
-            });
-        },
-
-        /**
-         * Clears all tiles and triggers an update on the next call to
-         * {@link OpenSeadragon.TiledImage#update}.
-         */
-        reset: function() {
-            this._tileCache.clearTilesFor(this);
-            this.lastResetTime = $.now();
-            this._needsDraw = true;
-        },
-
-        /**
-         * 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) {
-            let xUpdated = this._xSpring.update();
-            let yUpdated = this._ySpring.update();
-            let scaleUpdated = this._scaleSpring.update();
-            let degreesUpdated = this._degreesSpring.update();
-
-            let updated = (xUpdated || yUpdated || scaleUpdated || degreesUpdated || this._needsUpdate);
-
-            if (updated || viewportChanged || !this._fullyLoaded){
-                let 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;
-            return this._needsDraw;
-        },
-
-        /**
-         * Set the internal _isTainted 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 tainted data so that further
-         * checks are not needed and alternative rendering strategies can be used.
-         * @private
-         */
-        setTainted(isTainted){
-            this._isTainted = isTainted;
-        },
-
-        /**
-         * @private
-         * @returns {Boolean} whether the TiledImage has been marked as tainted
-         */
-        isTainted(){
-            return this._isTainted;
-        },
-
-        /**
-         * Destroy the TiledImage (unload current loaded tiles).
-         */
-        destroy: function() {
-            this.reset();
-
-            if (this.source.destroy) {
-                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) {
-            var bounds = this.getBoundsNoRotate(current);
-            if (this._clip) {
-                var worldWidth = current ?
-                    this._worldWidthCurrent : this._worldWidthTarget;
-                var ratio = worldWidth / this.source.dimensions.x;
-                var 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 ) {
-            var numTiles = this.source.getNumTiles(level);
-            var xMod    = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
-            var yMod    = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
-            var 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() {
-            var topLeft = this.imageToWindowCoordinates(new $.Point(0, 0));
-            var bottomRight = this.imageToWindowCoordinates(this.getContentSize());
-            return new $.Point(bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
-        },
-
-        // private
-        _viewportToImageDelta: function( viewerX, viewerY, current ) {
-            var 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) {
-            var 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 ) {
-            var 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;
-            }
-
-            var 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) {
-            var rect = imageX;
-            if (rect instanceof $.Rect) {
-                //they passed a rect instead of individual components
-                current = imageY;
-            } else {
-                rect = new $.Rect(imageX, imageY, pixelWidth, pixelHeight);
-            }
-
-            var coordA = this.imageToViewportCoordinates(rect.getTopLeft(), current);
-            var 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 ) {
-            var rect = viewerX;
-            if (viewerX instanceof $.Rect) {
-                //they passed a rect instead of individual components
-                current = viewerY;
-            } else {
-                rect = new $.Rect(viewerX, viewerY, pointWidth, pointHeight);
-            }
-
-            var coordA = this.viewportToImageCoordinates(rect.getTopLeft(), current);
-            var 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 ) {
-            var 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 ) {
-            var 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 ) {
-            var 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 ) {
-            var 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) {
-            var 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 ) {
-            var 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...
-         * Note: not accurate with multi-image.
-         * @function
-         * @param {Number} imageZoom The image zoom
-         * @returns {Number} viewportZoom The viewport zoom
-         */
-        imageToViewportZoom: function( imageZoom ) {
-            var 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) {
-            var 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 ) {
-            var isXYObject = function(obj) {
-                return obj instanceof $.Point || (typeof obj.x === 'number' && typeof obj.y === 'number');
-            };
-
-            var 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;
-            var anchorProperties = $.Placement.properties[anchor];
-            var aspectRatio = this.contentAspectX;
-            var xOffset = 0;
-            var yOffset = 0;
-            var displayedWidthRatio = 1;
-            var 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
-                var height = bounds.height / displayedHeightRatio;
-                var 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
-                var width = bounds.width / displayedWidthRatio;
-                var 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._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){
-            let changed = this._flipped !== !!flipped;
-            this._flipped = !!flipped;
-            if(changed){
-                this.update(true);
-                this._needsDraw = true;
-                this._raiseBoundsChange();
-            }
-        },
-
-        get wrapHorizontal(){
-            return this._wrapHorizontal;
-        },
-        set wrapHorizontal(wrap){
-            let 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){
-            let 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;
-            /**
-             * 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._xSpring.resetTo(position.x);
+            this._ySpring.resetTo(position.y);
             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;
-            }
-
-            var drawArea = this._viewportToTiledImageRectangle(
-                this.viewport.getBoundsWithMargins(true));
-
-            if (!this.wrapHorizontal && !this.wrapVertical) {
-                var tiledImageBounds = this._viewportToTiledImageRectangle(
-                    this.getClippedBounds(true));
-                drawArea = drawArea.intersection(tiledImageBounds);
-            }
-
-            return drawArea;
-        },
-
-        /**
-         *
-         * @returns {Array} 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
-            let tileArray = this._tilesToDraw.flat();
-
-            // update all tiles, which can change the coverage provided
-            this._updateTilesInViewport(tileArray);
-
-            // _tilesToDraw might have been updated by the update; refresh it
-            tileArray = this._tilesToDraw.flat();
-
-             // mark the tiles as being drawn, so that they won't be discarded from
-            // the tileCache
-            tileArray.forEach(tileInfo => {
-                tileInfo.tile.beingDrawn = true;
-            });
-            this._lastDrawn = tileArray;
-            return tileArray;
-        },
-
-        /**
-         * 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) {
+        } else {
+            if (sameTarget) {
                 return;
             }
-            this._compositeOperation = compositeOperation;
+
+            this._xSpring.springTo(position.x);
+            this._ySpring.springTo(position.y);
             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
+            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 ) {
+        var isXYObject = function(obj) {
+            return obj instanceof $.Point || (typeof obj.x === 'number' && typeof obj.y === 'number');
+        };
+
+        var 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');
+                }
             });
+        };
 
-        },
-
-        /**
-         * @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;
+        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();
+        }
+    },
 
-            this._ownAjaxHeaders = ajaxHeaders;
-            this._updateAjaxHeaders(propagate);
-        },
+    /**
+     * Resets the cropping polygons, thus next render will remove all cropping
+     * polygon effects.
+     */
+    resetCroppingPolygons: function() {
+        this._croppingPolygons = null;
+        this._needsDraw = true;
+    },
 
-        /**
-         * 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);
+    /**
+     * 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;
+        var anchorProperties = $.Placement.properties[anchor];
+        var aspectRatio = this.contentAspectX;
+        var xOffset = 0;
+        var yOffset = 0;
+        var displayedWidthRatio = 1;
+        var 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 {
-                this.ajaxHeaders = this._ownAjaxHeaders;
+                xOffset = this._clip.x / this._clip.width * bounds.width;
+                yOffset = this._clip.y / this._clip.width * bounds.width;
             }
+        }
 
-            // propagate header updates to all tiles and queued image loader jobs
-            if (propagate) {
-                var numTiles, xMod, yMod, tile;
+        if (bounds.getAspectRatio() > aspectRatio) {
+            // We will have margins on the X axis
+            var height = bounds.height / displayedHeightRatio;
+            var 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
+            var width = bounds.width / displayedWidthRatio;
+            var 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);
+        }
+    },
 
-                for (var level in this.tilesMatrix) {
-                    numTiles = this.source.getNumTiles(level);
+    /**
+     * @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();
+        }
 
-                    for (var x in this.tilesMatrix[level]) {
-                        xMod = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
+        return null;
+    },
 
-                        for (var y in this.tilesMatrix[level][x]) {
-                            yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
-                            tile = this.tilesMatrix[level][x][y];
+    /**
+     * @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");
 
-                            tile.loadWithAjax = this.loadTilesWithAjax;
-                            if (tile.loadWithAjax) {
-                                var tileAjaxHeaders = this.source.getTileAjaxHeaders( level, xMod, yMod );
-                                tile.ajaxHeaders = $.extend({}, this.ajaxHeaders, tileAjaxHeaders);
-                            } else {
-                                tile.ajaxHeaders = null;
-                            }
+        if (newClip instanceof $.Rect) {
+            this._clip = newClip.clone();
+        } else {
+            this._clip = null;
+        }
+
+        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){
+        let changed = this._flipped !== !!flipped;
+        this._flipped = !!flipped;
+        if(changed){
+            this.update(true);
+            this._needsDraw = true;
+            this._raiseBoundsChange();
+        }
+    },
+
+    get wrapHorizontal(){
+        return this._wrapHorizontal;
+    },
+    set wrapHorizontal(wrap){
+        let 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){
+        let 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;
+        /**
+         * 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;
+        }
+
+        var drawArea = this._viewportToTiledImageRectangle(
+            this.viewport.getBoundsWithMargins(true));
+
+        if (!this.wrapHorizontal && !this.wrapVertical) {
+            var tiledImageBounds = this._viewportToTiledImageRectangle(
+                this.getClippedBounds(true));
+            drawArea = drawArea.intersection(tiledImageBounds);
+        }
+
+        return drawArea;
+    },
+
+    /**
+     *
+     * @returns {Array} 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
+        let tileArray = this._tilesToDraw.flat();
+
+        // update all tiles, which can change the coverage provided
+        this._updateTilesInViewport(tileArray);
+
+        // _tilesToDraw might have been updated by the update; refresh it
+        tileArray = this._tilesToDraw.flat();
+
+            // mark the tiles as being drawn, so that they won't be discarded from
+        // the tileCache
+        tileArray.forEach(tileInfo => {
+            tileInfo.tile.beingDrawn = true;
+        });
+        this._lastDrawn = tileArray;
+        return tileArray;
+    },
+
+    /**
+     * 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) {
+            var numTiles, xMod, yMod, tile;
+
+            for (var level in this.tilesMatrix) {
+                numTiles = this.source.getNumTiles(level);
+
+                for (var x in this.tilesMatrix[level]) {
+                    xMod = ( numTiles.x + ( x % numTiles.x ) ) % numTiles.x;
+
+                    for (var y in this.tilesMatrix[level][x]) {
+                        yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y;
+                        tile = this.tilesMatrix[level][x][y];
+
+                        tile.loadWithAjax = this.loadTilesWithAjax;
+                        if (tile.loadWithAjax) {
+                            var tileAjaxHeaders = this.source.getTileAjaxHeaders( level, xMod, yMod );
+                            tile.ajaxHeaders = $.extend({}, this.ajaxHeaders, tileAjaxHeaders);
+                        } else {
+                            tile.ajaxHeaders = null;
                         }
                     }
                 }
-
-                for (var i = 0; i < this._imageLoader.jobQueue.length; i++) {
-                    var job = this._imageLoader.jobQueue[i];
-                    job.loadWithAjax = job.tile.loadWithAjax;
-                    job.ajaxHeaders = job.tile.loadWithAjax ? job.tile.ajaxHeaders : null;
-                }
-            }
-        },
-
-        // private
-        _setScale: function(scale, immediately) {
-            var 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();
+            for (var i = 0; i < this._imageLoader.jobQueue.length; i++) {
+                var job = this._imageLoader.jobQueue[i];
+                job.loadWithAjax = job.tile.loadWithAjax;
+                job.ajaxHeaders = job.tile.loadWithAjax ? job.tile.ajaxHeaders : null;
             }
-        },
+        }
+    },
 
-        // 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() {
-            var lowestLevel = Math.max(
-                this.source.minLevel,
-                Math.floor(Math.log(this.minZoomImageRatio) / Math.log(2))
-            );
-            var currentZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-                this.source.getPixelRatio(0), true).x *
-                this._scaleSpring.current.value;
-            var 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(){
-            var levelsInterval = this._getLevelsInterval();
-            var lowestLevel = levelsInterval.lowestLevel; // the lowest level we should draw at our current zoom
-            var highestLevel = levelsInterval.highestLevel; // the highest level we should draw at our current zoom
-            var bestTiles = [];
-            var drawArea = this.getDrawArea();
-            var currentTime = $.now();
-
-            // reset each tile's beingDrawn flag
-            this._lastDrawn.forEach(tileinfo => {
-                tileinfo.tile.beingDrawn = false;
-            });
-            // clear the list of tiles to draw
-            this._tilesToDraw = [];
-            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
-            var levelList = new Array(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++){
-                var 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
-            for (let i = 0; i < levelList.length; i++) {
-                let level = levelList[i];
-
-                var currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-                    this.source.getPixelRatio(level),
-                    true
-                ).x * this._scaleSpring.current.value;
-
-                var targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-                    this.source.getPixelRatio(level),
-                    false
-                ).x * this._scaleSpring.current.value;
-
-                var targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
-                    this.source.getPixelRatio(
-                        Math.max(
-                            this.source.getClosestLevel(),
-                            0
-                        )
-                    ),
-                    false
-                ).x * this._scaleSpring.current.value;
-
-                var optimalRatio = this.immediateRender ? 1 : targetZeroRatio;
-                var levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5);
-                var levelVisibility = optimalRatio / Math.abs(
-                    optimalRatio - targetRenderPixelRatio
-                );
-
-                // Update the level and keep track of 'best' tiles to load
-                var result = this._updateLevel(
-                    level,
-                    levelOpacity,
-                    levelVisibility,
-                    drawArea,
-                    currentTime,
-                    bestTiles
-                );
-
-                bestTiles = result.bestTiles;
-                var tiles = result.updatedTiles.filter(tile => tile.loaded);
-                var makeTileInfoObject = (function(level, levelOpacity, currentTime){
-                    return function(tile){
-                        return {
-                            tile: tile,
-                            level: level,
-                            levelOpacity: levelOpacity,
-                            currentTime: currentTime
-                        };
-                    };
-                })(level, levelOpacity, currentTime);
-
-                this._tilesToDraw[level] = tiles.map(makeTileInfoObject);
-
-                // 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 (bestTiles && bestTiles.length > 0) {
-                bestTiles.forEach(function (tile) {
-                    if (tile && !tile.context2D) {
-                        this._loadTile(tile, currentTime);
-                    }
-                }, this);
-
-                this._needsDraw = true;
-                return false;
-            } else {
-                return this._tilesLoading === 0;
-            }
-
-            // Update
-
-        },
-
-        /**
-         * Update all tiles that contribute to the current view
-         * @private
-         *
-         */
-        _updateTilesInViewport: function(tiles) {
-            let currentTime = $.now();
-            let _this = this;
-            this._tilesLoading = 0;
-            this._wasBlending = this._isBlending;
-            this._isBlending = false;
-            this.loadingCoverage = {};
-            let lowestLevel = tiles.length ? tiles[0].level : 0;
-
-            let drawArea = this.getDrawArea();
-            if(!drawArea){
+    // private
+    _setScale: function(scale, immediately) {
+        var sameTarget = (this._scaleSpring.target.value === scale);
+        if (immediately) {
+            if (sameTarget && this._scaleSpring.current.value === scale) {
                 return;
             }
 
-            function updateTile(info){
-                let tile = info.tile;
-                if(tile && tile.loaded){
-                    let 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;
-                }
+            this._scaleSpring.resetTo(scale);
+            this._updateForScale();
+            this._needsDraw = true;
+            this._needsUpdate = true;
+        } else {
+            if (sameTarget) {
+                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(let i = 0; i < tiles.length; i++){
-                let tile = tiles[i];
-                updateTile(tile);
-                if(this._providesCoverage(this.coverage, tile.level)){
-                    level = Math.max(level, tile.level);
-                }
-            }
-            if(level > 0){
-                for( let levelKey in this._tilesToDraw ){
-                    if( levelKey < level ){
-                        delete this._tilesToDraw[levelKey];
-                    }
-                }
-            }
+            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() {
         /**
-         * 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
+         * 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.
          */
-        _blendTile: function(tile, x, y, level, levelOpacity, currentTime, lowestLevel ){
-            let blendTimeMillis = 1000 * this.blendTime,
-                deltaTime,
-                opacity;
+        this.raiseEvent('bounds-change');
+    },
 
-            if ( !tile.blendStart ) {
-                tile.blendStart = currentTime;
+    // private
+    _isBottomItem: function() {
+        return this.viewer.world.getItemAt(0) === this;
+    },
+
+    // private
+    _getLevelsInterval: function() {
+        var lowestLevel = Math.max(
+            this.source.minLevel,
+            Math.floor(Math.log(this.minZoomImageRatio) / Math.log(2))
+        );
+        var currentZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+            this.source.getPixelRatio(0), true).x *
+            this._scaleSpring.current.value;
+        var 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(){
+        var levelsInterval = this._getLevelsInterval();
+        var lowestLevel = levelsInterval.lowestLevel; // the lowest level we should draw at our current zoom
+        var highestLevel = levelsInterval.highestLevel; // the highest level we should draw at our current zoom
+        var bestTiles = [];
+        var drawArea = this.getDrawArea();
+        var currentTime = $.now();
+
+        // reset each tile's beingDrawn flag
+        this._lastDrawn.forEach(tileinfo => {
+            tileinfo.tile.beingDrawn = false;
+        });
+        // clear the list of tiles to draw
+        this._tilesToDraw = [];
+        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
+        var levelList = new Array(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++){
+            var 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;
             }
+        }
 
-            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;
-            }
+        // 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
+        for (let i = 0; i < levelList.length; i++) {
+            let level = levelList[i];
 
-            if ( this.alwaysBlend ) {
-                opacity *= levelOpacity;
-            }
-            tile.opacity = opacity;
+            var currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+                this.source.getPixelRatio(level),
+                true
+            ).x * this._scaleSpring.current.value;
 
-            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;
-        },
+            var targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+                this.source.getPixelRatio(level),
+                false
+            ).x * this._scaleSpring.current.value;
 
-        /**
-         * Updates all tiles at a given resolution level.
-         * @private
-         * @param {Number} level
-         * @param {Number} levelOpacity
-         * @param {Number} levelVisibility
-         * @param {OpenSeadragon.Rect} drawArea
-         * @param {Number} currentTime
-         * @param {OpenSeadragon.Tile[]} best Array of the current best tiles
-         * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile - the current "best" tiles to draw, updatedTiles: OpenSeadragon.Tile) - the updated tiles}.
-         */
-        _updateLevel: function(level, levelOpacity,
-                               levelVisibility, drawArea, currentTime, best) {
+            var targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate(
+                this.source.getPixelRatio(
+                    Math.max(
+                        this.source.getClosestLevel(),
+                        0
+                    )
+                ),
+                false
+            ).x * this._scaleSpring.current.value;
 
-            var topLeftBound = drawArea.getBoundingBox().getTopLeft();
-            var bottomRightBound = 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: topLeftBound,
-                    bottomright: bottomRightBound,
-                    currenttime: currentTime,
-                    best: best
-                });
-            }
-
-            this._resetCoverage(this.coverage, level);
-            this._resetCoverage(this.loadingCoverage, level);
-
-            //OK, a new drawing so do your calculations
-            var cornerTiles = this._getCornerTiles(level, topLeftBound, bottomRightBound);
-            var topLeftTile = cornerTiles.topLeft;
-            var bottomRightTile = cornerTiles.bottomRight;
-            var numberOfTiles  = this.source.getNumTiles(level);
-
-            var viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter());
-
-            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.
-                bottomRightTile.x += 1;
-                if (!this.wrapHorizontal) {
-                    bottomRightTile.x  = Math.min(bottomRightTile.x, numberOfTiles.x - 1);
-                }
-            }
-            var numTiles = Math.max(0, (bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y));
-            var tiles = new Array(numTiles);
-            var tileIndex = 0;
-            for (var x = topLeftTile.x; x <= bottomRightTile.x; x++) {
-                for (var y = topLeftTile.y; y <= bottomRightTile.y; y++) {
-
-                    var flippedX;
-                    if (this.getFlip()) {
-                        var xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x;
-                        flippedX = x + numberOfTiles.x - xMod - xMod - 1;
-                    } else {
-                        flippedX = x;
-                    }
-
-                    if (drawArea.intersection(this.getTileBounds(level, flippedX, y)) === null) {
-                        // This tile is outside of the viewport, no need to draw it
-                        continue;
-                    }
-
-                    var result = this._updateTile(
-                        flippedX, y,
-                        level,
-                        levelVisibility,
-                        viewportCenter,
-                        numberOfTiles,
-                        currentTime,
-                        best
-                    );
-                    best = result.bestTiles;
-                    tiles[tileIndex] = result.tile;
-                    tileIndex += 1;
-                }
-            }
-
-            return {
-                bestTiles: best,
-                updatedTiles: tiles
-            };
-        },
-
-        /**
-         * @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 ){
-            var 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;
-
-            var 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;
-
-            var positionC = viewport.pixelFromPointNoRotate(boundsTL, true),
-                positionT = viewport.pixelFromPointNoRotate(boundsTL, false),
-                sizeC = viewport.deltaPixelsFromPointsNoRotate(boundsSize, true),
-                sizeT = viewport.deltaPixelsFromPointsNoRotate(boundsSize, false),
-                tileCenter = positionT.plus( sizeT.divide( 2 ) ),
-                tileSquaredDistance = viewportCenter.squaredDistanceTo( tileCenter );
-
-            if(this.viewer.drawer.minimumOverlapRequired()){
-                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;
-        },
-
-        /**
-         * Update a single tile at a particular resolution level.
-         * @private
-         * @param {Number} x
-         * @param {Number} y
-         * @param {Number} level
-         * @param {Number} levelVisibility
-         * @param {OpenSeadragon.Point} viewportCenter
-         * @param {Number} numberOfTiles
-         * @param {Number} currentTime
-         * @param {OpenSeadragon.Tile} best - The current "best" tile to draw.
-         * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile[] - the current best tiles, tile: OpenSeadragon.Tile the current tile}
-         */
-        _updateTile: function( x, y, level,
-                               levelVisibility, viewportCenter, numberOfTiles, currentTime, best){
-
-            var tile = this._getTile(
-                x, y,
-                level,
-                currentTime,
-                numberOfTiles
-                );
-
-            if( this.viewer ){
-                /**
-                 * <em>- Needs documentation -</em>
-                 *
-                 * @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 );
-
-            var loadingCoverage = tile.loaded || tile.loading || this._isCovered(this.loadingCoverage, level, x, y);
-            this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage);
-
-            if ( !tile.exists ) {
-                return {
-                    bestTiles: best,
-                    tile: tile
-                };
-            }
-            if (tile.loaded && tile.opacity === 1){
-                this._setCoverage( this.coverage, level, x, y, true );
-            }
-
-            this._positionTile(
-                tile,
-                this.source.tileOverlap,
-                this.viewport,
-                viewportCenter,
-                levelVisibility
+            var optimalRatio = this.immediateRender ? 1 : targetZeroRatio;
+            var levelOpacity = Math.min(1, (currentRenderPixelRatio - 0.5) / 0.5);
+            var levelVisibility = optimalRatio / Math.abs(
+                optimalRatio - targetRenderPixelRatio
             );
 
-            if (!tile.loaded) {
-                if (tile.context2D) {
-                    this._setTileLoaded(tile);
-                } else {
-                    var imageRecord = this._tileCache.getImageRecord(tile.cacheKey);
-                    if (imageRecord) {
-                        this._setTileLoaded(tile, imageRecord.getData());
-                    }
+            // Update the level and keep track of 'best' tiles to load
+            var result = this._updateLevel(
+                level,
+                levelOpacity,
+                levelVisibility,
+                drawArea,
+                currentTime,
+                bestTiles
+            );
+
+            bestTiles = result.bestTiles;
+            var tiles = result.updatedTiles.filter(tile => tile.loaded);
+            var makeTileInfoObject = (function(level, levelOpacity, currentTime){
+                return function(tile){
+                    return {
+                        tile: tile,
+                        level: level,
+                        levelOpacity: levelOpacity,
+                        currentTime: currentTime
+                    };
+                };
+            })(level, levelOpacity, currentTime);
+
+            this._tilesToDraw[level] = tiles.map(makeTileInfoObject);
+
+            // 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 (bestTiles && bestTiles.length > 0) {
+            bestTiles.forEach(function (tile) {
+                if (tile && !tile.context2D) {
+                    this._loadTile(tile, currentTime);
+                }
+            }, this);
+
+            this._needsDraw = true;
+            return false;
+        } else {
+            return this._tilesLoading === 0;
+        }
+
+        // Update
+
+    },
+
+    /**
+     * Update all tiles that contribute to the current view
+     * @private
+     *
+     */
+    _updateTilesInViewport: function(tiles) {
+        let currentTime = $.now();
+        let _this = this;
+        this._tilesLoading = 0;
+        this._wasBlending = this._isBlending;
+        this._isBlending = false;
+        this.loadingCoverage = {};
+        let lowestLevel = tiles.length ? tiles[0].level : 0;
+
+        let drawArea = this.getDrawArea();
+        if(!drawArea){
+            return;
+        }
+
+        function updateTile(info){
+            let tile = info.tile;
+            if(tile && tile.loaded){
+                let 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;
+            }
+        }
+
+        // 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(let i = 0; i < tiles.length; i++){
+            let tile = tiles[i];
+            updateTile(tile);
+            if(this._providesCoverage(this.coverage, tile.level)){
+                level = Math.max(level, tile.level);
+            }
+        }
+        if(level > 0){
+            for( let levelKey in this._tilesToDraw ){
+                if( levelKey < level ){
+                    delete this._tilesToDraw[levelKey];
                 }
             }
+        }
 
-            if ( tile.loading ) {
-                // the tile is already in the download queue
-                this._tilesLoading++;
-            } else if (!loadingCoverage) {
-                best = this._compareTiles( best, tile, this.maxTilesPerFrame );
+    },
+
+    /**
+     * 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 {Number} currentTime
+     * @param {OpenSeadragon.Tile[]} best Array of the current best tiles
+     * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile - the current "best" tiles to draw, updatedTiles: OpenSeadragon.Tile) - the updated tiles}.
+     */
+    _updateLevel: function(level, levelOpacity,
+                            levelVisibility, drawArea, currentTime, best) {
+
+        var topLeftBound = drawArea.getBoundingBox().getTopLeft();
+        var bottomRightBound = 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: topLeftBound,
+                bottomright: bottomRightBound,
+                currenttime: currentTime,
+                best: best
+            });
+        }
+
+        this._resetCoverage(this.coverage, level);
+        this._resetCoverage(this.loadingCoverage, level);
+
+        //OK, a new drawing so do your calculations
+        var cornerTiles = this._getCornerTiles(level, topLeftBound, bottomRightBound);
+        var topLeftTile = cornerTiles.topLeft;
+        var bottomRightTile = cornerTiles.bottomRight;
+        var numberOfTiles  = this.source.getNumTiles(level);
+
+        var viewportCenter = this.viewport.pixelFromPoint(this.viewport.getCenter());
+
+        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.
+            bottomRightTile.x += 1;
+            if (!this.wrapHorizontal) {
+                bottomRightTile.x  = Math.min(bottomRightTile.x, numberOfTiles.x - 1);
+            }
+        }
+        var numTiles = Math.max(0, (bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y));
+        var tiles = new Array(numTiles);
+        var tileIndex = 0;
+        for (var x = topLeftTile.x; x <= bottomRightTile.x; x++) {
+            for (var y = topLeftTile.y; y <= bottomRightTile.y; y++) {
+
+                var flippedX;
+                if (this.getFlip()) {
+                    var xMod = ( numberOfTiles.x + ( x % numberOfTiles.x ) ) % numberOfTiles.x;
+                    flippedX = x + numberOfTiles.x - xMod - xMod - 1;
+                } else {
+                    flippedX = x;
+                }
+
+                if (drawArea.intersection(this.getTileBounds(level, flippedX, y)) === null) {
+                    // This tile is outside of the viewport, no need to draw it
+                    continue;
+                }
+
+                var result = this._updateTile(
+                    flippedX, y,
+                    level,
+                    levelVisibility,
+                    viewportCenter,
+                    numberOfTiles,
+                    currentTime,
+                    best
+                );
+                best = result.bestTiles;
+                tiles[tileIndex] = result.tile;
+                tileIndex += 1;
+            }
+        }
+
+        return {
+            bestTiles: best,
+            updatedTiles: tiles
+        };
+    },
+
+    /**
+     * @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 ){
+        var 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;
+
+        var 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;
+
+        var positionC = viewport.pixelFromPointNoRotate(boundsTL, true),
+            positionT = viewport.pixelFromPointNoRotate(boundsTL, false),
+            sizeC = viewport.deltaPixelsFromPointsNoRotate(boundsSize, true),
+            sizeT = viewport.deltaPixelsFromPointsNoRotate(boundsSize, false),
+            tileCenter = positionT.plus( sizeT.divide( 2 ) ),
+            tileSquaredDistance = viewportCenter.squaredDistanceTo( tileCenter );
+
+        if(this.viewer.drawer.minimumOverlapRequired()){
+            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;
+    },
+
+    /**
+     * Update a single tile at a particular resolution level.
+     * @private
+     * @param {Number} x
+     * @param {Number} y
+     * @param {Number} level
+     * @param {Number} levelVisibility
+     * @param {OpenSeadragon.Point} viewportCenter
+     * @param {Number} numberOfTiles
+     * @param {Number} currentTime
+     * @param {OpenSeadragon.Tile} best - The current "best" tile to draw.
+     * @returns {Object} Dictionary {bestTiles: OpenSeadragon.Tile[] - the current best tiles, tile: OpenSeadragon.Tile the current tile}
+     */
+    _updateTile: function( x, y, level,
+                            levelVisibility, viewportCenter, numberOfTiles, currentTime, best){
+
+        var tile = this._getTile(
+            x, y,
+            level,
+            currentTime,
+            numberOfTiles
+            );
+
+        if( this.viewer ){
+            /**
+             * <em>- Needs documentation -</em>
+             *
+             * @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 );
+
+        var loadingCoverage = tile.loaded || tile.loading || this._isCovered(this.loadingCoverage, level, x, y);
+        this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage);
+
+        if ( !tile.exists ) {
             return {
                 bestTiles: best,
                 tile: tile
             };
-        },
+        }
+        if (tile.loaded && tile.opacity === 1){
+            this._setCoverage( this.coverage, level, x, y, true );
+        }
 
-        // private
-        _getCornerTiles: function(level, topLeftBound, bottomRightBound) {
-            var leftX;
-            var rightX;
-            if (this.wrapHorizontal) {
-                leftX = $.positiveModulo(topLeftBound.x, 1);
-                rightX = $.positiveModulo(bottomRightBound.x, 1);
+        this._positionTile(
+            tile,
+            this.source.tileOverlap,
+            this.viewport,
+            viewportCenter,
+            levelVisibility
+        );
+
+        if (!tile.loaded) {
+            if (tile.context2D) {
+                this._setTileLoaded(tile);
             } else {
-                leftX = Math.max(0, topLeftBound.x);
-                rightX = Math.min(1, bottomRightBound.x);
+                var imageRecord = this._tileCache.getImageRecord(tile.cacheKey);
+                if (imageRecord) {
+                    this._setTileLoaded(tile, imageRecord.getData());
+                }
             }
-            var topY;
-            var bottomY;
-            var aspectRatio = 1 / this.source.aspectRatio;
-            if (this.wrapVertical) {
-                topY = $.positiveModulo(topLeftBound.y, aspectRatio);
-                bottomY = $.positiveModulo(bottomRightBound.y, aspectRatio);
+        }
+
+        if ( tile.loading ) {
+            // the tile is already in the download queue
+            this._tilesLoading++;
+        } else if (!loadingCoverage) {
+            best = this._compareTiles( best, tile, this.maxTilesPerFrame );
+        }
+
+        return {
+            bestTiles: best,
+            tile: tile
+        };
+    },
+
+    // private
+    _getCornerTiles: function(level, topLeftBound, bottomRightBound) {
+        var leftX;
+        var 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);
+        }
+        var topY;
+        var bottomY;
+        var 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);
+        }
+
+        var topLeftTile = this.source.getTileAtPoint(level, new $.Point(leftX, topY));
+        var bottomRightTile = this.source.getTileAtPoint(level, new $.Point(rightX, bottomY));
+        var 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,
+        };
+    },
+
+    /**
+     * 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
+    ) {
+        var xMod,
+            yMod,
+            bounds,
+            sourceBounds,
+            exists,
+            urlOrGetter,
+            post,
+            ajaxHeaders,
+            context2D,
+            tile,
+            tilesMatrix = this.tilesMatrix,
+            tileSource = this.source;
+
+        if ( !tilesMatrix[ level ] ) {
+            tilesMatrix[ level ] = {};
+        }
+        if ( !tilesMatrix[ level ][ x ] ) {
+            tilesMatrix[ level ][ x ] = {};
+        }
+
+        if ( !tilesMatrix[ level ][ x ][ y ] || !tilesMatrix[ level ][ 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 {
-                topY = Math.max(0, topLeftBound.y);
-                bottomY = Math.min(aspectRatio, bottomRightBound.y);
+                ajaxHeaders = null;
             }
 
-            var topLeftTile = this.source.getTileAtPoint(level, new $.Point(leftX, topY));
-            var bottomRightTile = this.source.getTileAtPoint(level, new $.Point(rightX, bottomY));
-            var numTiles  = this.source.getNumTiles(level);
+            context2D = tileSource.getContext2D ?
+                tileSource.getContext2D(level, xMod, yMod) : undefined;
 
-            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,
-            };
-        },
-
-        /**
-         * 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
-        ) {
-            var xMod,
-                yMod,
+            tile = new $.Tile(
+                level,
+                x,
+                y,
                 bounds,
-                sourceBounds,
                 exists,
                 urlOrGetter,
-                post,
-                ajaxHeaders,
                 context2D,
-                tile,
-                tilesMatrix = this.tilesMatrix,
-                tileSource = this.source;
+                this.loadTilesWithAjax,
+                ajaxHeaders,
+                sourceBounds,
+                post,
+                tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post)
+            );
 
-            if ( !tilesMatrix[ level ] ) {
-                tilesMatrix[ level ] = {};
-            }
-            if ( !tilesMatrix[ level ][ x ] ) {
-                tilesMatrix[ level ][ x ] = {};
-            }
-
-            if ( !tilesMatrix[ level ][ x ][ y ] || !tilesMatrix[ level ][ 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;
+            if (this.getFlip()) {
+                if (xMod === 0) {
+                    tile.isRightMost = true;
                 }
-
-                context2D = tileSource.getContext2D ?
-                    tileSource.getContext2D(level, xMod, yMod) : undefined;
-
-                tile = new $.Tile(
-                    level,
-                    x,
-                    y,
-                    bounds,
-                    exists,
-                    urlOrGetter,
-                    context2D,
-                    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;
-
-                tilesMatrix[ level ][ x ][ y ] = tile;
-            }
-
-            tile = tilesMatrix[ level ][ 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 ) {
-            var _this = this;
-            tile.loading = true;
-            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 ){
-                    _this._onTileLoad( tile, time, data, errorMsg, tileRequest );
-                },
-                abort: function() {
-                    tile.loading = false;
-                }
-            });
-        },
-
-        /**
-         * 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
-         */
-        _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) {
-            if ( !data ) {
-                $.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 {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
-                });
-                tile.loading = false;
-                tile.exists = false;
-                return;
             } else {
-                tile.exists = true;
+                if (xMod === numTiles.x - 1) {
+                    tile.isRightMost = true;
+                }
             }
 
-            if ( time < this.lastResetTime ) {
-                $.console.warn( "Ignoring tile %s loaded before reset: %s", tile, tile.getUrl() );
+            if (yMod === numTiles.y - 1) {
+                tile.isBottomMost = true;
+            }
+
+            tile.flipped = this.flipped;
+
+            tilesMatrix[ level ][ x ][ y ] = tile;
+        }
+
+        tile = tilesMatrix[ level ][ 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 ) {
+        var _this = this;
+        tile.loading = true;
+        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 ){
+                _this._onTileLoad( tile, time, data, errorMsg, tileRequest );
+            },
+            abort: function() {
                 tile.loading = false;
-                return;
-            }
-
-            var _this = this,
-                finish = function() {
-                    var ccc = _this.source;
-                    var cutoff = ccc.getClosestLevel();
-                    _this._setTileLoaded(tile, data, cutoff, tileRequest);
-            };
-
-
-            finish();
-        },
-
-        /**
-         * @private
-         * @param {OpenSeadragon.Tile} tile
-         * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object
-         * @param {Number|undefined} cutoff
-         * @param {XMLHttpRequest|undefined} tileRequest
-         */
-        _setTileLoaded: function(tile, data, cutoff, tileRequest) {
-            var increment = 0,
-                eventFinished = false,
-                _this = this;
-
-            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 completionCallback() {
-                increment--;
-                if (increment === 0) {
-                    tile.loading = false;
-                    tile.loaded = true;
-                    tile.hasTransparency = _this.source.hasTransparency(
-                        tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData
-                    );
-                    if (!tile.context2D) {
-                        _this._tileCache.cacheTile({
-                            data: data,
-                            tile: tile,
-                            cutoff: cutoff,
-                            tiledImage: _this
-                        });
-                    }
-                    /**
-                     * Triggered when a tile is loaded and pre-processing is compelete,
-                     * and the tile is ready to draw.
-                     *
-                     * @event tile-ready
-                     * @memberof OpenSeadragon.Viewer
-                     * @type {object}
-                     * @property {OpenSeadragon.Tile} tile - The tile which has been loaded.
-                     * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile.
-                     * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable).
-                     */
-                    _this.viewer.raiseEvent("tile-ready", {
-                        tile: tile,
-                        tiledImage: _this,
-                        tileRequest: tileRequest
-                    });
-                    _this._needsDraw = true;
-                }
             }
+        });
+    },
 
+    /**
+     * 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
+     */
+    _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) {
+        if ( !data ) {
+            $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg );
             /**
-             * 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.
+             * Triggered when a tile fails to load.
              *
-             * @event tile-loaded
+             * @event tile-load-failed
              * @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
-             * @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 {function} getCompletionCallback - A function giving a callback to call
-             * when the asynchronous processing of the image is done. The image will be
-             * marked as entirely loaded when the callback has been called once for each
-             * call to getCompletionCallback.
+             * @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 {XMLHttpRequest} tileRequest - The XMLHttpRequest used to load the tile if available.
              */
-
-            var fallbackCompletion = getCompletionCallback();
-            this.viewer.raiseEvent("tile-loaded", {
+            this.viewer.raiseEvent("tile-load-failed", {
                 tile: tile,
                 tiledImage: this,
-                tileRequest: tileRequest,
-                get image() {
-                    $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead.");
-                    return data;
-                },
-                data: data,
-                getCompletionCallback: getCompletionCallback
+                time: time,
+                message: errorMsg,
+                tileRequest: tileRequest
             });
-            eventFinished = true;
-            // In case the completion callback is never called, we at least force it once.
-            fallbackCompletion();
-        },
+            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;
+        }
+
+        var _this = this,
+            finish = function() {
+                var ccc = _this.source;
+                var cutoff = ccc.getClosestLevel();
+                _this._setTileLoaded(tile, data, cutoff, tileRequest);
+        };
+
+
+        finish();
+    },
+
+    /**
+     * @private
+     * @param {OpenSeadragon.Tile} tile
+     * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object
+     * @param {Number|undefined} cutoff
+     * @param {XMLHttpRequest|undefined} tileRequest
+     */
+    _setTileLoaded: function(tile, data, cutoff, tileRequest) {
+        var increment = 0,
+            eventFinished = false,
+            _this = this;
+
+        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 completionCallback() {
+            increment--;
+            if (increment === 0) {
+                tile.loading = false;
+                tile.loaded = true;
+                tile.hasTransparency = _this.source.hasTransparency(
+                    tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData
+                );
+                if (!tile.context2D) {
+                    _this._tileCache.cacheTile({
+                        data: data,
+                        tile: tile,
+                        cutoff: cutoff,
+                        tiledImage: _this
+                    });
+                }
+                /**
+                 * Triggered when a tile is loaded and pre-processing is compelete,
+                 * and the tile is ready to draw.
+                 *
+                 * @event tile-ready
+                 * @memberof OpenSeadragon.Viewer
+                 * @type {object}
+                 * @property {OpenSeadragon.Tile} tile - The tile which has been loaded.
+                 * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile.
+                 * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable).
+                 */
+                _this.viewer.raiseEvent("tile-ready", {
+                    tile: tile,
+                    tiledImage: _this,
+                    tileRequest: tileRequest
+                });
+                _this._needsDraw = true;
+            }
+        }
 
         /**
-         * Determines the 'best tiles' from the given 'last best' tiles and the
-         * tile in question.
-         * @private
+         * 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.
          *
-         * @param {OpenSeadragon.Tile[]} previousBest The best tiles so far.
-         * @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.
+         * @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
+         * @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 {function} getCompletionCallback - A function giving a callback to call
+         * when the asynchronous processing of the image is done. The image will be
+         * marked as entirely loaded when the callback has been called once for each
+         * call to getCompletionCallback.
          */
-        _compareTiles: function( previousBest, tile, maxNTiles ) {
-            if ( !previousBest ) {
-                return [tile];
+
+        var fallbackCompletion = getCompletionCallback();
+        this.viewer.raiseEvent("tile-loaded", {
+            tile: tile,
+            tiledImage: this,
+            tileRequest: tileRequest,
+            get image() {
+                $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead.");
+                return data;
+            },
+            data: data,
+            getCompletionCallback: getCompletionCallback
+        });
+        eventFinished = true;
+        // In case the completion callback is never called, we at least force it once.
+        fallbackCompletion();
+    },
+
+
+    /**
+     * Determines the 'best tiles' from the given 'last best' tiles and the
+     * tile in question.
+     * @private
+     *
+     * @param {OpenSeadragon.Tile[]} previousBest The best tiles so far.
+     * @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];
+        }
+        previousBest.push(tile);
+        this._sortTiles(previousBest);
+        if (previousBest.length > maxNTiles) {
+            previousBest.pop();
+        }
+        return previousBest;
+    },
+
+    /**
+     * Sorts tiles in an array according to distance and visibility.
+     * @private
+     *
+     * @param {OpenSeadragon.Tile[]} tiles The tiles.
+     */
+    _sortTiles: function( tiles ) {
+        tiles.sort(function (a, b) {
+            if (a === null) {
+                return 1;
             }
-            previousBest.push(tile);
-            this._sortTiles(previousBest);
-            if (previousBest.length > maxNTiles) {
-                previousBest.pop();
+            if (b === null) {
+                return -1;
             }
-            return previousBest;
-        },
-
-        /**
-         * Sorts tiles in an array according to distance and visibility.
-         * @private
-         *
-         * @param {OpenSeadragon.Tile[]} tiles The tiles.
-         */
-        _sortTiles: function( tiles ) {
-            tiles.sort(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);
-                } else {
-                    // sort by largest visibility value
-                    return (b.visibility - a.visibility);
-                }
-            });
-        },
-
-
-        /**
-         * 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 ) {
-            var rows,
-                cols,
-                i, j;
-
-            if ( !coverage[ level ] ) {
-                return false;
+            if (a.visibility === b.visibility) {
+                // sort by smallest squared distance
+                return (a.squaredDistance - b.squaredDistance);
+            } else {
+                // sort by largest visibility value
+                return (b.visibility - a.visibility);
             }
+        });
+    },
 
-            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;
-                            }
+
+    /**
+     * 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 ) {
+        var rows,
+            cols,
+            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 ] = {};
+            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 ));

From 65d30e7ce114a47f35ce41f45cb1dc5d3c890465 Mon Sep 17 00:00:00 2001
From: Tom <tmpearce@gmail.com>
Date: Fri, 31 May 2024 16:24:04 -0400
Subject: [PATCH 7/8] add minPixelRatio guard back in; fix tabs and spaces in
 comments'

---
 src/tiledimage.js | 35 ++++++++++++++++++++++-------------
 1 file changed, 22 insertions(+), 13 deletions(-)

diff --git a/src/tiledimage.js b/src/tiledimage.js
index a227a8dd..c1443bf1 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -72,18 +72,18 @@
  * @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} [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.
+ */
 $.TiledImage = function( options ) {
     this._initialized = false;
     /**
@@ -1081,7 +1081,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         // _tilesToDraw might have been updated by the update; refresh it
         tileArray = this._tilesToDraw.flat();
 
-            // mark the tiles as being drawn, so that they won't be discarded from
+        // mark the tiles as being drawn, so that they won't be discarded from
         // the tileCache
         tileArray.forEach(tileInfo => {
             tileInfo.tile.beingDrawn = true;
@@ -1359,6 +1359,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         // 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++) {
             let level = levelList[i];
 
@@ -1367,6 +1368,14 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
                 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;
+            }
+
             var targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate(
                 this.source.getPixelRatio(level),
                 false

From feb5e13c3270b7cbf9216c9feb339fe4303747a8 Mon Sep 17 00:00:00 2001
From: Ian Gilman <ian@iangilman.com>
Date: Tue, 4 Jun 2024 09:29:40 -0700
Subject: [PATCH 8/8] Changelog for #2537

---
 changelog.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/changelog.txt b/changelog.txt
index fd03a346..525f28e6 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -5,7 +5,7 @@ OPENSEADRAGON CHANGELOG
 
 * BREAKING CHANGE: Dropped support for IE11 (#2300, #2361 @AndrewADev)
 * DEPRECATION: The OpenSeadragon.createCallback function is no longer recommended (#2367 @akansjain)
-* The viewer now uses WebGL when available (#2310, #2462, #2466, #2468, #2469, #2472, #2478, #2488, #2492, #2521 @pearcetm, @Aiosa, @thec0keman)
+* The viewer now uses WebGL when available (#2310, #2462, #2466, #2468, #2469, #2472, #2478, #2488, #2492, #2521, #2537 @pearcetm, @Aiosa, @thec0keman)
 * Added webp to supported image formats (#2455 @BeebBenjamin)
 * Introduced maxTilesPerFrame option to allow loading more tiles simultaneously (#2387 @jetic83)
 * Now when creating a viewer or navigator, we leave its position style alone if possible (#2393 @VIRAT9358)