diff --git a/Gruntfile.js b/Gruntfile.js index 29e37cda..72460432 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", @@ -59,11 +60,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/changelog.txt b/changelog.txt index b3b0ae36..866f0394 100644 --- a/changelog.txt +++ b/changelog.txt @@ -5,6 +5,8 @@ OPENSEADRAGON CHANGELOG * BREAKING CHANGE: Dropped support for IE11 (#2300, #2361 @AndrewADev) * DEPRECATION: The OpenSeadragon.createCallback function is no longer recommended (#2367 @akansjain) +* The viewer now uses WebGL when available (#2310, #2462, #2466 @pearcetm, @Aiosa) +* Added webp to supported image formats (#2455 @BeebBenjamin) * Introduced maxTilesPerFrame option to allow loading more tiles simultaneously (#2387 @jetic83) * Now when creating a viewer or navigator, we leave its position style alone if possible (#2393 @VIRAT9358) * Test improvements (#2382 @AndrewADev) @@ -12,6 +14,7 @@ OPENSEADRAGON CHANGELOG * Fixed: Sometimes if the viewport was flipped and the user zoomed in far enough, it would flip back (#2364 @SebDelile) * Fixed: Strange behavior if IIIF sizes were not in ascending order (#2416 @lutzhelm) * Fixed: Two-finger tap on a Mac trackpad would zoom you out (#2431 @cavenel) +* Fixed: dragToPan gesture could not be disabled when flickEnabled was activated (#2464 @jonasengelmann) 4.1.0: 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..d4ec6231 --- /dev/null +++ b/src/canvasdrawer.js @@ -0,0 +1,1105 @@ +/* + * 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( $ ){ + + const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc +/** + * @class OpenSeadragon.CanvasDrawer + * @extends OpenSeadragon.DrawerBase + * @classdesc Default implementation of CanvasDrawer for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + +class CanvasDrawer extends OpenSeadragon.DrawerBase{ + constructor(options){ + super(options); + + /** + * The HTML element (canvas) that this drawer uses for drawing + * @member {Element} canvas + * @memberof OpenSeadragon.CanvasDrawer# + */ + + /** + * The parent element of this Drawer instance, passed in when the Drawer was created. + * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}. + * @member {Element} container + * @memberof OpenSeadragon.CanvasDrawer# + */ + + /** + * 2d drawing context for {@link OpenSeadragon.CanvasDrawer#canvas}. + * @member {Object} context + * @memberof OpenSeadragon.CanvasDrawer# + * @private + */ + this.context = this.canvas.getContext( '2d' ); + + // Sketch canvas used to temporarily draw tiles which cannot be drawn directly + // to the main canvas due to opacity. Lazily initialized. + this.sketchCanvas = null; + this.sketchContext = null; + + // Image smoothing for canvas rendering (only if canvas is used). + // Canvas default is "true", so this will only be changed if user specifies "false" in the options or via setImageSmoothinEnabled. + this._imageSmoothingEnabled = true; + + + // Since the tile-drawn and tile-drawing events are fired by this drawer, make sure handlers can be added for them + this.viewer.allowEventHandler("tile-drawn"); + this.viewer.allowEventHandler("tile-drawing"); + + } + + /** + * @returns {Boolean} true if canvas is supported by the browser, otherwise false + */ + static isSupported(){ + return $.supportsCanvas; + } + + getType(){ + return 'canvas'; + } + + /** + * 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) { + 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 3428befd..0a8b7ca6 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/datatypeconvertor.js b/src/datatypeconvertor.js index 438545c4..2cc6c2da 100644 --- a/src/datatypeconvertor.js +++ b/src/datatypeconvertor.js @@ -2,7 +2,7 @@ * OpenSeadragon.convertor (static property) * * 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..29d7a3b4 --- /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( $ ){ + + const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc +/** + * @class OpenSeadragon.DrawerBase + * @classdesc Base class for Drawers that handle rendering of tiles for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {HTMLElement} options.element - Parent element. + * @abstract + */ + +OpenSeadragon.DrawerBase = class DrawerBase{ + constructor(options){ + $.console.assert( options.viewer, "[Drawer] options.viewer is required" ); + $.console.assert( options.viewport, "[Drawer] options.viewport is required" ); + $.console.assert( options.element, "[Drawer] options.element is required" ); + + this.viewer = options.viewer; + this.viewport = options.viewport; + this.debugGridColor = typeof options.debugGridColor === 'string' ? [options.debugGridColor] : options.debugGridColor || $.DEFAULT_SETTINGS.debugGridColor; + this.options = options.options || {}; + + 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; + } + + /** + * @abstract + * @returns {String | undefined} What type of drawer this is. Must be overridden by extending classes. + */ + getType(){ + $.console.error('Drawer.getType must be implemented by child class'); + return undefined; + } + + /** + * @abstract + * @returns {Boolean} Whether the drawer implementation is supported by the browser. Must be overridden by extending classes. + */ + static isSupported() { + $.console.error('Drawer.isSupported must be implemented by child class'); + } + + /** + * @abstract + * @returns {Element} the element to draw into + * @private + */ + _createDrawingElement() { + $.console.error('Drawer._createDrawingElement must be implemented by child class'); + return null; + } + + /** + * @abstract + * @param {Array} tiledImages - An array of TiledImages that are ready to be drawn. + * @private + */ + draw(tiledImages) { + $.console.error('Drawer.draw must be implemented by child class'); + } + + /** + * @abstract + * @returns {Boolean} True if rotation is supported. + */ + canRotate() { + $.console.error('Drawer.canRotate must be implemented by child class'); + } + + /** + * @abstract + */ + 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. + * @private + */ + minimumOverlapRequired() { + return false; + } + + + /** + * @abstract + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(imageSmoothingEnabled){ + $.console.error('Drawer.setImageSmoothingEnabled must be implemented by child class'); + } + + /** + * Optional public API to draw a rectangle (e.g. for debugging purposes) + * Child classes can override this method if they wish to support this + * @param {OpenSeadragon.Rect} rect + */ + drawDebuggingRect(rect) { + $.console.warn('[drawer].drawDebuggingRect is not implemented by this drawer'); + } + + // Deprecated functions + clear(){ + $.console.warn('[drawer].clear() is deprecated. The drawer is responsible for clearing itself as needed before drawing tiles.'); + } + + // 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 492bedec..96be0453 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 7d77e8d6..058a4220 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) { const 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; + } + let events = this.events[ eventName ]; if ( !events ) { this.events[ eventName ] = events = []; @@ -106,6 +115,7 @@ $.EventSource.prototype = { index--; } } + return true; }, /** @@ -226,16 +236,22 @@ $.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( "Event fired:", eventName ); + if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){ + $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`); + return false; + } + const handler = this.getHandler( eventName ); if ( handler ) { - return handler( this, eventArgs || {} ); + handler( this, eventArgs || {} ); } - return undefined; + return true; }, /** @@ -249,11 +265,32 @@ $.EventSource.prototype = { //uncomment if you want to get a log of all events //$.console.log( "Awaiting event fired:", eventName ); - const awaitingHandler = this.getAwaitingHandler( eventName ); - if ( awaitingHandler ) { - return awaitingHandler( this, eventArgs || {} ); + const awaitingHandler = this.getAwaitingHandler(eventName); + if (awaitingHandler) { + return awaitingHandler(this, eventArgs || {}); } return $.Promise.resolve("No handler for this event registered."); + }, + + /** + * 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]; } }; 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..824976ef --- /dev/null +++ b/src/htmldrawer.js @@ -0,0 +1,252 @@ +/* + * 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( $ ){ + + const OpenSeadragon = $; // alias back for JSDoc + +/** + * @class OpenSeadragon.HTMLDrawer + * @extends OpenSeadragon.DrawerBase + * @classdesc HTML-based implementation of DrawerBase for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + +class HTMLDrawer extends OpenSeadragon.DrawerBase{ + constructor(options){ + super(options); + + /** + * The HTML element (div) that this drawer uses for drawing + * @member {Element} canvas + * @memberof OpenSeadragon.HTMLDrawer# + */ + + /** + * The parent element of this Drawer instance, passed in when the Drawer was created. + * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}. + * @member {Element} container + * @memberof OpenSeadragon.HTMLDrawer# + */ + + // Reject listening for the tile-drawing event, which this drawer does not fire + this.viewer.rejectEventHandler("tile-drawing", "The HTMLDrawer does not raise the tile-drawing event"); + // Since the tile-drawn event is fired by this drawer, make sure handlers can be added for it + this.viewer.allowEventHandler("tile-drawn"); + } + + /** + * @returns {Boolean} always true + */ + static isSupported(){ + return true; + } + + /** + * + * @returns 'html' + */ + 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) { + _this._drawTiles(tiledImage); + } + }); + + } + + /** + * @returns {Boolean} False - rotation is not supported. + */ + canRotate() { + return false; + } + + /** + * Destroy the drawer (unload current loaded tiles) + */ + destroy() { + this.canvas.innerHTML = ""; + } + + /** + * This function is ignored by the HTML Drawer. Implementing it is required by DrawerBase. + * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is + * drawn smoothly on the canvas; see imageSmoothingEnabled in + * {@link OpenSeadragon.Options} for more explanation. + */ + setImageSmoothingEnabled(){ + // noop - HTML Drawer does not deal with this property + } + + /** + * Clears the Drawer so it's ready to draw another frame. + * @private + * + */ + _prepareNewFrame() { + this.canvas.innerHTML = ""; + } + + /** + * Draws a TiledImage. + * @private + * + */ + _drawTiles( tiledImage ) { + 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.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 afa196cb..100297ee 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 59ff63c4..c07b6e22 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,7 +116,8 @@ $.ImageJob.prototype = { * @param {*} data data that has been downloaded * @param {XMLHttpRequest} request reference to the request if used * @param {string} dataType data type identifier - * old behavior: dataType treated as errorMessage if data is falsey value + * fallback compatibility behavior: dataType treated as errorMessage if data is falsey value + * @memberof OpenSeadragon.ImageJob# */ finish: function(data, request, dataType) { // old behavior, no deprecation due to possible finish calls with invalid data item (e.g. different error) diff --git a/src/imagetilesource.js b/src/imagetilesource.js index c5990266..aab005a2 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 @@ -31,9 +31,7 @@ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ - (function ($) { - /** * @class ImageTileSource * @classdesc The ImageTileSource allows a simple image to be loaded @@ -68,7 +66,6 @@ $.ImageTileSource = class extends $.TileSource { buildPyramid: true, crossOriginPolicy: false, ajaxWithCredentials: false, - useCanvas: true }, props)); } @@ -196,13 +193,9 @@ $.ImageTileSource = class extends $.TileSource { * @deprecated */ getContext2D(level, x, y) { - $.console.warn('Using [TiledImage.getContext2D] (for plain images only) is deprecated. ' + + $.console.error('Using [TiledImage.getContext2D] (for plain images only) is deprecated. ' + 'Use overridden downloadTileStart (https://openseadragon.github.io/examples/advanced-data-model/) instead.'); - var context = null; - if (level >= this.minLevel && level <= this.maxLevel) { - context = this.levels[level].context2D; - } - return context; + return this._createContext2D(); } downloadTileStart(job) { @@ -242,21 +235,8 @@ $.ImageTileSource = class extends $.TileSource { let currentWidth = image.naturalWidth, currentHeight = image.naturalHeight; - - // We cache the context of the highest level because the browser - // is a lot faster at downsampling something it already has - // downsampled before. - levels[0].context2D = this._createContext2D(image, currentWidth, currentHeight); - // We don't need the image anymore. Allows it to be GC. - - if ($.isCanvasTainted(levels[0].context2D)) { - // If the canvas is tainted, we can't compute the pyramid. - this.buildPyramid = false; - return levels; - } - // We build smaller levels until either width or height becomes - // 1 pixel wide. + // 2 pixel wide. while (currentWidth >= 2 && currentHeight >= 2) { currentWidth = Math.floor(currentWidth / 2); currentHeight = Math.floor(currentHeight / 2); diff --git a/src/legacytilesource.js b/src/legacytilesource.js index 3ddb6122..c51231c3 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 f24b1c54..3afa2acb 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 @@ -750,6 +760,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. * @@ -1350,12 +1370,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, @@ -1378,7 +1418,6 @@ function OpenSeadragon( options ){ imageLoaderLimit: 0, maxImageCacheCount: 200, timeout: 30000, - useCanvas: true, // Use canvas element for drawing if available tileRetryMax: 0, tileRetryDelay: 2500, @@ -1448,18 +1487,6 @@ function OpenSeadragon( options ){ }, - - /** - * TODO: remove soon - * @deprecated - * @ignore - */ - get SIGNAL() { - $.console.error("OpenSeadragon.SIGNAL is deprecated and should not be used."); - return "----seadragon----"; - }, - - /** * Returns a function which invokes the method as if it were a method belonging to the object. * @function @@ -2598,13 +2625,14 @@ function OpenSeadragon( options ){ * jpg: true, * png: true, * tif: false, - * wdp: false + * wdp: false, + * webp: true * } * * @function * @example - * // sets webp as supported and png as unsupported - * setImageFormatsSupported({webp: true, png: false}); + * // sets bmp as supported and png as unsupported + * setImageFormatsSupported({bmp: true, png: false}); * @param {Object} formats An object containing format extensions as * keys and booleans as values. */ @@ -2728,7 +2756,8 @@ function OpenSeadragon( options ){ jpg: true, png: true, tif: false, - wdp: false + wdp: false, + webp: true }, URLPARAMS = {}; diff --git a/src/osmtilesource.js b/src/osmtilesource.js index 5a380d71..1dfeba52 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/priorityqueue.js b/src/priorityqueue.js index 3e3bd02a..312c410e 100644 --- a/src/priorityqueue.js +++ b/src/priorityqueue.js @@ -1,7 +1,7 @@ /* * OpenSeadragon - Queue * - * Copyright (C) 2023 OpenSeadragon contributors (modified) + * Copyright (C) 2024 OpenSeadragon contributors (modified) * Copyright (C) Google Inc., The Closure Library Authors. * https://github.com/google/closure-library * 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 6a2cfbdd..071e5d53 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. @@ -298,59 +304,6 @@ $.Tile.prototype = { return this.level + "/" + this.x + "_" + this.y; }, - /** - * Renders the tile in an html container. - * @function - * @param {Element} container - */ - drawHTML: function( container ) { - 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 ) { - const image = this.getImage(); - if (!image) { - $.console.warn( - '[Tile.drawHTML] attempting to draw tile %s when it\'s not cached', - this.toString()); - 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 @@ -380,7 +333,7 @@ $.Tile.prototype = { * @returns {?Image} */ getImage: function() { - //TODO: after-merge-aiosa $.console.error("[Tile.getImage] property has been deprecated. Use [Tile.getData] instead."); + $.console.error("[Tile.getImage] property has been deprecated. Use [Tile.getData] instead."); //this method used to ensure the underlying data model conformed to given type - convert instead of getData() const cache = this.getCache(this.cacheKey); if (!cache) { @@ -408,7 +361,7 @@ $.Tile.prototype = { * @returns {?CanvasRenderingContext2D} */ getCanvasContext: function() { - //TODO: after-merge-aiosa $.console.error("[Tile.getCanvasContext] property has been deprecated. Use [Tile.getData] instead."); + $.console.error("[Tile.getCanvasContext] property has been deprecated. Use [Tile.getData] instead."); //this method used to ensure the underlying data model conformed to given type - convert instead of getData() const cache = this.getCache(this.cacheKey); if (!cache) { @@ -622,145 +575,14 @@ $.Tile.prototype = { } }, - /** - * Renders the tile in a canvas-based context. - * @function - * @param {CanvasRenderingContext2D} 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); - - const _this = this; - // This gives the application a chance to make image manipulation - // changes as we are rendering the image - drawingHandler({context: context, get tile() { - $.console.warn("[tile-drawing] event is deprecated. " + - "Use 'tile-drawn' event instead."); - return _this; - }, get rendered() { - $.console.warn("[tile-drawing] rendered property and this event itself are deprecated. " + - "Use Tile data API and `tile-drawn` event instead."); - const context = _this.getCanvasContext(); - if (!context) { - $.console.warn( - '[Tile.drawCanvas] attempting to draw tile %s when it\'s not cached', - _this.toString()); - return undefined; - } - - if ( !_this.loaded || !context ){ - $.console.warn( - "Attempting to draw tile %s when it's not yet loaded.", - _this.toString() - ); - return undefined; - } - return _this.getCanvasContext(); - }}); - - //Now really get the tile data - const cache = this.getCache(this.cacheKey); - if (!cache) { - $.console.error( - "Attempting to draw tile %s when it's main cache key has no associated cache record!", - this.toString() - ); - return; - } - - if (cache.type !== "context2d") { - //cache not ready to render, wait - cache.transformTo("context2d"); - return; - } - - if ( !cache.loaded ){ - //cache not ready to render, wait - return; - } - const rendered = cache.data; - - 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 - ); - } - - 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(); - }, - /** * Get the ratio between current and original size. * @function - * @returns {Number} + * @returns {number} */ getScaleForEdgeSmoothing: function() { + // getCanvasContext is deprecated and so should be this method. + $.console.warn("[Tile.getScaleForEdgeSmoothing] is deprecated, the following error is the consequence:"); const context = this.getCanvasContext(); if (!context) { $.console.warn( @@ -800,13 +622,13 @@ $.Tile.prototype = { * @function */ unload: function() { + //TODO AIOSA remove this.element and move it to a data constructor if ( this.imgElement && this.imgElement.parentNode ) { this.imgElement.parentNode.removeChild( this.imgElement ); } if ( this.element && this.element.parentNode ) { this.element.parentNode.removeChild( this.element ); } - this.tiledImage = null; this._caches = []; this._cacheSize = 0; @@ -814,7 +636,7 @@ $.Tile.prototype = { this.imgElement = null; this.loaded = false; this.loading = false; - this.cacheKey = this.originalCacheKey; + this.cacheKey = this.originalCacheKey; } }; diff --git a/src/tilecache.js b/src/tilecache.js index a686f1e9..ad0aa3fc 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 @@ -34,649 +34,646 @@ (function( $ ){ -/** - * Cached Data Record, the cache object. - * Keeps only latest object type required. - * - * This class acts like the Maybe type: - * - it has 'loaded' flag indicating whether the tile data is ready - * - it has 'data' property that has value if loaded=true - * - * Furthermore, it has a 'getData' function that returns a promise resolving - * with the value on the desired type passed to the function. - * - * @typedef {{ - * destroy: function, - * revive: function, - * save: function, - * getDataAs: function, - * transformTo: function, - * data: ?, - * loaded: boolean - * }} OpenSeadragon.CacheRecord - */ -$.CacheRecord = class { - constructor() { - this.revive(); - } - /** - * Access the cache record data directly. Preferred way of data access. - * Might be undefined if this.loaded = false. - * You can access the data in synchronous way, but the data might not be available. - * If you want to access the data indirectly (await), use this.transformTo or this.getDataAs - * @returns {any} + * Cached Data Record, the cache object. + * Keeps only latest object type required. + * + * This class acts like the Maybe type: + * - it has 'loaded' flag indicating whether the tile data is ready + * - it has 'data' property that has value if loaded=true + * + * Furthermore, it has a 'getData' function that returns a promise resolving + * with the value on the desired type passed to the function. + * + * @typedef {{ + * destroy: function, + * revive: function, + * save: function, + * getDataAs: function, + * transformTo: function, + * data: ?, + * loaded: boolean + * }} OpenSeadragon.CacheRecord */ - get data() { - return this._data; - } - - /** - * Read the cache type. The type can dynamically change, but should be consistent at - * one point in the time. For available types see the OpenSeadragon.Convertor, or the tutorials. - * @returns {string} - */ - get type() { - return this._type; - } - - /** - * Await ongoing process so that we get cache ready on callback. - * @returns {null|*} - */ - await() { - if (!this._promise) { //if not cache loaded, do not fail - return $.Promise.resolve(); - } - return this._promise; - } - - getImage() { - $.console.error("[CacheRecord.getImage] options.image is deprecated. Moreover, it might not work" + - " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); - this.transformTo("image"); - return this.data; - } - - getRenderedContext() { - $.console.error("[CacheRecord.getRenderedContext] options.getRenderedContext is deprecated. Moreover, it might not work" + - " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); - this.transformTo("context2d"); - return this.data; - } - - /** - * Set the cache data. Asynchronous. - * @param {any} data - * @param {string} type - * @returns {OpenSeadragon.Promise} the old cache data that has been overwritten - */ - setDataAs(data, type) { - //allow set data with destroyed state, destroys the data if necessary - $.console.assert(data !== undefined, "[CacheRecord.setDataAs] needs valid data to set!"); - if (this._conversionJobQueue) { - //delay saving if ongiong conversion, these were registered first - let resolver = null; - const promise = new $.Promise((resolve, reject) => { - resolver = resolve; - }); - this._conversionJobQueue.push(() => resolver(this._overwriteData(data, type))); - return promise; - } - return this._overwriteData(data, type); - } - - /** - * Access the cache record data indirectly. Preferred way of data access. Asynchronous. - * @param {string?} [type=this.type] - * @param {boolean?} [copy=true] if false and same type is retrieved as the cache type, - * copy is not performed: note that this is potentially dangerous as it might - * introduce race conditions (you get a cache data direct reference you modify, - * but others might also access it, for example drawers to draw the viewport). - * @returns {OpenSeadragon.Promise} desired data type in promise, undefined if the cache was destroyed - */ - getDataAs(type = this._type, copy = true) { - if (this.loaded && type === this._type) { - return copy ? $.convertor.copy(this._data, type) : this._promise; + $.CacheRecord = class { + constructor() { + this.revive(); } - return this._promise.then(data => { - //might get destroyed in meanwhile - if (this._destroyed) { - return undefined; - } - if (type !== this._type) { - return $.convertor.convert(data, this._type, type); - } - if (copy) { //convert does not copy data if same type, do explicitly - return $.convertor.copy(data, type); - } - return data; - }); - } + /** + * Access the cache record data directly. Preferred way of data access. + * Might be undefined if this.loaded = false. + * You can access the data in synchronous way, but the data might not be available. + * If you want to access the data indirectly (await), use this.transformTo or this.getDataAs + * @returns {any} + */ + get data() { + return this._data; + } - /** - * Transform cache to desired type and get the data after conversion. - * Does nothing if the type equals to the current type. Asynchronous. - * @param {string} type - * @return {OpenSeadragon.Promise|*} - */ - transformTo(type = this._type) { - if (!this.loaded || type !== this._type) { - if (!this.loaded) { - this._conversionJobQueue = this._conversionJobQueue || []; + /** + * Read the cache type. The type can dynamically change, but should be consistent at + * one point in the time. For available types see the OpenSeadragon.Convertor, or the tutorials. + * @returns {string} + */ + get type() { + return this._type; + } + + /** + * Await ongoing process so that we get cache ready on callback. + * @returns {null|*} + */ + await() { + if (!this._promise) { //if not cache loaded, do not fail + return $.Promise.resolve(); + } + return this._promise; + } + + getImage() { + $.console.error("[CacheRecord.getImage] options.image is deprecated. Moreover, it might not work" + + " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); + this.transformTo("image"); + return this.data; + } + + getRenderedContext() { + $.console.error("[CacheRecord.getRenderedContext] options.getRenderedContext is deprecated. Moreover, it might not work" + + " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); + this.transformTo("context2d"); + return this.data; + } + + /** + * Set the cache data. Asynchronous. + * @param {any} data + * @param {string} type + * @returns {OpenSeadragon.Promise} the old cache data that has been overwritten + */ + setDataAs(data, type) { + //allow set data with destroyed state, destroys the data if necessary + $.console.assert(data !== undefined, "[CacheRecord.setDataAs] needs valid data to set!"); + if (this._conversionJobQueue) { + //delay saving if ongiong conversion, these were registered first let resolver = null; const promise = new $.Promise((resolve, reject) => { resolver = resolve; }); - this._conversionJobQueue.push(() => { - if (this._destroyed) { - return; - } - if (type !== this._type) { - //ensures queue gets executed after finish - this._convert(this._type, type); - this._promise.then(data => resolver(data)); - } else { - //must ensure manually, but after current promise finished, we won't wait for the following job - this._promise.then(data => { - this._checkAwaitsConvert(); - return resolver(data); - }); - } - }); + this._conversionJobQueue.push(() => resolver(this._overwriteData(data, type))); return promise; } - this._convert(this._type, type); + return this._overwriteData(data, type); } - return this._promise; - } - /** - * Set initial state, prepare for usage. - * Must not be called on active cache, e.g. first call destroy(). - */ - revive() { - $.console.assert(!this.loaded && !this._type, "[CacheRecord::revive] must not be called when loaded!"); - this._tiles = []; - this._data = null; - this._type = null; - this.loaded = false; - this._promise = null; - this._destroyed = false; - } + /** + * Access the cache record data indirectly. Preferred way of data access. Asynchronous. + * @param {string?} [type=this.type] + * @param {boolean?} [copy=true] if false and same type is retrieved as the cache type, + * copy is not performed: note that this is potentially dangerous as it might + * introduce race conditions (you get a cache data direct reference you modify, + * but others might also access it, for example drawers to draw the viewport). + * @returns {OpenSeadragon.Promise} desired data type in promise, undefined if the cache was destroyed + */ + getDataAs(type = this._type, copy = true) { + if (this.loaded && type === this._type) { + return copy ? $.convertor.copy(this._data, type) : this._promise; + } - /** - * Free all the data and call data destructors if defined. - */ - destroy() { - delete this._conversionJobQueue; - this._destroyed = true; + return this._promise.then(data => { + //might get destroyed in meanwhile + if (this._destroyed) { + return undefined; + } + if (type !== this._type) { + return $.convertor.convert(data, this._type, type); + } + if (copy) { //convert does not copy data if same type, do explicitly + return $.convertor.copy(data, type); + } + return data; + }); + } - //make sure this gets destroyed even if loaded=false - if (this.loaded) { - $.convertor.destroy(this._data, this._type); - this._tiles = null; + /** + * Transform cache to desired type and get the data after conversion. + * Does nothing if the type equals to the current type. Asynchronous. + * @param {string} type + * @return {OpenSeadragon.Promise|*} + */ + transformTo(type = this._type) { + if (!this.loaded || type !== this._type) { + if (!this.loaded) { + this._conversionJobQueue = this._conversionJobQueue || []; + let resolver = null; + const promise = new $.Promise((resolve, reject) => { + resolver = resolve; + }); + this._conversionJobQueue.push(() => { + if (this._destroyed) { + return; + } + if (type !== this._type) { + //ensures queue gets executed after finish + this._convert(this._type, type); + this._promise.then(data => resolver(data)); + } else { + //must ensure manually, but after current promise finished, we won't wait for the following job + this._promise.then(data => { + this._checkAwaitsConvert(); + return resolver(data); + }); + } + }); + return promise; + } + this._convert(this._type, type); + } + return this._promise; + } + + /** + * Set initial state, prepare for usage. + * Must not be called on active cache, e.g. first call destroy(). + */ + revive() { + $.console.assert(!this.loaded && !this._type, "[CacheRecord::revive] must not be called when loaded!"); + this._tiles = []; this._data = null; this._type = null; + this.loaded = false; this._promise = null; - } else { - const oldType = this._type; - this._promise.then(x => { - //ensure old data destroyed - $.convertor.destroy(x, oldType); - //might get revived... - if (!this._destroyed) { - return; - } + this._destroyed = false; + } + + /** + * Free all the data and call data destructors if defined. + */ + destroy() { + delete this._conversionJobQueue; + this._destroyed = true; + + //make sure this gets destroyed even if loaded=false + if (this.loaded) { + $.convertor.destroy(this._data, this._type); this._tiles = null; this._data = null; this._type = null; this._promise = null; - }); + } else { + const oldType = this._type; + this._promise.then(x => { + //ensure old data destroyed + $.convertor.destroy(x, oldType); + //might get revived... + if (!this._destroyed) { + return; + } + this._tiles = null; + this._data = null; + this._type = null; + this._promise = null; + }); + } + this.loaded = false; } - this.loaded = false; - } - /** - * Add tile dependency on this record - * @param tile - * @param data - * @param type - */ - addTile(tile, data, type) { - if (this._destroyed) { - return; + /** + * Add tile dependency on this record + * @param tile + * @param data + * @param type + */ + addTile(tile, data, type) { + if (this._destroyed) { + return; + } + $.console.assert(tile, '[CacheRecord.addTile] tile is required'); + + //allow overriding the cache - existing tile or different type + if (this._tiles.includes(tile)) { + this.removeTile(tile); + + } else if (!this.loaded) { + this._type = type; + this._promise = $.Promise.resolve(data); + this._data = data; + this.loaded = true; + } + //else pass: the tile data type will silently change as it inherits this cache + this._tiles.push(tile); } - $.console.assert(tile, '[CacheRecord.addTile] tile is required'); - //allow overriding the cache - existing tile or different type - if (this._tiles.includes(tile)) { - this.removeTile(tile); - - } else if (!this.loaded) { - this._type = type; - this._promise = $.Promise.resolve(data); - this._data = data; - this.loaded = true; - } - //else pass: the tile data type will silently change as it inherits this cache - this._tiles.push(tile); - } - - /** - * Remove tile dependency on this record. - * @param tile - * @returns {Boolean} true if record removed - */ - removeTile(tile) { - if (this._destroyed) { + /** + * Remove tile dependency on this record. + * @param tile + * @returns {Boolean} true if record removed + */ + removeTile(tile) { + if (this._destroyed) { + return false; + } + for (let i = 0; i < this._tiles.length; i++) { + if (this._tiles[i] === tile) { + this._tiles.splice(i, 1); + return true; + } + } + $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile); return false; } - for (let i = 0; i < this._tiles.length; i++) { - if (this._tiles[i] === tile) { - this._tiles.splice(i, 1); - return true; - } - } - $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile); - return false; - } - /** - * Get the amount of tiles sharing this record. - * @return {number} - */ - getTileCount() { - return this._tiles ? this._tiles.length : 0; - } - - /** - * Private conversion that makes sure collided requests are - * processed eventually - * @private - */ - _checkAwaitsConvert() { - if (!this._conversionJobQueue || this._destroyed) { - return; + /** + * Get the amount of tiles sharing this record. + * @return {number} + */ + getTileCount() { + return this._tiles ? this._tiles.length : 0; } - //let other code finish first - setTimeout(() => { - //check again, meanwhile things might've changed + + /** + * Private conversion that makes sure collided requests are + * processed eventually + * @private + */ + _checkAwaitsConvert() { if (!this._conversionJobQueue || this._destroyed) { return; } - const job = this._conversionJobQueue[0]; - this._conversionJobQueue.splice(0, 1); - if (this._conversionJobQueue.length === 0) { - delete this._conversionJobQueue; - } - job(); - }); - } - - _triggerNeedsDraw() { - for (let tile of this._tiles) { - tile.tiledImage._needsDraw = true; - } - } - - /** - * Safely overwrite the cache data and return the old data - * @private - */ - _overwriteData(data, type) { - if (this._destroyed) { - //we take ownership of the data, destroy - $.convertor.destroy(data, type); - return $.Promise.resolve(); - } - if (this.loaded) { - $.convertor.destroy(this._data, this._type); - this._type = type; - this._data = data; - this._promise = $.Promise.resolve(data); - this._triggerNeedsDraw(); - return this._promise; - } - return this._promise.then(x => { - $.convertor.destroy(x, this._type); - this._type = type; - this._data = data; - this._promise = $.Promise.resolve(data); - this._triggerNeedsDraw(); - return x; - }); - } - - /** - * Private conversion that makes sure the cache knows its data is ready - * @private - */ - _convert(from, to) { - const convertor = $.convertor, - conversionPath = convertor.getConversionPath(from, to); - if (!conversionPath) { - $.console.error(`[OpenSeadragon.convertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`); - return; //no-op - } - - const originalData = this._data, - stepCount = conversionPath.length, - _this = this, - convert = (x, i) => { - if (i >= stepCount) { - _this._data = x; - _this.loaded = true; - _this._checkAwaitsConvert(); - return $.Promise.resolve(x); + //let other code finish first + setTimeout(() => { + //check again, meanwhile things might've changed + if (!this._conversionJobQueue || this._destroyed) { + return; } - let edge = conversionPath[i]; - return $.Promise.resolve(edge.transform(x)).then( - y => { - if (!y) { - $.console.error(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting using %s)`, edge); - //try to recover using original data, but it returns inconsistent type (the log be hopefully enough) - _this._data = from; - _this._type = from; - _this.loaded = true; - return originalData; - } - //node.value holds the type string - convertor.destroy(x, edge.origin.value); - return convert(y, i + 1); - } - ); - }; + const job = this._conversionJobQueue[0]; + this._conversionJobQueue.splice(0, 1); + if (this._conversionJobQueue.length === 0) { + delete this._conversionJobQueue; + } + job(); + }); + } - this.loaded = false; - this._data = undefined; - this._type = to; - this._promise = convert(originalData, 0); - } -}; + _triggerNeedsDraw() { + for (let tile of this._tiles) { + tile.tiledImage._needsDraw = true; + } + } -/** - * @class TileCache - * @memberof OpenSeadragon - * @classdesc Stores all the tiles displayed in a {@link OpenSeadragon.Viewer}. - * You generally won't have to interact with the TileCache directly. - * @param {Object} options - Configuration for this TileCache. - * @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in - * {@link OpenSeadragon.Options} for details. - */ -$.TileCache = class { - constructor( options ) { - options = options || {}; + /** + * Safely overwrite the cache data and return the old data + * @private + */ + _overwriteData(data, type) { + if (this._destroyed) { + //we take ownership of the data, destroy + $.convertor.destroy(data, type); + return $.Promise.resolve(); + } + if (this.loaded) { + $.convertor.destroy(this._data, this._type); + this._type = type; + this._data = data; + this._promise = $.Promise.resolve(data); + this._triggerNeedsDraw(); + return this._promise; + } + return this._promise.then(x => { + $.convertor.destroy(x, this._type); + this._type = type; + this._data = data; + this._promise = $.Promise.resolve(data); + this._triggerNeedsDraw(); + return x; + }); + } - this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount; - this._tilesLoaded = []; - this._zombiesLoaded = []; - this._zombiesLoadedCount = 0; - this._cachesLoaded = []; - this._cachesLoadedCount = 0; - } - - /** - * @returns {Number} The total number of tiles that have been loaded by - * this TileCache. Note that the tile might be recorded here mutliple times, - * once for each cache it uses. - */ - numTilesLoaded() { - return this._tilesLoaded.length; - } - - /** - * @returns {Number} The total number of cached objects (+ zombies) - */ - numCachesLoaded() { - return this._zombiesLoadedCount + this._cachesLoadedCount; - } - - /** - * Caches the specified tile, removing an old tile if necessary to stay under the - * maxImageCacheCount specified on construction. Note that if multiple tiles reference - * the same image, there may be more tiles than maxImageCacheCount; the goal is to keep - * the number of images below that number. Note, as well, that even the number of images - * may temporarily surpass that number, but should eventually come back down to the max specified. - * @private - * @param {Object} options - Tile info. - * @param {OpenSeadragon.Tile} options.tile - The tile to cache. - * @param {?String} [options.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey - * @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache. - * Used if cacheKey not set. - * @param {Image} options.image - The image of the tile to cache. Deprecated. - * @param {*} options.data - The data of the tile to cache. - * @param {string} [options.dataType] - The data type of the tile to cache. Required. - * @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this - * function will release an old tile. The cutoff option specifies a tile level at or below which - * tiles will not be released. - * @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to. - */ - cacheTile( options ) { - $.console.assert( options, "[TileCache.cacheTile] options is required" ); - const theTile = options.tile; - $.console.assert( theTile, "[TileCache.cacheTile] options.tile is required" ); - $.console.assert( theTile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); - - let cutoff = options.cutoff || 0, - insertionIndex = this._tilesLoaded.length, - cacheKey = options.cacheKey || theTile.cacheKey; - - let cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; - if (!cacheRecord) { - if (options.data === undefined) { - $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " + - "has been deprecated and will be removed in the future."); - options.data = options.image; + /** + * Private conversion that makes sure the cache knows its data is ready + * @private + */ + _convert(from, to) { + const convertor = $.convertor, + conversionPath = convertor.getConversionPath(from, to); + if (!conversionPath) { + $.console.error(`[OpenSeadragon.convertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`); + return; //no-op } - //allow anything but undefined, null, false (other values mean the data was set, for example '0') - $.console.assert( options.data !== undefined && options.data !== null && options.data !== false, - "[TileCache.cacheTile] options.data is required to create an CacheRecord" ); - cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord(); - this._cachesLoadedCount++; - } else if (cacheRecord._destroyed) { - cacheRecord.revive(); - delete this._zombiesLoaded[cacheKey]; - this._zombiesLoadedCount--; + const originalData = this._data, + stepCount = conversionPath.length, + _this = this, + convert = (x, i) => { + if (i >= stepCount) { + _this._data = x; + _this.loaded = true; + _this._checkAwaitsConvert(); + return $.Promise.resolve(x); + } + let edge = conversionPath[i]; + return $.Promise.resolve(edge.transform(x)).then( + y => { + if (!y) { + $.console.error(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting using %s)`, edge); + //try to recover using original data, but it returns inconsistent type (the log be hopefully enough) + _this._data = from; + _this._type = from; + _this.loaded = true; + return originalData; + } + //node.value holds the type string + convertor.destroy(x, edge.origin.value); + return convert(y, i + 1); + } + ); + }; + + this.loaded = false; + this._data = undefined; + this._type = to; + this._promise = convert(originalData, 0); + } + }; + + /** + * @class TileCache + * @memberof OpenSeadragon + * @classdesc Stores all the tiles displayed in a {@link OpenSeadragon.Viewer}. + * You generally won't have to interact with the TileCache directly. + * @param {Object} options - Configuration for this TileCache. + * @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in + * {@link OpenSeadragon.Options} for details. + */ + $.TileCache = class { + constructor( options ) { + options = options || {}; + + this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount; + this._tilesLoaded = []; + this._zombiesLoaded = []; + this._zombiesLoadedCount = 0; + this._cachesLoaded = []; + this._cachesLoadedCount = 0; } - if (!options.dataType) { - $.console.error("[TileCache.cacheTile] options.dataType is newly required. " + - "For easier use of the cache system, use the tile instance API."); - options.dataType = $.convertor.guessType(options.data); + /** + * @returns {Number} The total number of tiles that have been loaded by + * this TileCache. Note that the tile might be recorded here mutliple times, + * once for each cache it uses. + */ + numTilesLoaded() { + return this._tilesLoaded.length; } - cacheRecord.addTile(theTile, options.data, options.dataType); - if (cacheKey === theTile.cacheKey) { - theTile.tiledImage._needsDraw = true; + /** + * @returns {Number} The total number of cached objects (+ zombies) + */ + numCachesLoaded() { + return this._zombiesLoadedCount + this._cachesLoadedCount; } - // Note that just because we're unloading a tile doesn't necessarily mean - // we're unloading its cache records. With repeated calls it should sort itself out, though. - let worstTileIndex = -1; - if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) { - //prefer zombie deletion, faster, better - if (this._zombiesLoadedCount > 0) { + /** + * Caches the specified tile, removing an old tile if necessary to stay under the + * maxImageCacheCount specified on construction. Note that if multiple tiles reference + * the same image, there may be more tiles than maxImageCacheCount; the goal is to keep + * the number of images below that number. Note, as well, that even the number of images + * may temporarily surpass that number, but should eventually come back down to the max specified. + * @private + * @param {Object} options - Tile info. + * @param {OpenSeadragon.Tile} options.tile - The tile to cache. + * @param {?String} [options.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey + * @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache. + * Used if options.cacheKey not set. + * @param {Image} options.image - The image of the tile to cache. Deprecated. + * @param {*} options.data - The data of the tile to cache. + * @param {string} [options.dataType] - The data type of the tile to cache. Required. + * @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this + * function will release an old tile. The cutoff option specifies a tile level at or below which + * tiles will not be released. + * @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to. + */ + cacheTile( options ) { + $.console.assert( options, "[TileCache.cacheTile] options is required" ); + const theTile = options.tile; + $.console.assert( theTile, "[TileCache.cacheTile] options.tile is required" ); + $.console.assert( theTile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); + + let cutoff = options.cutoff || 0, + insertionIndex = this._tilesLoaded.length, + cacheKey = options.cacheKey || theTile.cacheKey; + + let cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; + if (!cacheRecord) { + if (options.data === undefined) { + $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " + + "has been deprecated and will be removed in the future."); + options.data = options.image; + } + + //allow anything but undefined, null, false (other values mean the data was set, for example '0') + $.console.assert( options.data !== undefined && options.data !== null && options.data !== false, + "[TileCache.cacheTile] options.data is required to create an CacheRecord" ); + cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord(); + this._cachesLoadedCount++; + } else if (cacheRecord._destroyed) { + cacheRecord.revive(); + delete this._zombiesLoaded[cacheKey]; + this._zombiesLoadedCount--; + } + + if (!options.dataType) { + $.console.error("[TileCache.cacheTile] options.dataType is newly required. " + + "For easier use of the cache system, use the tile instance API."); + options.dataType = $.convertor.guessType(options.data); + } + + cacheRecord.addTile(theTile, options.data, options.dataType); + if (cacheKey === theTile.cacheKey) { + theTile.tiledImage._needsDraw = true; + } + + // Note that just because we're unloading a tile doesn't necessarily mean + // we're unloading its cache records. With repeated calls it should sort itself out, though. + let worstTileIndex = -1; + if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) { + //prefer zombie deletion, faster, better + if (this._zombiesLoadedCount > 0) { + for (let zombie in this._zombiesLoaded) { + this._zombiesLoaded[zombie].destroy(); + delete this._zombiesLoaded[zombie]; + this._zombiesLoadedCount--; + break; + } + } else { + let worstTile = null; + let prevTile, worstTime, worstLevel, prevTime, prevLevel; + + for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { + prevTile = this._tilesLoaded[ i ]; + + if ( prevTile.level <= cutoff || prevTile.beingDrawn ) { + continue; + } else if ( !worstTile ) { + worstTile = prevTile; + worstTileIndex = i; + continue; + } + + prevTime = prevTile.lastTouchTime; + worstTime = worstTile.lastTouchTime; + prevLevel = prevTile.level; + worstLevel = worstTile.level; + + if ( prevTime < worstTime || + ( prevTime === worstTime && prevLevel > worstLevel )) { + worstTile = prevTile; + worstTileIndex = i; + } + } + + if ( worstTile && worstTileIndex >= 0 ) { + this.unloadTile(worstTile, true); + insertionIndex = worstTileIndex; + } + } + } + + if (theTile.getCacheSize() === 0) { + this._tilesLoaded[ insertionIndex ] = theTile; + } else if (worstTileIndex >= 0) { + //tile is already recorded, do not add tile, but remove the tile at insertion index + this._tilesLoaded.splice(insertionIndex, 1); + } + + return cacheRecord; + } + + /** + * Clears all tiles associated with the specified tiledImage. + * @param {OpenSeadragon.TiledImage} tiledImage + */ + clearTilesFor( tiledImage ) { + $.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required'); + let tile; + + let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount; + if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) { + //prefer newer (fresh ;) zombies for (let zombie in this._zombiesLoaded) { this._zombiesLoaded[zombie].destroy(); delete this._zombiesLoaded[zombie]; - this._zombiesLoadedCount--; - break; } - } else { - let worstTile = null; - let prevTile, worstTime, worstLevel, prevTime, prevLevel; + this._zombiesLoadedCount = 0; + cacheOverflows = this._cachesLoadedCount > this._maxCacheItemCount; + } + for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { + tile = this._tilesLoaded[ i ]; - for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { - prevTile = this._tilesLoaded[ i ]; - - if ( prevTile.level <= cutoff || prevTile.beingDrawn ) { - continue; - } else if ( !worstTile ) { - worstTile = prevTile; - worstTileIndex = i; - continue; - } - - prevTime = prevTile.lastTouchTime; - worstTime = worstTile.lastTouchTime; - prevLevel = prevTile.level; - worstLevel = worstTile.level; - - if ( prevTime < worstTime || - ( prevTime === worstTime && prevLevel > worstLevel )) { - worstTile = prevTile; - worstTileIndex = i; + if (tile.tiledImage === tiledImage) { + if (!tile.loaded) { + //iterates from the array end, safe to remove + this._tilesLoaded.splice( i, 1 ); + } else if ( tile.tiledImage === tiledImage ) { + this.unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i); } } - - if ( worstTile && worstTileIndex >= 0 ) { - this.unloadTile(worstTile, true); - insertionIndex = worstTileIndex; - } } } - if (theTile.getCacheSize() === 0) { - this._tilesLoaded[ insertionIndex ] = theTile; - } else if (worstTileIndex >= 0) { - //tile is already recorded, do not add tile, but remove the tile at insertion index - this._tilesLoaded.splice(insertionIndex, 1); - } - - return cacheRecord; - } - - /** - * Clears all tiles associated with the specified tiledImage. - * @param {OpenSeadragon.TiledImage} tiledImage - */ - clearTilesFor( tiledImage ) { - $.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required'); - let tile; - - let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount; - if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) { - //prefer newer (fresh ;) zombies - for (let zombie in this._zombiesLoaded) { - this._zombiesLoaded[zombie].destroy(); - delete this._zombiesLoaded[zombie]; - } - this._zombiesLoadedCount = 0; - cacheOverflows = this._cachesLoadedCount > this._maxCacheItemCount; - } - for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { - tile = this._tilesLoaded[ i ]; - - if (tile.tiledImage === tiledImage) { - if (!tile.loaded) { - //iterates from the array end, safe to remove - this._tilesLoaded.splice( i, 1 ); - } else if ( tile.tiledImage === tiledImage ) { - this.unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i); - } - } - } - } - - /** - * Returns reference to all tiles loaded by a particular - * tiled image item - * @param {OpenSeadragon.TiledImage|Boolean} tiledImage true for all, reference for selection - */ - getLoadedTilesFor(tiledImage) { - if (tiledImage === true) { - return [...this._tilesLoaded]; - } - return this._tilesLoaded.filter(tile => tile.tiledImage === tiledImage); - } - - /** - * Get cache record (might be a unattached record, i.e. a zombie) - * @param cacheKey - * @returns {OpenSeadragon.CacheRecord|undefined} - */ - getCacheRecord(cacheKey) { - $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required'); - return this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; - } - - /** - * Delete cache record for a given til - * @param {OpenSeadragon.Tile} tile - * @param {string} key cache key - * @param {boolean} destroy if true, empty cache is destroyed, else left as a zombie - * @private - */ - unloadCacheForTile(tile, key, destroy) { - const cacheRecord = this._cachesLoaded[key]; - //unload record only if relevant - the tile exists in the record - if (cacheRecord) { - if (cacheRecord.removeTile(tile)) { - if (!cacheRecord.getTileCount()) { - if (destroy) { - // #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie) - cacheRecord.destroy(); - } else { - // #2 Tile is a zombie. Do not delete record, reuse. - this._zombiesLoaded[key] = cacheRecord; - this._zombiesLoadedCount++; - } - // Either way clear cache - delete this._cachesLoaded[key]; - this._cachesLoadedCount--; - } - return true; - } - $.console.error("[TileCache.unloadCacheForTile] System tried to delete tile from cache it " + - "does not belong to! This could mean a bug in the cache system."); - return false; - } - $.console.warn("[TileCache.unloadCacheForTile] Attempting to delete missing cache!"); - return false; - } - - /** - * @param tile tile to unload - * @param destroy destroy tile cache if the cache tile counts falls to zero - * @param deleteAtIndex index to remove the tile record at, will not remove from _tiledLoaded if not set - * @private - */ - unloadTile(tile, destroy, deleteAtIndex) { - $.console.assert(tile, '[TileCache.unloadTile] tile is required'); - - for (let key in tile._caches) { - //we are 'ok' to remove tile caches here since we later call destroy on tile, otherwise - //tile has count of its cache size --> would be inconsistent - this.unloadCacheForTile(tile, key, destroy); - } - //delete also the tile record - if (deleteAtIndex !== undefined) { - this._tilesLoaded.splice( deleteAtIndex, 1 ); - } - - const tiledImage = tile.tiledImage; - tile.unload(); - /** - * Triggered when a tile has just been unloaded from memory. - * - * @event tile-unloaded - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the unloaded tile. - * @property {OpenSeadragon.Tile} tile - The tile which has been unloaded. - * @property {boolean} destroyed - False if the tile data was kept in the system. + * Returns reference to all tiles loaded by a particular + * tiled image item + * @param {OpenSeadragon.TiledImage|Boolean} tiledImage true for all, reference for selection */ - tiledImage.viewer.raiseEvent("tile-unloaded", { - tile: tile, - tiledImage: tiledImage, - destroyed: destroy - }); - } -}; + getLoadedTilesFor(tiledImage) { + if (tiledImage === true) { + return [...this._tilesLoaded]; + } + return this._tilesLoaded.filter(tile => tile.tiledImage === tiledImage); + } + /** + * Get cache record (might be a unattached record, i.e. a zombie) + * @param cacheKey + * @returns {OpenSeadragon.CacheRecord|undefined} + */ + getCacheRecord(cacheKey) { + $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required'); + return this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; + } + + /** + * Delete cache record for a given til + * @param {OpenSeadragon.Tile} tile + * @param {string} key cache key + * @param {boolean} destroy if true, empty cache is destroyed, else left as a zombie + * @private + */ + unloadCacheForTile(tile, key, destroy) { + const cacheRecord = this._cachesLoaded[key]; + //unload record only if relevant - the tile exists in the record + if (cacheRecord) { + if (cacheRecord.removeTile(tile)) { + if (!cacheRecord.getTileCount()) { + if (destroy) { + // #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie) + cacheRecord.destroy(); + } else { + // #2 Tile is a zombie. Do not delete record, reuse. + this._zombiesLoaded[key] = cacheRecord; + this._zombiesLoadedCount++; + } + // Either way clear cache + delete this._cachesLoaded[key]; + this._cachesLoadedCount--; + } + return true; + } + $.console.error("[TileCache.unloadCacheForTile] System tried to delete tile from cache it " + + "does not belong to! This could mean a bug in the cache system."); + return false; + } + $.console.warn("[TileCache.unloadCacheForTile] Attempting to delete missing cache!"); + return false; + } + + /** + * @param tile tile to unload + * @param destroy destroy tile cache if the cache tile counts falls to zero + * @param deleteAtIndex index to remove the tile record at, will not remove from _tiledLoaded if not set + * @private + */ + unloadTile(tile, destroy, deleteAtIndex) { + $.console.assert(tile, '[TileCache.unloadTile] tile is required'); + + for (let key in tile._caches) { + //we are 'ok' to remove tile caches here since we later call destroy on tile, otherwise + //tile has count of its cache size --> would be inconsistent + this.unloadCacheForTile(tile, key, destroy); + } + //delete also the tile record + if (deleteAtIndex !== undefined) { + this._tilesLoaded.splice( deleteAtIndex, 1 ); + } + + const tiledImage = tile.tiledImage; + tile.unload(); + + /** + * Triggered when a tile has just been unloaded from memory. + @@ -255,12 +668,15 @@ $.TileCache.prototype = { + * @type {object} + * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the unloaded tile. + * @property {OpenSeadragon.Tile} tile - The tile which has been unloaded. + * @property {boolean} destroyed - False if the tile data was kept in the system. + */ + tiledImage.viewer.raiseEvent("tile-unloaded", { + tile: tile, + tiledImage: tiledImage, + destroyed: destroy + }); + } + }; }( OpenSeadragon )); diff --git a/src/tiledimage.js b/src/tiledimage.js index e757d24b..26cf204b 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 @@ -87,7 +87,7 @@ * Invoke tile-loded event for also for tiles loaded from cache if true. */ $.TiledImage = function( options ) { - var _this = this; + this._initialized = false; /** * The {@link OpenSeadragon.TileSource} that defines this TiledImage. * @member {OpenSeadragon.TileSource} source @@ -161,11 +161,14 @@ $.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. _zombieCache: false, // Allow cache to stay in memory upon deletion. + _tilesToDraw: [], // info about the tiles currently in the viewport, two deep: array[level][tile] + _lastDrawn: [], // array of tiles that were last fetched by the drawer + _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, @@ -225,31 +228,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. - * @deprecated - */ - _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 */{ @@ -260,6 +241,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. */ @@ -328,17 +316,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; } @@ -347,18 +346,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; }, /** @@ -366,11 +361,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag */ destroy: function() { this.reset(); - - if (this.source.destroy) { - $.console.warn("[TileSource.destroy] is deprecated. Use advanced data model API."); - this.source.destroy(); - } + this.source.destroy(this.viewer); }, /** @@ -446,7 +437,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); @@ -527,7 +518,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; @@ -759,7 +750,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'); }; @@ -785,10 +775,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(); } }, @@ -798,6 +789,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag */ resetCroppingPolygons: function() { this._croppingPolygons = null; + this._needsDraw = true; }, /** @@ -906,7 +898,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; }, /** @@ -914,9 +906,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(); }, /** @@ -931,11 +968,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. @@ -999,6 +1044,54 @@ $.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. + * Returns false for images with opacity==0 unless preload==true + */ + getDrawArea: function(){ + + if( this._opacity === 0 && !this._preload){ + return false; + } + + var drawArea = this._viewportToTiledImageRectangle( + this.viewport.getBoundsWithMargins(true)); + + if (!this.wrapHorizontal && !this.wrapVertical) { + var tiledImageBounds = this._viewportToTiledImageRectangle( + this.getClippedBounds(true)); + drawArea = drawArea.intersection(tiledImageBounds); + } + + return drawArea; + }, + + /** + * + * @returns {Array} Array of Tiles that make up the current view + */ + getTilesToDraw: function(){ + // start with all the tiles added to this._tilesToDraw during the most recent + // call to this.update. Then update them so the blending and coverage properties + // are updated based on the current time + let tileArray = this._tilesToDraw.flat(); + + // update all tiles, which can change the coverage provided + this._updateTilesInViewport(tileArray); + + // _tilesToDraw might have been updated by the update; refresh it + tileArray = this._tilesToDraw.flat(); + + // mark the tiles as being drawn, so that they won't be discarded from + // the tileCache + tileArray.forEach(tileInfo => { + tileInfo.tile.beingDrawn = true; + }); + this._lastDrawn = tileArray; + return tileArray; + }, + /** * Get the point around which this tiled image is rotated * @private @@ -1009,23 +1102,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. @@ -1038,8 +1124,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 }, /** @@ -1219,56 +1321,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) { @@ -1276,12 +1388,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(), @@ -1297,8 +1409,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, @@ -1309,6 +1422,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)) { @@ -1316,8 +1444,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) { @@ -1327,51 +1453,114 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } } this._needsDraw = true; - this._setFullyLoaded(false); + return false; } else { - this._setFullyLoaded(this._tilesLoading === 0); + return this._tilesLoading === 0; } }, - // 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; }, /** @@ -1384,7 +1573,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) { @@ -1449,7 +1639,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++) { @@ -1466,52 +1658,107 @@ $.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 - ), - drawTile = drawLevel; + const tile = this._getTile( + x, y, + level, + currentTime, + numberOfTiles + ); + let drawTile = drawLevel; if( this.viewer ){ /** @@ -1537,9 +1784,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 ); @@ -1549,7 +1801,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } if ( !drawTile ) { - return best; + return { + bestTiles: best, + tile: tile + }; } this._positionTile( @@ -1567,29 +1822,62 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this._tryFindTileCacheRecord(tile); } - 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, + }; }, /** +<<<<<<< HEAD * @private * @inner * Try to find existing cache of the tile @@ -1630,23 +1918,22 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag /** * @private * @inner +======= +>>>>>>> origin * 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, @@ -1728,9 +2015,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 */ @@ -1756,9 +2042,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 @@ -1802,24 +2087,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return; } - var _this = this, - finish = function() { - _this._setTileLoaded(tile, data, null, tileRequest, dataType); - }; + this._setTileLoaded(tile, data, null, tileRequest, dataType); - // 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); - } + //TODO aiosa missing timeout might damage the cache system }, /** * @private - * @inner * @param {OpenSeadragon.Tile} tile * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object, * can be null: in that case, cache is assigned to a tile without further processing @@ -1929,105 +2203,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }).then(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. @@ -2047,9 +2227,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. */ @@ -2070,299 +2249,6 @@ $.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() && - (tile.hasTransparency || this.source.hasTransparency( - undefined, 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 (let i = lastDrawn.length - 1; i >= 0; i--) { - tile = lastDrawn[ i ]; - - if (tile.loaded) { - const cache = tile.getCache(); - if (cache._updateStamp && cache._updateStamp !== $.__updated) { - console.warn("Tile not updated", cache); - } - } - - this._drawer.drawTile( tile, this._drawingHandler, useSketch, sketchScale, - sketchTranslate, shouldRoundPositionAndSize, this.source ); - tile.beingDrawn = true; - - if( this.viewer ){ - const targetTile = tile; - /** - * This event is fired after a tile has been drawn on the viewport. You can - * use this event to modify the tile data if necessary. - * This event is _awaiting_, it supports asynchronous functions or functions that return a promise. - * - * @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.raiseEventAwaiting( 'tile-drawn', { - tiledImage: this, - tile: targetTile - }).then(() => { - const cache = targetTile.getCache(targetTile.cacheKey), - // TODO: after-merge-aiosa dynamic type declaration from the drawer base class interface - requiredType = this._drawer.useCanvas ? "context2d" : "image"; - if (!cache) { - $.console.warn("Tile %s not cached at the end of tile-drawn event: tile will not be drawn - it has no data!", targetTile); - } else if (cache.type !== requiredType) { - //initiate conversion as soon as possible if incompatible with the drawer - cache.transformTo(requiredType); - } - }); - } - } - - 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. @@ -2370,6 +2256,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. @@ -2410,11 +2297,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. @@ -2436,9 +2322,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. @@ -2463,11 +2348,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. @@ -2478,71 +2362,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 33124ca4..f6712122 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 @@ -376,6 +376,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; @@ -624,6 +625,16 @@ $.TileSource.prototype = { throw new Error( "Method not implemented." ); }, + /** + * Shall this source need to free some objects + * upon unloading, it must be done here. For example, canvas + * size must be set to 0 for safari to free. + * @param {OpenSeadragon.Viewer} viewer + */ + destroy: function ( viewer ) { + //no-op + }, + /** * Responsible for retrieving the url which will return an image for the * region specified by the given x, y, and level components. 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 cb866f4e..53a8b74a 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 16e4f093..f1211315 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 ), @@ -434,13 +450,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" ); @@ -486,6 +552,7 @@ $.Viewer = function( options ) { displayRegionColor: this.navigatorDisplayRegionColor, crossOriginPolicy: this.crossOriginPolicy, animationTime: this.animationTime, + drawer: this.drawer.getType(), }); } @@ -512,11 +579,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); }; @@ -1056,7 +1118,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; }, @@ -1103,7 +1165,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, return this; } - if ( fullPage ) { + if ( fullPage && this.element ) { this.elementSize = $.getElementSize( this.element ); this.pageScroll = $.getPageScroll(); @@ -2426,7 +2488,6 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, width: this.referenceStripWidth, tileSources: this.tileSources, prefixUrl: this.prefixUrl, - useCanvas: this.useCanvas, viewer: this }); @@ -2575,7 +2636,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 ); } @@ -2593,9 +2653,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 @@ -3198,10 +3255,11 @@ function onCanvasDragEnd( event ) { */ this.raiseEvent('canvas-drag-end', canvasDragEndEventArgs); - gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); + gestureSettings = this.gestureSettingsByDeviceType( event.pointerType ); if (!canvasDragEndEventArgs.preventDefaultAction && this.viewport) { if ( !THIS[ this.hash ].draggingToZoom && + gestureSettings.dragToPan && gestureSettings.flickEnabled && event.speed >= gestureSettings.flickMinSpeed) { var amplitudeX = 0; @@ -3728,7 +3786,7 @@ function updateOnce( viewer ) { var viewportChange = viewer.viewport.update(); - var animated = viewer.world.update() || viewportChange; + var animated = viewer.world.update(viewportChange) || viewportChange; if (viewportChange) { /** @@ -3818,7 +3876,6 @@ function updateOnce( viewer ) { function drawWorld( viewer ) { viewer.imageLoader.clear(); - viewer.drawer.clear(); viewer.world.draw(); /** @@ -3972,4 +4029,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..bf40266d --- /dev/null +++ b/src/webgldrawer.js @@ -0,0 +1,1141 @@ + +/* + * 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( $ ){ + + const OpenSeadragon = $; // alias for JSDoc + + /** + * @class OpenSeadragon.WebGLDrawer + * @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. + */ + + OpenSeadragon.WebGLDrawer = class WebGLDrawer extends OpenSeadragon.DrawerBase{ + constructor(options){ + super(options); + + /** + * The HTML element (canvas) that this drawer uses for drawing + * @member {Element} canvas + * @memberof OpenSeadragon.WebGLDrawer# + */ + + /** + * The parent element of this Drawer instance, passed in when the Drawer was created. + * The parent of {@link OpenSeadragon.WebGLDrawer#canvas}. + * @member {Element} container + * @memberof OpenSeadragon.WebGLDrawer# + */ + + // private members + this._destroyed = false; + 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 + */ + 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 ); + } + + /** + * + * @returns 'webgl' + */ + 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 || tiledImage.getOpacity() === 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 + /** + * Required by DrawerBase, but has no effect on WebGLDrawer. + * @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 1d70f019..264e276b 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 @@ -265,11 +265,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; @@ -279,11 +282,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 = $('