diff --git a/Gruntfile.js b/Gruntfile.js index 3111bd65..d526fabe 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -28,6 +28,7 @@ module.exports = function(grunt) { coverageDir = 'coverage/' + dateFormat(new Date(), 'yyyymmdd-HHMMss'), sources = [ "src/openseadragon.js", + "src/matrix3.js", "src/fullscreen.js", "src/eventsource.js", "src/mousetracker.js", @@ -57,11 +58,14 @@ module.exports = function(grunt) { "src/imageloader.js", "src/tile.js", "src/overlay.js", - "src/drawer.js", + "src/drawerbase.js", + "src/htmldrawer.js", + "src/canvasdrawer.js", + "src/webgldrawer.js", "src/viewport.js", "src/tiledimage.js", "src/tilecache.js", - "src/world.js" + "src/world.js", ]; var banner = "//! <%= pkg.name %> <%= pkg.version %>\n" + diff --git a/LICENSE.txt b/LICENSE.txt index 7c6df831..247d11af 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,5 +1,5 @@ Copyright (C) 2009 CodePlex Foundation -Copyright (C) 2010-2023 OpenSeadragon contributors +Copyright (C) 2010-2024 OpenSeadragon contributors Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/src/button.js b/src/button.js index e6c525d7..34d1f27a 100644 --- a/src/button.js +++ b/src/button.js @@ -2,7 +2,7 @@ * OpenSeadragon - Button * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/buttongroup.js b/src/buttongroup.js index 6cab6395..8a1b4c9e 100644 --- a/src/buttongroup.js +++ b/src/buttongroup.js @@ -2,7 +2,7 @@ * OpenSeadragon - ButtonGroup * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/canvasdrawer.js b/src/canvasdrawer.js new file mode 100644 index 00000000..a637fb39 --- /dev/null +++ b/src/canvasdrawer.js @@ -0,0 +1,1090 @@ +/* + * OpenSeadragon - CanvasDrawer + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + +/** + * @class CanvasDrawer + * @memberof OpenSeadragon + * @classdesc Default implementation of CanvasDrawer for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + +class CanvasDrawer extends $.DrawerBase{ + constructor(options){ + super(options); + + /** + * 2d drawing context for {@link OpenSeadragon.CanvasDrawer#canvas}. + * @member {Object} context + * @memberof OpenSeadragon.CanvasDrawer# + */ + this.context = this.canvas.getContext( '2d' ); + + // Sketch canvas used to temporarily draw tiles which cannot be drawn directly + // to the main canvas due to opacity. Lazily initialized. + this.sketchCanvas = null; + this.sketchContext = null; + + // Image smoothing for canvas rendering (only if canvas is used). + // Canvas default is "true", so this will only be changed if user specifies "false" in the options or via setImageSmoothinEnabled. + this._imageSmoothingEnabled = true; + + + // Since the tile-drawn and tile-drawing events are fired by this drawer, make sure handlers can be added for them + this.viewer.allowEventHandler("tile-drawn"); + this.viewer.allowEventHandler("tile-drawing"); + + } + + /** + * @returns {Boolean} true if canvas is supported by the browser, otherwise false + */ + static isSupported(){ + return $.supportsCanvas; + } + + getType(){ + return 'canvas'; + } + + /** + * create the HTML element (e.g. canvas, div) that the image will be drawn into + * @returns {Element} the canvas to draw into + */ + _createDrawingElement(){ + let canvas = $.makeNeutralElement("canvas"); + let viewportSize = this._calculateCanvasSize(); + canvas.width = viewportSize.x; + canvas.height = viewportSize.y; + return canvas; + } + + /** + * Draws the TiledImages + */ + draw(tiledImages) { + this._prepareNewFrame(); // prepare to draw a new frame + + for(const tiledImage of tiledImages){ + if (tiledImage.opacity !== 0 || tiledImage._preload) { + this._drawTiles(tiledImage); + } + } + } + + /** + * @returns {Boolean} True - rotation is supported. + */ + canRotate() { + return true; + } + + /** + * Destroy the drawer (unload current loaded tiles) + */ + destroy() { + //force unloading of current canvas (1x1 will be gc later, trick not necessarily needed) + this.canvas.width = 1; + this.canvas.height = 1; + this.sketchCanvas = null; + this.sketchContext = null; + } + + /** + * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. + */ + minimumOverlapRequired() { + return true; + } + + + /** + * Turns image smoothing on or off for this viewer. Note: Ignored in some (especially older) browsers that do not support this property. + * + * @function + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(imageSmoothingEnabled){ + this._imageSmoothingEnabled = !!imageSmoothingEnabled; + this._updateImageSmoothingEnabled(this.context); + this.viewer.forceRedraw(); + } + + /** + * Draw a rectangle onto the canvas + * @param {OpenSeadragon.Rect} rect + */ + drawDebuggingRect(rect) { + var context = this.context; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.strokeStyle = this.debugGridColor[0]; + context.fillStyle = this.debugGridColor[0]; + + context.strokeRect( + rect.x * $.pixelDensityRatio, + rect.y * $.pixelDensityRatio, + rect.width * $.pixelDensityRatio, + rect.height * $.pixelDensityRatio + ); + + context.restore(); + } + + /** + * Fires the tile-drawing event. + * @private + */ + _raiseTileDrawingEvent(tiledImage, context, tile, rendered){ + /** + * This event is fired just before the tile is drawn giving the application a chance to alter the image. + * + * NOTE: This event is only fired when the 'canvas' drawer is being used + * + * @event tile-drawing + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.Tile} tile - The Tile being drawn. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {CanvasRenderingContext2D} context - The HTML canvas context being drawn into. + * @property {CanvasRenderingContext2D} rendered - The HTML canvas context containing the tile imagery. + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent('tile-drawing', { + tiledImage: tiledImage, + context: context, + tile: tile, + rendered: rendered + }); + } + + /** + * Clears the Drawer so it's ready to draw another frame. + * @private + * + */ + _prepareNewFrame() { + var viewportSize = this._calculateCanvasSize(); + if( this.canvas.width !== viewportSize.x || + this.canvas.height !== viewportSize.y ) { + this.canvas.width = viewportSize.x; + this.canvas.height = viewportSize.y; + this._updateImageSmoothingEnabled(this.context); + if ( this.sketchCanvas !== null ) { + var sketchCanvasSize = this._calculateSketchCanvasSize(); + this.sketchCanvas.width = sketchCanvasSize.x; + this.sketchCanvas.height = sketchCanvasSize.y; + this._updateImageSmoothingEnabled(this.sketchContext); + } + } + this._clear(); + } + + /** + * @private + * @param {Boolean} useSketch Whether to clear sketch canvas or main canvas + * @param {OpenSeadragon.Rect} [bounds] The rectangle to clear + */ + _clear(useSketch, bounds){ + var context = this._getContext(useSketch); + if (bounds) { + context.clearRect(bounds.x, bounds.y, bounds.width, bounds.height); + } else { + var canvas = context.canvas; + context.clearRect(0, 0, canvas.width, canvas.height); + } + } + + /** + * Draws a TiledImage. + * @private + * + */ + _drawTiles( tiledImage ) { + var lastDrawn = tiledImage.getTilesToDraw().map(info => info.tile); + if (tiledImage.opacity === 0 || (lastDrawn.length === 0 && !tiledImage.placeholderFillStyle)) { + return; + } + + var tile = lastDrawn[0]; + var useSketch; + + if (tile) { + useSketch = tiledImage.opacity < 1 || + (tiledImage.compositeOperation && tiledImage.compositeOperation !== 'source-over') || + (!tiledImage._isBottomItem() && + tiledImage.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData)); + } + + var sketchScale; + var sketchTranslate; + + var zoom = this.viewport.getZoom(true); + var imageZoom = tiledImage.viewportToImageZoom(zoom); + + if (lastDrawn.length > 1 && + imageZoom > tiledImage.smoothTileEdgesMinZoom && + !tiledImage.iOSDevice && + tiledImage.getRotation(true) % 360 === 0 ){ // TODO: support tile edge smoothing with tiled image rotation. + // When zoomed in a lot (>100%) the tile edges are visible. + // So we have to composite them at ~100% and scale them up together. + // Note: Disabled on iOS devices per default as it causes a native crash + useSketch = true; + sketchScale = tile.getScaleForEdgeSmoothing(); + sketchTranslate = tile.getTranslationForEdgeSmoothing(sketchScale, + this._getCanvasSize(false), + this._getCanvasSize(true)); + } + + var bounds; + if (useSketch) { + if (!sketchScale) { + // Except when edge smoothing, we only clean the part of the + // sketch canvas we are going to use for performance reasons. + bounds = this.viewport.viewportToViewerElementRectangle( + tiledImage.getClippedBounds(true)) + .getIntegerBoundingBox(); + + if(this.viewer.viewport.getFlip()) { + if (this.viewport.getRotation(true) % 360 !== 0 || + tiledImage.getRotation(true) % 360 !== 0) { + bounds.x = this.viewer.container.clientWidth - (bounds.x + bounds.width); + } + } + + bounds = bounds.times($.pixelDensityRatio); + } + this._clear(true, bounds); + } + + // When scaling, we must rotate only when blending the sketch canvas to + // avoid interpolation + if (!sketchScale) { + if (this.viewport.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: this.viewport.getRotation(true), + useSketch: useSketch + }); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: tiledImage.getRotation(true), + point: this.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true), + useSketch: useSketch + }); + } + + if (this.viewport.getRotation(true) % 360 === 0 && + tiledImage.getRotation(true) % 360 === 0) { + if(this.viewer.viewport.getFlip()) { + this._flip(); + } + } + } + + var usedClip = false; + if ( tiledImage._clip ) { + this._saveContext(useSketch); + + var box = tiledImage.imageToViewportRectangle(tiledImage._clip, true); + box = box.rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true)); + var clipRect = this.viewportToDrawerRectangle(box); + if (sketchScale) { + clipRect = clipRect.times(sketchScale); + } + if (sketchTranslate) { + clipRect = clipRect.translate(sketchTranslate); + } + this._setClip(clipRect, useSketch); + + usedClip = true; + } + + if (tiledImage._croppingPolygons) { + var self = this; + if(!usedClip){ + this._saveContext(useSketch); + } + try { + var polygons = tiledImage._croppingPolygons.map(function (polygon) { + return polygon.map(function (coord) { + var point = tiledImage + .imageToViewportCoordinates(coord.x, coord.y, true) + .rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true)); + var clipPoint = self.viewportCoordToDrawerCoord(point); + if (sketchScale) { + clipPoint = clipPoint.times(sketchScale); + } + if (sketchTranslate) { // mostly fixes #2312 + clipPoint = clipPoint.plus(sketchTranslate); + } + return clipPoint; + }); + }); + this._clipWithPolygons(polygons, useSketch); + } catch (e) { + $.console.error(e); + } + usedClip = true; + } + + if ( tiledImage.placeholderFillStyle && tiledImage._hasOpaqueTile === false ) { + var placeholderRect = this.viewportToDrawerRectangle(tiledImage.getBounds(true)); + if (sketchScale) { + placeholderRect = placeholderRect.times(sketchScale); + } + if (sketchTranslate) { + placeholderRect = placeholderRect.translate(sketchTranslate); + } + + var fillStyle = null; + if ( typeof tiledImage.placeholderFillStyle === "function" ) { + fillStyle = tiledImage.placeholderFillStyle(tiledImage, this.context); + } + else { + fillStyle = tiledImage.placeholderFillStyle; + } + + this._drawRectangle(placeholderRect, fillStyle, useSketch); + } + + var subPixelRoundingRule = determineSubPixelRoundingRule(tiledImage.subPixelRoundingForTransparency); + + var shouldRoundPositionAndSize = false; + + if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS) { + shouldRoundPositionAndSize = true; + } else if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST) { + var isAnimating = this.viewer && this.viewer.isAnimating(); + shouldRoundPositionAndSize = !isAnimating; + } + + // Iterate over the tiles to draw, and draw them + for (var i = 0; i < lastDrawn.length; i++) { + tile = lastDrawn[ i ]; + this._drawTile( tile, tiledImage, useSketch, sketchScale, + sketchTranslate, shouldRoundPositionAndSize, tiledImage.source ); + + if( this.viewer ){ + /** + * Raised when a tile is drawn to the canvas. Only valid for + * context2d and html drawers. + * + * @event tile-drawn + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {OpenSeadragon.Tile} tile + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent( 'tile-drawn', { + tiledImage: tiledImage, + tile: tile + }); + } + } + + if ( usedClip ) { + this._restoreContext( useSketch ); + } + + if (!sketchScale) { + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(useSketch); + } + if (this.viewport.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(useSketch); + } + } + + if (useSketch) { + if (sketchScale) { + if (this.viewport.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: this.viewport.getRotation(true), + useSketch: false + }); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: tiledImage.getRotation(true), + point: this.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true), + useSketch: false + }); + } + } + this.blendSketch({ + opacity: tiledImage.opacity, + scale: sketchScale, + translate: sketchTranslate, + compositeOperation: tiledImage.compositeOperation, + bounds: bounds + }); + if (sketchScale) { + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(false); + } + if (this.viewport.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(false); + } + } + } + + if (!sketchScale) { + if (this.viewport.getRotation(true) % 360 === 0 && + tiledImage.getRotation(true) % 360 === 0) { + if(this.viewer.viewport.getFlip()) { + this._flip(); + } + } + } + + this._drawDebugInfo( tiledImage, lastDrawn ); + + // Fire tiled-image-drawn event. + + this._raiseTiledImageDrawnEvent(tiledImage, lastDrawn); + + } + + /** + * Draws special debug information for a TiledImage if in debug mode. + * @private + * @param {OpenSeadragon.Tile[]} lastDrawn - An unordered list of Tiles drawn last frame. + */ + _drawDebugInfo( tiledImage, lastDrawn ) { + if( tiledImage.debugMode ) { + for ( var i = lastDrawn.length - 1; i >= 0; i-- ) { + var tile = lastDrawn[ i ]; + try { + this._drawDebugInfoOnTile(tile, lastDrawn.length, i, tiledImage); + } catch(e) { + $.console.error(e); + } + } + } + } + + /** + * This function will create multiple polygon paths on the drawing context by provided polygons, + * then clip the context to the paths. + * @private + * @param {OpenSeadragon.Point[][]} polygons - an array of polygons. A polygon is an array of OpenSeadragon.Point + * @param {Boolean} useSketch - Whether to use the sketch canvas or not. + */ + _clipWithPolygons (polygons, useSketch) { + var context = this._getContext(useSketch); + context.beginPath(); + for(const polygon of polygons){ + for(const [i, coord] of polygon.entries() ){ + context[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y); + } + } + + context.clip(); + } + + /** + * Draws the given tile. + * @private + * @param {OpenSeadragon.Tile} tile - The tile to draw. + * @param {OpenSeadragon.TiledImage} tiledImage - The tiled image being drawn. + * @param {Boolean} useSketch - Whether to use the sketch canvas or not. + * where rendered is the context with the pre-drawn image. + * @param {Float} [scale=1] - Apply a scale to tile position and size. Defaults to 1. + * @param {OpenSeadragon.Point} [translate] A translation vector to offset tile position + * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round + * position and size of tiles supporting alpha channel in non-transparency + * context. + * @param {OpenSeadragon.TileSource} source - The source specification of the tile. + */ + _drawTile( tile, tiledImage, useSketch, scale, translate, shouldRoundPositionAndSize, source) { + $.console.assert(tile, '[Drawer._drawTile] tile is required'); + $.console.assert(tiledImage, '[Drawer._drawTile] drawingHandler is required'); + + var context = this._getContext(useSketch); + scale = scale || 1; + this._drawTileToCanvas(tile, context, tiledImage, scale, translate, shouldRoundPositionAndSize, source); + + } + + /** + * Renders the tile in a canvas-based context. + * @private + * @function + * @param {OpenSeadragon.Tile} tile - the tile to draw to the canvas + * @param {Canvas} context + * @param {OpenSeadragon.TiledImage} tiledImage - Method for firing the drawing event. + * drawingHandler({context, tile, rendered}) + * where rendered is the context with the pre-drawn image. + * @param {Number} [scale=1] - Apply a scale to position and size + * @param {OpenSeadragon.Point} [translate] - A translation vector + * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round + * position and size of tiles supporting alpha channel in non-transparency + * context. + * @param {OpenSeadragon.TileSource} source - The source specification of the tile. + */ + _drawTileToCanvas( tile, context, tiledImage, scale, translate, shouldRoundPositionAndSize, source) { + + var position = tile.position.times($.pixelDensityRatio), + size = tile.size.times($.pixelDensityRatio), + rendered; + + if (!tile.context2D && !tile.cacheImageRecord) { + $.console.warn( + '[Drawer._drawTileToCanvas] attempting to draw tile %s when it\'s not cached', + tile.toString()); + return; + } + + rendered = tile.getCanvasContext(); + + if ( !tile.loaded || !rendered ){ + $.console.warn( + "Attempting to draw tile %s when it's not yet loaded.", + tile.toString() + ); + + return; + } + + context.save(); + // context.globalAlpha = this.options.opacity; // this was deprecated previously and should not be applied as it is set per TiledImage + + if (typeof scale === 'number' && scale !== 1) { + // draw tile at a different scale + position = position.times(scale); + size = size.times(scale); + } + + if (translate instanceof $.Point) { + // shift tile position slightly + position = position.plus(translate); + } + + //if we are supposed to be rendering fully opaque rectangle, + //ie its done fading or fading is turned off, and if we are drawing + //an image with an alpha channel, then the only way + //to avoid seeing the tile underneath is to clear the rectangle + if (context.globalAlpha === 1 && tile.hasTransparency) { + if (shouldRoundPositionAndSize) { + // Round to the nearest whole pixel so we don't get seams from overlap. + position.x = Math.round(position.x); + position.y = Math.round(position.y); + size.x = Math.round(size.x); + size.y = Math.round(size.y); + } + + //clearing only the inside of the rectangle occupied + //by the png prevents edge flikering + context.clearRect( + position.x, + position.y, + size.x, + size.y + ); + } + + this._raiseTileDrawingEvent(tiledImage, context, tile, rendered); + + var sourceWidth, sourceHeight; + if (tile.sourceBounds) { + sourceWidth = Math.min(tile.sourceBounds.width, rendered.canvas.width); + sourceHeight = Math.min(tile.sourceBounds.height, rendered.canvas.height); + } else { + sourceWidth = rendered.canvas.width; + sourceHeight = rendered.canvas.height; + } + + context.translate(position.x + size.x / 2, 0); + if (tile.flipped) { + context.scale(-1, 1); + } + context.drawImage( + rendered.canvas, + 0, + 0, + sourceWidth, + sourceHeight, + -size.x / 2, + position.y, + size.x, + size.y + ); + + context.restore(); + } + + /** + * Get the context of the main or sketch canvas + * @private + * @param {Boolean} useSketch + * @returns {CanvasRenderingContext2D} + */ + _getContext( useSketch ) { + var context = this.context; + if ( useSketch ) { + if (this.sketchCanvas === null) { + this.sketchCanvas = document.createElement( "canvas" ); + var sketchCanvasSize = this._calculateSketchCanvasSize(); + this.sketchCanvas.width = sketchCanvasSize.x; + this.sketchCanvas.height = sketchCanvasSize.y; + this.sketchContext = this.sketchCanvas.getContext( "2d" ); + + // If the viewport is not currently rotated, the sketchCanvas + // will have the same size as the main canvas. However, if + // the viewport get rotated later on, we will need to resize it. + if (this.viewport.getRotation() === 0) { + var self = this; + this.viewer.addHandler('rotate', function resizeSketchCanvas() { + if (self.viewport.getRotation() === 0) { + return; + } + self.viewer.removeHandler('rotate', resizeSketchCanvas); + var sketchCanvasSize = self._calculateSketchCanvasSize(); + self.sketchCanvas.width = sketchCanvasSize.x; + self.sketchCanvas.height = sketchCanvasSize.y; + }); + } + this._updateImageSmoothingEnabled(this.sketchContext); + } + context = this.sketchContext; + } + return context; + } + + /** + * Save the context of the main or sketch canvas + * @private + * @param {Boolean} useSketch + */ + _saveContext( useSketch ) { + this._getContext( useSketch ).save(); + } + + /** + * Restore the context of the main or sketch canvas + * @private + * @param {Boolean} useSketch + */ + _restoreContext( useSketch ) { + this._getContext( useSketch ).restore(); + } + + // private + _setClip(rect, useSketch) { + var context = this._getContext( useSketch ); + context.beginPath(); + context.rect(rect.x, rect.y, rect.width, rect.height); + context.clip(); + } + + // private + // used to draw a placeholder rectangle + _drawRectangle(rect, fillStyle, useSketch) { + var context = this._getContext( useSketch ); + context.save(); + context.fillStyle = fillStyle; + context.fillRect(rect.x, rect.y, rect.width, rect.height); + context.restore(); + } + + /** + * Blends the sketch canvas in the main canvas. + * @param {Object} options The options + * @param {Float} options.opacity The opacity of the blending. + * @param {Float} [options.scale=1] The scale at which tiles were drawn on + * the sketch. Default is 1. + * Use scale to draw at a lower scale and then enlarge onto the main canvas. + * @param {OpenSeadragon.Point} [options.translate] A translation vector + * that was used to draw the tiles + * @param {String} [options.compositeOperation] - How the image is + * composited onto other images; see compositeOperation in + * {@link OpenSeadragon.Options} for possible values. + * @param {OpenSeadragon.Rect} [options.bounds] The part of the sketch + * canvas to blend in the main canvas. If specified, options.scale and + * options.translate get ignored. + */ + blendSketch(opacity, scale, translate, compositeOperation) { + var options = opacity; + if (!$.isPlainObject(options)) { + options = { + opacity: opacity, + scale: scale, + translate: translate, + compositeOperation: compositeOperation + }; + } + + opacity = options.opacity; + compositeOperation = options.compositeOperation; + var bounds = options.bounds; + + this.context.save(); + this.context.globalAlpha = opacity; + if (compositeOperation) { + this.context.globalCompositeOperation = compositeOperation; + } + if (bounds) { + // Internet Explorer, Microsoft Edge, and Safari have problems + // when you call context.drawImage with negative x or y + // or x + width or y + height greater than the canvas width or height respectively. + if (bounds.x < 0) { + bounds.width += bounds.x; + bounds.x = 0; + } + if (bounds.x + bounds.width > this.canvas.width) { + bounds.width = this.canvas.width - bounds.x; + } + if (bounds.y < 0) { + bounds.height += bounds.y; + bounds.y = 0; + } + if (bounds.y + bounds.height > this.canvas.height) { + bounds.height = this.canvas.height - bounds.y; + } + + this.context.drawImage( + this.sketchCanvas, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + bounds.x, + bounds.y, + bounds.width, + bounds.height + ); + } else { + scale = options.scale || 1; + translate = options.translate; + var position = translate instanceof $.Point ? + translate : new $.Point(0, 0); + + var widthExt = 0; + var heightExt = 0; + if (translate) { + var widthDiff = this.sketchCanvas.width - this.canvas.width; + var heightDiff = this.sketchCanvas.height - this.canvas.height; + widthExt = Math.round(widthDiff / 2); + heightExt = Math.round(heightDiff / 2); + } + this.context.drawImage( + this.sketchCanvas, + position.x - widthExt * scale, + position.y - heightExt * scale, + (this.canvas.width + 2 * widthExt) * scale, + (this.canvas.height + 2 * heightExt) * scale, + -widthExt, + -heightExt, + this.canvas.width + 2 * widthExt, + this.canvas.height + 2 * heightExt + ); + } + this.context.restore(); + } + + // private + _drawDebugInfoOnTile(tile, count, i, tiledImage) { + + var colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length; + var context = this.context; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial'; + context.strokeStyle = this.debugGridColor[colorIndex]; + context.fillStyle = this.debugGridColor[colorIndex]; + + if (this.viewport.getRotation(true) % 360 !== 0 ) { + this._offsetForRotation({degrees: this.viewport.getRotation(true)}); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: tiledImage.getRotation(true), + point: tiledImage.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true) + }); + } + if (tiledImage.viewport.getRotation(true) % 360 === 0 && + tiledImage.getRotation(true) % 360 === 0) { + if(tiledImage._drawer.viewer.viewport.getFlip()) { + tiledImage._drawer._flip(); + } + } + + context.strokeRect( + tile.position.x * $.pixelDensityRatio, + tile.position.y * $.pixelDensityRatio, + tile.size.x * $.pixelDensityRatio, + tile.size.y * $.pixelDensityRatio + ); + + var tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio; + var tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio; + + // Rotate the text the right way around. + context.translate( tileCenterX, tileCenterY ); + context.rotate( Math.PI / 180 * -this.viewport.getRotation(true) ); + context.translate( -tileCenterX, -tileCenterY ); + + if( tile.x === 0 && tile.y === 0 ){ + context.fillText( + "Zoom: " + this.viewport.getZoom(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 30) * $.pixelDensityRatio + ); + context.fillText( + "Pan: " + this.viewport.getBounds().toString(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 20) * $.pixelDensityRatio + ); + } + context.fillText( + "Level: " + tile.level, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 20) * $.pixelDensityRatio + ); + context.fillText( + "Column: " + tile.x, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 30) * $.pixelDensityRatio + ); + context.fillText( + "Row: " + tile.y, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 40) * $.pixelDensityRatio + ); + context.fillText( + "Order: " + i + " of " + count, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 50) * $.pixelDensityRatio + ); + context.fillText( + "Size: " + tile.size.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 60) * $.pixelDensityRatio + ); + context.fillText( + "Position: " + tile.position.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 70) * $.pixelDensityRatio + ); + + if (this.viewport.getRotation(true) % 360 !== 0 ) { + this._restoreRotationChanges(); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(); + } + + if (tiledImage.viewport.getRotation(true) % 360 === 0 && + tiledImage.getRotation(true) % 360 === 0) { + if(tiledImage._drawer.viewer.viewport.getFlip()) { + tiledImage._drawer._flip(); + } + } + + context.restore(); + } + + // private + _updateImageSmoothingEnabled(context){ + context.msImageSmoothingEnabled = this._imageSmoothingEnabled; + context.imageSmoothingEnabled = this._imageSmoothingEnabled; + } + + /** + * Get the canvas size + * @private + * @param {Boolean} sketch If set to true return the size of the sketch canvas + * @returns {OpenSeadragon.Point} The size of the canvas + */ + _getCanvasSize(sketch) { + var canvas = this._getContext(sketch).canvas; + return new $.Point(canvas.width, canvas.height); + } + + /** + * Get the canvas center + * @private + * @param {Boolean} sketch If set to true return the center point of the sketch canvas + * @returns {OpenSeadragon.Point} The center point of the canvas + */ + _getCanvasCenter() { + return new $.Point(this.canvas.width / 2, this.canvas.height / 2); + } + + // private + _offsetForRotation(options) { + var point = options.point ? + options.point.times($.pixelDensityRatio) : + this._getCanvasCenter(); + + var context = this._getContext(options.useSketch); + context.save(); + + context.translate(point.x, point.y); + if(this.viewer.viewport.flipped){ + context.rotate(Math.PI / 180 * -options.degrees); + context.scale(-1, 1); + } else{ + context.rotate(Math.PI / 180 * options.degrees); + } + context.translate(-point.x, -point.y); + } + + // private + _flip(options) { + options = options || {}; + var point = options.point ? + options.point.times($.pixelDensityRatio) : + this._getCanvasCenter(); + var context = this._getContext(options.useSketch); + + context.translate(point.x, 0); + context.scale(-1, 1); + context.translate(-point.x, 0); + } + + // private + _restoreRotationChanges(useSketch) { + var context = this._getContext(useSketch); + context.restore(); + } + + // private + _calculateCanvasSize() { + var pixelDensityRatio = $.pixelDensityRatio; + var viewportSize = this.viewport.getContainerSize(); + return { + // canvas width and height are integers + x: Math.round(viewportSize.x * pixelDensityRatio), + y: Math.round(viewportSize.y * pixelDensityRatio) + }; + } + + // private + _calculateSketchCanvasSize() { + var canvasSize = this._calculateCanvasSize(); + if (this.viewport.getRotation() === 0) { + return canvasSize; + } + // If the viewport is rotated, we need a larger sketch canvas in order + // to support edge smoothing. + var sketchCanvasSize = Math.ceil(Math.sqrt( + canvasSize.x * canvasSize.x + + canvasSize.y * canvasSize.y)); + return { + x: sketchCanvasSize, + y: sketchCanvasSize + }; + } +} +$.CanvasDrawer = CanvasDrawer; + + +/** + * Defines the value for subpixel rounding to fallback to in case of missing or + * invalid value. + * @private + */ +var DEFAULT_SUBPIXEL_ROUNDING_RULE = $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; + +/** + * Checks whether the input value is an invalid subpixel rounding enum value. + * @private + * + * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to check. + * @returns {Boolean} Returns true if the input value is none of the expected + * {@link SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS}, {@link SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST} or {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} value. + */ +function isSubPixelRoundingRuleUnknown(value) { + return value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS && + value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST && + value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; +} + +/** + * Ensures the returned value is always a valid subpixel rounding enum value, + * defaulting to {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} if input is missing or invalid. + * @private + * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to normalize. + * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns a valid subpixel rounding enum value. + */ +function normalizeSubPixelRoundingRule(value) { + if (isSubPixelRoundingRuleUnknown(value)) { + return DEFAULT_SUBPIXEL_ROUNDING_RULE; + } + return value; +} + +/** + * Ensures the returned value is always a valid subpixel rounding enum value, + * defaulting to 'NEVER' if input is missing or invalid. + * @private + * + * @param {Object} subPixelRoundingRules - A subpixel rounding enum values dictionary [{@link BROWSERS}] --> {@link SUBPIXEL_ROUNDING_OCCURRENCES}. + * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns the determined subpixel rounding enum value for the + * current browser. + */ +function determineSubPixelRoundingRule(subPixelRoundingRules) { + if (typeof subPixelRoundingRules === 'number') { + return normalizeSubPixelRoundingRule(subPixelRoundingRules); + } + + if (!subPixelRoundingRules || !$.Browser) { + return DEFAULT_SUBPIXEL_ROUNDING_RULE; + } + + var subPixelRoundingRule = subPixelRoundingRules[$.Browser.vendor]; + + if (isSubPixelRoundingRuleUnknown(subPixelRoundingRule)) { + subPixelRoundingRule = subPixelRoundingRules['*']; + } + + return normalizeSubPixelRoundingRule(subPixelRoundingRule); +} + +}( OpenSeadragon )); diff --git a/src/control.js b/src/control.js index 3cba9943..51fe020a 100644 --- a/src/control.js +++ b/src/control.js @@ -2,7 +2,7 @@ * OpenSeadragon - Control * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/controldock.js b/src/controldock.js index 0ab9e5cc..31f44b52 100644 --- a/src/controldock.js +++ b/src/controldock.js @@ -2,7 +2,7 @@ * OpenSeadragon - ControlDock * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/displayrectangle.js b/src/displayrectangle.js index 58610058..3c3284d5 100644 --- a/src/displayrectangle.js +++ b/src/displayrectangle.js @@ -2,7 +2,7 @@ * OpenSeadragon - DisplayRect * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/drawer.js b/src/drawer.js deleted file mode 100644 index 2aa2c3ad..00000000 --- a/src/drawer.js +++ /dev/null @@ -1,767 +0,0 @@ -/* - * OpenSeadragon - Drawer - * - * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * - Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * - Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * - Neither the name of CodePlex Foundation nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED - * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -(function( $ ){ - -/** - * @class Drawer - * @memberof OpenSeadragon - * @classdesc Handles rendering of tiles for an {@link OpenSeadragon.Viewer}. - * @param {Object} options - Options for this Drawer. - * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. - * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. - * @param {Element} options.element - Parent element. - * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. - */ -$.Drawer = function( options ) { - - $.console.assert( options.viewer, "[Drawer] options.viewer is required" ); - - //backward compatibility for positional args while preferring more - //idiomatic javascript options object as the only argument - var args = arguments; - - if( !$.isPlainObject( options ) ){ - options = { - source: args[ 0 ], // Reference to Viewer tile source. - viewport: args[ 1 ], // Reference to Viewer viewport. - element: args[ 2 ] // Parent element. - }; - } - - $.console.assert( options.viewport, "[Drawer] options.viewport is required" ); - $.console.assert( options.element, "[Drawer] options.element is required" ); - - if ( options.source ) { - $.console.error( "[Drawer] options.source is no longer accepted; use TiledImage instead" ); - } - - this.viewer = options.viewer; - this.viewport = options.viewport; - this.debugGridColor = typeof options.debugGridColor === 'string' ? [options.debugGridColor] : options.debugGridColor || $.DEFAULT_SETTINGS.debugGridColor; - if (options.opacity) { - $.console.error( "[Drawer] options.opacity is no longer accepted; set the opacity on the TiledImage instead" ); - } - - this.useCanvas = $.supportsCanvas && ( this.viewer ? this.viewer.useCanvas : true ); - /** - * The parent element of this Drawer instance, passed in when the Drawer was created. - * The parent of {@link OpenSeadragon.Drawer#canvas}. - * @member {Element} container - * @memberof OpenSeadragon.Drawer# - */ - this.container = $.getElement( options.element ); - /** - * A <canvas> element if the browser supports them, otherwise a <div> element. - * Child element of {@link OpenSeadragon.Drawer#container}. - * @member {Element} canvas - * @memberof OpenSeadragon.Drawer# - */ - this.canvas = $.makeNeutralElement( this.useCanvas ? "canvas" : "div" ); - /** - * 2d drawing context for {@link OpenSeadragon.Drawer#canvas} if it's a <canvas> element, otherwise null. - * @member {Object} context - * @memberof OpenSeadragon.Drawer# - */ - this.context = this.useCanvas ? this.canvas.getContext( "2d" ) : null; - - /** - * Sketch canvas used to temporarily draw tiles which cannot be drawn directly - * to the main canvas due to opacity. Lazily initialized. - */ - this.sketchCanvas = null; - this.sketchContext = null; - - /** - * @member {Element} element - * @memberof OpenSeadragon.Drawer# - * @deprecated Alias for {@link OpenSeadragon.Drawer#container}. - */ - this.element = this.container; - - // We force our container to ltr because our drawing math doesn't work in rtl. - // This issue only affects our canvas renderer, but we do it always for consistency. - // Note that this means overlays you want to be rtl need to be explicitly set to rtl. - this.container.dir = 'ltr'; - - // check canvas available width and height, set canvas width and height such that the canvas backing store is set to the proper pixel density - if (this.useCanvas) { - var viewportSize = this._calculateCanvasSize(); - this.canvas.width = viewportSize.x; - this.canvas.height = viewportSize.y; - } - - this.canvas.style.width = "100%"; - this.canvas.style.height = "100%"; - this.canvas.style.position = "absolute"; - $.setElementOpacity( this.canvas, this.opacity, true ); - // Allow pointer events to pass through the canvas element so implicit - // pointer capture works on touch devices - $.setElementPointerEventsNone( this.canvas ); - $.setElementTouchActionNone( this.canvas ); - - // explicit left-align - this.container.style.textAlign = "left"; - this.container.appendChild( this.canvas ); - - // Image smoothing for canvas rendering (only if canvas is used). - // Canvas default is "true", so this will only be changed if user specified "false". - this._imageSmoothingEnabled = true; -}; - -/** @lends OpenSeadragon.Drawer.prototype */ -$.Drawer.prototype = { - // deprecated - addOverlay: function( element, location, placement, onDraw ) { - $.console.error("drawer.addOverlay is deprecated. Use viewer.addOverlay instead."); - this.viewer.addOverlay( element, location, placement, onDraw ); - return this; - }, - - // deprecated - updateOverlay: function( element, location, placement ) { - $.console.error("drawer.updateOverlay is deprecated. Use viewer.updateOverlay instead."); - this.viewer.updateOverlay( element, location, placement ); - return this; - }, - - // deprecated - removeOverlay: function( element ) { - $.console.error("drawer.removeOverlay is deprecated. Use viewer.removeOverlay instead."); - this.viewer.removeOverlay( element ); - return this; - }, - - // deprecated - clearOverlays: function() { - $.console.error("drawer.clearOverlays is deprecated. Use viewer.clearOverlays instead."); - this.viewer.clearOverlays(); - return this; - }, - - /** - * This function converts the given point from to the drawer coordinate by - * multiplying it with the pixel density. - * This function does not take rotation into account, thus assuming provided - * point is at 0 degree. - * @param {OpenSeadragon.Point} point - the pixel point to convert - * @returns {OpenSeadragon.Point} Point in drawer coordinate system. - */ - viewportCoordToDrawerCoord: function(point) { - var vpPoint = this.viewport.pixelFromPointNoRotate(point, true); - return new $.Point( - vpPoint.x * $.pixelDensityRatio, - vpPoint.y * $.pixelDensityRatio - ); - }, - - /** - * This function will create multiple polygon paths on the drawing context by provided polygons, - * then clip the context to the paths. - * @param {OpenSeadragon.Point[][]} polygons - an array of polygons. A polygon is an array of OpenSeadragon.Point - * @param {Boolean} useSketch - Whether to use the sketch canvas or not. - */ - clipWithPolygons: function (polygons, useSketch) { - if (!this.useCanvas) { - return; - } - var context = this._getContext(useSketch); - context.beginPath(); - polygons.forEach(function (polygon) { - polygon.forEach(function (coord, i) { - context[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y); - }); - }); - context.clip(); - }, - - /** - * Set the opacity of the drawer. - * @param {Number} opacity - * @returns {OpenSeadragon.Drawer} Chainable. - */ - setOpacity: function( opacity ) { - $.console.error("drawer.setOpacity is deprecated. Use tiledImage.setOpacity instead."); - var world = this.viewer.world; - for (var i = 0; i < world.getItemCount(); i++) { - world.getItemAt( i ).setOpacity( opacity ); - } - return this; - }, - - /** - * Get the opacity of the drawer. - * @returns {Number} - */ - getOpacity: function() { - $.console.error("drawer.getOpacity is deprecated. Use tiledImage.getOpacity instead."); - var world = this.viewer.world; - var maxOpacity = 0; - for (var i = 0; i < world.getItemCount(); i++) { - var opacity = world.getItemAt( i ).getOpacity(); - if ( opacity > maxOpacity ) { - maxOpacity = opacity; - } - } - return maxOpacity; - }, - - // deprecated - needsUpdate: function() { - $.console.error( "[Drawer.needsUpdate] this function is deprecated. Use World.needsDraw instead." ); - return this.viewer.world.needsDraw(); - }, - - // deprecated - numTilesLoaded: function() { - $.console.error( "[Drawer.numTilesLoaded] this function is deprecated. Use TileCache.numTilesLoaded instead." ); - return this.viewer.tileCache.numTilesLoaded(); - }, - - // deprecated - reset: function() { - $.console.error( "[Drawer.reset] this function is deprecated. Use World.resetItems instead." ); - this.viewer.world.resetItems(); - return this; - }, - - // deprecated - update: function() { - $.console.error( "[Drawer.update] this function is deprecated. Use Drawer.clear and World.draw instead." ); - this.clear(); - this.viewer.world.draw(); - return this; - }, - - /** - * @returns {Boolean} True if rotation is supported. - */ - canRotate: function() { - return this.useCanvas; - }, - - /** - * Destroy the drawer (unload current loaded tiles) - */ - destroy: function() { - //force unloading of current canvas (1x1 will be gc later, trick not necessarily needed) - this.canvas.width = 1; - this.canvas.height = 1; - this.sketchCanvas = null; - this.sketchContext = null; - }, - - /** - * Clears the Drawer so it's ready to draw another frame. - */ - clear: function() { - this.canvas.innerHTML = ""; - if ( this.useCanvas ) { - var viewportSize = this._calculateCanvasSize(); - if( this.canvas.width !== viewportSize.x || - this.canvas.height !== viewportSize.y ) { - this.canvas.width = viewportSize.x; - this.canvas.height = viewportSize.y; - this._updateImageSmoothingEnabled(this.context); - if ( this.sketchCanvas !== null ) { - var sketchCanvasSize = this._calculateSketchCanvasSize(); - this.sketchCanvas.width = sketchCanvasSize.x; - this.sketchCanvas.height = sketchCanvasSize.y; - this._updateImageSmoothingEnabled(this.sketchContext); - } - } - this._clear(); - } - }, - - _clear: function (useSketch, bounds) { - if (!this.useCanvas) { - return; - } - var context = this._getContext(useSketch); - if (bounds) { - context.clearRect(bounds.x, bounds.y, bounds.width, bounds.height); - } else { - var canvas = context.canvas; - context.clearRect(0, 0, canvas.width, canvas.height); - } - }, - - /** - * Scale from OpenSeadragon viewer rectangle to drawer rectangle - * (ignoring rotation) - * @param {OpenSeadragon.Rect} rectangle - The rectangle in viewport coordinate system. - * @returns {OpenSeadragon.Rect} Rectangle in drawer coordinate system. - */ - viewportToDrawerRectangle: function(rectangle) { - var topLeft = this.viewport.pixelFromPointNoRotate(rectangle.getTopLeft(), true); - var size = this.viewport.deltaPixelsFromPointsNoRotate(rectangle.getSize(), true); - - return new $.Rect( - topLeft.x * $.pixelDensityRatio, - topLeft.y * $.pixelDensityRatio, - size.x * $.pixelDensityRatio, - size.y * $.pixelDensityRatio - ); - }, - - /** - * Draws the given tile. - * @param {OpenSeadragon.Tile} tile - The tile to draw. - * @param {Function} drawingHandler - Method for firing the drawing event if using canvas. - * drawingHandler({context, tile, rendered}) - * @param {Boolean} useSketch - Whether to use the sketch canvas or not. - * where rendered is the context with the pre-drawn image. - * @param {Float} [scale=1] - Apply a scale to tile position and size. Defaults to 1. - * @param {OpenSeadragon.Point} [translate] A translation vector to offset tile position - * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round - * position and size of tiles supporting alpha channel in non-transparency - * context. - * @param {OpenSeadragon.TileSource} source - The source specification of the tile. - */ - drawTile: function( tile, drawingHandler, useSketch, scale, translate, shouldRoundPositionAndSize, source) { - $.console.assert(tile, '[Drawer.drawTile] tile is required'); - $.console.assert(drawingHandler, '[Drawer.drawTile] drawingHandler is required'); - - if (this.useCanvas) { - var context = this._getContext(useSketch); - scale = scale || 1; - tile.drawCanvas(context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source); - } else { - tile.drawHTML( this.canvas ); - } - }, - - _getContext: function( useSketch ) { - var context = this.context; - if ( useSketch ) { - if (this.sketchCanvas === null) { - this.sketchCanvas = document.createElement( "canvas" ); - var sketchCanvasSize = this._calculateSketchCanvasSize(); - this.sketchCanvas.width = sketchCanvasSize.x; - this.sketchCanvas.height = sketchCanvasSize.y; - this.sketchContext = this.sketchCanvas.getContext( "2d" ); - - // If the viewport is not currently rotated, the sketchCanvas - // will have the same size as the main canvas. However, if - // the viewport get rotated later on, we will need to resize it. - if (this.viewport.getRotation() === 0) { - var self = this; - this.viewer.addHandler('rotate', function resizeSketchCanvas() { - if (self.viewport.getRotation() === 0) { - return; - } - self.viewer.removeHandler('rotate', resizeSketchCanvas); - var sketchCanvasSize = self._calculateSketchCanvasSize(); - self.sketchCanvas.width = sketchCanvasSize.x; - self.sketchCanvas.height = sketchCanvasSize.y; - }); - } - this._updateImageSmoothingEnabled(this.sketchContext); - } - context = this.sketchContext; - } - return context; - }, - - // private - saveContext: function( useSketch ) { - if (!this.useCanvas) { - return; - } - - this._getContext( useSketch ).save(); - }, - - // private - restoreContext: function( useSketch ) { - if (!this.useCanvas) { - return; - } - - this._getContext( useSketch ).restore(); - }, - - // private - setClip: function(rect, useSketch) { - if (!this.useCanvas) { - return; - } - - var context = this._getContext( useSketch ); - context.beginPath(); - context.rect(rect.x, rect.y, rect.width, rect.height); - context.clip(); - }, - - // private - drawRectangle: function(rect, fillStyle, useSketch) { - if (!this.useCanvas) { - return; - } - - var context = this._getContext( useSketch ); - context.save(); - context.fillStyle = fillStyle; - context.fillRect(rect.x, rect.y, rect.width, rect.height); - context.restore(); - }, - - /** - * Blends the sketch canvas in the main canvas. - * @param {Object} options The options - * @param {Float} options.opacity The opacity of the blending. - * @param {Float} [options.scale=1] The scale at which tiles were drawn on - * the sketch. Default is 1. - * Use scale to draw at a lower scale and then enlarge onto the main canvas. - * @param {OpenSeadragon.Point} [options.translate] A translation vector - * that was used to draw the tiles - * @param {String} [options.compositeOperation] - How the image is - * composited onto other images; see compositeOperation in - * {@link OpenSeadragon.Options} for possible values. - * @param {OpenSeadragon.Rect} [options.bounds] The part of the sketch - * canvas to blend in the main canvas. If specified, options.scale and - * options.translate get ignored. - */ - blendSketch: function(opacity, scale, translate, compositeOperation) { - var options = opacity; - if (!$.isPlainObject(options)) { - options = { - opacity: opacity, - scale: scale, - translate: translate, - compositeOperation: compositeOperation - }; - } - if (!this.useCanvas || !this.sketchCanvas) { - return; - } - opacity = options.opacity; - compositeOperation = options.compositeOperation; - var bounds = options.bounds; - - this.context.save(); - this.context.globalAlpha = opacity; - if (compositeOperation) { - this.context.globalCompositeOperation = compositeOperation; - } - if (bounds) { - // Internet Explorer, Microsoft Edge, and Safari have problems - // when you call context.drawImage with negative x or y - // or x + width or y + height greater than the canvas width or height respectively. - if (bounds.x < 0) { - bounds.width += bounds.x; - bounds.x = 0; - } - if (bounds.x + bounds.width > this.canvas.width) { - bounds.width = this.canvas.width - bounds.x; - } - if (bounds.y < 0) { - bounds.height += bounds.y; - bounds.y = 0; - } - if (bounds.y + bounds.height > this.canvas.height) { - bounds.height = this.canvas.height - bounds.y; - } - - this.context.drawImage( - this.sketchCanvas, - bounds.x, - bounds.y, - bounds.width, - bounds.height, - bounds.x, - bounds.y, - bounds.width, - bounds.height - ); - } else { - scale = options.scale || 1; - translate = options.translate; - var position = translate instanceof $.Point ? - translate : new $.Point(0, 0); - - var widthExt = 0; - var heightExt = 0; - if (translate) { - var widthDiff = this.sketchCanvas.width - this.canvas.width; - var heightDiff = this.sketchCanvas.height - this.canvas.height; - widthExt = Math.round(widthDiff / 2); - heightExt = Math.round(heightDiff / 2); - } - this.context.drawImage( - this.sketchCanvas, - position.x - widthExt * scale, - position.y - heightExt * scale, - (this.canvas.width + 2 * widthExt) * scale, - (this.canvas.height + 2 * heightExt) * scale, - -widthExt, - -heightExt, - this.canvas.width + 2 * widthExt, - this.canvas.height + 2 * heightExt - ); - } - this.context.restore(); - }, - - // private - drawDebugInfo: function(tile, count, i, tiledImage) { - if ( !this.useCanvas ) { - return; - } - - var colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length; - var context = this.context; - context.save(); - context.lineWidth = 2 * $.pixelDensityRatio; - context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial'; - context.strokeStyle = this.debugGridColor[colorIndex]; - context.fillStyle = this.debugGridColor[colorIndex]; - - if (this.viewport.getRotation(true) % 360 !== 0 ) { - this._offsetForRotation({degrees: this.viewport.getRotation(true)}); - } - if (tiledImage.getRotation(true) % 360 !== 0) { - this._offsetForRotation({ - degrees: tiledImage.getRotation(true), - point: tiledImage.viewport.pixelFromPointNoRotate( - tiledImage._getRotationPoint(true), true) - }); - } - if (tiledImage.viewport.getRotation(true) % 360 === 0 && - tiledImage.getRotation(true) % 360 === 0) { - if(tiledImage._drawer.viewer.viewport.getFlip()) { - tiledImage._drawer._flip(); - } - } - - context.strokeRect( - tile.position.x * $.pixelDensityRatio, - tile.position.y * $.pixelDensityRatio, - tile.size.x * $.pixelDensityRatio, - tile.size.y * $.pixelDensityRatio - ); - - var tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio; - var tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio; - - // Rotate the text the right way around. - context.translate( tileCenterX, tileCenterY ); - context.rotate( Math.PI / 180 * -this.viewport.getRotation(true) ); - context.translate( -tileCenterX, -tileCenterY ); - - if( tile.x === 0 && tile.y === 0 ){ - context.fillText( - "Zoom: " + this.viewport.getZoom(), - tile.position.x * $.pixelDensityRatio, - (tile.position.y - 30) * $.pixelDensityRatio - ); - context.fillText( - "Pan: " + this.viewport.getBounds().toString(), - tile.position.x * $.pixelDensityRatio, - (tile.position.y - 20) * $.pixelDensityRatio - ); - } - context.fillText( - "Level: " + tile.level, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 20) * $.pixelDensityRatio - ); - context.fillText( - "Column: " + tile.x, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 30) * $.pixelDensityRatio - ); - context.fillText( - "Row: " + tile.y, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 40) * $.pixelDensityRatio - ); - context.fillText( - "Order: " + i + " of " + count, - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 50) * $.pixelDensityRatio - ); - context.fillText( - "Size: " + tile.size.toString(), - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 60) * $.pixelDensityRatio - ); - context.fillText( - "Position: " + tile.position.toString(), - (tile.position.x + 10) * $.pixelDensityRatio, - (tile.position.y + 70) * $.pixelDensityRatio - ); - - if (this.viewport.getRotation(true) % 360 !== 0 ) { - this._restoreRotationChanges(); - } - if (tiledImage.getRotation(true) % 360 !== 0) { - this._restoreRotationChanges(); - } - - if (tiledImage.viewport.getRotation(true) % 360 === 0 && - tiledImage.getRotation(true) % 360 === 0) { - if(tiledImage._drawer.viewer.viewport.getFlip()) { - tiledImage._drawer._flip(); - } - } - - context.restore(); - }, - - // private - debugRect: function(rect) { - if ( this.useCanvas ) { - var context = this.context; - context.save(); - context.lineWidth = 2 * $.pixelDensityRatio; - context.strokeStyle = this.debugGridColor[0]; - context.fillStyle = this.debugGridColor[0]; - - context.strokeRect( - rect.x * $.pixelDensityRatio, - rect.y * $.pixelDensityRatio, - rect.width * $.pixelDensityRatio, - rect.height * $.pixelDensityRatio - ); - - context.restore(); - } - }, - - /** - * Turns image smoothing on or off for this viewer. Note: Ignored in some (especially older) browsers that do not support this property. - * - * @function - * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is - * drawn smoothly on the canvas; see imageSmoothingEnabled in - * {@link OpenSeadragon.Options} for more explanation. - */ - setImageSmoothingEnabled: function(imageSmoothingEnabled){ - if ( this.useCanvas ) { - this._imageSmoothingEnabled = imageSmoothingEnabled; - this._updateImageSmoothingEnabled(this.context); - this.viewer.forceRedraw(); - } - }, - - // private - _updateImageSmoothingEnabled: function(context){ - context.msImageSmoothingEnabled = this._imageSmoothingEnabled; - context.imageSmoothingEnabled = this._imageSmoothingEnabled; - }, - - /** - * Get the canvas size - * @param {Boolean} sketch If set to true return the size of the sketch canvas - * @returns {OpenSeadragon.Point} The size of the canvas - */ - getCanvasSize: function(sketch) { - var canvas = this._getContext(sketch).canvas; - return new $.Point(canvas.width, canvas.height); - }, - - getCanvasCenter: function() { - return new $.Point(this.canvas.width / 2, this.canvas.height / 2); - }, - - // private - _offsetForRotation: function(options) { - var point = options.point ? - options.point.times($.pixelDensityRatio) : - this.getCanvasCenter(); - - var context = this._getContext(options.useSketch); - context.save(); - - context.translate(point.x, point.y); - if(this.viewer.viewport.flipped){ - context.rotate(Math.PI / 180 * -options.degrees); - context.scale(-1, 1); - } else{ - context.rotate(Math.PI / 180 * options.degrees); - } - context.translate(-point.x, -point.y); - }, - - // private - _flip: function(options) { - options = options || {}; - var point = options.point ? - options.point.times($.pixelDensityRatio) : - this.getCanvasCenter(); - var context = this._getContext(options.useSketch); - - context.translate(point.x, 0); - context.scale(-1, 1); - context.translate(-point.x, 0); - }, - - // private - _restoreRotationChanges: function(useSketch) { - var context = this._getContext(useSketch); - context.restore(); - }, - - // private - _calculateCanvasSize: function() { - var pixelDensityRatio = $.pixelDensityRatio; - var viewportSize = this.viewport.getContainerSize(); - return { - // canvas width and height are integers - x: Math.round(viewportSize.x * pixelDensityRatio), - y: Math.round(viewportSize.y * pixelDensityRatio) - }; - }, - - // private - _calculateSketchCanvasSize: function() { - var canvasSize = this._calculateCanvasSize(); - if (this.viewport.getRotation() === 0) { - return canvasSize; - } - // If the viewport is rotated, we need a larger sketch canvas in order - // to support edge smoothing. - var sketchCanvasSize = Math.ceil(Math.sqrt( - canvasSize.x * canvasSize.x + - canvasSize.y * canvasSize.y)); - return { - x: sketchCanvasSize, - y: sketchCanvasSize - }; - } -}; - -}( OpenSeadragon )); diff --git a/src/drawerbase.js b/src/drawerbase.js new file mode 100644 index 00000000..3206c69f --- /dev/null +++ b/src/drawerbase.js @@ -0,0 +1,285 @@ +/* + * OpenSeadragon - DrawerBase + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + +/** + * @class DrawerBase + * @memberof OpenSeadragon + * @classdesc Base class for Drawers that handle rendering of tiles for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + */ + +$.DrawerBase = class DrawerBase{ + constructor(options){ + $.console.assert( options.viewer, "[Drawer] options.viewer is required" ); + $.console.assert( options.viewport, "[Drawer] options.viewport is required" ); + $.console.assert( options.element, "[Drawer] options.element is required" ); + + this.viewer = options.viewer; + this.viewport = options.viewport; + this.debugGridColor = typeof options.debugGridColor === 'string' ? [options.debugGridColor] : options.debugGridColor || $.DEFAULT_SETTINGS.debugGridColor; + this.options = options.options || {}; + + /** + * The parent element of this Drawer instance, passed in when the Drawer was created. + * The parent of {@link OpenSeadragon.DrawerBase#canvas}. + * @member {Element} container + * @memberof OpenSeadragon.DrawerBase# + */ + this.container = $.getElement( options.element ); + + this._renderingTarget = this._createDrawingElement(); + + + this.canvas.style.width = "100%"; + this.canvas.style.height = "100%"; + this.canvas.style.position = "absolute"; + // set canvas.style.left = 0 so the canvas is positioned properly in ltr and rtl html + this.canvas.style.left = "0"; + $.setElementOpacity( this.canvas, this.viewer.opacity, true ); + + // Allow pointer events to pass through the canvas element so implicit + // pointer capture works on touch devices + $.setElementPointerEventsNone( this.canvas ); + $.setElementTouchActionNone( this.canvas ); + + // explicit left-align + this.container.style.textAlign = "left"; + this.container.appendChild( this.canvas ); + + this._checkForAPIOverrides(); + } + + // protect the canvas member with a getter + get canvas(){ + return this._renderingTarget; + } + get element(){ + $.console.error('Drawer.element is deprecated. Use Drawer.container instead.'); + return this.container; + } + + /** + * @returns {String|undefined} What type of drawer this is. + */ + getType(){ + $.console.error('Drawer.getType must be implemented by child class'); + return undefined; + } + + /** + * @returns {Boolean} whether the drawer implementation is supported by the browser + */ + static isSupported() { + $.console.error('Drawer.isSupported must be implemented by child class'); + } + + /** + * create the HTML element (e.g. canvas, div) that the image will be drawn into + * @returns {Element} the element to draw into + */ + _createDrawingElement() { + $.console.error('Drawer._createDrawingElement must be implemented by child class'); + return null; + } + + /** + * @param {Array} tiledImages - An array of TiledImages that are ready to be drawn + */ + draw(tiledImages) { + $.console.error('Drawer.draw must be implemented by child class'); + } + + /** + * @returns {Boolean} True if rotation is supported. + */ + canRotate() { + $.console.error('Drawer.canRotate must be implemented by child class'); + } + + /** + * Destroy the drawer (unload current loaded tiles) + */ + destroy() { + $.console.error('Drawer.destroy must be implemented by child class'); + } + + /** + * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. + */ + minimumOverlapRequired() { + return false; + } + + + /** + * Turns image smoothing on or off for this viewer. Note: Ignored in some (especially older) browsers that do not support this property. + * + * @function + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(imageSmoothingEnabled){ + $.console.error('Drawer.setImageSmoothingEnabled must be implemented by child class'); + } + + /** + * Optional public API to draw a rectangle (e.g. for debugging purposes) + * Child classes can override this method if they wish to support this + * @param {OpenSeadragon.Rect} rect + */ + drawDebuggingRect(rect) { + $.console.warn('[drawer].drawDebuggingRect is not implemented by this drawer'); + } + + // Deprecated functions + clear(){ + $.console.warn('[drawer].clear() is deprecated. The drawer is responsible for clearing itself as needed before drawing tiles.'); + } + + // Private functions + + /** + * Ensures that child classes have provided implementations for public API methods + * draw, canRotate, destroy, and setImageSmoothinEnabled. Throws an exception if the original + * placeholder methods are still in place. + * @private + * + */ + _checkForAPIOverrides(){ + if(this._createDrawingElement === $.DrawerBase.prototype._createDrawingElement){ + throw(new Error("[drawer]._createDrawingElement must be implemented by child class")); + } + if(this.draw === $.DrawerBase.prototype.draw){ + throw(new Error("[drawer].draw must be implemented by child class")); + } + if(this.canRotate === $.DrawerBase.prototype.canRotate){ + throw(new Error("[drawer].canRotate must be implemented by child class")); + } + if(this.destroy === $.DrawerBase.prototype.destroy){ + throw(new Error("[drawer].destroy must be implemented by child class")); + } + if(this.setImageSmoothingEnabled === $.DrawerBase.prototype.setImageSmoothingEnabled){ + throw(new Error("[drawer].setImageSmoothingEnabled must be implemented by child class")); + } + } + + + // Utility functions + + /** + * Scale from OpenSeadragon viewer rectangle to drawer rectangle + * (ignoring rotation) + * @param {OpenSeadragon.Rect} rectangle - The rectangle in viewport coordinate system. + * @returns {OpenSeadragon.Rect} Rectangle in drawer coordinate system. + */ + viewportToDrawerRectangle(rectangle) { + var topLeft = this.viewport.pixelFromPointNoRotate(rectangle.getTopLeft(), true); + var size = this.viewport.deltaPixelsFromPointsNoRotate(rectangle.getSize(), true); + + return new $.Rect( + topLeft.x * $.pixelDensityRatio, + topLeft.y * $.pixelDensityRatio, + size.x * $.pixelDensityRatio, + size.y * $.pixelDensityRatio + ); + } + + /** + * This function converts the given point from to the drawer coordinate by + * multiplying it with the pixel density. + * This function does not take rotation into account, thus assuming provided + * point is at 0 degree. + * @param {OpenSeadragon.Point} point - the pixel point to convert + * @returns {OpenSeadragon.Point} Point in drawer coordinate system. + */ + viewportCoordToDrawerCoord(point) { + var vpPoint = this.viewport.pixelFromPointNoRotate(point, true); + return new $.Point( + vpPoint.x * $.pixelDensityRatio, + vpPoint.y * $.pixelDensityRatio + ); + } + + + // Internal utility functions + + /** + * Calculate width and height of the canvas based on viewport dimensions + * and pixelDensityRatio + * @private + * @returns {OpenSeadragon.Point} {x, y} size of the canvas + */ + _calculateCanvasSize() { + var pixelDensityRatio = $.pixelDensityRatio; + var viewportSize = this.viewport.getContainerSize(); + return new OpenSeadragon.Point( Math.round(viewportSize.x * pixelDensityRatio), Math.round(viewportSize.y * pixelDensityRatio)); + } + + /** + * Called by implementations to fire the tiled-image-drawn event (used by tests) + * @private + */ + _raiseTiledImageDrawnEvent(tiledImage, tiles){ + if(!this.viewer) { + return; + } + + /** + * Raised when a tiled image is drawn to the canvas. Used internally for testing. + * The update-viewport event is preferred if you want to know when a frame has been drawn. + * + * @event tiled-image-drawn + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {Array} tiles - An array of Tile objects that were drawn. + * @property {?Object} userData - Arbitrary subscriber-defined object. + * @private + */ + this.viewer.raiseEvent( 'tiled-image-drawn', { + tiledImage: tiledImage, + tiles: tiles, + }); + } + +}; + +}( OpenSeadragon )); diff --git a/src/dzitilesource.js b/src/dzitilesource.js index 57b9f5a3..5ee28bfc 100644 --- a/src/dzitilesource.js +++ b/src/dzitilesource.js @@ -2,7 +2,7 @@ * OpenSeadragon - DziTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/eventsource.js b/src/eventsource.js index 94949f0f..0edea517 100644 --- a/src/eventsource.js +++ b/src/eventsource.js @@ -2,7 +2,7 @@ * OpenSeadragon - EventSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -51,6 +51,7 @@ */ $.EventSource = function() { this.events = {}; + this._rejectedEventList = {}; }; /** @lends OpenSeadragon.EventSource.prototype */ @@ -68,6 +69,7 @@ $.EventSource.prototype = { * @param {Number} [times=1] - The number of times to handle the event * before removing it. * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. + * @returns {Boolean} - True if the handler was added, false if it was rejected */ addOnceHandler: function(eventName, handler, userData, times, priority) { var self = this; @@ -80,7 +82,7 @@ $.EventSource.prototype = { } return handler(event); }; - this.addHandler(eventName, onceHandler, userData, priority); + return this.addHandler(eventName, onceHandler, userData, priority); }, /** @@ -90,8 +92,15 @@ $.EventSource.prototype = { * @param {OpenSeadragon.EventHandler} handler - Function to call when event is triggered. * @param {Object} [userData=null] - Arbitrary object to be passed unchanged to the handler. * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. + * @returns {Boolean} - True if the handler was added, false if it was rejected */ addHandler: function ( eventName, handler, userData, priority ) { + + if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){ + $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`); + return false; + } + var events = this.events[ eventName ]; if ( !events ) { this.events[ eventName ] = events = []; @@ -106,6 +115,7 @@ $.EventSource.prototype = { index--; } } + return true; }, /** @@ -191,17 +201,45 @@ $.EventSource.prototype = { * @function * @param {String} eventName - Name of event to register. * @param {Object} eventArgs - Event-specific data. + * @returns {Boolean} True if the event was fired, false if it was rejected because of rejectEventHandler(eventName) */ raiseEvent: function( eventName, eventArgs ) { //uncomment if you want to get a log of all events //$.console.log( eventName ); + if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){ + $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`); + return false; + } + var handler = this.getHandler( eventName ); if ( handler ) { - return handler( this, eventArgs || {} ); + handler( this, eventArgs || {} ); } - return undefined; + return true; + }, + + /** + * Set an event name as being disabled, and provide an optional error message + * to be printed to the console + * @param {String} eventName - Name of the event + * @param {String} [errorMessage] - Optional string to print to the console + * @private + */ + rejectEventHandler(eventName, errorMessage = ''){ + this._rejectedEventList[eventName] = errorMessage; + }, + + /** + * Explicitly allow an event handler to be added for this event type, undoing + * the effects of rejectEventHandler + * @param {String} eventName - Name of the event + * @private + */ + allowEventHandler(eventName){ + delete this._rejectedEventList[eventName]; } + }; }( OpenSeadragon )); diff --git a/src/fullscreen.js b/src/fullscreen.js index 1b80464c..4af8630d 100644 --- a/src/fullscreen.js +++ b/src/fullscreen.js @@ -2,7 +2,7 @@ * OpenSeadragon - full-screen support functions * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/htmldrawer.js b/src/htmldrawer.js new file mode 100644 index 00000000..a9030702 --- /dev/null +++ b/src/htmldrawer.js @@ -0,0 +1,242 @@ +/* + * OpenSeadragon - HTMLDrawer + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + +/** + * @class HTMLDrawer + * @memberof OpenSeadragon + * @classdesc HTML-based implementation of DrawerBase for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + +class HTMLDrawer extends $.DrawerBase{ + constructor(options){ + super(options); + + // Reject listening for the tile-drawing event, which this drawer does not fire + this.viewer.rejectEventHandler("tile-drawing", "The HTMLDrawer does not raise the tile-drawing event"); + // Since the tile-drawn event is fired by this drawer, make sure handlers can be added for it + this.viewer.allowEventHandler("tile-drawn"); + } + + /** + * @returns {Boolean} always true + */ + static isSupported(){ + return true; + } + + getType(){ + return 'html'; + } + + /** + * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. + */ + minimumOverlapRequired() { + return true; + } + + /** + * create the HTML element (e.g. canvas, div) that the image will be drawn into + * @returns {Element} the div to draw into + */ + _createDrawingElement(){ + let canvas = $.makeNeutralElement("div"); + return canvas; + } + + /** + * Draws the TiledImages + */ + draw(tiledImages) { + var _this = this; + this._prepareNewFrame(); // prepare to draw a new frame + tiledImages.forEach(function(tiledImage){ + if (tiledImage.opacity !== 0 || tiledImage._preload) { + _this._drawTiles(tiledImage); + } + }); + + } + + /** + * @returns {Boolean} False - rotation is not supported. + */ + canRotate() { + return false; + } + + /** + * Destroy the drawer (unload current loaded tiles) + */ + destroy() { + this.canvas.innerHTML = ""; + } + + /** + * Turns image smoothing on or off for this viewer. Note: Ignored by HTML Drawer + * + * @function + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(){ + // noop - HTML Drawer does not deal with this property + } + + /** + * Clears the Drawer so it's ready to draw another frame. + * @private + * + */ + _prepareNewFrame() { + this.canvas.innerHTML = ""; + } + + /** + * Draws a TiledImage. + * @private + * + */ + _drawTiles( tiledImage ) { + var lastDrawn = tiledImage.getTilesToDraw().map(info => info.tile); + if (tiledImage.opacity === 0 || (lastDrawn.length === 0 && !tiledImage.placeholderFillStyle)) { + return; + } + + // Iterate over the tiles to draw, and draw them + for (var i = lastDrawn.length - 1; i >= 0; i--) { + var tile = lastDrawn[ i ]; + this._drawTile( tile ); + + if( this.viewer ){ + /** + * Raised when a tile is drawn to the canvas. Only valid for + * context2d and html drawers. + * + * @event tile-drawn + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {OpenSeadragon.Tile} tile + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent( 'tile-drawn', { + tiledImage: tiledImage, + tile: tile + }); + } + } + + } + + /** + * Draws the given tile. + * @private + * @param {OpenSeadragon.Tile} tile - The tile to draw. + * @param {Function} drawingHandler - Method for firing the drawing event if using canvas. + * drawingHandler({context, tile, rendered}) + */ + _drawTile( tile ) { + $.console.assert(tile, '[Drawer._drawTile] tile is required'); + + let container = this.canvas; + + if (!tile.cacheImageRecord) { + $.console.warn( + '[Drawer._drawTileToHTML] attempting to draw tile %s when it\'s not cached', + tile.toString()); + return; + } + + if ( !tile.loaded ) { + $.console.warn( + "Attempting to draw tile %s when it's not yet loaded.", + tile.toString() + ); + return; + } + + //EXPERIMENTAL - trying to figure out how to scale the container + // content during animation of the container size. + + if ( !tile.element ) { + var image = tile.getImage(); + if (!image) { + return; + } + + tile.element = $.makeNeutralElement( "div" ); + tile.imgElement = image.cloneNode(); + tile.imgElement.style.msInterpolationMode = "nearest-neighbor"; + tile.imgElement.style.width = "100%"; + tile.imgElement.style.height = "100%"; + + tile.style = tile.element.style; + tile.style.position = "absolute"; + } + + if ( tile.element.parentNode !== container ) { + container.appendChild( tile.element ); + } + if ( tile.imgElement.parentNode !== tile.element ) { + tile.element.appendChild( tile.imgElement ); + } + + tile.style.top = tile.position.y + "px"; + tile.style.left = tile.position.x + "px"; + tile.style.height = tile.size.y + "px"; + tile.style.width = tile.size.x + "px"; + + if (tile.flipped) { + tile.style.transform = "scaleX(-1)"; + } + + $.setElementOpacity( tile.element, tile.opacity ); + } + +} + +$.HTMLDrawer = HTMLDrawer; + + +}( OpenSeadragon )); diff --git a/src/iiiftilesource.js b/src/iiiftilesource.js index 54ec073f..1433a968 100644 --- a/src/iiiftilesource.js +++ b/src/iiiftilesource.js @@ -2,7 +2,7 @@ * OpenSeadragon - IIIFTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/imageloader.js b/src/imageloader.js index e66bd820..00e413c1 100644 --- a/src/imageloader.js +++ b/src/imageloader.js @@ -2,7 +2,7 @@ * OpenSeadragon - ImageLoader * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -37,6 +37,8 @@ /** * @class ImageJob * @classdesc Handles downloading of a single image. + * + * @memberof OpenSeadragon * @param {Object} options - Options for this ImageJob. * @param {String} [options.src] - URL of image to download. * @param {Tile} [options.tile] - Tile that belongs the data to. @@ -87,6 +89,7 @@ $.ImageJob.prototype = { /** * Starts the image job. * @method + * @memberof OpenSeadragon.ImageJob# */ start: function() { this.tries++; @@ -113,6 +116,7 @@ $.ImageJob.prototype = { * @param {*} data data that has been downloaded * @param {XMLHttpRequest} request reference to the request if used * @param {string} errorMessage description upon failure + * @memberof OpenSeadragon.ImageJob# */ finish: function(data, request, errorMessage ) { this.data = data; diff --git a/src/imagetilesource.js b/src/imagetilesource.js index e3b7a43b..4ebc7591 100644 --- a/src/imagetilesource.js +++ b/src/imagetilesource.js @@ -2,7 +2,7 @@ * OpenSeadragon - ImageTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -42,8 +42,8 @@ * 1. viewer.open({type: 'image', url: fooUrl}); * 2. viewer.open(new OpenSeadragon.ImageTileSource({url: fooUrl})); * - * With the first syntax, the crossOriginPolicy, ajaxWithCredentials and - * useCanvas options are inherited from the viewer if they are not + * With the first syntax, the crossOriginPolicy and ajaxWithCredentials + * options are inherited from the viewer if they are not * specified directly in the options object. * * @memberof OpenSeadragon @@ -58,16 +58,13 @@ * domains. * @param {String|Boolean} [options.ajaxWithCredentials=false] Whether to set * the withCredentials XHR flag for AJAX requests (when loading tile sources). - * @param {Boolean} [options.useCanvas=true] Set to false to prevent any use - * of the canvas API. */ $.ImageTileSource = function (options) { options = $.extend({ buildPyramid: true, crossOriginPolicy: false, - ajaxWithCredentials: false, - useCanvas: true + ajaxWithCredentials: false }, options); $.TileSource.apply(this, [options]); @@ -198,9 +195,11 @@ /** * Destroys ImageTileSource * @function + * @param {OpenSeadragon.Viewer} viewer the viewer that is calling + * destroy on the ImageTileSource */ - destroy: function () { - this._freeupCanvasMemory(); + destroy: function (viewer) { + this._freeupCanvasMemory(viewer); }, // private @@ -214,7 +213,7 @@ height: this._image.naturalHeight }]; - if (!this.buildPyramid || !$.supportsCanvas || !this.useCanvas) { + if (!this.buildPyramid || !$.supportsCanvas) { // We don't need the image anymore. Allows it to be GC. delete this._image; return levels; @@ -270,11 +269,26 @@ * and Safari keeps canvas until its height and width will be set to 0). * @function */ - _freeupCanvasMemory: function () { + _freeupCanvasMemory: function (viewer) { for (var i = 0; i < this.levels.length; i++) { if(this.levels[i].context2D){ this.levels[i].context2D.canvas.height = 0; this.levels[i].context2D.canvas.width = 0; + + if(viewer){ + /** + * Triggered when an image has just been unloaded + * + * @event image-unloaded + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {CanvasRenderingContext2D} context2D - The context that is being unloaded + */ + viewer.raiseEvent("image-unloaded", { + context2D: this.levels[i].context2D + }); + } + } } }, diff --git a/src/legacytilesource.js b/src/legacytilesource.js index e80b931f..f500d728 100644 --- a/src/legacytilesource.js +++ b/src/legacytilesource.js @@ -2,7 +2,7 @@ * OpenSeadragon - LegacyTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/matrix3.js b/src/matrix3.js new file mode 100644 index 00000000..b4ba0f9b --- /dev/null +++ b/src/matrix3.js @@ -0,0 +1,209 @@ +/* + * OpenSeadragon - Mat3 + * + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + + +/* + * Portions of this source file are taken from WegGL Fundamentals: + * + * Copyright 2012, Gregg Tavares. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Gregg Tavares. nor the names of his + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ + + + + +(function( $ ){ + +// Modified from https://webglfundamentals.org/webgl/lessons/webgl-2d-matrices.html + +/** + * + * + * @class Mat3 + * @classdesc A left-to-right matrix representation, useful for affine transforms for + * positioning tiles for drawing + * + * @memberof OpenSeadragon + * + * @param {Array} [values] - Initial values for the matrix + * + **/ +class Mat3{ + constructor(values){ + if(!values) { + values = [ + 0, 0, 0, + 0, 0, 0, + 0, 0, 0 + ]; + } + this.values = values; + } + + /** + * @function makeIdentity + * @memberof OpenSeadragon.Mat3 + * @static + * @returns {OpenSeadragon.Mat3} an identity matrix + */ + static makeIdentity(){ + return new Mat3([ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + ]); + } + + /** + * @function makeTranslation + * @memberof OpenSeadragon.Mat3 + * @static + * @param {Number} tx The x value of the translation + * @param {Number} ty The y value of the translation + * @returns {OpenSeadragon.Mat3} A translation matrix + */ + static makeTranslation(tx, ty) { + return new Mat3([ + 1, 0, 0, + 0, 1, 0, + tx, ty, 1, + ]); + } + + /** + * @function makeRotation + * @memberof OpenSeadragon.Mat3 + * @static + * @param {Number} angleInRadians The desired rotation angle, in radians + * @returns {OpenSeadragon.Mat3} A rotation matrix + */ + static makeRotation(angleInRadians) { + var c = Math.cos(angleInRadians); + var s = Math.sin(angleInRadians); + return new Mat3([ + c, -s, 0, + s, c, 0, + 0, 0, 1, + ]); + } + + /** + * @function makeScaling + * @memberof OpenSeadragon.Mat3 + * @static + * @param {Number} sx The x value of the scaling + * @param {Number} sy The y value of the scaling + * @returns {OpenSeadragon.Mat3} A scaling matrix + */ + static makeScaling(sx, sy) { + return new Mat3([ + sx, 0, 0, + 0, sy, 0, + 0, 0, 1, + ]); + } + + /** + * @alias multiply + * @memberof! OpenSeadragon.Mat3 + * @param {OpenSeadragon.Mat3} other the matrix to multiply with + * @returns {OpenSeadragon.Mat3} The result of matrix multiplication + */ + multiply(other) { + let a = this.values; + let b = other.values; + + var a00 = a[0 * 3 + 0]; + var a01 = a[0 * 3 + 1]; + var a02 = a[0 * 3 + 2]; + var a10 = a[1 * 3 + 0]; + var a11 = a[1 * 3 + 1]; + var a12 = a[1 * 3 + 2]; + var a20 = a[2 * 3 + 0]; + var a21 = a[2 * 3 + 1]; + var a22 = a[2 * 3 + 2]; + var b00 = b[0 * 3 + 0]; + var b01 = b[0 * 3 + 1]; + var b02 = b[0 * 3 + 2]; + var b10 = b[1 * 3 + 0]; + var b11 = b[1 * 3 + 1]; + var b12 = b[1 * 3 + 2]; + var b20 = b[2 * 3 + 0]; + var b21 = b[2 * 3 + 1]; + var b22 = b[2 * 3 + 2]; + return new Mat3([ + b00 * a00 + b01 * a10 + b02 * a20, + b00 * a01 + b01 * a11 + b02 * a21, + b00 * a02 + b01 * a12 + b02 * a22, + b10 * a00 + b11 * a10 + b12 * a20, + b10 * a01 + b11 * a11 + b12 * a21, + b10 * a02 + b11 * a12 + b12 * a22, + b20 * a00 + b21 * a10 + b22 * a20, + b20 * a01 + b21 * a11 + b22 * a21, + b20 * a02 + b21 * a12 + b22 * a22, + ]); + } +} + + +$.Mat3 = Mat3; + +}( OpenSeadragon )); diff --git a/src/mousetracker.js b/src/mousetracker.js index 57d89d5b..2a0b6a3c 100644 --- a/src/mousetracker.js +++ b/src/mousetracker.js @@ -2,7 +2,7 @@ * OpenSeadragon - MouseTracker * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -357,7 +357,7 @@ getActivePointersListByType: function ( type ) { var delegate = THIS[ this.hash ], i, - len = delegate.activePointersLists.length, + len = delegate ? delegate.activePointersLists.length : 0, list; for ( i = 0; i < len; i++ ) { @@ -367,7 +367,9 @@ } list = new $.MouseTracker.GesturePointList( type ); - delegate.activePointersLists.push( list ); + if(delegate){ + delegate.activePointersLists.push( list ); + } return list; }, diff --git a/src/navigator.js b/src/navigator.js index 0665b58d..6a213624 100644 --- a/src/navigator.js +++ b/src/navigator.js @@ -2,7 +2,7 @@ * OpenSeadragon - Navigator * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -170,9 +170,6 @@ $.Navigator = function( options ){ style.border = borderWidth + 'px solid ' + options.displayRegionColor; style.margin = '0px'; style.padding = '0px'; - //TODO: IE doesn't like this property being set - //try{ style.outline = '2px auto #909'; }catch(e){/*ignore*/} - style.background = 'transparent'; // We use square bracket notation on the statement below, because float is a keyword. @@ -310,7 +307,7 @@ $.extend( $.Navigator.prototype, $.EventSource.prototype, $.Viewer.prototype, /* this.viewport.resize( containerSize, true ); this.viewport.goHome(true); this.oldContainerSize = containerSize; - this.drawer.clear(); + this.world.update(); this.world.draw(); } } diff --git a/src/openseadragon.js b/src/openseadragon.js index ffe198a7..66036afa 100644 --- a/src/openseadragon.js +++ b/src/openseadragon.js @@ -2,7 +2,7 @@ * OpenSeadragon * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -190,6 +190,16 @@ * Zoom level to use when image is first opened or the home button is clicked. * If 0, adjusts to fit viewer. * + * @property {String|DrawerImplementation|Array} [drawer = ['webgl', 'canvas', 'html']] + * Which drawer to use. Valid strings are 'webgl', 'canvas', and 'html'. Valid drawer + * implementations are constructors of classes that extend OpenSeadragon.DrawerBase. + * An array of strings and/or constructors can be used to indicate the priority + * of different implementations, which will be tried in order based on browser support. + * + * @property {Object} drawerOptions + * Options to pass to the selected drawer implementation. For details + * please see {@link OpenSeadragon.DrawerOptions}. + * * @property {Number} [opacity=1] * Default proportional opacity of the tiled images (1=opaque, 0=hidden) * Hidden images do not draw and only load when preloading is allowed. @@ -204,9 +214,9 @@ * For complete list of modes, please @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation/ globalCompositeOperation} * * @property {Boolean} [imageSmoothingEnabled=true] - * Image smoothing for canvas rendering (only if canvas is used). Note: Ignored + * Image smoothing for canvas rendering (only if the canvas drawer is used). Note: Ignored * by some (especially older) browsers which do not support this canvas property. - * This property can be changed in {@link Viewer.Drawer.setImageSmoothingEnabled}. + * This property can be changed in {@link Viewer.DrawerBase.setImageSmoothingEnabled}. * * @property {String|CanvasGradient|CanvasPattern|Function} [placeholderFillStyle=null] * Draws a colored rectangle behind the tile if it is not loaded yet. @@ -508,7 +518,7 @@ * Milliseconds to wait after each tile retry if tileRetryMax is set. * * @property {Boolean} [useCanvas=true] - * Set to false to not use an HTML canvas element for image rendering even if canvas is supported. + * Deprecated. Use the `drawer` option to specify preferred renderer. * * @property {Number} [minPixelRatio=0.5] * The higher the minPixelRatio, the lower the quality of the image that @@ -744,6 +754,16 @@ * */ + /** + * @typedef {Object} DrawerOptions + * @memberof OpenSeadragon + * @property {Object} webgl - options if the WebGLDrawer is used. No options are currently supported. + * @property {Object} canvas - options if the CanvasDrawer is used. No options are currently supported. + * @property {Object} html - options if the HTMLDrawer is used. No options are currently supported. + * @property {Object} custom - options if a custom drawer is used. No options are currently supported. + */ + + /** * The names for the image resources used for the image navigation buttons. * @@ -1339,12 +1359,32 @@ function OpenSeadragon( options ){ flipped: false, // APPEARANCE - opacity: 1, - preload: false, - compositeOperation: null, - imageSmoothingEnabled: true, - placeholderFillStyle: null, - subPixelRoundingForTransparency: null, + opacity: 1, // to be passed into each TiledImage + compositeOperation: null, // to be passed into each TiledImage + + // DRAWER SETTINGS + drawer: ['webgl', 'canvas', 'html'], // prefer using webgl, then canvas (i.e. context2d), then fallback to html + + drawerOptions: { + webgl: { + + }, + canvas: { + + }, + html: { + + }, + custom: { + + } + }, + + // TILED IMAGE SETTINGS + preload: false, // to be passed into each TiledImage + imageSmoothingEnabled: true, // to be passed into each TiledImage + placeholderFillStyle: null, // to be passed into each TiledImage + subPixelRoundingForTransparency: null, // to be passed into each TiledImage //REFERENCE STRIP SETTINGS showReferenceStrip: false, @@ -1367,7 +1407,6 @@ function OpenSeadragon( options ){ imageLoaderLimit: 0, maxImageCacheCount: 200, timeout: 30000, - useCanvas: true, // Use canvas element for drawing if available tileRetryMax: 0, tileRetryDelay: 2500, @@ -1437,16 +1476,6 @@ function OpenSeadragon( options ){ }, - - /** - * TODO: get rid of this. I can't see how it's required at all. Looks - * like an early legacy code artifact. - * @static - * @ignore - */ - SIGNAL: "----seadragon----", - - /** * Returns a function which invokes the method as if it were a method belonging to the object. * @function diff --git a/src/osmtilesource.js b/src/osmtilesource.js index 43e525ab..4f9d1f35 100644 --- a/src/osmtilesource.js +++ b/src/osmtilesource.js @@ -2,7 +2,7 @@ * OpenSeadragon - OsmTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/overlay.js b/src/overlay.js index bc25a051..7dec6a6b 100644 --- a/src/overlay.js +++ b/src/overlay.js @@ -2,7 +2,7 @@ * OpenSeadragon - Overlay * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/placement.js b/src/placement.js index 561d5daf..2db3e2b1 100644 --- a/src/placement.js +++ b/src/placement.js @@ -1,7 +1,7 @@ /* * OpenSeadragon - Placement * - * Copyright (C) 2010-2016 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/point.js b/src/point.js index 8df11d63..cc831b26 100644 --- a/src/point.js +++ b/src/point.js @@ -2,7 +2,7 @@ * OpenSeadragon - Point * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/profiler.js b/src/profiler.js index d0ffc2b2..01566bc0 100644 --- a/src/profiler.js +++ b/src/profiler.js @@ -2,7 +2,7 @@ * OpenSeadragon - Profiler * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/rectangle.js b/src/rectangle.js index fdeab18f..d154691d 100644 --- a/src/rectangle.js +++ b/src/rectangle.js @@ -2,7 +2,7 @@ * OpenSeadragon - Rect * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/referencestrip.js b/src/referencestrip.js index ab21da50..1f9bb35b 100644 --- a/src/referencestrip.js +++ b/src/referencestrip.js @@ -2,7 +2,7 @@ * OpenSeadragon - ReferenceStrip * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -46,7 +46,7 @@ var THIS = {}; * * This idea is a reexpression of the idea of dzi collections * which allows a clearer algorithm to reuse the tile sources already - * supported by OpenSeadragon, in heterogenious or homogenious + * supported by OpenSeadragon, in heterogeneous or homogeneous * sequences just like mixed groups already supported by the viewer * for the purpose of image sequnces. * @@ -455,7 +455,7 @@ function loadPanels( strip, viewerSize, scroll ) { animationTime: 0, loadTilesWithAjax: strip.viewer.loadTilesWithAjax, ajaxHeaders: strip.viewer.ajaxHeaders, - useCanvas: strip.useCanvas + drawer: 'canvas', //always use canvas for the reference strip } ); // Allow pointer events to pass through miniViewer's canvas/container // elements so implicit pointer capture works on touch devices diff --git a/src/spring.js b/src/spring.js index 12592889..f65f076e 100644 --- a/src/spring.js +++ b/src/spring.js @@ -2,7 +2,7 @@ * OpenSeadragon - Spring * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -206,7 +206,8 @@ $.Spring.prototype = { /** * @function - * @returns true if the value got updated, false otherwise + * @returns true if the spring is still updating its value, false if it is + * already at the target value. */ update: function() { this.current.time = $.now(); @@ -230,14 +231,13 @@ $.Spring.prototype = { ( this.target.time - this.start.time ) ); - var oldValue = this.current.value; if (this._exponential) { this.current.value = Math.exp(currentValue); } else { this.current.value = currentValue; } - return oldValue !== this.current.value; + return currentValue !== targetValue; }, /** diff --git a/src/strings.js b/src/strings.js index e423bef9..9d2b30d7 100644 --- a/src/strings.js +++ b/src/strings.js @@ -2,7 +2,7 @@ * OpenSeadragon - getString/setString * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/tile.js b/src/tile.js index 201034fe..0b6d5fb2 100644 --- a/src/tile.js +++ b/src/tile.js @@ -2,7 +2,7 @@ * OpenSeadragon - Tile * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -81,6 +81,12 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @memberof OpenSeadragon.Tile# */ this.bounds = bounds; + /** + * Where this tile fits, in normalized coordinates, after positioning + * @member {OpenSeadragon.Rect} positionedBounds + * @memberof OpenSeadragon.Tile# + */ + this.positionedBounds = new OpenSeadragon.Rect(bounds.x, bounds.y, bounds.width, bounds.height); /** * The portion of the tile to use as the source of the drawing operation, in pixels. Note that * this only works when drawing with canvas; when drawing with HTML the entire tile is always used. @@ -274,64 +280,6 @@ $.Tile.prototype = { return !!this.context2D || this.getUrl().match('.png'); }, - /** - * Renders the tile in an html container. - * @function - * @param {Element} container - */ - drawHTML: function( container ) { - if (!this.cacheImageRecord) { - $.console.warn( - '[Tile.drawHTML] attempting to draw tile %s when it\'s not cached', - this.toString()); - return; - } - - if ( !this.loaded ) { - $.console.warn( - "Attempting to draw tile %s when it's not yet loaded.", - this.toString() - ); - return; - } - - //EXPERIMENTAL - trying to figure out how to scale the container - // content during animation of the container size. - - if ( !this.element ) { - var image = this.getImage(); - if (!image) { - return; - } - - this.element = $.makeNeutralElement( "div" ); - this.imgElement = image.cloneNode(); - this.imgElement.style.msInterpolationMode = "nearest-neighbor"; - this.imgElement.style.width = "100%"; - this.imgElement.style.height = "100%"; - - this.style = this.element.style; - this.style.position = "absolute"; - } - if ( this.element.parentNode !== container ) { - container.appendChild( this.element ); - } - if ( this.imgElement.parentNode !== this.element ) { - this.element.appendChild( this.imgElement ); - } - - this.style.top = this.position.y + "px"; - this.style.left = this.position.x + "px"; - this.style.height = this.size.y + "px"; - this.style.width = this.size.x + "px"; - - if (this.flipped) { - this.style.transform = "scaleX(-1)"; - } - - $.setElementOpacity( this.element, this.opacity ); - }, - /** * The Image object for this tile. * @member {Object} image @@ -382,114 +330,7 @@ $.Tile.prototype = { * @returns {CanvasRenderingContext2D} */ getCanvasContext: function() { - return this.context2D || this.cacheImageRecord.getRenderedContext(); - }, - - /** - * Renders the tile in a canvas-based context. - * @function - * @param {Canvas} context - * @param {Function} drawingHandler - Method for firing the drawing event. - * drawingHandler({context, tile, rendered}) - * where rendered is the context with the pre-drawn image. - * @param {Number} [scale=1] - Apply a scale to position and size - * @param {OpenSeadragon.Point} [translate] - A translation vector - * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round - * position and size of tiles supporting alpha channel in non-transparency - * context. - * @param {OpenSeadragon.TileSource} source - The source specification of the tile. - */ - drawCanvas: function( context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source) { - - var position = this.position.times($.pixelDensityRatio), - size = this.size.times($.pixelDensityRatio), - rendered; - - if (!this.context2D && !this.cacheImageRecord) { - $.console.warn( - '[Tile.drawCanvas] attempting to draw tile %s when it\'s not cached', - this.toString()); - return; - } - - rendered = this.getCanvasContext(); - - if ( !this.loaded || !rendered ){ - $.console.warn( - "Attempting to draw tile %s when it's not yet loaded.", - this.toString() - ); - - return; - } - - context.save(); - context.globalAlpha = this.opacity; - - if (typeof scale === 'number' && scale !== 1) { - // draw tile at a different scale - position = position.times(scale); - size = size.times(scale); - } - - if (translate instanceof $.Point) { - // shift tile position slightly - position = position.plus(translate); - } - - //if we are supposed to be rendering fully opaque rectangle, - //ie its done fading or fading is turned off, and if we are drawing - //an image with an alpha channel, then the only way - //to avoid seeing the tile underneath is to clear the rectangle - if (context.globalAlpha === 1 && this.hasTransparency) { - if (shouldRoundPositionAndSize) { - // Round to the nearest whole pixel so we don't get seams from overlap. - position.x = Math.round(position.x); - position.y = Math.round(position.y); - size.x = Math.round(size.x); - size.y = Math.round(size.y); - } - - //clearing only the inside of the rectangle occupied - //by the png prevents edge flikering - context.clearRect( - position.x, - position.y, - size.x, - size.y - ); - } - - // This gives the application a chance to make image manipulation - // changes as we are rendering the image - drawingHandler({context: context, tile: this, rendered: rendered}); - - var sourceWidth, sourceHeight; - if (this.sourceBounds) { - sourceWidth = Math.min(this.sourceBounds.width, rendered.canvas.width); - sourceHeight = Math.min(this.sourceBounds.height, rendered.canvas.height); - } else { - sourceWidth = rendered.canvas.width; - sourceHeight = rendered.canvas.height; - } - - context.translate(position.x + size.x / 2, 0); - if (this.flipped) { - context.scale(-1, 1); - } - context.drawImage( - rendered.canvas, - 0, - 0, - sourceWidth, - sourceHeight, - -size.x / 2, - position.y, - size.x, - size.y - ); - - context.restore(); + return this.context2D || (this.cacheImageRecord && this.cacheImageRecord.getRenderedContext()); }, /** diff --git a/src/tilecache.js b/src/tilecache.js index d890b8a8..f85cb643 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -2,7 +2,7 @@ * OpenSeadragon - TileCache * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -236,19 +236,51 @@ $.TileCache.prototype = { var tile = tileRecord.tile; var tiledImage = tileRecord.tiledImage; + // tile.getCanvasContext should always exist in normal usage (with $.Tile) + // but the tile cache test passes in a dummy object + let context2D = tile.getCanvasContext && tile.getCanvasContext(); + tile.unload(); tile.cacheImageRecord = null; var imageRecord = this._imagesLoaded[tile.cacheKey]; + if(!imageRecord){ + return; + } imageRecord.removeTile(tile); if (!imageRecord.getTileCount()) { + imageRecord.destroy(); delete this._imagesLoaded[tile.cacheKey]; this._imagesLoadedCount--; + + if(context2D){ + /** + * Free up canvas memory + * (iOS 12 or higher on 2GB RAM device has only 224MB canvas memory, + * and Safari keeps canvas until its height and width will be set to 0). + */ + context2D.canvas.width = 0; + context2D.canvas.height = 0; + + /** + * Triggered when an image has just been unloaded + * + * @event image-unloaded + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {CanvasRenderingContext2D} context2D - The context that is being unloaded + */ + tiledImage.viewer.raiseEvent("image-unloaded", { + context2D: context2D, + tile: tile + }); + } + } /** - * Triggered when a tile has just been unloaded from memory. + * Triggered when a tile has just been unloaded from the cache. * * @event tile-unloaded * @memberof OpenSeadragon.Viewer @@ -260,6 +292,7 @@ $.TileCache.prototype = { tile: tile, tiledImage: tiledImage }); + } }; diff --git a/src/tiledimage.js b/src/tiledimage.js index 0bedb701..0b1862a7 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -2,7 +2,7 @@ * OpenSeadragon - TiledImage * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -85,7 +85,7 @@ * A set of headers to include when making tile AJAX requests. */ $.TiledImage = function( options ) { - var _this = this; + this._initialized = false; /** * The {@link OpenSeadragon.TileSource} that defines this TiledImage. * @member {OpenSeadragon.TileSource} source @@ -159,10 +159,13 @@ $.TiledImage = function( options ) { 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. - _midDraw: false, // Is the tiledImage currently updating the viewport? _needsDraw: 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? //configurable settings springStiffness: $.DEFAULT_SETTINGS.springStiffness, animationTime: $.DEFAULT_SETTINGS.animationTime, @@ -221,30 +224,9 @@ $.TiledImage = function( options ) { this.fitBounds(fitBounds, fitBoundsPlacement, true); } - // We need a callback to give image manipulation a chance to happen - this._drawingHandler = function(args) { - /** - * This event is fired just before the tile is drawn giving the application a chance to alter the image. - * - * NOTE: This event is only fired when the drawer is using a <canvas>. - * - * @event tile-drawing - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. - * @property {OpenSeadragon.Tile} tile - The Tile being drawn. - * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. - * @property {OpenSeadragon.Tile} context - The HTML canvas context being drawn into. - * @property {OpenSeadragon.Tile} rendered - The HTML canvas context containing the tile imagery. - * @property {?Object} userData - Arbitrary subscriber-defined object. - */ - _this.viewer.raiseEvent('tile-drawing', $.extend({ - tiledImage: _this - }, args)); - }; - this._ownAjaxHeaders = {}; this.setAjaxHeaders(ajaxHeaders, false); + this._initialized = true; }; $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.TiledImage.prototype */{ @@ -255,6 +237,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag 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. */ @@ -297,17 +286,28 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * Updates the TiledImage's bounds, animating if needed. - * @returns {Boolean} Whether the TiledImage animated. + * 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() { - var xUpdated = this._xSpring.update(); - var yUpdated = this._ySpring.update(); - var scaleUpdated = this._scaleSpring.update(); - var degreesUpdated = this._degreesSpring.update(); + update: function(viewportChanged) { + let xUpdated = this._xSpring.update(); + let yUpdated = this._ySpring.update(); + let scaleUpdated = this._scaleSpring.update(); + let degreesUpdated = this._degreesSpring.update(); - if (xUpdated || yUpdated || scaleUpdated || degreesUpdated) { + let updated = (xUpdated || yUpdated || scaleUpdated || degreesUpdated); + + if (updated || viewportChanged || !this._fullyLoaded){ + let fullyLoadedFlag = this._updateLevelsForViewport(); + this._setFullyLoaded(fullyLoadedFlag); + } + + + if (updated) { this._updateForScale(); + this._raiseBoundsChange(); this._needsDraw = true; return true; } @@ -316,18 +316,14 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * Draws the TiledImage to its Drawer. + * 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 */ - draw: function() { - if (this.opacity !== 0 || this._preload) { - this._midDraw = true; - this._updateViewport(); - this._midDraw = false; - } - // Images with opacity 0 should not need to be drawn in future. this._needsDraw = false is set in this._updateViewport() for other images. - else { - this._needsDraw = false; - } + setDrawn: function(){ + this._needsDraw = this._isBlending || this._wasBlending; + return this._needsDraw; }, /** @@ -337,7 +333,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this.reset(); if (this.source.destroy) { - this.source.destroy(); + this.source.destroy(this.viewer); } }, @@ -414,7 +410,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag var yMod = ( numTiles.y + ( y % numTiles.y ) ) % numTiles.y; var bounds = this.source.getTileBounds(level, xMod, yMod); if (this.getFlip()) { - bounds.x = 1 - bounds.x - bounds.width; + 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); @@ -495,7 +491,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag imageX = imageX.x; } - var point = this._imageToViewportDelta(imageX, imageY); + var point = this._imageToViewportDelta(imageX, imageY, current); if (current) { point.x += this._xSpring.current.value; point.y += this._ySpring.current.value; @@ -727,7 +723,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * ] */ setCroppingPolygons: function( polygons ) { - var isXYObject = function(obj) { return obj instanceof $.Point || (typeof obj.x === 'number' && typeof obj.y === 'number'); }; @@ -753,10 +748,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag 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._croppingPolygons = null; + this.resetCroppingPolygons(); } }, @@ -766,6 +762,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag */ resetCroppingPolygons: function() { this._croppingPolygons = null; + this._needsDraw = true; }, /** @@ -874,7 +871,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @returns {Boolean} Whether the TiledImage should be flipped before rendering. */ getFlip: function() { - return !!this.flipped; + return this.flipped; }, /** @@ -882,9 +879,54 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @fires OpenSeadragon.TiledImage.event:bounds-change */ setFlip: function(flip) { - this.flipped = !!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; - this._raiseBoundsChange(); }, /** @@ -899,11 +941,19 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @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._opacity = opacity; this._needsDraw = true; /** * Raised when the TiledImage's opacity is changed. @@ -967,6 +1017,49 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag 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. + */ + getDrawArea: function(){ + + 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 @@ -977,23 +1070,16 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return this.getBoundsNoRotate(current).getCenter(); }, - /** - * @returns {String} The TiledImage's current compositeOperation. - */ - getCompositeOperation: function() { - return this.compositeOperation; + get compositeOperation(){ + 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) { - if (compositeOperation === this.compositeOperation) { + set compositeOperation(compositeOperation){ + + if (compositeOperation === this._compositeOperation) { return; } - - this.compositeOperation = compositeOperation; + this._compositeOperation = compositeOperation; this._needsDraw = true; /** * Raised when the TiledImage's opacity is changed. @@ -1006,8 +1092,24 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @property {?Object} userData - Arbitrary subscriber-defined object. */ this.raiseEvent('composite-operation-change', { - compositeOperation: this.compositeOperation + 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 }, /** @@ -1175,56 +1277,66 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }; }, - /** - * @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. - */ - _updateViewport: function() { - this._needsDraw = false; - this._tilesLoading = 0; - this.loadingCoverage = {}; - - // 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; - } - } - + // 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 (var level = highestLevel; level >= lowestLevel; level--) { + 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 = viewport.deltaPixelsFromPointsNoRotate( + var currentRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate( this.source.getPixelRatio(level), true ).x * this._scaleSpring.current.value; - if (level === lowestLevel || - (!haveDrawn && currentRenderPixelRatio >= this.minPixelRatio)) { + if (i === levelList.length - 1 || + (!haveDrawn && currentRenderPixelRatio >= this.minPixelRatio) ) { drawLevel = true; haveDrawn = true; } else if (!haveDrawn) { @@ -1232,12 +1344,12 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } //Perform calculations for draw if we haven't drawn this - var targetRenderPixelRatio = viewport.deltaPixelsFromPointsNoRotate( + var targetRenderPixelRatio = this.viewport.deltaPixelsFromPointsNoRotate( this.source.getPixelRatio(level), false ).x * this._scaleSpring.current.value; - var targetZeroRatio = viewport.deltaPixelsFromPointsNoRotate( + var targetZeroRatio = this.viewport.deltaPixelsFromPointsNoRotate( this.source.getPixelRatio( Math.max( this.source.getClosestLevel(), @@ -1253,8 +1365,9 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag optimalRatio - targetRenderPixelRatio ); - // Update the level and keep track of 'best' tile to load - bestTiles = this._updateLevel( + // Update the level and keep track of 'best' tiles to load + // the bestTiles + var result = this._updateLevel( haveDrawn, drawLevel, level, @@ -1265,6 +1378,21 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag 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)) { @@ -1272,8 +1400,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } } - // Perform the actual drawing - this._drawTiles(this.lastDrawn); // Load the new 'best' n tiles if (bestTiles && bestTiles.length > 0) { @@ -1282,52 +1408,119 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._loadTile(tile, currentTime); } }, this); + this._needsDraw = true; - this._setFullyLoaded(false); + return false; } else { - this._setFullyLoaded(this._tilesLoading === 0); + return this._tilesLoading === 0; } + + // Update + }, - // 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); + /** + * 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; } - 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); + 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; + } } - return { - topLeft: topLeftTile, - bottomRight: bottomRightTile, - }; + // 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; }, /** @@ -1340,7 +1533,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @param {Number} levelVisibility * @param {OpenSeadragon.Rect} drawArea * @param {Number} currentTime - * @param {OpenSeadragon.Tile[]} best - The current "best" n tiles to draw. + * @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) { @@ -1405,7 +1599,9 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag 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++) { @@ -1422,50 +1618,105 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag continue; } - best = this._updateTile( + var result = this._updateTile( drawLevel, haveDrawn, flippedX, y, level, - levelOpacity, levelVisibility, viewportCenter, numberOfTiles, currentTime, best ); + best = result.bestTiles; + tiles[tileIndex] = result.tile; + tileIndex += 1; } } - return best; + return { + bestTiles: best, + updatedTiles: tiles + }; }, /** * @private - * @inner + * @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 {Boolean} haveDrawn * @param {Boolean} drawLevel * @param {Number} x * @param {Number} y * @param {Number} level - * @param {Number} levelOpacity * @param {Number} levelVisibility * @param {OpenSeadragon.Point} viewportCenter * @param {Number} numberOfTiles * @param {Number} currentTime - * @param {OpenSeadragon.Tile[]} best - The current "best" tiles to draw. + * @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, levelOpacity, + _updateTile: function( haveDrawn, drawLevel, x, y, level, levelVisibility, viewportCenter, numberOfTiles, currentTime, best){ var tile = this._getTile( x, y, level, currentTime, - numberOfTiles, - this._worldWidthCurrent, - this._worldHeightCurrent + numberOfTiles ), drawTile = drawLevel; @@ -1493,9 +1744,14 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._setCoverage(this.loadingCoverage, level, x, y, loadingCoverage); if ( !tile.exists ) { - return best; + return { + bestTiles: best, + tile: tile + }; + } + if (tile.loaded && tile.opacity === 1){ + this._setCoverage( this.coverage, level, x, y, true ); } - if ( haveDrawn && !drawTile ) { if ( this._isCovered( this.coverage, level, x, y ) ) { this._setCoverage( this.coverage, level, x, y, true ); @@ -1505,7 +1761,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } if ( !drawTile ) { - return best; + return { + bestTiles: best, + tile: tile + }; } this._positionTile( @@ -1527,48 +1786,75 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } } - if ( tile.loaded ) { - var needsDraw = this._blendTile( - tile, - x, y, - level, - levelOpacity, - currentTime - ); - - if ( needsDraw ) { - this._needsDraw = true; - } - } else if ( tile.loading ) { + if ( tile.loading ) { // the tile is already in the download queue this._tilesLoading++; } else if (!loadingCoverage) { best = this._compareTiles( best, tile, this.maxTilesPerFrame ); } - return best; + 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, + }; }, /** - * @private - * @inner * Obtains a tile at the given location. + * @private * @param {Number} x * @param {Number} y * @param {Number} level * @param {Number} time * @param {Number} numTiles - * @param {Number} worldWidth - * @param {Number} worldHeight * @returns {OpenSeadragon.Tile} */ _getTile: function( x, y, level, time, - numTiles, - worldWidth, - worldHeight + numTiles ) { var xMod, yMod, @@ -1654,9 +1940,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * Dispatch a job to the ImageLoader to load the Image for a Tile. + * @private * @param {OpenSeadragon.Tile} tile * @param {Number} time */ @@ -1682,9 +1967,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * Callback fired when a Tile's Image finished downloading. + * @private * @param {OpenSeadragon.Tile} tile * @param {Number} time * @param {*} data image data @@ -1733,19 +2017,12 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag _this._setTileLoaded(tile, data, cutoff, tileRequest); }; - // Check if we're mid-update; this can happen on IE8 because image load events for - // cached images happen immediately there - if ( !this._midDraw ) { - finish(); - } else { - // Wait until after the update, in case caching unloads any tiles - window.setTimeout( finish, 1); - } + + finish(); }, /** * @private - * @inner * @param {OpenSeadragon.Tile} tile * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object * @param {Number|undefined} cutoff @@ -1781,6 +2058,22 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag 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; } } @@ -1820,105 +2113,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag fallbackCompletion(); }, - /** - * @private - * @inner - * @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; - - 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 ( !overlap ) { - sizeC = sizeC.plus( new $.Point( 1, 1 ) ); - } - - if (tile.isRightMost && this.wrapHorizontal) { - sizeC.x += 0.75; // Otherwise Firefox and Safari show seams - } - - if (tile.isBottomMost && this.wrapVertical) { - sizeC.y += 0.75; // Otherwise Firefox and Safari show seams - } - - tile.position = positionC; - tile.size = sizeC; - tile.squaredDistance = tileSquaredDistance; - tile.visibility = levelVisibility; - }, /** - * @private - * @inner - * 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. - * - * @param {OpenSeadragon.Tile} tile - * @param {Number} x - * @param {Number} y - * @param {Number} level - * @param {Number} levelOpacity - * @param {Number} currentTime - * @returns {Boolean} - */ - _blendTile: function( tile, x, y, level, levelOpacity, currentTime ){ - var 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.alwaysBlend ) { - opacity *= levelOpacity; - } - - tile.opacity = opacity; - - this.lastDrawn.push( tile ); - - if ( opacity === 1 ) { - this._setCoverage( this.coverage, level, x, y, true ); - this._hasOpaqueTile = true; - } else if ( deltaTime < blendTimeMillis ) { - return true; - } - - return false; - }, - - - /** - * @private - * @inner * 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. @@ -1938,9 +2137,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * Sorts tiles in an array according to distance and visibility. + * @private * * @param {OpenSeadragon.Tile[]} tiles The tiles. */ @@ -1960,277 +2158,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }); }, - /** - * @private - * @inner - * Draws a TiledImage. - * @param {OpenSeadragon.Tile[]} lastDrawn - An unordered list of Tiles drawn last frame. - */ - _drawTiles: function( lastDrawn ) { - if (this.opacity === 0 || (lastDrawn.length === 0 && !this.placeholderFillStyle)) { - return; - } - - var tile = lastDrawn[0]; - var useSketch; - - if (tile) { - useSketch = this.opacity < 1 || - (this.compositeOperation && this.compositeOperation !== 'source-over') || - (!this._isBottomItem() && - this.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData)); - } - - var sketchScale; - var sketchTranslate; - - var zoom = this.viewport.getZoom(true); - var imageZoom = this.viewportToImageZoom(zoom); - - if (lastDrawn.length > 1 && - imageZoom > this.smoothTileEdgesMinZoom && - !this.iOSDevice && - this.getRotation(true) % 360 === 0 && // TODO: support tile edge smoothing with tiled image rotation (viewport rotation is not a problem). - this._drawer.viewer.viewport.getFlip() === false && // TODO: support tile edge smoothing with viewport flip (tiled image flip is not a problem). - $.supportsCanvas && this.viewer.useCanvas) { - // When zoomed in a lot (>100%) the tile edges are visible. - // So we have to composite them at ~100% and scale them up together. - // Note: Disabled on iOS devices per default as it causes a native crash - useSketch = true; - sketchScale = tile.getScaleForEdgeSmoothing(); - sketchTranslate = tile.getTranslationForEdgeSmoothing(sketchScale, - this._drawer.getCanvasSize(false), - this._drawer.getCanvasSize(true)); - } - - var bounds; - if (useSketch) { - if (!sketchScale) { - // Except when edge smoothing, we only clean the part of the - // sketch canvas we are going to use for performance reasons. - bounds = this.viewport.viewportToViewerElementRectangle( - this.getClippedBounds(true)) - .getIntegerBoundingBox(); - - if(this._drawer.viewer.viewport.getFlip()) { - if (this.viewport.getRotation(true) % 360 !== 0 || - this.getRotation(true) % 360 !== 0) { - bounds.x = this._drawer.viewer.container.clientWidth - (bounds.x + bounds.width); - } - } - - bounds = bounds.times($.pixelDensityRatio); - } - this._drawer._clear(true, bounds); - } - - // When scaling, we must rotate only when blending the sketch canvas to - // avoid interpolation - if (!sketchScale) { - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.viewport.getRotation(true), - useSketch: useSketch - }); - } - if (this.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.getRotation(true), - point: this.viewport.pixelFromPointNoRotate( - this._getRotationPoint(true), true), - useSketch: useSketch - }); - } - - if (this.viewport.getRotation(true) % 360 === 0 && - this.getRotation(true) % 360 === 0) { - if(this._drawer.viewer.viewport.getFlip()) { - this._drawer._flip(); - } - } - } - - var usedClip = false; - if ( this._clip ) { - this._drawer.saveContext(useSketch); - - var box = this.imageToViewportRectangle(this._clip, true); - box = box.rotate(-this.getRotation(true), this._getRotationPoint(true)); - var clipRect = this._drawer.viewportToDrawerRectangle(box); - if (sketchScale) { - clipRect = clipRect.times(sketchScale); - } - if (sketchTranslate) { - clipRect = clipRect.translate(sketchTranslate); - } - this._drawer.setClip(clipRect, useSketch); - - usedClip = true; - } - - if (this._croppingPolygons) { - var self = this; - this._drawer.saveContext(useSketch); - try { - var polygons = this._croppingPolygons.map(function (polygon) { - return polygon.map(function (coord) { - var point = self - .imageToViewportCoordinates(coord.x, coord.y, true) - .rotate(-self.getRotation(true), self._getRotationPoint(true)); - var clipPoint = self._drawer.viewportCoordToDrawerCoord(point); - if (sketchScale) { - clipPoint = clipPoint.times(sketchScale); - } - if (sketchTranslate) { - clipPoint = clipPoint.plus(sketchTranslate); - } - return clipPoint; - }); - }); - this._drawer.clipWithPolygons(polygons, useSketch); - } catch (e) { - $.console.error(e); - } - usedClip = true; - } - - if ( this.placeholderFillStyle && this._hasOpaqueTile === false ) { - var placeholderRect = this._drawer.viewportToDrawerRectangle(this.getBounds(true)); - if (sketchScale) { - placeholderRect = placeholderRect.times(sketchScale); - } - if (sketchTranslate) { - placeholderRect = placeholderRect.translate(sketchTranslate); - } - - var fillStyle = null; - if ( typeof this.placeholderFillStyle === "function" ) { - fillStyle = this.placeholderFillStyle(this, this._drawer.context); - } - else { - fillStyle = this.placeholderFillStyle; - } - - this._drawer.drawRectangle(placeholderRect, fillStyle, useSketch); - } - - var subPixelRoundingRule = determineSubPixelRoundingRule(this.subPixelRoundingForTransparency); - - var shouldRoundPositionAndSize = false; - - if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS) { - shouldRoundPositionAndSize = true; - } else if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST) { - var isAnimating = this.viewer && this.viewer.isAnimating(); - shouldRoundPositionAndSize = !isAnimating; - } - - for (var i = lastDrawn.length - 1; i >= 0; i--) { - tile = lastDrawn[ i ]; - this._drawer.drawTile( tile, this._drawingHandler, useSketch, sketchScale, - sketchTranslate, shouldRoundPositionAndSize, this.source ); - tile.beingDrawn = true; - - if( this.viewer ){ - /** - * - Needs documentation - - * - * @event tile-drawn - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. - * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. - * @property {OpenSeadragon.Tile} tile - * @property {?Object} userData - Arbitrary subscriber-defined object. - */ - this.viewer.raiseEvent( 'tile-drawn', { - tiledImage: this, - tile: tile - }); - } - } - - if ( usedClip ) { - this._drawer.restoreContext( useSketch ); - } - - if (!sketchScale) { - if (this.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(useSketch); - } - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(useSketch); - } - } - - if (useSketch) { - if (sketchScale) { - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.viewport.getRotation(true), - useSketch: false - }); - } - if (this.getRotation(true) % 360 !== 0) { - this._drawer._offsetForRotation({ - degrees: this.getRotation(true), - point: this.viewport.pixelFromPointNoRotate( - this._getRotationPoint(true), true), - useSketch: false - }); - } - } - this._drawer.blendSketch({ - opacity: this.opacity, - scale: sketchScale, - translate: sketchTranslate, - compositeOperation: this.compositeOperation, - bounds: bounds - }); - if (sketchScale) { - if (this.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(false); - } - if (this.viewport.getRotation(true) % 360 !== 0) { - this._drawer._restoreRotationChanges(false); - } - } - } - - if (!sketchScale) { - if (this.viewport.getRotation(true) % 360 === 0 && - this.getRotation(true) % 360 === 0) { - if(this._drawer.viewer.viewport.getFlip()) { - this._drawer._flip(); - } - } - } - - this._drawDebugInfo( lastDrawn ); - }, /** - * @private - * @inner - * Draws special debug information for a TiledImage if in debug mode. - * @param {OpenSeadragon.Tile[]} lastDrawn - An unordered list of Tiles drawn last frame. - */ - _drawDebugInfo: function( lastDrawn ) { - if( this.debugMode ) { - for ( var i = lastDrawn.length - 1; i >= 0; i-- ) { - var tile = lastDrawn[ i ]; - try { - this._drawer.drawDebugInfo(tile, lastDrawn.length, i, this); - } catch(e) { - $.console.error(e); - } - } - } - }, - - /** - * @private - * @inner * 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. @@ -2238,6 +2167,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * 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. @@ -2278,11 +2208,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * 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. @@ -2304,9 +2233,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * 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. @@ -2331,11 +2259,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** - * @private - * @inner * 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. @@ -2346,71 +2273,5 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }); -/** - * @private - * @inner - * Defines the value for subpixel rounding to fallback to in case of missing or - * invalid value. - */ -var DEFAULT_SUBPIXEL_ROUNDING_RULE = $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; - -/** - * @private - * @inner - * Checks whether the input value is an invalid subpixel rounding enum value. - * - * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to check. - * @returns {Boolean} Returns true if the input value is none of the expected - * {@link SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS}, {@link SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST} or {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} value. - */ -function isSubPixelRoundingRuleUnknown(value) { - return value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS && - value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST && - value !== $.SUBPIXEL_ROUNDING_OCCURRENCES.NEVER; -} - -/** - * @private - * @inner - * Ensures the returned value is always a valid subpixel rounding enum value, - * defaulting to {@link SUBPIXEL_ROUNDING_OCCURRENCES.NEVER} if input is missing or invalid. - * - * @param {SUBPIXEL_ROUNDING_OCCURRENCES} value - The subpixel rounding enum value to normalize. - * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns a valid subpixel rounding enum value. - */ -function normalizeSubPixelRoundingRule(value) { - if (isSubPixelRoundingRuleUnknown(value)) { - return DEFAULT_SUBPIXEL_ROUNDING_RULE; - } - return value; -} - -/** - * @private - * @inner - * Ensures the returned value is always a valid subpixel rounding enum value, - * defaulting to 'NEVER' if input is missing or invalid. - * - * @param {Object} subPixelRoundingRules - A subpixel rounding enum values dictionary [{@link BROWSERS}] --> {@link SUBPIXEL_ROUNDING_OCCURRENCES}. - * @returns {SUBPIXEL_ROUNDING_OCCURRENCES} Returns the determined subpixel rounding enum value for the - * current browser. - */ -function determineSubPixelRoundingRule(subPixelRoundingRules) { - if (typeof subPixelRoundingRules === 'number') { - return normalizeSubPixelRoundingRule(subPixelRoundingRules); - } - - if (!subPixelRoundingRules || !$.Browser) { - return DEFAULT_SUBPIXEL_ROUNDING_RULE; - } - - var subPixelRoundingRule = subPixelRoundingRules[$.Browser.vendor]; - - if (isSubPixelRoundingRuleUnknown(subPixelRoundingRule)) { - subPixelRoundingRule = subPixelRoundingRules['*']; - } - - return normalizeSubPixelRoundingRule(subPixelRoundingRule); -} }( OpenSeadragon )); diff --git a/src/tilesource.js b/src/tilesource.js index fefa0098..3195a90c 100644 --- a/src/tilesource.js +++ b/src/tilesource.js @@ -2,7 +2,7 @@ * OpenSeadragon - TileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -370,6 +370,7 @@ $.TileSource.prototype = { point.y >= 0 && point.y <= 1 / this.aspectRatio; $.console.assert(validPoint, "[TileSource.getTileAtPoint] must be called with a valid point."); + var widthScaled = this.dimensions.x * this.getLevelScale(level); var pixelX = point.x * widthScaled; var pixelY = point.y * widthScaled; diff --git a/src/tilesourcecollection.js b/src/tilesourcecollection.js index e1483184..29d7ec94 100644 --- a/src/tilesourcecollection.js +++ b/src/tilesourcecollection.js @@ -2,7 +2,7 @@ * OpenSeadragon - TileSourceCollection * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/tmstilesource.js b/src/tmstilesource.js index b6deeb03..5f8c999f 100644 --- a/src/tmstilesource.js +++ b/src/tmstilesource.js @@ -2,7 +2,7 @@ * OpenSeadragon - TmsTileSource * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are diff --git a/src/viewer.js b/src/viewer.js index d82a2d77..954bfc56 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -2,7 +2,7 @@ * OpenSeadragon - Viewer * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -89,6 +89,21 @@ $.Viewer = function( options ) { delete options.config; } + // Move deprecated drawer options from the base options object into a sub-object + // This is an array to make it easy to add additional properties to convert to + // drawer options later if it makes sense to set at the drawer level rather than + // per tiled image (for example, subPixelRoundingForTransparency). + let drawerOptionList = [ + 'useCanvas', // deprecated + ]; + options.drawerOptions = Object.assign({}, + drawerOptionList.reduce((drawerOptions, option) => { + drawerOptions[option] = options[option]; + delete options[option]; + return drawerOptions; + }, {}), + options.drawerOptions); + //Public properties //Allow the options object to override global defaults $.extend( true, this, { @@ -198,6 +213,7 @@ $.Viewer = function( options ) { $.console.warn("Hash " + this.hash + " has already been used."); } + //Private state properties THIS[ this.hash ] = { fsBoundsDelta: new $.Point( 1, 1 ), @@ -418,13 +434,63 @@ $.Viewer = function( options ) { maxImageCacheCount: this.maxImageCacheCount }); - // Create the drawer - this.drawer = new $.Drawer({ - viewer: this, - viewport: this.viewport, - element: this.canvas, - debugGridColor: this.debugGridColor - }); + //Create the drawer based on selected options + if (Object.prototype.hasOwnProperty.call(this.drawerOptions, 'useCanvas') ){ + $.console.error('useCanvas is deprecated, use the "drawer" option to indicate preferred drawer(s)'); + + // for backwards compatibility, use HTMLDrawer if useCanvas is defined and is falsey + if (!this.drawerOptions.useCanvas){ + this.drawer = $.HTMLDrawer; + } + + delete this.drawerOptions.useCanvas; + } + let drawerCandidates = Array.isArray(this.drawer) ? this.drawer : [this.drawer]; + if (drawerCandidates.length === 0){ + // if an empty array was passed in, throw a warning and use the defaults + // note: if the drawer option is not specified, the defaults will already be set so this won't apply + drawerCandidates = [$.DEFAULT_SETTINGS.drawer].flat(); // ensure it is a list + $.console.warn('No valid drawers were selected. Using the default value.'); + } + + + this.drawer = null; + for (let i = 0; i < drawerCandidates.length; i++) { + + let drawerCandidate = drawerCandidates[i]; + let Drawer = null; + + //if inherits from a drawer base, use it + if (drawerCandidate && drawerCandidate.prototype instanceof $.DrawerBase) { + Drawer = drawerCandidate; + drawerCandidate = 'custom'; + } else if (typeof drawerCandidate === "string") { + Drawer = $.determineDrawer(drawerCandidate); + } else { + $.console.warn('Unsupported drawer! Drawer must be an existing string type, or a class that extends OpenSeadragon.DrawerBase.'); + continue; + } + + // if the drawer is supported, create it and break the loop + if (Drawer && Drawer.isSupported()) { + this.drawer = new Drawer({ + viewer: this, + viewport: this.viewport, + element: this.canvas, + debugGridColor: this.debugGridColor, + options: this.drawerOptions[drawerCandidate], + }); + + break; + } + } + if (!this.drawer){ + $.console.error('No drawer could be created!'); + throw('Error with creating the selected drawer(s)'); + } + + // Pass the imageSmoothingEnabled option along to the drawer + this.drawer.setImageSmoothingEnabled(this.imageSmoothingEnabled); // Overlay container this.overlaysContainer = $.makeNeutralElement( "div" ); @@ -470,6 +536,7 @@ $.Viewer = function( options ) { displayRegionColor: this.navigatorDisplayRegionColor, crossOriginPolicy: this.crossOriginPolicy, animationTime: this.animationTime, + drawer: this.drawer.getType(), }); } @@ -496,11 +563,6 @@ $.Viewer = function( options ) { beginControlsAutoHide( _this ); } ); - // Initial canvas options - if ( this.imageSmoothingEnabled !== undefined && !this.imageSmoothingEnabled){ - this.drawer.setImageSmoothingEnabled(this.imageSmoothingEnabled); - } - // Register the viewer $._viewers.set(this.element, this); }; @@ -1040,7 +1102,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, * @returns {Boolean} */ isFullPage: function () { - return THIS[ this.hash ].fullPage; + return THIS[this.hash] && THIS[ this.hash ].fullPage; }, @@ -1087,7 +1149,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, return this; } - if ( fullPage ) { + if ( fullPage && this.element ) { this.elementSize = $.getElementSize( this.element ); this.pageScroll = $.getPageScroll(); @@ -2403,7 +2465,6 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, width: this.referenceStripWidth, tileSources: this.tileSources, prefixUrl: this.prefixUrl, - useCanvas: this.useCanvas, viewer: this }); @@ -2552,7 +2613,6 @@ function getTileSourceImplementation( viewer, tileSource, imgOptions, successCal ajaxHeaders: imgOptions.ajaxHeaders ? imgOptions.ajaxHeaders : viewer.ajaxHeaders, splitHashDataForPost: viewer.splitHashDataForPost, - useCanvas: viewer.useCanvas, success: function( event ) { successCallback( event.tileSource ); } @@ -2570,9 +2630,6 @@ function getTileSourceImplementation( viewer, tileSource, imgOptions, successCal if (tileSource.ajaxWithCredentials === undefined) { tileSource.ajaxWithCredentials = viewer.ajaxWithCredentials; } - if (tileSource.useCanvas === undefined) { - tileSource.useCanvas = viewer.useCanvas; - } if ( $.isFunction( tileSource.getTileUrl ) ) { //Custom tile source @@ -3705,7 +3762,7 @@ function updateOnce( viewer ) { var viewportChange = viewer.viewport.update(); - var animated = viewer.world.update() || viewportChange; + var animated = viewer.world.update(viewportChange) || viewportChange; if (viewportChange) { /** @@ -3795,7 +3852,6 @@ function updateOnce( viewer ) { function drawWorld( viewer ) { viewer.imageLoader.clear(); - viewer.drawer.clear(); viewer.world.draw(); /** @@ -3949,4 +4005,22 @@ function onFlip() { this.viewport.toggleFlip(); } +/** + * Find drawer + */ +$.determineDrawer = function( id ){ + for (let property in OpenSeadragon) { + const drawer = OpenSeadragon[ property ], + proto = drawer.prototype; + if( proto && + proto instanceof OpenSeadragon.DrawerBase && + $.isFunction( proto.getType ) && + proto.getType.call( drawer ) === id + ){ + return drawer; + } + } + return null; +}; + }( OpenSeadragon )); diff --git a/src/viewport.js b/src/viewport.js index 02e173f0..cfa114cb 100644 --- a/src/viewport.js +++ b/src/viewport.js @@ -2,7 +2,7 @@ * OpenSeadragon - Viewport * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -1133,7 +1133,7 @@ $.Viewport.prototype = { /** * Update the zoom, degrees, and center (X and Y) springs. * @function - * @returns {Boolean} True if any change has been made, false otherwise. + * @returns {Boolean} True if the viewport is still animating, false otherwise. */ update: function() { var _this = this; @@ -1165,7 +1165,13 @@ $.Viewport.prototype = { this._oldZoom = this.zoomSpring.current.value; this._oldDegrees = this.degreesSpring.current.value; - return changed; + var isAnimating = changed || + !this.zoomSpring.isAtTargetValue() || + !this.centerSpringX.isAtTargetValue() || + !this.centerSpringY.isAtTargetValue() || + !this.degreesSpring.isAtTargetValue(); + + return isAnimating; }, // private - pass true to use spring, or a number for degrees for immediate rotation diff --git a/src/webgldrawer.js b/src/webgldrawer.js new file mode 100644 index 00000000..a3cda706 --- /dev/null +++ b/src/webgldrawer.js @@ -0,0 +1,1124 @@ + +/* + * OpenSeadragon - WebGLDrawer + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + + /** + * @class WebGLDrawer + * @memberof OpenSeadragon + * @classdesc Default implementation of WebGLDrawer for an {@link OpenSeadragon.Viewer}. The WebGLDrawer + * loads tile data as textures to the graphics card as soon as it is available (via the tile-ready event), + * and unloads the data (via the image-unloaded event). The drawer utilizes a context-dependent two pass drawing pipeline. + * For the first pass, tile composition for a given TiledImage is always done using a canvas with a WebGL context. + * This allows tiles to be stitched together without seams or artifacts, without requiring a tile source with overlap. If overlap is present, + * overlapping pixels are discarded. The second pass copies all pixel data from the WebGL context onto an output canvas + * with a Context2d context. This allows applications to have access to pixel data and other functionality provided by + * Context2d, regardless of whether the CanvasDrawer or the WebGLDrawer is used. Certain options, including compositeOperation, + * clip, croppingPolygons, and debugMode are implemented using Context2d operations; in these scenarios, each TiledImage is + * drawn onto the output canvas immediately after the tile composition step (pass 1). Otherwise, for efficiency, all TiledImages + * are copied over to the output canvas at once, after all tiles have been composited for all images. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + + $.WebGLDrawer = class WebGLDrawer extends OpenSeadragon.DrawerBase{ + constructor(options){ + super(options); + + this.destroyed = false; + // private members + + this._TextureMap = new Map(); + this._TileMap = new Map(); + + this._gl = null; + this._firstPass = null; + this._secondPass = null; + this._glFrameBuffer = null; + this._renderToTexture = null; + this._glFramebufferToCanvasTransform = null; + this._outputCanvas = null; + this._outputContext = null; + this._clippingCanvas = null; + this._clippingContext = null; + this._renderingCanvas = null; + + // Add listeners for events that require modifying the scene or camera + this.viewer.addHandler("tile-ready", ev => this._tileReadyHandler(ev)); + this.viewer.addHandler("image-unloaded", ev => this._imageUnloadedHandler(ev)); + + // Reject listening for the tile-drawing and tile-drawn events, which this drawer does not fire + this.viewer.rejectEventHandler("tile-drawn", "The WebGLDrawer does not raise the tile-drawn event"); + this.viewer.rejectEventHandler("tile-drawing", "The WebGLDrawer does not raise the tile-drawing event"); + + // this.viewer and this.canvas are part of the public DrawerBase API + // and are defined by the parent DrawerBase class. Additional setup is done by + // the private _setupCanvases and _setupRenderer functions. + this._setupCanvases(); + this._setupRenderer(); + + this.context = this._outputContext; // API required by tests + + } + + // Public API required by all Drawer implementations + /** + * Clean up the renderer, removing all resources + */ + destroy(){ + if(this.destroyed){ + return; + } + // clear all resources used by the renderer, geometries, textures etc + let gl = this._gl; + + // adapted from https://stackoverflow.com/a/23606581/1214731 + var numTextureUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS); + for (let unit = 0; unit < numTextureUnits; ++unit) { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindTexture(gl.TEXTURE_CUBE_MAP, null); + } + gl.bindBuffer(gl.ARRAY_BUFFER, null); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + let canvases = Array.from(this._TextureMap.keys()); + canvases.forEach(canvas => { + this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap + }); + + // Delete all our created resources + gl.deleteBuffer(this._secondPass.bufferOutputPosition); + gl.deleteFramebuffer(this._glFrameBuffer); + + // make canvases 1 x 1 px and delete references + this._renderingCanvas.width = this._renderingCanvas.height = 1; + this._clippingCanvas.width = this._clippingCanvas.height = 1; + this._outputCanvas.width = this._outputCanvas.height = 1; + this._renderingCanvas = null; + this._clippingCanvas = this._clippingContext = null; + this._outputCanvas = this._outputContext = null; + + let ext = gl.getExtension('WEBGL_lose_context'); + if(ext){ + ext.loseContext(); + } + + // set our webgl context reference to null to enable garbage collection + this._gl = null; + + // set our destroyed flag to true + this.destroyed = true; + } + + // Public API required by all Drawer implementations + /** + * + * @returns {Boolean} true if the drawer supports rotation + */ + canRotate(){ + return true; + } + + // Public API required by all Drawer implementations + /** + * @returns {Boolean} true if canvas and webgl are supported + */ + static isSupported(){ + let canvasElement = document.createElement( 'canvas' ); + let webglContext = $.isFunction( canvasElement.getContext ) && + canvasElement.getContext( 'webgl' ); + let ext = webglContext.getExtension('WEBGL_lose_context'); + if(ext){ + ext.loseContext(); + } + return !!( webglContext ); + } + + getType(){ + return 'webgl'; + } + + /** + * create the HTML element (canvas in this case) that the image will be drawn into + * @private + * @returns {Element} the canvas to draw into + */ + _createDrawingElement(){ + let canvas = $.makeNeutralElement("canvas"); + let viewportSize = this._calculateCanvasSize(); + canvas.width = viewportSize.x; + canvas.height = viewportSize.y; + return canvas; + } + + /** + * + * @param {Array} tiledImages Array of TiledImage objects to draw + */ + draw(tiledImages){ + let gl = this._gl; + let view = { + bounds: this.viewport.getBoundsNoRotate(true), + center: this.viewport.getCenter(true), + rotation: this.viewport.getRotation(true) * Math.PI / 180 + }; + + let flipMultiplier = this.viewport.flipped ? -1 : 1; + // calculate view matrix for viewer + let posMatrix = $.Mat3.makeTranslation(-view.center.x, -view.center.y); + let scaleMatrix = $.Mat3.makeScaling(2 / view.bounds.width * flipMultiplier, -2 / view.bounds.height); + let rotMatrix = $.Mat3.makeRotation(-view.rotation); + let viewMatrix = scaleMatrix.multiply(rotMatrix).multiply(posMatrix); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer + + // clear the output canvas + this._outputContext.clearRect(0, 0, this._outputCanvas.width, this._outputCanvas.height); + + + let renderingBufferHasImageData = false; + + //iterate over tiled images and draw each one using a two-pass rendering pipeline if needed + tiledImages.forEach( (tiledImage, tiledImageIndex) => { + + let tilesToDraw = tiledImage.getTilesToDraw(); + + if(tilesToDraw.length === 0){ + return; + } + let firstTile = tilesToDraw[0]; + + let useContext2dPipeline = ( tiledImage.compositeOperation || + this.viewer.compositeOperation || + tiledImage._clip || + tiledImage._croppingPolygons || + tiledImage.debugMode + ); + + let useTwoPassRendering = useContext2dPipeline || (tiledImage.opacity < 1) || firstTile.hasTransparency; + + // using the context2d pipeline requires a clean rendering (back) buffer to start + if(useContext2dPipeline){ + // if the rendering buffer has image data currently, write it to the output canvas now and clear it + + if(renderingBufferHasImageData){ + this._outputContext.drawImage(this._renderingCanvas, 0, 0); + } + + // clear the buffer + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer + } + + // First rendering pass: compose tiles that make up this tiledImage + gl.useProgram(this._firstPass.shaderProgram); + + // bind to the framebuffer for render-to-texture if using two-pass rendering, otherwise back buffer (null) + if(useTwoPassRendering){ + gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer); + // clear the buffer to draw a new image + gl.clear(gl.COLOR_BUFFER_BIT); + } else { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + // no need to clear, just draw on top of the existing pixels + } + + let overallMatrix = viewMatrix; + + let imageRotation = tiledImage.getRotation(true); + // if needed, handle the tiledImage being rotated + if( imageRotation % 360 !== 0){ + let imageRotationMatrix = $.Mat3.makeRotation(-imageRotation * Math.PI / 180); + let imageCenter = tiledImage.getBoundsNoRotate(true).getCenter(); + let t1 = $.Mat3.makeTranslation(imageCenter.x, imageCenter.y); + let t2 = $.Mat3.makeTranslation(-imageCenter.x, -imageCenter.y); + + // update the view matrix to account for this image's rotation + let localMatrix = t1.multiply(imageRotationMatrix).multiply(t2); + overallMatrix = viewMatrix.multiply(localMatrix); + } + + let maxTextures = this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS); + if(maxTextures <= 0){ + // This can apparently happen on some systems if too many WebGL contexts have been created + // in which case maxTextures can be null, leading to out of bounds errors with the array. + // For example, when viewers were created and not destroyed in the test suite, this error + // occured in the TravisCI tests, though it did not happen when testing locally either in + // a browser or on the command line via grunt test. + + throw(new Error(`WegGL error: bad value for gl parameter MAX_TEXTURE_IMAGE_UNITS (${maxTextures}). This could happen + if too many contexts have been created and not released, or there is another problem with the graphics card.`)); + } + + let texturePositionArray = new Float32Array(maxTextures * 12); // 6 vertices (2 triangles) x 2 coordinates per vertex + let textureDataArray = new Array(maxTextures); + let matrixArray = new Array(maxTextures); + let opacityArray = new Array(maxTextures); + + // iterate over tiles and add data for each one to the buffers + for(let tileIndex = 0; tileIndex < tilesToDraw.length; tileIndex++){ + let tile = tilesToDraw[tileIndex].tile; + let indexInDrawArray = tileIndex % maxTextures; + let numTilesToDraw = indexInDrawArray + 1; + let tileContext = tile.getCanvasContext(); + + let textureInfo = tileContext ? this._TextureMap.get(tileContext.canvas) : null; + if(textureInfo){ + this._getTileData(tile, tiledImage, textureInfo, overallMatrix, indexInDrawArray, texturePositionArray, textureDataArray, matrixArray, opacityArray); + } else { + // console.log('No tile info', tile); + } + if( (numTilesToDraw === maxTextures) || (tileIndex === tilesToDraw.length - 1)){ + // We've filled up the buffers: time to draw this set of tiles + + // bind each tile's texture to the appropriate gl.TEXTURE# + for(let i = 0; i <= numTilesToDraw; i++){ + gl.activeTexture(gl.TEXTURE0 + i); + gl.bindTexture(gl.TEXTURE_2D, textureDataArray[i]); + } + + // set the buffer data for the texture coordinates to use for each tile + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferTexturePosition); + gl.bufferData(gl.ARRAY_BUFFER, texturePositionArray, gl.DYNAMIC_DRAW); + + // set the transform matrix uniform for each tile + matrixArray.forEach( (matrix, index) => { + gl.uniformMatrix3fv(this._firstPass.uTransformMatrices[index], false, matrix); + }); + // set the opacity uniform for each tile + gl.uniform1fv(this._firstPass.uOpacities, new Float32Array(opacityArray)); + + // bind vertex buffers and (re)set attributes before calling gl.drawArrays() + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferOutputPosition); + gl.vertexAttribPointer(this._firstPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferTexturePosition); + gl.vertexAttribPointer(this._firstPass.aTexturePosition, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferIndex); + gl.vertexAttribPointer(this._firstPass.aIndex, 1, gl.FLOAT, false, 0, 0); + + // Draw! 6 vertices per tile (2 triangles per rectangle) + gl.drawArrays(gl.TRIANGLES, 0, 6 * numTilesToDraw ); + } + } + + if(useTwoPassRendering){ + // Second rendering pass: Render the tiled image from the framebuffer into the back buffer + gl.useProgram(this._secondPass.shaderProgram); + + // set the rendering target to the back buffer (null) + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // bind the rendered texture from the first pass to use during this second pass + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture); + + // set opacity to the value for the current tiledImage + this._gl.uniform1f(this._secondPass.uOpacityMultiplier, tiledImage.opacity); + + // bind buffers and set attributes before calling gl.drawArrays + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferTexturePosition); + gl.vertexAttribPointer(this._secondPass.aTexturePosition, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferOutputPosition); + gl.vertexAttribPointer(this._firstPass.aOutputPosition, 2, gl.FLOAT, false, 0, 0); + + // Draw the quad (two triangles) + gl.drawArrays(gl.TRIANGLES, 0, 6); + + } + + renderingBufferHasImageData = true; + + if(useContext2dPipeline){ + // draw from the rendering canvas onto the output canvas, clipping/cropping if needed. + this._applyContext2dPipeline(tiledImage, tilesToDraw, tiledImageIndex); + renderingBufferHasImageData = false; + // clear the buffer + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clear(gl.COLOR_BUFFER_BIT); // clear the back buffer + } + + // after drawing the first TiledImage, fire the tiled-image-drawn event (for testing) + if(tiledImageIndex === 0){ + this._raiseTiledImageDrawnEvent(tiledImage, tilesToDraw.map(info=>info.tile)); + } + + }); + + if(renderingBufferHasImageData){ + this._outputContext.drawImage(this._renderingCanvas, 0, 0); + } + + } + + // Public API required by all Drawer implementations + /** + * Set the context2d imageSmoothingEnabled parameter + * @param {Boolean} enabled + */ + setImageSmoothingEnabled(enabled){ + // noop - this property does not impact WebGLDrawer + } + + /** + * Draw a rect onto the output canvas for debugging purposes + * @param {OpenSeadragon.Rect} rect + */ + drawDebuggingRect(rect){ + let context = this._outputContext; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.strokeStyle = this.debugGridColor[0]; + context.fillStyle = this.debugGridColor[0]; + + context.strokeRect( + rect.x * $.pixelDensityRatio, + rect.y * $.pixelDensityRatio, + rect.width * $.pixelDensityRatio, + rect.height * $.pixelDensityRatio + ); + + context.restore(); + } + + // private + _getTextureDataFromTile(tile){ + return tile.getCanvasContext().canvas; + } + + /** + * Draw data from the rendering canvas onto the output canvas, with clipping, + * cropping and/or debug info as requested. + * @private + * @param {OpenSeadragon.TiledImage} tiledImage - the tiledImage to draw + * @param {Array} tilesToDraw - array of objects containing tiles that were drawn + */ + _applyContext2dPipeline(tiledImage, tilesToDraw, tiledImageIndex){ + // composite onto the output canvas, clipping if necessary + this._outputContext.save(); + + // set composite operation; ignore for first image drawn + this._outputContext.globalCompositeOperation = tiledImageIndex === 0 ? null : tiledImage.compositeOperation || this.viewer.compositeOperation; + if(tiledImage._croppingPolygons || tiledImage._clip){ + this._renderToClippingCanvas(tiledImage); + this._outputContext.drawImage(this._clippingCanvas, 0, 0); + + } else { + this._outputContext.drawImage(this._renderingCanvas, 0, 0); + } + this._outputContext.restore(); + if(tiledImage.debugMode){ + let colorIndex = this.viewer.world.getIndexOfItem(tiledImage) % this.debugGridColor.length; + let strokeStyle = this.debugGridColor[colorIndex]; + let fillStyle = this.debugGridColor[colorIndex]; + this._drawDebugInfo(tilesToDraw, tiledImage, strokeStyle, fillStyle); + } + + + } + + // private + _getTileData(tile, tiledImage, textureInfo, viewMatrix, index, texturePositionArray, textureDataArray, matrixArray, opacityArray){ + + let texture = textureInfo.texture; + let textureQuad = textureInfo.position; + + // set the position of this texture + texturePositionArray.set(textureQuad, index * 12); + + // compute offsets that account for tile overlap; needed for calculating the transform matrix appropriately + let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); + let xOffset = tile.positionedBounds.width * overlapFraction.x; + let yOffset = tile.positionedBounds.height * overlapFraction.y; + + // x, y, w, h in viewport coords + let x = tile.positionedBounds.x + (tile.x === 0 ? 0 : xOffset); + let y = tile.positionedBounds.y + (tile.y === 0 ? 0 : yOffset); + let right = tile.positionedBounds.x + tile.positionedBounds.width - (tile.isRightMost ? 0 : xOffset); + let bottom = tile.positionedBounds.y + tile.positionedBounds.height - (tile.isBottomMost ? 0 : yOffset); + let w = right - x; + let h = bottom - y; + + let matrix = new $.Mat3([ + w, 0, 0, + 0, h, 0, + x, y, 1, + ]); + + if(tile.flipped){ + // flip the tile around the center of the unit quad + let t1 = $.Mat3.makeTranslation(0.5, 0); + let t2 = $.Mat3.makeTranslation(-0.5, 0); + + // update the view matrix to account for this image's rotation + let localMatrix = t1.multiply($.Mat3.makeScaling(-1, 1)).multiply(t2); + matrix = matrix.multiply(localMatrix); + } + + let overallMatrix = viewMatrix.multiply(matrix); + + opacityArray[index] = tile.opacity; + textureDataArray[index] = texture; + matrixArray[index] = overallMatrix.values; + + } + + // private + _setupRenderer(){ + let gl = this._gl; + if(!gl){ + $.console.error('_setupCanvases must be called before _setupRenderer'); + } + this._unitQuad = this._makeQuadVertexBuffer(0, 1, 0, 1); // used a few places; create once and store the result + + this._makeFirstPassShaderProgram(); + this._makeSecondPassShaderProgram(); + + // set up the texture to render to in the first pass, and which will be used for rendering the second pass + this._renderToTexture = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this._renderingCanvas.width, this._renderingCanvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // set up the framebuffer for render-to-texture + this._glFrameBuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, // attach texture as COLOR_ATTACHMENT0 + gl.TEXTURE_2D, // attach a 2D texture + this._renderToTexture, // the texture to attach + 0 + ); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + + } + + //private + _makeFirstPassShaderProgram(){ + let numTextures = this._glNumTextures = this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS); + let makeMatrixUniforms = () => { + return [...Array(numTextures).keys()].map(index => `uniform mat3 u_matrix_${index};`).join('\n'); + }; + let makeConditionals = () => { + return [...Array(numTextures).keys()].map(index => `${index > 0 ? 'else ' : ''}if(int(a_index) == ${index}) { transform_matrix = u_matrix_${index}; }`).join('\n'); + }; + + const vertexShaderProgram = ` + attribute vec2 a_output_position; + attribute vec2 a_texture_position; + attribute float a_index; + + ${makeMatrixUniforms()} // create a uniform mat3 for each potential tile to draw + + varying vec2 v_texture_position; + varying float v_image_index; + + void main() { + + mat3 transform_matrix; // value will be set by the if/elses in makeConditional() + + ${makeConditionals()} + + gl_Position = vec4(transform_matrix * vec3(a_output_position, 1), 1); + + v_texture_position = a_texture_position; + v_image_index = a_index; + } + `; + + const fragmentShaderProgram = ` + precision mediump float; + + // our textures + uniform sampler2D u_images[${numTextures}]; + // our opacities + uniform float u_opacities[${numTextures}]; + + // the varyings passed in from the vertex shader. + varying vec2 v_texture_position; + varying float v_image_index; + + void main() { + // can't index directly with a variable, need to use a loop iterator hack + for(int i = 0; i < ${numTextures}; ++i){ + if(i == int(v_image_index)){ + gl_FragColor = texture2D(u_images[i], v_texture_position) * u_opacities[i]; + } + } + } + `; + + let gl = this._gl; + + let program = this.constructor.initShaderProgram(gl, vertexShaderProgram, fragmentShaderProgram); + gl.useProgram(program); + + // get locations of attributes and uniforms, and create buffers for each attribute + this._firstPass = { + shaderProgram: program, + aOutputPosition: gl.getAttribLocation(program, 'a_output_position'), + aTexturePosition: gl.getAttribLocation(program, 'a_texture_position'), + aIndex: gl.getAttribLocation(program, 'a_index'), + uTransformMatrices: [...Array(this._glNumTextures).keys()].map(i=>gl.getUniformLocation(program, `u_matrix_${i}`)), + uImages: gl.getUniformLocation(program, 'u_images'), + uOpacities: gl.getUniformLocation(program, 'u_opacities'), + bufferOutputPosition: gl.createBuffer(), + bufferTexturePosition: gl.createBuffer(), + bufferIndex: gl.createBuffer(), + }; + + gl.uniform1iv(this._firstPass.uImages, [...Array(numTextures).keys()]); + + // provide coordinates for the rectangle in output space, i.e. a unit quad for each one. + let outputQuads = new Float32Array(numTextures * 12); + for(let i = 0; i < numTextures; ++i){ + outputQuads.set(Float32Array.from(this._unitQuad), i * 12); + } + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferOutputPosition); + gl.bufferData(gl.ARRAY_BUFFER, outputQuads, gl.STATIC_DRAW); // bind data statically here, since it's unchanging + gl.enableVertexAttribArray(this._firstPass.aOutputPosition); + + // provide texture coordinates for the rectangle in image (texture) space. Data will be set later. + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferTexturePosition); + gl.enableVertexAttribArray(this._firstPass.aTexturePosition); + + // for each vertex, provide an index into the array of textures/matrices to use for the correct tile + gl.bindBuffer(gl.ARRAY_BUFFER, this._firstPass.bufferIndex); + let indices = [...Array(this._glNumTextures).keys()].map(i => Array(6).fill(i)).flat(); // repeat each index 6 times, for the 6 vertices per tile (2 triangles) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(indices), gl.STATIC_DRAW); // bind data statically here, since it's unchanging + gl.enableVertexAttribArray(this._firstPass.aIndex); + + } + + // private + _makeSecondPassShaderProgram(){ + const vertexShaderProgram = ` + attribute vec2 a_output_position; + attribute vec2 a_texture_position; + + uniform mat3 u_matrix; + + varying vec2 v_texture_position; + + void main() { + gl_Position = vec4(u_matrix * vec3(a_output_position, 1), 1); + + v_texture_position = a_texture_position; + } + `; + + const fragmentShaderProgram = ` + precision mediump float; + + // our texture + uniform sampler2D u_image; + + // the texCoords passed in from the vertex shader. + varying vec2 v_texture_position; + + // the opacity multiplier for the image + uniform float u_opacity_multiplier; + + void main() { + gl_FragColor = texture2D(u_image, v_texture_position); + gl_FragColor *= u_opacity_multiplier; + } + `; + + let gl = this._gl; + + let program = this.constructor.initShaderProgram(gl, vertexShaderProgram, fragmentShaderProgram); + gl.useProgram(program); + + // get locations of attributes and uniforms, and create buffers for each attribute + this._secondPass = { + shaderProgram: program, + aOutputPosition: gl.getAttribLocation(program, 'a_output_position'), + aTexturePosition: gl.getAttribLocation(program, 'a_texture_position'), + uMatrix: gl.getUniformLocation(program, 'u_matrix'), + uImage: gl.getUniformLocation(program, 'u_image'), + uOpacityMultiplier: gl.getUniformLocation(program, 'u_opacity_multiplier'), + bufferOutputPosition: gl.createBuffer(), + bufferTexturePosition: gl.createBuffer(), + }; + + + // provide coordinates for the rectangle in output space, i.e. a unit quad for each one. + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferOutputPosition); + gl.bufferData(gl.ARRAY_BUFFER, this._unitQuad, gl.STATIC_DRAW); // bind data statically here since it's unchanging + gl.enableVertexAttribArray(this._secondPass.aOutputPosition); + + // provide texture coordinates for the rectangle in image (texture) space. + gl.bindBuffer(gl.ARRAY_BUFFER, this._secondPass.bufferTexturePosition); + gl.bufferData(gl.ARRAY_BUFFER, this._unitQuad, gl.DYNAMIC_DRAW); // bind data statically here since it's unchanging + gl.enableVertexAttribArray(this._secondPass.aTexturePosition); + + // set the matrix that transforms the framebuffer to clip space + let matrix = $.Mat3.makeScaling(2, 2).multiply($.Mat3.makeTranslation(-0.5, -0.5)); + gl.uniformMatrix3fv(this._secondPass.uMatrix, false, matrix.values); + } + + // private + _resizeRenderer(){ + let gl = this._gl; + let w = this._renderingCanvas.width; + let h = this._renderingCanvas.height; + gl.viewport(0, 0, w, h); + + //release the old texture + gl.deleteTexture(this._renderToTexture); + //create a new texture and set it up + this._renderToTexture = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._renderToTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + //bind the frame buffer to the new texture + gl.bindFramebuffer(gl.FRAMEBUFFER, this._glFrameBuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._renderToTexture, 0); + + } + + // private + _setupCanvases(){ + let _this = this; + + this._outputCanvas = this.canvas; //output canvas + this._outputContext = this._outputCanvas.getContext('2d'); + + this._renderingCanvas = document.createElement('canvas'); + + this._clippingCanvas = document.createElement('canvas'); + this._clippingContext = this._clippingCanvas.getContext('2d'); + this._renderingCanvas.width = this._clippingCanvas.width = this._outputCanvas.width; + this._renderingCanvas.height = this._clippingCanvas.height = this._outputCanvas.height; + + this._gl = this._renderingCanvas.getContext('webgl'); + + //make the additional canvas elements mirror size changes to the output canvas + this.viewer.addHandler("resize", function(){ + + if(_this._outputCanvas !== _this.viewer.drawer.canvas){ + _this._outputCanvas.style.width = _this.viewer.drawer.canvas.clientWidth + 'px'; + _this._outputCanvas.style.height = _this.viewer.drawer.canvas.clientHeight + 'px'; + } + + let viewportSize = _this._calculateCanvasSize(); + if( _this._outputCanvas.width !== viewportSize.x || + _this._outputCanvas.height !== viewportSize.y ) { + _this._outputCanvas.width = viewportSize.x; + _this._outputCanvas.height = viewportSize.y; + } + + _this._renderingCanvas.style.width = _this._outputCanvas.clientWidth + 'px'; + _this._renderingCanvas.style.height = _this._outputCanvas.clientHeight + 'px'; + _this._renderingCanvas.width = _this._clippingCanvas.width = _this._outputCanvas.width; + _this._renderingCanvas.height = _this._clippingCanvas.height = _this._outputCanvas.height; + + // important - update the size of the rendering viewport! + _this._resizeRenderer(); + }); + } + + // private + _makeQuadVertexBuffer(left, right, top, bottom){ + return new Float32Array([ + left, bottom, + right, bottom, + left, top, + left, top, + right, bottom, + right, top]); + } + + // private + _tileReadyHandler(event){ + let tile = event.tile; + let tiledImage = event.tiledImage; + let tileContext = tile.getCanvasContext(); + let canvas = tileContext.canvas; + let textureInfo = this._TextureMap.get(canvas); + + // if this is a new image for us, create a texture + if(!textureInfo){ + let gl = this._gl; + + // create a gl Texture for this tile and bind the canvas with the image data + let texture = gl.createTexture(); + let position; + let overlap = tiledImage.source.tileOverlap; + if( overlap > 0){ + // calculate the normalized position of the rect to actually draw + // discarding overlap. + let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); + + let left = tile.x === 0 ? 0 : overlapFraction.x; + let top = tile.y === 0 ? 0 : overlapFraction.y; + let right = tile.isRightMost ? 1 : 1 - overlapFraction.x; + let bottom = tile.isBottomMost ? 1 : 1 - overlapFraction.y; + position = this._makeQuadVertexBuffer(left, right, top, bottom); + } else { + // no overlap: this texture can use the unit quad as its position data + position = this._unitQuad; + } + + let textureInfo = { + texture: texture, + position: position, + }; + + // add it to our _TextureMap + this._TextureMap.set(canvas, textureInfo); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + // Set the parameters so we can render any size image. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + + // Upload the image into the texture. + this._uploadImageData(tileContext); + + } + + } + + // private + _calculateOverlapFraction(tile, tiledImage){ + let overlap = tiledImage.source.tileOverlap; + let nativeWidth = tile.sourceBounds.width; // in pixels + let nativeHeight = tile.sourceBounds.height; // in pixels + let overlapWidth = (tile.x === 0 ? 0 : overlap) + (tile.isRightMost ? 0 : overlap); // in pixels + let overlapHeight = (tile.y === 0 ? 0 : overlap) + (tile.isBottomMost ? 0 : overlap); // in pixels + let widthOverlapFraction = overlap / (nativeWidth + overlapWidth); // as a fraction of image including overlap + let heightOverlapFraction = overlap / (nativeHeight + overlapHeight); // as a fraction of image including overlap + return { + x: widthOverlapFraction, + y: heightOverlapFraction + }; + } + + // private + _uploadImageData(tileContext){ + + let gl = this._gl; + let canvas = tileContext.canvas; + + try{ + if(!canvas){ + throw('Tile context does not have a canvas', tileContext); + } + // This depends on gl.TEXTURE_2D being bound to the texture + // associated with this canvas before calling this function + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); + } catch (e){ + $.console.error('Error uploading image data to WebGL', e); + } + } + + // private + _imageUnloadedHandler(event){ + let canvas = event.context2D.canvas; + this._cleanupImageData(canvas); + } + + // private + _cleanupImageData(tileCanvas){ + let textureInfo = this._TextureMap.get(tileCanvas); + //remove from the map + this._TextureMap.delete(tileCanvas); + + //release the texture from the GPU + if(textureInfo){ + this._gl.deleteTexture(textureInfo.texture); + } + + } + + // private + _setClip(rect){ + this._clippingContext.beginPath(); + this._clippingContext.rect(rect.x, rect.y, rect.width, rect.height); + this._clippingContext.clip(); + } + + // private + _renderToClippingCanvas(item){ + + this._clippingContext.clearRect(0, 0, this._clippingCanvas.width, this._clippingCanvas.height); + this._clippingContext.save(); + + if(item._clip){ + var box = item.imageToViewportRectangle(item._clip, true); + var rect = this.viewportToDrawerRectangle(box); + this._setClip(rect); + } + if(item._croppingPolygons){ + let polygons = item._croppingPolygons.map(polygon => { + return polygon.map(coord => { + let point = item.imageToViewportCoordinates(coord.x, coord.y, true) + .rotate(this.viewer.viewport.getRotation(true), this.viewer.viewport.getCenter(true)); + let clipPoint = this.viewportCoordToDrawerCoord(point); + return clipPoint; + }); + }); + this._clippingContext.beginPath(); + polygons.forEach(polygon => { + polygon.forEach( (coord, i) => { + this._clippingContext[i === 0 ? 'moveTo' : 'lineTo'](coord.x, coord.y); + }); + }); + this._clippingContext.clip(); + } + + this._clippingContext.drawImage(this._renderingCanvas, 0, 0); + + this._clippingContext.restore(); + } + + // private + _offsetForRotation(options) { + var point = options.point ? + options.point.times($.pixelDensityRatio) : + new $.Point(this._outputCanvas.width / 2, this._outputCanvas.height / 2); + + var context = this._outputContext; + context.save(); + + context.translate(point.x, point.y); + if(this.viewport.flipped){ + context.rotate(Math.PI / 180 * -options.degrees); + context.scale(-1, 1); + } else{ + context.rotate(Math.PI / 180 * options.degrees); + } + context.translate(-point.x, -point.y); + } + + // private + _drawDebugInfo( tilesToDraw, tiledImage, stroke, fill ) { + + for ( var i = tilesToDraw.length - 1; i >= 0; i-- ) { + var tile = tilesToDraw[ i ].tile; + try { + this._drawDebugInfoOnTile(tile, tilesToDraw.length, i, tiledImage, stroke, fill); + } catch(e) { + $.console.error(e); + } + } + } + + // private + _drawDebugInfoOnTile(tile, count, i, tiledImage, stroke, fill) { + + var context = this._outputContext; + context.save(); + context.lineWidth = 2 * $.pixelDensityRatio; + context.font = 'small-caps bold ' + (13 * $.pixelDensityRatio) + 'px arial'; + context.strokeStyle = stroke; + context.fillStyle = fill; + + if (this.viewport.getRotation(true) % 360 !== 0 ) { + this._offsetForRotation({degrees: this.viewport.getRotation(true)}); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._offsetForRotation({ + degrees: tiledImage.getRotation(true), + point: tiledImage.viewport.pixelFromPointNoRotate( + tiledImage._getRotationPoint(true), true) + }); + } + if (tiledImage.viewport.getRotation(true) % 360 === 0 && + tiledImage.getRotation(true) % 360 === 0) { + if(tiledImage._drawer.viewer.viewport.getFlip()) { + tiledImage._drawer._flip(); + } + } + + context.strokeRect( + tile.position.x * $.pixelDensityRatio, + tile.position.y * $.pixelDensityRatio, + tile.size.x * $.pixelDensityRatio, + tile.size.y * $.pixelDensityRatio + ); + + var tileCenterX = (tile.position.x + (tile.size.x / 2)) * $.pixelDensityRatio; + var tileCenterY = (tile.position.y + (tile.size.y / 2)) * $.pixelDensityRatio; + + // Rotate the text the right way around. + context.translate( tileCenterX, tileCenterY ); + context.rotate( Math.PI / 180 * -this.viewport.getRotation(true) ); + context.translate( -tileCenterX, -tileCenterY ); + + if( tile.x === 0 && tile.y === 0 ){ + context.fillText( + "Zoom: " + this.viewport.getZoom(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 30) * $.pixelDensityRatio + ); + context.fillText( + "Pan: " + this.viewport.getBounds().toString(), + tile.position.x * $.pixelDensityRatio, + (tile.position.y - 20) * $.pixelDensityRatio + ); + } + context.fillText( + "Level: " + tile.level, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 20) * $.pixelDensityRatio + ); + context.fillText( + "Column: " + tile.x, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 30) * $.pixelDensityRatio + ); + context.fillText( + "Row: " + tile.y, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 40) * $.pixelDensityRatio + ); + context.fillText( + "Order: " + i + " of " + count, + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 50) * $.pixelDensityRatio + ); + context.fillText( + "Size: " + tile.size.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 60) * $.pixelDensityRatio + ); + context.fillText( + "Position: " + tile.position.toString(), + (tile.position.x + 10) * $.pixelDensityRatio, + (tile.position.y + 70) * $.pixelDensityRatio + ); + + if (this.viewport.getRotation(true) % 360 !== 0 ) { + this._restoreRotationChanges(); + } + if (tiledImage.getRotation(true) % 360 !== 0) { + this._restoreRotationChanges(); + } + + if (tiledImage.viewport.getRotation(true) % 360 === 0 && + tiledImage.getRotation(true) % 360 === 0) { + if(tiledImage._drawer.viewer.viewport.getFlip()) { + tiledImage._drawer._flip(); + } + } + + context.restore(); + } + + // private + _restoreRotationChanges() { + var context = this._outputContext; + context.restore(); + } + + // modified from https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Tutorial/Adding_2D_content_to_a_WebGL_context + static initShaderProgram(gl, vsSource, fsSource) { + + function loadShader(gl, type, source) { + const shader = gl.createShader(type); + + // Send the source to the shader object + + gl.shaderSource(shader, source); + + // Compile the shader program + + gl.compileShader(shader); + + // See if it compiled successfully + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + $.console.error( + `An error occurred compiling the shaders: ${gl.getShaderInfoLog(shader)}` + ); + gl.deleteShader(shader); + return null; + } + + return shader; + } + + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); + + // Create the shader program + + const shaderProgram = gl.createProgram(); + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + + // If creating the shader program failed, alert + + if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { + $.console.error( + `Unable to initialize the shader program: ${gl.getProgramInfoLog( + shaderProgram + )}` + ); + return null; + } + + return shaderProgram; + } + + }; + + + +}( OpenSeadragon )); diff --git a/src/world.js b/src/world.js index a525997b..5539d2bc 100644 --- a/src/world.js +++ b/src/world.js @@ -2,7 +2,7 @@ * OpenSeadragon - World * * Copyright (C) 2009 CodePlex Foundation - * Copyright (C) 2010-2023 OpenSeadragon contributors + * Copyright (C) 2010-2024 OpenSeadragon contributors * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are @@ -242,11 +242,14 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W /** * Updates (i.e. animates bounds of) all items. + * @function + * @param viewportChanged Whether the viewport changed, which indicates that + * all TiledImages need to be updated. */ - update: function() { + update: function(viewportChanged) { var animated = false; for ( var i = 0; i < this._items.length; i++ ) { - animated = this._items[i].update() || animated; + animated = this._items[i].update(viewportChanged) || animated; } return animated; @@ -256,11 +259,11 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W * Draws all items. */ draw: function() { - for ( var i = 0; i < this._items.length; i++ ) { - this._items[i].draw(); - } - + this.viewer.drawer.draw(this._items); this._needsDraw = false; + this._items.forEach(function(item){ + this._needsDraw = item.setDrawn() || this._needsDraw; + }); }, /** diff --git a/test/demo/basic.html b/test/demo/basic.html index 6a677420..ae4b96af 100644 --- a/test/demo/basic.html +++ b/test/demo/basic.html @@ -25,7 +25,7 @@ id: "contentDiv", prefixUrl: "../../build/openseadragon/images/", tileSources: "../data/testpattern.dzi", - showNavigator: true + showNavigator: true, }); diff --git a/test/demo/collections/main.js b/test/demo/collections/main.js index 83563085..ef5d8b60 100644 --- a/test/demo/collections/main.js +++ b/test/demo/collections/main.js @@ -16,7 +16,6 @@ // debugMode: true, zoomPerScroll: 1.02, showNavigator: testNavigator, - useCanvas: true, // defaultZoomLevel: 2, // homeFillsViewer: true, // sequenceMode: true, @@ -131,8 +130,9 @@ var box = new OpenSeadragon.Rect(margins.left, margins.top, $('#contentDiv').width() - (margins.left + margins.right), $('#contentDiv').height() - (margins.top + margins.bottom)); - - self.viewer.drawer.debugRect(box); + // If drawDebuggingRect is implemented, use it to show the box. + // This is not implemented by all drawers however. + self.viewer.drawer.drawDebuggingRect(box); }); } diff --git a/test/demo/drawercomparison.html b/test/demo/drawercomparison.html new file mode 100644 index 00000000..e8ca6e48 --- /dev/null +++ b/test/demo/drawercomparison.html @@ -0,0 +1,114 @@ + + + + Drawer Comparison Demo + + + + + + + + + +
+ +

Compare behavior of Context2d and WebGL drawers

+
+
+

Loading...

+
+
+ +
+

Loading...

+
+
+
+ + +
+

Image options (drag and drop to re-order images)

+ + +
+ +

HTMLDrawer: legacy pre-HTML5 drawer that uses <img> elements for tiles

+
+
+
+ HTML-based rendering can be selected in two different ways: +
+
+    // via the 'html' drawer option:
+    let viewer = OpenSeadragon({
+        ...
+        drawer: 'html',
+        ...
+    });
+
+    // or by passing the HTMLDrawer constructor
+    let viewer = OpenSeadragon({
+        ...
+        drawer:OpenSeadragon.HTMLDrawer,
+        ...
+    });
+                
+
+
+
+
+ + + + + + diff --git a/test/demo/drawercomparison.js b/test/demo/drawercomparison.js new file mode 100644 index 00000000..96793915 --- /dev/null +++ b/test/demo/drawercomparison.js @@ -0,0 +1,356 @@ +const sources = { + "rainbow":"../data/testpattern.dzi", + "leaves":"../data/iiif_2_0_sizes/info.json", + "bblue":{ + type:'image', + url: "../data/BBlue.png", + }, + "duomo":"https://openseadragon.github.io/example-images/duomo/duomo.dzi", +} +const labels = { + rainbow: 'Rainbow Grid', + leaves: 'Leaves', + bblue: 'Blue B', + duomo: 'Duomo', +} +const drawers = { + canvas: "Context2d drawer (default in OSD <= 4.1.0)", + webgl: "New WebGL drawer" +} + +//Support drawer type from the url +const url = new URL(window.location.href); +const drawer1 = url.searchParams.get("left") || 'canvas'; +const drawer2 = url.searchParams.get("right") || 'webgl'; + +$("#title-w1").html(drawers[drawer1]); +$("#title-w2").html(drawers[drawer2]); + +//Double viewer setup for comparison - CanvasDrawer and WebGLDrawer +// viewer1: canvas drawer +let viewer1 = window.viewer1 = OpenSeadragon({ + id: "canvasdrawer", + prefixUrl: "../../build/openseadragon/images/", + minZoomImageRatio:0.01, + maxZoomPixelRatio:100, + smoothTileEdgesMinZoom:1.1, + crossOriginPolicy: 'Anonymous', + ajaxWithCredentials: false, + // maxImageCacheCount: 30, + drawer:drawer1, + blendTime:0, + showNavigator:true, +}); + +// viewer2: webgl drawer +let viewer2 = window.viewer2 = OpenSeadragon({ + id: "webgl", + prefixUrl: "../../build/openseadragon/images/", + minZoomImageRatio:0.01, + maxZoomPixelRatio:100, + smoothTileEdgesMinZoom:1.1, + crossOriginPolicy: 'Anonymous', + ajaxWithCredentials: false, + // maxImageCacheCount: 30, + drawer:drawer2, + blendTime:0, + showNavigator:true, +}); + +// // viewer3: html drawer, unused +var viewer3 = window.viewer3 = OpenSeadragon({ + id: "htmldrawer", + drawer:'html', + blendTime:2, + prefixUrl: "../../build/openseadragon/images/", + minZoomImageRatio:0.01, + customDrawer: OpenSeadragon.HTMLDrawer, + tileSources: [sources['leaves'], sources['rainbow'], sources['duomo']], + sequenceMode: true, + crossOriginPolicy: 'Anonymous', + ajaxWithCredentials: false +}); + + +// Sync navigation of viewer1 and viewer 2 +var viewer1Leading = false; +var viewer2Leading = false; + +var viewer1Handler = function() { + if (viewer2Leading) { + return; + } + + viewer1Leading = true; + viewer2.viewport.zoomTo(viewer1.viewport.getZoom()); + viewer2.viewport.panTo(viewer1.viewport.getCenter()); + viewer2.viewport.rotateTo(viewer1.viewport.getRotation()); + viewer2.viewport.setFlip(viewer1.viewport.flipped); + viewer1Leading = false; +}; + +var viewer2Handler = function() { + if (viewer1Leading) { + return; + } + + viewer2Leading = true; + viewer1.viewport.zoomTo(viewer2.viewport.getZoom()); + viewer1.viewport.panTo(viewer2.viewport.getCenter()); + viewer1.viewport.rotateTo(viewer2.viewport.getRotation()); + viewer1.viewport.setFlip(viewer1.viewport.flipped); + viewer2Leading = false; +}; + +viewer1.addHandler('zoom', viewer1Handler); +viewer2.addHandler('zoom', viewer2Handler); +viewer1.addHandler('pan', viewer1Handler); +viewer2.addHandler('pan', viewer2Handler); +viewer1.addHandler('rotate', viewer1Handler); +viewer2.addHandler('rotate', viewer2Handler); +viewer1.addHandler('flip', viewer1Handler); +viewer2.addHandler('flip', viewer2Handler); + + +$('#image-picker').sortable({ + update: function(event, ui){ + let thisItem = ui.item.find('.toggle').data('item1'); + let items = $('#image-picker input.toggle:checked').toArray().map(item=>$(item).data('item1')); + let newIndex = items.indexOf(thisItem); + if(thisItem){ + viewer1.world.setItemIndex(thisItem, newIndex); + } + + thisItem = ui.item.find('.toggle').data('item2'); + items = $('#image-picker input.toggle:checked').toArray().map(item=>$(item).data('item2')); + newIndex = items.indexOf(thisItem); + if(thisItem){ + viewer2.world.setItemIndex(thisItem, newIndex); + } + } +}); + +Object.keys(sources).forEach((key, index)=>{ + let element = makeImagePickerElement(key, labels[key]) + $('#image-picker').append(element); + if(index === 0){ + element.find('.toggle').prop('checked',true); + } +}) + +$('#image-picker').append(makeComparisonSwitcher()); + +$('#image-picker input.toggle').on('change',function(){ + let data = $(this).data(); + if(this.checked){ + addTileSource(viewer1, data.image, this); + addTileSource(viewer2, data.image, this); + } else { + if(data.item1){ + viewer1.world.removeItem(data.item1); + viewer2.world.removeItem(data.item2); + $(this).data({item1: null, item2: null}); + } + } +}).trigger('change'); + +$('#image-picker input:not(.toggle)').on('change',function(){ + let data = $(this).data(); + let value = $(this).val(); + let tiledImage1 = $(`#image-picker input.toggle[data-image=${data.image}]`).data('item1'); + let tiledImage2 = $(`#image-picker input.toggle[data-image=${data.image}]`).data('item2'); + updateTiledImage(tiledImage1, data, value, this); + updateTiledImage(tiledImage2, data, value, this); +}); + +function updateTiledImage(tiledImage, data, value, item){ + let field = data.field; + + if(tiledImage){ + //item = tiledImage + if(field == 'x'){ + let bounds = tiledImage.getBoundsNoRotate(); + let position = new OpenSeadragon.Point(Number(value), bounds.y); + tiledImage.setPosition(position); + } else if ( field == 'y'){ + let bounds = tiledImage.getBoundsNoRotate(); + let position = new OpenSeadragon.Point(bounds.x, Number(value)); + tiledImage.setPosition(position); + } else if (field == 'width'){ + tiledImage.setWidth(Number(value)); + } else if (field == 'degrees'){ + tiledImage.setRotation(Number(value)); + } else if (field == 'opacity'){ + tiledImage.setOpacity(Number(value)); + } else if (field == 'flipped'){ + tiledImage.setFlip($(item).prop('checked')); + } else if (field == 'cropped'){ + if( $(item).prop('checked') ){ + let scale = tiledImage.source.width; + let croppingPolygons = [ [{x:0.2*scale, y:0.2*scale}, {x:0.8*scale, y:0.2*scale}, {x:0.5*scale, y:0.8*scale}] ]; + tiledImage.setCroppingPolygons(croppingPolygons); + } else { + tiledImage.resetCroppingPolygons(); + } + } else if (field == 'clipped'){ + if( $(item).prop('checked') ){ + let scale = tiledImage.source.width; + let clipRect = new OpenSeadragon.Rect(0.1*scale, 0.2*scale, 0.6*scale, 0.4*scale); + tiledImage.setClip(clipRect); + } else { + tiledImage.setClip(null); + } + } else if (field == 'debug'){ + if( $(item).prop('checked') ){ + tiledImage.debugMode = true; + } else { + tiledImage.debugMode = false; + } + } + } else { + //viewer-level option + } +} + +$('.image-options select[data-field=composite]').append(getCompositeOperationOptions()).on('change',function(){ + let data = $(this).data(); + let tiledImage1 = $(`#image-picker input.toggle[data-image=${data.image}]`).data('item1'); + if(tiledImage1){ + tiledImage1.setCompositeOperation(this.value == 'null' ? null : this.value); + } + let tiledImage2 = $(`#image-picker input.toggle[data-image=${data.image}]`).data('item2'); + if(tiledImage2){ + tiledImage2.setCompositeOperation(this.value == 'null' ? null : this.value); + } +}).trigger('change'); + +$('.image-options select[data-field=wrapping]').append(getWrappingOptions()).on('change',function(){ + let data = $(this).data(); + let tiledImage = $(`#image-picker input.toggle[data-image=${data.image}]`).data('item1'); + if(tiledImage){ + switch(this.value){ + case "None": tiledImage.wrapHorizontal = tiledImage.wrapVertical = false; break; + case "Horizontal": tiledImage.wrapHorizontal = true; tiledImage.wrapVertical = false; break; + case "Vertical": tiledImage.wrapHorizontal = false; tiledImage.wrapVertical = true; break; + case "Both": tiledImage.wrapHorizontal = tiledImage.wrapVertical = true; break; + } + tiledImage.redraw();//trigger a redraw for the webgl renderer. + } + tiledImage = $(`#image-picker input.toggle[data-image=${data.image}]`).data('item2'); + if(tiledImage){ + switch(this.value){ + case "None": tiledImage.wrapHorizontal = tiledImage.wrapVertical = false; break; + case "Horizontal": tiledImage.wrapHorizontal = true; tiledImage.wrapVertical = false; break; + case "Vertical": tiledImage.wrapHorizontal = false; tiledImage.wrapVertical = true; break; + case "Both": tiledImage.wrapHorizontal = tiledImage.wrapVertical = true; break; + } + tiledImage.redraw();//trigger a redraw for the webgl renderer. + } +}).trigger('change'); + +function getWrappingOptions(){ + let opts = ['None', 'Horizontal', 'Vertical', 'Both']; + let elements = opts.map((opt, i)=>{ + let el = $('