diff --git a/src/drawer.js b/src/drawer.js index cc13cacb..02a036aa 100644 --- a/src/drawer.js +++ b/src/drawer.js @@ -328,8 +328,7 @@ $.Drawer.prototype = { // the viewport get rotated later on, we will need to resize it. if (this.viewport.getRotation() === 0) { var self = this; - this.viewer.addHandler('rotate', function resizeSketchCanvas() { - self.viewer.removeHandler('rotate', resizeSketchCanvas); + this.viewer.addOnceHandler('rotate', function resizeSketchCanvas() { var sketchCanvasSize = self._calculateSketchCanvasSize(); self.sketchCanvas.width = sketchCanvasSize.x; self.sketchCanvas.height = sketchCanvasSize.y; @@ -482,7 +481,7 @@ $.Drawer.prototype = { }, // private - drawDebugInfo: function( tile, count, i ){ + drawDebugInfo: function(tile, count, i, tiledImage) { if ( !this.useCanvas ) { return; } @@ -495,7 +494,14 @@ $.Drawer.prototype = { context.fillStyle = this.debugGridColor; if ( this.viewport.degrees !== 0 ) { - this._offsetForRotation(this.viewport.degrees); + this._offsetForRotation({degrees: this.viewport.degrees}); + } + if (tiledImage.getRotation() !== 0) { + this._offsetForRotation({ + degrees: tiledImage.getRotation(), + point: tiledImage.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true) + }); } context.strokeRect( @@ -559,6 +565,9 @@ $.Drawer.prototype = { if ( this.viewport.degrees !== 0 ) { this._restoreRotationChanges(); } + if (tiledImage.getRotation() !== 0) { + this._restoreRotationChanges(); + } context.restore(); }, @@ -592,17 +601,22 @@ $.Drawer.prototype = { return new $.Point(canvas.width, canvas.height); }, - // private - _offsetForRotation: function(degrees, useSketch) { - var cx = this.canvas.width / 2; - var cy = this.canvas.height / 2; + getCanvasCenter: function() { + return new $.Point(this.canvas.width / 2, this.canvas.height / 2); + }, - var context = this._getContext(useSketch); + // private + _offsetForRotation: function(options) { + var point = options.point ? + options.point.times($.pixelDensityRatio) : + this.getCanvasCenter(); + + var context = this._getContext(options.useSketch); context.save(); - context.translate(cx, cy); - context.rotate(Math.PI / 180 * degrees); - context.translate(-cx, -cy); + context.translate(point.x, point.y); + context.rotate(Math.PI / 180 * options.degrees); + context.translate(-point.x, -point.y); }, // private diff --git a/src/navigator.js b/src/navigator.js index 7b74d6ea..fe977d0c 100644 --- a/src/navigator.js +++ b/src/navigator.js @@ -341,9 +341,12 @@ $.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /* myItem._originalForNavigator = original; _this._matchBounds(myItem, original, true); - original.addHandler('bounds-change', function() { + function matchBounds() { _this._matchBounds(myItem, original); - }); + } + + original.addHandler('bounds-change', matchBounds); + original.addHandler('clip-change', matchBounds); } }); diff --git a/src/openseadragon.js b/src/openseadragon.js index de7399e8..bd252e1c 100644 --- a/src/openseadragon.js +++ b/src/openseadragon.js @@ -1375,6 +1375,21 @@ function OpenSeadragon( options ){ return string.charAt(0).toUpperCase() + string.slice(1); }, + /** + * Compute the modulo of a number but makes sure to always return + * a positive value. + * @param {Number} number the number to computes the modulo of + * @param {Number} modulo the modulo + * @returns {Number} the result of the modulo of number + */ + positiveModulo: function(number, modulo) { + var result = number % modulo; + if (result < 0) { + result += modulo; + } + return result; + }, + /** * Determines if a point is within the bounding rectangle of the given element (hit-test). * @function diff --git a/src/point.js b/src/point.js index 72c91378..2d042416 100644 --- a/src/point.js +++ b/src/point.js @@ -202,10 +202,7 @@ $.Point.prototype = { var sin; // Avoid float computations when possible if (degrees % 90 === 0) { - var d = degrees % 360; - if (d < 0) { - d += 360; - } + var d = $.positiveModulo(degrees, 360); switch (d) { case 0: cos = 1; diff --git a/src/rectangle.js b/src/rectangle.js index 98c839de..a73ad3f3 100644 --- a/src/rectangle.js +++ b/src/rectangle.js @@ -81,10 +81,7 @@ $.Rect = function(x, y, width, height, degrees) { this.degrees = typeof(degrees) === "number" ? degrees : 0; // Normalizes the rectangle. - this.degrees = this.degrees % 360; - if (this.degrees < 0) { - this.degrees += 360; - } + this.degrees = $.positiveModulo(this.degrees, 360); var newTopLeft, newWidth; if (this.degrees >= 270) { newTopLeft = this.getTopRight(); @@ -442,19 +439,21 @@ $.Rect.prototype = { * @return {OpenSeadragon.Rect} */ rotate: function(degrees, pivot) { - degrees = degrees % 360; + degrees = $.positiveModulo(degrees, 360); if (degrees === 0) { return this.clone(); } - if (degrees < 0) { - degrees += 360; - } pivot = pivot || this.getCenter(); var newTopLeft = this.getTopLeft().rotate(degrees, pivot); var newTopRight = this.getTopRight().rotate(degrees, pivot); var diff = newTopRight.minus(newTopLeft); + // Handle floating point error + diff = diff.apply(function(x) { + var EPSILON = 1e-15; + return Math.abs(x) < EPSILON ? 0 : x; + }); var radians = Math.atan(diff.y / diff.x); if (diff.x < 0) { radians += Math.PI; diff --git a/src/tiledimage.js b/src/tiledimage.js index c65b0503..b0969e44 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -132,6 +132,9 @@ $.TiledImage = function( options ) { var fitBoundsPlacement = options.fitBoundsPlacement || OpenSeadragon.Placement.CENTER; delete options.fitBoundsPlacement; + this._degrees = $.positiveModulo(options.degrees || 0, 360); + delete options.degrees; + $.extend( true, this, { //internal state properties @@ -160,7 +163,6 @@ $.TiledImage = function( options ) { placeholderFillStyle: $.DEFAULT_SETTINGS.placeholderFillStyle, opacity: $.DEFAULT_SETTINGS.opacity, compositeOperation: $.DEFAULT_SETTINGS.compositeOperation - }, options ); this._fullyLoaded = false; @@ -290,7 +292,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag draw: function() { if (this.opacity !== 0) { this._midDraw = true; - updateViewport(this); + this._updateViewport(); this._midDraw = false; } }, @@ -303,17 +305,35 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** + * 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. - * @param {Boolean} [current=false] - Pass true for the current location; false for target location. */ getBounds: function(current) { - if (current) { - return new $.Rect( this._xSpring.current.value, this._ySpring.current.value, - this._worldWidthCurrent, this._worldHeightCurrent ); - } + return this.getBoundsNoRotate(current) + .rotate(this._degrees, this._getRotationPoint(current)); + }, - return new $.Rect( this._xSpring.target.value, this._ySpring.target.value, - this._worldWidthTarget, this._worldHeightTarget ); + /** + * 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 @@ -329,9 +349,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @returns {$.Rect} The clipped bounds in viewport coordinates. */ getClippedBounds: function(current) { - var bounds = this.getBounds(current); + var bounds = this.getBoundsNoRotate(current); if (this._clip) { - var ratio = this._worldWidthCurrent / this.source.dimensions.x; + 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, @@ -339,7 +361,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag clip.width, clip.height); } - return bounds; + return bounds.rotate(this._degrees, this._getRotationPoint(current)); }, /** @@ -364,21 +386,24 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @param {Boolean} [current=false] - Pass true to use the current location; false for target location. * @return {OpenSeadragon.Point} A point representing the coordinates in the image. */ - viewportToImageCoordinates: function( viewerX, viewerY, current ) { + viewportToImageCoordinates: function(viewerX, viewerY, current) { + var point; if (viewerX instanceof $.Point) { //they passed a point instead of individual components current = viewerY; - viewerY = viewerX.y; - viewerX = viewerX.x; + point = viewerX; + } else { + point = new $.Point(viewerX, viewerY); } - if (current) { - return this._viewportToImageDelta(viewerX - this._xSpring.current.value, - viewerY - this._ySpring.current.value); - } - - return this._viewportToImageDelta(viewerX - this._xSpring.target.value, - viewerY - this._ySpring.target.value); + point = point.rotate(-this._degrees, 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 @@ -396,7 +421,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @param {Boolean} [current=false] - Pass true to use the current location; false for target location. * @return {OpenSeadragon.Point} A point representing the coordinates in the viewport. */ - imageToViewportCoordinates: function( imageX, imageY, current ) { + imageToViewportCoordinates: function(imageX, imageY, current) { if (imageX instanceof $.Point) { //they passed a point instead of individual components current = imageY; @@ -413,7 +438,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag point.y += this._ySpring.target.value; } - return point; + return point.rotate(this._degrees, this._getRotationPoint(current)); }, /** @@ -444,7 +469,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag coordA.y, coordB.x, coordB.y, - rect.degrees + rect.degrees + this._degrees ); }, @@ -476,7 +501,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag coordA.y, coordB.x, coordB.y, - rect.degrees + rect.degrees - this._degrees ); }, @@ -524,6 +549,20 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag 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(), 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. @@ -688,6 +727,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @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, @@ -700,6 +740,16 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } 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'); }, /** @@ -717,6 +767,39 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._needsDraw = true; }, + /** + * Get the current rotation of this tiled image in degrees. + * @returns {Number} the current rotation of this tiled image in degrees. + */ + getRotation: function() { + return this._degrees; + }, + + /** + * Set the current rotation of this tiled image in degrees. + * @param {Number} degrees the rotation in degrees. + * @fires OpenSeadragon.TiledImage.event:bounds-change + */ + setRotation: function(degrees) { + degrees = $.positiveModulo(degrees, 360); + if (this._degrees === degrees) { + return; + } + this._degrees = degrees; + this._needsDraw = true; + this._raiseBoundsChange(); + }, + + /** + * @private + * Get the point around which this tiled image is rotated + * @param {Boolean} current True for current rotation point, false for target. + * @returns {OpenSeadragon.Point} + */ + _getRotationPoint: function(current) { + return this.getBoundsNoRotate(current).getCenter(); + }, + /** * @returns {String} The TiledImage's current compositeOperation. */ @@ -775,7 +858,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @event bounds-change * @memberOf OpenSeadragon.TiledImage * @type {object} - * @property {OpenSeadragon.World} eventSource - A reference to the TiledImage which raised the event. + * @property {OpenSeadragon.TiledImage} eventSource - A reference to the + * TiledImage which raised the event. * @property {?Object} userData - Arbitrary subscriber-defined object. */ this.raiseEvent('bounds-change'); @@ -784,187 +868,144 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag // 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 + lowestLevel = Math.min(lowestLevel, highestLevel); + return { + lowestLevel: lowestLevel, + highestLevel: highestLevel + }; + }, + + // private + _updateViewport: function() { + this._needsDraw = false; + + // Reset tile's internal drawn state + while (this.lastDrawn.length > 0) { + var tile = this.lastDrawn.pop(); + tile.beingDrawn = false; + } + + var viewport = this.viewport; + var drawArea = this._viewportToTiledImageRectangle( + viewport.getBoundsWithMargins(true)); + + if (!this.wrapHorizontal && !this.wrapVertical) { + var tiledImageBounds = this._viewportToTiledImageRectangle( + this.getClippedBounds(true)); + drawArea = drawArea.intersection(tiledImageBounds); + if (drawArea === null) { + return; + } + } + + var levelsInterval = this._getLevelsInterval(); + var lowestLevel = levelsInterval.lowestLevel; + var highestLevel = levelsInterval.highestLevel; + var bestTile = null; + var haveDrawn = false; + var currentTime = $.now(); + + // Update any level that will be drawn + for (var level = highestLevel; level >= lowestLevel; level--) { + var drawLevel = false; + + //Avoid calculations for draw if we have already drawn this + var currentRenderPixelRatio = viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(level), + true + ).x * this._scaleSpring.current.value; + + if (level === lowestLevel || + (!haveDrawn && currentRenderPixelRatio >= this.minPixelRatio)) { + drawLevel = true; + haveDrawn = true; + } else if (!haveDrawn) { + continue; + } + + //Perform calculations for draw if we haven't drawn this + var targetRenderPixelRatio = viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio(level), + false + ).x * this._scaleSpring.current.value; + + var targetZeroRatio = viewport.deltaPixelsFromPointsNoRotate( + this.source.getPixelRatio( + Math.max( + this.source.getClosestLevel(viewport.containerSize) - 1, + 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' tile to load + bestTile = updateLevel( + this, + haveDrawn, + drawLevel, + level, + levelOpacity, + levelVisibility, + drawArea, + currentTime, + bestTile + ); + + // Stop the loop if lower-res tiles would all be covered by + // already drawn tiles + if (providesCoverage(this.coverage, level)) { + break; + } + } + + // Perform the actual drawing + drawTiles(this, this.lastDrawn); + + // Load the new 'best' tile + if (bestTile && !bestTile.context2D) { + loadTile(this, bestTile, currentTime); + this._needsDraw = true; + this._setFullyLoaded(false); + } else { + this._setFullyLoaded(true); + } } }); -/** - * @private - * @inner - * Pretty much every other line in this needs to be documented so it's clear - * how each piece of this routine contributes to the drawing process. That's - * why there are so many TODO's inside this function. - */ -function updateViewport( tiledImage ) { +function updateLevel(tiledImage, haveDrawn, drawLevel, level, levelOpacity, + levelVisibility, drawArea, currentTime, best) { - tiledImage._needsDraw = false; + var topLeftBound = drawArea.getBoundingBox().getTopLeft(); + var bottomRightBound = drawArea.getBoundingBox().getBottomRight(); - var tile, - level, - best = null, - haveDrawn = false, - currentTime = $.now(), - viewportBounds = tiledImage.viewport.getBoundsWithMargins( true ), - zeroRatioC = tiledImage.viewport.deltaPixelsFromPointsNoRotate( - tiledImage.source.getPixelRatio( 0 ), - true - ).x * tiledImage._scaleSpring.current.value, - lowestLevel = Math.max( - tiledImage.source.minLevel, - Math.floor( - Math.log( tiledImage.minZoomImageRatio ) / - Math.log( 2 ) - ) - ), - highestLevel = Math.min( - Math.abs(tiledImage.source.maxLevel), - Math.abs(Math.floor( - Math.log( zeroRatioC / tiledImage.minPixelRatio ) / - Math.log( 2 ) - )) - ), - renderPixelRatioC, - renderPixelRatioT, - zeroRatioT, - optimalRatio, - levelOpacity, - levelVisibility; - - // Reset tile's internal drawn state - while (tiledImage.lastDrawn.length > 0) { - tile = tiledImage.lastDrawn.pop(); - tile.beingDrawn = false; - } - - if (!tiledImage.wrapHorizontal && !tiledImage.wrapVertical) { - var tiledImageBounds = tiledImage.getClippedBounds(true); - var intersection = viewportBounds.intersection(tiledImageBounds); - if (intersection === null) { - return; - } - viewportBounds = intersection; - } - viewportBounds = viewportBounds.getBoundingBox(); - viewportBounds.x -= tiledImage._xSpring.current.value; - viewportBounds.y -= tiledImage._ySpring.current.value; - - var viewportTL = viewportBounds.getTopLeft(); - var viewportBR = viewportBounds.getBottomRight(); - - //Don't draw if completely outside of the viewport - if ( !tiledImage.wrapHorizontal && (viewportBR.x < 0 || viewportTL.x > tiledImage._worldWidthCurrent ) ) { - return; - } - - if ( !tiledImage.wrapVertical && ( viewportBR.y < 0 || viewportTL.y > tiledImage._worldHeightCurrent ) ) { - return; - } - - // Calculate viewport rect / bounds - if ( !tiledImage.wrapHorizontal ) { - viewportTL.x = Math.max( viewportTL.x, 0 ); - viewportBR.x = Math.min( viewportBR.x, tiledImage._worldWidthCurrent ); - } - - if ( !tiledImage.wrapVertical ) { - viewportTL.y = Math.max( viewportTL.y, 0 ); - viewportBR.y = Math.min( viewportBR.y, tiledImage._worldHeightCurrent ); - } - - // Calculations for the interval of levels to draw - // (above in initial var statement) - // can return invalid intervals; fix that here if necessary - lowestLevel = Math.min( lowestLevel, highestLevel ); - - // Update any level that will be drawn - var drawLevel; // FIXME: drawLevel should have a more explanatory name - for ( level = highestLevel; level >= lowestLevel; level-- ) { - drawLevel = false; - - //Avoid calculations for draw if we have already drawn this - renderPixelRatioC = tiledImage.viewport.deltaPixelsFromPointsNoRotate( - tiledImage.source.getPixelRatio( level ), - true - ).x * tiledImage._scaleSpring.current.value; - - if ( ( !haveDrawn && renderPixelRatioC >= tiledImage.minPixelRatio ) || - ( level == lowestLevel ) ) { - drawLevel = true; - haveDrawn = true; - } else if ( !haveDrawn ) { - continue; - } - - //Perform calculations for draw if we haven't drawn this - renderPixelRatioT = tiledImage.viewport.deltaPixelsFromPointsNoRotate( - tiledImage.source.getPixelRatio( level ), - false - ).x * tiledImage._scaleSpring.current.value; - - zeroRatioT = tiledImage.viewport.deltaPixelsFromPointsNoRotate( - tiledImage.source.getPixelRatio( - Math.max( - tiledImage.source.getClosestLevel( tiledImage.viewport.containerSize ) - 1, - 0 - ) - ), - false - ).x * tiledImage._scaleSpring.current.value; - - optimalRatio = tiledImage.immediateRender ? - 1 : - zeroRatioT; - - levelOpacity = Math.min( 1, ( renderPixelRatioC - 0.5 ) / 0.5 ); - - levelVisibility = optimalRatio / Math.abs( - optimalRatio - renderPixelRatioT - ); - - // Update the level and keep track of 'best' tile to load - best = updateLevel( - tiledImage, - haveDrawn, - drawLevel, - level, - levelOpacity, - levelVisibility, - viewportTL, - viewportBR, - currentTime, - best - ); - - // Stop the loop if lower-res tiles would all be covered by - // already drawn tiles - if ( providesCoverage( tiledImage.coverage, level ) ) { - break; - } - } - - // Perform the actual drawing - drawTiles( tiledImage, tiledImage.lastDrawn ); - - // Load the new 'best' tile - if (best && !best.context2D) { - loadTile( tiledImage, best, currentTime ); - tiledImage._needsDraw = true; - tiledImage._setFullyLoaded(false); - } else { - tiledImage._setFullyLoaded(true); - } -} - - -function updateLevel( tiledImage, haveDrawn, drawLevel, level, levelOpacity, levelVisibility, viewportTL, viewportBR, currentTime, best ){ - - var x, y, - tileTL, - tileBR, - numberOfTiles, - viewportCenter = tiledImage.viewport.pixelFromPoint( tiledImage.viewport.getCenter() ); - - - if( tiledImage.viewer ){ + if (tiledImage.viewer) { /** * - Needs documentation - * @@ -977,45 +1018,59 @@ function updateLevel( tiledImage, haveDrawn, drawLevel, level, levelOpacity, lev * @property {Object} level * @property {Object} opacity * @property {Object} visibility - * @property {Object} topleft - * @property {Object} bottomright + * @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. */ - tiledImage.viewer.raiseEvent( 'update-level', { + tiledImage.viewer.raiseEvent('update-level', { tiledImage: tiledImage, havedrawn: haveDrawn, level: level, opacity: levelOpacity, visibility: levelVisibility, - topleft: viewportTL, - bottomright: viewportBR, + drawArea: drawArea, + topleft: topLeftBound, + bottomright: bottomRightBound, currenttime: currentTime, best: best }); } //OK, a new drawing so do your calculations - tileTL = tiledImage.source.getTileAtPoint( level, viewportTL.divide( tiledImage._scaleSpring.current.value )); - tileBR = tiledImage.source.getTileAtPoint( level, viewportBR.divide( tiledImage._scaleSpring.current.value )); - numberOfTiles = tiledImage.source.getNumTiles( level ); + var topLeftTile = tiledImage.source.getTileAtPoint(level, topLeftBound); + var bottomRightTile = tiledImage.source.getTileAtPoint(level, bottomRightBound); + var numberOfTiles = tiledImage.source.getNumTiles(level); - resetCoverage( tiledImage.coverage, level ); + resetCoverage(tiledImage.coverage, level); - if ( tiledImage.wrapHorizontal ) { - tileTL.x -= 1; // left invisible column (othervise we will have empty space after scroll at left) - } else { - tileBR.x = Math.min( tileBR.x, numberOfTiles.x - 1 ); + if (!tiledImage.wrapHorizontal) { + // Adjust for floating point error + topLeftTile.x = Math.max(topLeftTile.x, 0); + bottomRightTile.x = Math.min(bottomRightTile.x, numberOfTiles.x - 1); } - if ( tiledImage.wrapVertical ) { - tileTL.y -= 1; // top invisible row (othervise we will have empty space after scroll at top) - } else { - tileBR.y = Math.min( tileBR.y, numberOfTiles.y - 1 ); + if (!tiledImage.wrapVertical) { + // Adjust for floating point error + topLeftTile.y = Math.max(topLeftTile.y, 0); + bottomRightTile.y = Math.min(bottomRightTile.y, numberOfTiles.y - 1); } - for ( x = tileTL.x; x <= tileBR.x; x++ ) { - for ( y = tileTL.y; y <= tileBR.y; y++ ) { + var viewportCenter = tiledImage.viewport.pixelFromPoint( + tiledImage.viewport.getCenter()); + for (var x = topLeftTile.x; x <= bottomRightTile.x; x++) { + for (var y = topLeftTile.y; y <= bottomRightTile.y; y++) { + + // Optimisation disabled with wrapping because getTileBounds does not + // work correctly with x and y outside of the number of tiles + if (!tiledImage.wrapHorizontal && !tiledImage.wrapVertical) { + var tileBounds = tiledImage.source.getTileBounds(level, x, y); + if (drawArea.intersection(tileBounds) === null) { + // This tile is outside of the viewport, no need to draw it + continue; + } + } best = updateTile( tiledImage, @@ -1476,7 +1531,9 @@ function drawTiles( tiledImage, lastDrawn ) { var zoom = tiledImage.viewport.getZoom(true); var imageZoom = tiledImage.viewportToImageZoom(zoom); - if (imageZoom > tiledImage.smoothTileEdgesMinZoom && !tiledImage.iOSDevice) { + // TODO: support tile edge smoothing with tiled image rotation. + if (imageZoom > tiledImage.smoothTileEdgesMinZoom && !tiledImage.iOSDevice && + tiledImage.getRotation() === 0) { // When zoomed in a lot (>100%) the tile edges are visible. // So we have to composite them at ~100% and scale them up together. // Note: Disabled on iOS devices per default as it causes a native crash @@ -1500,10 +1557,23 @@ function drawTiles( tiledImage, lastDrawn ) { tiledImage._drawer._clear(true, bounds); } - // When scaling, we must rotate only when blending the sketch canvas to avoid - // interpolation - if (tiledImage.viewport.degrees !== 0 && !sketchScale) { - tiledImage._drawer._offsetForRotation(tiledImage.viewport.degrees, useSketch); + // When scaling, we must rotate only when blending the sketch canvas to + // avoid interpolation + if (!sketchScale) { + if (tiledImage.viewport.degrees !== 0) { + tiledImage._drawer._offsetForRotation({ + degrees: tiledImage.viewport.degrees, + useSketch: useSketch + }); + } + if (tiledImage._degrees !== 0) { + tiledImage._drawer._offsetForRotation({ + degrees: tiledImage._degrees, + point: tiledImage.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true), + useSketch: useSketch + }); + } } var usedClip = false; @@ -1511,6 +1581,7 @@ function drawTiles( tiledImage, lastDrawn ) { tiledImage._drawer.saveContext(useSketch); var box = tiledImage.imageToViewportRectangle(tiledImage._clip, true); + box = box.rotate(-tiledImage._degrees, tiledImage._getRotationPoint()); var clipRect = tiledImage._drawer.viewportToDrawerRectangle(box); if (sketchScale) { clipRect = clipRect.times(sketchScale); @@ -1571,14 +1642,31 @@ function drawTiles( tiledImage, lastDrawn ) { tiledImage._drawer.restoreContext( useSketch ); } - if (tiledImage.viewport.degrees !== 0 && !sketchScale) { - tiledImage._drawer._restoreRotationChanges(useSketch); + if (!sketchScale) { + if (tiledImage._degrees !== 0) { + tiledImage._drawer._restoreRotationChanges(useSketch); + } + if (tiledImage.viewport.degrees !== 0) { + tiledImage._drawer._restoreRotationChanges(useSketch); + } } if (useSketch) { - var offsetForRotation = tiledImage.viewport.degrees !== 0 && sketchScale; - if (offsetForRotation) { - tiledImage._drawer._offsetForRotation(tiledImage.viewport.degrees, false); + if (sketchScale) { + if (tiledImage.viewport.degrees !== 0) { + tiledImage._drawer._offsetForRotation({ + degrees: tiledImage.viewport.degrees, + useSketch: false + }); + } + if (tiledImage._degrees !== 0) { + tiledImage._drawer._offsetForRotation({ + degrees: tiledImage._degrees, + point: tiledImage.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true), + useSketch: false + }); + } } tiledImage._drawer.blendSketch({ opacity: tiledImage.opacity, @@ -1587,8 +1675,13 @@ function drawTiles( tiledImage, lastDrawn ) { compositeOperation: tiledImage.compositeOperation, bounds: bounds }); - if (offsetForRotation) { - tiledImage._drawer._restoreRotationChanges(false); + if (sketchScale) { + if (tiledImage._degrees !== 0) { + tiledImage._drawer._restoreRotationChanges(false); + } + if (tiledImage.viewport.degrees !== 0) { + tiledImage._drawer._restoreRotationChanges(false); + } } } drawDebugInfo( tiledImage, lastDrawn ); @@ -1599,7 +1692,8 @@ function drawDebugInfo( tiledImage, lastDrawn ) { for ( var i = lastDrawn.length - 1; i >= 0; i-- ) { var tile = lastDrawn[ i ]; try { - tiledImage._drawer.drawDebugInfo( tile, lastDrawn.length, i ); + tiledImage._drawer.drawDebugInfo( + tile, lastDrawn.length, i, tiledImage); } catch(e) { $.console.error(e); } diff --git a/src/tilesource.js b/src/tilesource.js index eb5aabc0..fa52f61e 100644 --- a/src/tilesource.js +++ b/src/tilesource.js @@ -344,12 +344,20 @@ $.TileSource.prototype = { * @param {Number} level * @param {OpenSeadragon.Point} point */ - getTileAtPoint: function( level, point ) { - var numTiles = this.getNumTiles( level ); - return new $.Point( - Math.floor( (point.x * numTiles.x) / 1 ), - Math.floor( (point.y * numTiles.y * this.dimensions.x) / this.dimensions.y ) - ); + getTileAtPoint: function(level, point) { + var widthScaled = this.dimensions.x * this.getLevelScale(level); + var pixelX = $.positiveModulo(point.x, 1) * widthScaled; + var pixelY = $.positiveModulo(point.y, 1 / this.aspectRatio) * widthScaled; + + var x = Math.floor(pixelX / this.getTileWidth()); + var y = Math.floor(pixelY / this.getTileHeight()); + + // Fix for wrapping + var numTiles = this.getNumTiles(level); + x += numTiles.x * Math.floor(point.x); + y += numTiles.y * Math.floor(point.y * this.aspectRatio); + + return new $.Point(x, y); }, /** diff --git a/src/viewer.js b/src/viewer.js index 94dc6248..06b224e0 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -1228,6 +1228,8 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, * (portions of the image outside of this area will not be visible). Only works on * browsers that support the HTML5 canvas. * @param {Number} [options.opacity] Opacity the tiled image should be drawn at by default. + * @param {Number} [options.degrees=0] Initial rotation of the tiled image around + * its top left corner in degrees. * @param {String} [options.compositeOperation] How the image is composited onto other images. * @param {String} [options.crossOriginPolicy] The crossOriginPolicy for this specific image, * overriding viewer.crossOriginPolicy. @@ -1369,6 +1371,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, clip: queueItem.options.clip, placeholderFillStyle: queueItem.options.placeholderFillStyle, opacity: queueItem.options.opacity, + degrees: queueItem.options.degrees, compositeOperation: queueItem.options.compositeOperation, springStiffness: _this.springStiffness, animationTime: _this.animationTime, diff --git a/src/viewport.js b/src/viewport.js index 2ac499d4..4cac87c3 100644 --- a/src/viewport.js +++ b/src/viewport.js @@ -852,11 +852,7 @@ $.Viewport.prototype = { return this; } - degrees = degrees % 360; - if (degrees < 0) { - degrees += 360; - } - this.degrees = degrees; + this.degrees = $.positiveModulo(degrees, 360); this._setContentBounds( this.viewer.world.getHomeBounds(), this.viewer.world.getContentFactor()); diff --git a/src/world.js b/src/world.js index 07e99b81..e68590b3 100644 --- a/src/world.js +++ b/src/world.js @@ -94,6 +94,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W this._needsDraw = true; item.addHandler('bounds-change', this._delegatedFigureSizes); + item.addHandler('clip-change', this._delegatedFigureSizes); /** * Raised when an item is added to the World. @@ -194,6 +195,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W } item.removeHandler('bounds-change', this._delegatedFigureSizes); + item.removeHandler('clip-change', this._delegatedFigureSizes); item.destroy(); this._items.splice( index, 1 ); this._figureSizes(); @@ -213,6 +215,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W for (var i = 0; i < this._items.length; i++) { item = this._items[i]; item.removeHandler('bounds-change', this._delegatedFigureSizes); + item.removeHandler('clip-change', this._delegatedFigureSizes); item.destroy(); } @@ -383,7 +386,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W var item = this._items[0]; var bounds = item.getBounds(); this._contentFactor = item.getContentSize().x / bounds.width; - var clippedBounds = item.getClippedBounds(); + var clippedBounds = item.getClippedBounds().getBoundingBox(); var left = clippedBounds.x; var top = clippedBounds.y; var right = clippedBounds.x + clippedBounds.width; @@ -393,7 +396,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W bounds = item.getBounds(); this._contentFactor = Math.max(this._contentFactor, item.getContentSize().x / bounds.width); - clippedBounds = item.getClippedBounds(); + clippedBounds = item.getClippedBounds().getBoundingBox(); left = Math.min(left, clippedBounds.x); top = Math.min(top, clippedBounds.y); right = Math.max(right, clippedBounds.x + clippedBounds.width); diff --git a/test/modules/tiledimage.js b/test/modules/tiledimage.js index 6dcae216..20171432 100644 --- a/test/modules/tiledimage.js +++ b/test/modules/tiledimage.js @@ -222,6 +222,27 @@ }); }); + // ---------- + asyncTest('clip-change event', function() { + expect(0); + var clip = new OpenSeadragon.Rect(100, 100, 800, 800); + + viewer.addHandler('open', function() { + var image = viewer.world.getItemAt(0); + image.addOnceHandler('clip-change', function() { + image.addOnceHandler('clip-change', function() { + start(); + }); + image.setClip(clip); + }); + image.setClip(null); + }); + + viewer.open({ + tileSource: '/test/data/testpattern.dzi' + }); + }); + // ---------- asyncTest('getClipBounds', function() { var clip = new OpenSeadragon.Rect(100, 200, 800, 500); @@ -294,6 +315,34 @@ }); // ---------- + asyncTest('rotation', function() { + + function testDefaultRotation() { + var image = viewer.world.getItemAt(0); + strictEqual(image.getRotation(), 0, 'image has default rotation'); + + image.setRotation(400); + strictEqual(image.getRotation(), 40, 'rotation is set correctly'); + + viewer.addOnceHandler('open', testTileSourceRotation); + viewer.open({ + tileSource: '/test/data/testpattern.dzi', + degrees: -60 + }); + } + + function testTileSourceRotation() { + var image = viewer.world.getItemAt(0); + strictEqual(image.getRotation(), 300, 'image has correct rotation'); + start(); + } + + viewer.addOnceHandler('open', testDefaultRotation); + viewer.open({ + tileSource: '/test/data/testpattern.dzi', + }); + }); + asyncTest('fitBounds', function() { function assertRectEquals(actual, expected, message) { diff --git a/test/modules/viewport.js b/test/modules/viewport.js index fb94d11d..e49c1e68 100644 --- a/test/modules/viewport.js +++ b/test/modules/viewport.js @@ -105,7 +105,8 @@ orig = config.getOrig(config.testArray[i], viewport); expected = config.getExpected(orig, viewport); actual = viewport[config.method](orig); - propEqual( + var assert = config.assert || propEqual; + assert( actual, expected, "Correctly converted coordinates " + orig @@ -118,6 +119,10 @@ viewer.open(DZI_PATH); }; + function assertPointsEquals(actual, expected, message) { + Util.assertPointsEquals(actual, expected, 1e-15, message); + } + // Tests start here. asyncTest('getContainerSize', function() { @@ -872,7 +877,8 @@ getExpected: function(orig, viewport) { return orig.divide(viewer.source.dimensions.x); }, - method: 'imageToViewportCoordinates' + method: 'imageToViewportCoordinates', + assert: assertPointsEquals }); }); @@ -885,7 +891,8 @@ getExpected: function(orig, viewport) { return orig.divide(ZOOM_FACTOR * viewport.getContainerSize().x); }, - method: 'imageToViewportCoordinates' + method: 'imageToViewportCoordinates', + assert: assertPointsEquals }); }); asyncTest('imageToViewportRectangle', function() { @@ -902,7 +909,8 @@ orig.height / viewer.source.dimensions.x ); }, - method: 'imageToViewportRectangle' + method: 'imageToViewportRectangle', + assert: assertPointsEquals }); });