From f01a7a4b3c5d60a225e24f2cb82742f8f3ef6ffb Mon Sep 17 00:00:00 2001 From: Aiosa Date: Fri, 8 Sep 2023 08:47:43 +0200 Subject: [PATCH] Cache Overhaul for OpenSeadragon (areas to discuss marked with FIXME). --- Gruntfile.js | 2 + src/datatypeconvertor.js | 308 +++++++++++++++++++++++++ src/imageloader.js | 57 +++-- src/imagetilesource.js | 441 ++++++++++++++++++------------------ src/openseadragon.js | 25 +- src/priorityqueue.js | 360 +++++++++++++++++++++++++++++ src/tile.js | 186 +++++++++++---- src/tilecache.js | 414 ++++++++++++++++++++++++--------- src/tiledimage.js | 106 +++++---- src/tilesource.js | 123 ++++++---- src/viewer.js | 6 +- src/world.js | 1 + test/helpers/test.js | 12 +- test/modules/basic.js | 11 +- test/modules/multi-image.js | 24 +- test/modules/tilecache.js | 15 ++ 16 files changed, 1584 insertions(+), 507 deletions(-) create mode 100644 src/datatypeconvertor.js create mode 100644 src/priorityqueue.js diff --git a/Gruntfile.js b/Gruntfile.js index 3111bd65..7bab26d5 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -48,6 +48,8 @@ module.exports = function(grunt) { "src/legacytilesource.js", "src/imagetilesource.js", "src/tilesourcecollection.js", + "src/priorityqueue.js", + "src/datatypeconvertor.js", "src/button.js", "src/buttongroup.js", "src/rectangle.js", diff --git a/src/datatypeconvertor.js b/src/datatypeconvertor.js new file mode 100644 index 00000000..2698b9d4 --- /dev/null +++ b/src/datatypeconvertor.js @@ -0,0 +1,308 @@ +/* + * OpenSeadragon.convertor (static property) + * + * 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($){ + +//modified from https://gist.github.com/Prottoy2938/66849e04b0bac459606059f5f9f3aa1a +class WeightedGraph { + constructor() { + this.adjacencyList = {}; + this.vertices = {}; + } + addVertex(vertex) { + if (!this.vertices[vertex]) { + this.vertices[vertex] = new $.PriorityQueue.Node(0, vertex); + this.adjacencyList[vertex] = []; + return true; + } + return false; + } + addEdge(vertex1, vertex2, weight, data) { + this.adjacencyList[vertex1].push({ target: this.vertices[vertex2], weight, data }); + } + + /** + * @return {{path: *[], cost: number}|undefined} cheapest path for + * + */ + dijkstra(start, finish) { + let path = []; //to return at end + if (start === finish) { + return {path: path, cost: 0}; + } + const nodes = new OpenSeadragon.PriorityQueue(); + let smallestNode; + //build up initial state + for (let vertex in this.vertices) { + vertex = this.vertices[vertex]; + if (vertex.value === start) { + vertex.key = 0; //keys are known distances + nodes.insertNode(vertex); + } else { + vertex.key = Infinity; + delete vertex.index; + } + vertex._previous = null; + } + // as long as there is something to visit + while (nodes.getCount() > 0) { + smallestNode = nodes.remove(); + if (smallestNode.value === finish) { + break; + } + const neighbors = this.adjacencyList[smallestNode.value]; + for (let neighborKey in neighbors) { + let edge = neighbors[neighborKey]; + //relax node + let newCost = smallestNode.key + edge.weight; + let nextNeighbor = edge.target; + if (newCost < nextNeighbor.key) { + nextNeighbor._previous = smallestNode; + //key change + nodes.decreaseKey(nextNeighbor, newCost); + } + } + } + + if (!smallestNode._previous) { + return undefined; //no path + } + + let finalCost = smallestNode.key; //final weight last node + + // done, build the shortest path + while (smallestNode._previous) { + //backtrack + const to = smallestNode.value, + parent = smallestNode._previous, + from = parent.value; + + path.push(this.adjacencyList[from].find(x => x.target.value === to)); + smallestNode = parent; + } + + return { + path: path.reverse(), + cost: finalCost + }; + } +} + +class DataTypeConvertor { + + constructor() { + this.graph = new WeightedGraph(); + + this.learn("canvas", "string", (canvas) => canvas.toDataURL(), 1, 1); + this.learn("image", "string", (image) => image.url); + this.learn("canvas", "context2d", (canvas) => canvas.getContext("2d")); + this.learn("context2d", "canvas", (context2D) => context2D.canvas); + + //OpenSeadragon supports two conversions out of the box: canvas and image. + this.learn("image", "canvas", (image) => { + const canvas = document.createElement( 'canvas' ); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + context.drawImage( image, 0, 0 ); + return canvas; + }, 1, 1); + + this.learn("string", "image", (url) => { + const img = new Image(); + img.src = url; + //FIXME: support async functions! some function conversions are async (like image here) + // and returning immediatelly will possibly cause the system work with incomplete data + // - a) remove canvas->image conversion path support + // - b) busy wait cycle (ugly as..) + // - c) async conversion execution (makes the whole cache -> transitively rendering async) + // - d) callbacks (makes the cache API more complicated) + while (!img.complete) { + console.log("Burning through CPU :)"); + } + return img; + }, 1, 1); + } + + /** + * FIXME: types are sensitive thing. Same data type might have different data semantics. + * - 'string' can be anything, for images, dataUrl or some URI, or incompatible stuff: vector data (JSON) + * - using $.type makes explicit requirements on its extensibility, and makes mess in naming + * - most types are [object X] + * - selected types are 'nice' -> string, canvas... + * - hard to debug + * + * Unique identifier (unlike toString.call(x)) to be guessed + * from the data value + * + * @function uniqueType + * @param x object to get unique identifier for + * - can be array, in that case, alphabetically-ordered list of inner unique types + * is returned (null, undefined are ignored) + * - if $.isPlainObject(x) is true, then the object can define + * getType function to specify its type + * - otherwise, toString.call(x) is applied to get the parameter description + * @return {string} unique variable descriptor + */ + guessType( x ) { + if (Array.isArray(x)) { + const types = []; + for (let item of x) { + if (item === undefined || item === null) { + continue; + } + + const type = this.guessType(item); + if (!types.includes(type)) { + types.push(type); + } + } + types.sort(); + return `Array [${types.join(",")}]`; + } + + const guessType = $.type(x); + if (guessType === "dom-node") { + //distinguish nodes + return guessType.nodeName.toLowerCase(); + } + + //todo consider event... + if (guessType === "object") { + if ($.isFunction(x.getType)) { + return x.getType(); + } + } + return guessType; + } + + /** + * @param {string} from unique ID of the data item 'from' + * @param {string} to unique ID of the data item 'to' + * @param {function} callback convertor that takes type 'from', and converts to type 'to' + * @param {Number} [costPower=0] positive cost class of the conversion, smaller or equal than 7. + * Should reflect the actual cost of the conversion: + * - if nothing must be done and only reference is retrieved (or a constant operation done), + * return 0 (default) + * - if a linear amount of work is necessary, + * return 1 + * ... and so on, basically the number in O() complexity power exponent (for simplification) + * @param {Number} [costMultiplier=1] multiplier of the cost class, e.g. O(3n^2) would + * use costPower=2, costMultiplier=3; can be between 1 and 10^5 + */ + learn(from, to, callback, costPower = 0, costMultiplier = 1) { + $.console.assert(costPower >= 0 && costPower <= 7, "[DataTypeConvertor] Conversion costPower must be between <0, 7>."); + $.console.assert($.isFunction(callback), "[DataTypeConvertor:learn] Callback must be a valid function!"); + + //we won't know if somebody added multiple edges, though it will choose some edge anyway + costPower++; + costMultiplier = Math.min(Math.max(costMultiplier, 1), 10 ^ 5); + this.graph.addVertex(from); + this.graph.addVertex(to); + this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback); + this._known = {}; + } + + /** + * FIXME: we could convert as 'convert(x, from, ...to)' and get cheapest path to any of the data + * for example, we could say tile.getCache(key)..getData("image", "canvas") if we do not care what we use and + * our system would then choose the cheapest option (both can be rendered by html for example). + * + * FIXME: conversion should be allowed to await results (e.g. image creation), now it is buggy, + * because we do not await image creation... + * + * @param {*} x data item to convert + * @param {string} from data item type + * @param {string} to desired type + * @return {*} data item with type 'to', or undefined if the conversion failed + */ + convert(x, from, to) { + const conversionPath = this.getConversionPath(from, to); + + if (!conversionPath) { + $.console.warn(`[DataTypeConvertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`); + return undefined; + } + + for (let node of conversionPath) { + x = node.data(x); + if (!x) { + $.console.warn(`[DataTypeConvertor.convert] data mid result falsey value (conversion to ${node.node})`); + return undefined; + } + } + return x; + } + + /** + * Get possible system type conversions and cache result. + * @param {string} from data item type + * @param {string} to desired type + * @return {[object]|undefined} array of required conversions (returns empty array + * for from===to), or undefined if the system cannot convert between given types. + */ + getConversionPath(from, ...to) { + $.console.assert(to.length > 0, "[getConversionPath] conversion 'to' type must be defined."); + + let bestConvertorPath; + const knownFrom = this._known[from]; + if (knownFrom) { + let bestCost = Infinity; + for (const outType of to) { + const conversion = knownFrom[outType]; + if (conversion && bestCost > conversion.cost) { + bestConvertorPath = conversion; + bestCost = conversion.cost; + } + } + } else { + this._known[from] = {}; + } + if (!bestConvertorPath) { + //FIXME: pre-compute all paths? could be efficient for multiple + // type system, but overhead for simple use cases... + bestConvertorPath = this.graph.dijkstra(from, to[0]); + this._known[from][to[0]] = bestConvertorPath; + } + return bestConvertorPath ? bestConvertorPath.path : undefined; + } +} + +/** + * Static convertor available throughout OpenSeadragon + * @memberOf OpenSeadragon + */ +$.convertor = new DataTypeConvertor(); + +}(OpenSeadragon)); diff --git a/src/imageloader.js b/src/imageloader.js index e66bd820..97cd9f6b 100644 --- a/src/imageloader.js +++ b/src/imageloader.js @@ -112,12 +112,38 @@ $.ImageJob.prototype = { * Finish this job. * @param {*} data data that has been downloaded * @param {XMLHttpRequest} request reference to the request if used - * @param {string} errorMessage description upon failure + * @param {string} dataType data type identifier + * old behavior: dataType treated as errorMessage if data is falsey value */ - finish: function(data, request, errorMessage ) { + finish: function(data, request, dataType) { + // old behavior, no deprecation due to possible finish calls with invalid data item (e.g. different error) + if (data === null || data === undefined || data === false) { + this.fail(dataType || "[downloadTileStart->finish()] Retrieved data is invalid!", request); + return; + } + this.data = data; this.request = request; + this.errorMsg = null; + this.dataType = dataType; + + if (this.jobId) { + window.clearTimeout(this.jobId); + } + + this.callback(this); + }, + + /** + * Finish this job as a failure. + * @param {string} errorMessage description upon failure + * @param {XMLHttpRequest} request reference to the request if used + */ + fail: function(errorMessage, request) { + this.data = null; + this.request = request; this.errorMsg = errorMessage; + this.dataType = null; if (this.jobId) { window.clearTimeout(this.jobId); @@ -180,10 +206,7 @@ $.ImageLoader.prototype = { }; } - var _this = this, - complete = function(job) { - completeJob(_this, job, options.callback); - }, + const _this = this, jobOptions = { src: options.src, tile: options.tile || {}, @@ -193,7 +216,7 @@ $.ImageLoader.prototype = { crossOriginPolicy: options.crossOriginPolicy, ajaxWithCredentials: options.ajaxWithCredentials, postData: options.postData, - callback: complete, + callback: (job) => completeJob(_this, job, options.callback), abort: options.abort, timeout: this.timeout }, @@ -234,10 +257,10 @@ $.ImageLoader.prototype = { * @param callback - Called once cleanup is finished. */ function completeJob(loader, job, callback) { - if (job.errorMsg !== '' && (job.data === null || job.data === undefined) && job.tries < 1 + loader.tileRetryMax) { + if (job.errorMsg && job.data === null && job.tries < 1 + loader.tileRetryMax) { loader.failedTiles.push(job); } - var nextJob; + let nextJob; loader.jobsInProgress--; @@ -249,15 +272,15 @@ function completeJob(loader, job, callback) { if (loader.tileRetryMax > 0 && loader.jobQueue.length === 0) { if ((!loader.jobLimit || loader.jobsInProgress < loader.jobLimit) && loader.failedTiles.length > 0) { - nextJob = loader.failedTiles.shift(); - setTimeout(function () { - nextJob.start(); - }, loader.tileRetryDelay); - loader.jobsInProgress++; - } - } + nextJob = loader.failedTiles.shift(); + setTimeout(function () { + nextJob.start(); + }, loader.tileRetryDelay); + loader.jobsInProgress++; + } + } - callback(job.data, job.errorMsg, job.request); + callback(job.data, job.errorMsg, job.request, job.dataType); } }(OpenSeadragon)); diff --git a/src/imagetilesource.js b/src/imagetilesource.js index e3b7a43b..7c4b571e 100644 --- a/src/imagetilesource.js +++ b/src/imagetilesource.js @@ -34,250 +34,243 @@ (function ($) { - /** - * @class ImageTileSource - * @classdesc The ImageTileSource allows a simple image to be loaded - * into an OpenSeadragon Viewer. - * There are 2 ways to open an ImageTileSource: - * 1. viewer.open({type: 'image', url: fooUrl}); - * 2. viewer.open(new OpenSeadragon.ImageTileSource({url: fooUrl})); - * - * With the first syntax, the crossOriginPolicy, ajaxWithCredentials and - * useCanvas options are inherited from the viewer if they are not - * specified directly in the options object. - * - * @memberof OpenSeadragon - * @extends OpenSeadragon.TileSource - * @param {Object} options Options object. - * @param {String} options.url URL of the image - * @param {Boolean} [options.buildPyramid=true] If set to true (default), a - * pyramid will be built internally to provide a better downsampling. - * @param {String|Boolean} [options.crossOriginPolicy=false] Valid values are - * 'Anonymous', 'use-credentials', and false. If false, image requests will - * not use CORS preventing internal pyramid building for images from other - * domains. - * @param {String|Boolean} [options.ajaxWithCredentials=false] Whether to set - * the withCredentials XHR flag for AJAX requests (when loading tile sources). - * @param {Boolean} [options.useCanvas=true] Set to false to prevent any use - * of the canvas API. - */ - $.ImageTileSource = function (options) { +/** + * @class ImageTileSource + * @classdesc The ImageTileSource allows a simple image to be loaded + * into an OpenSeadragon Viewer. + * There are 2 ways to open an ImageTileSource: + * 1. viewer.open({type: 'image', url: fooUrl}); + * 2. viewer.open(new OpenSeadragon.ImageTileSource({url: fooUrl})); + * + * With the first syntax, the crossOriginPolicy, ajaxWithCredentials and + * useCanvas options are inherited from the viewer if they are not + * specified directly in the options object. + * + * @memberof OpenSeadragon + * @extends OpenSeadragon.TileSource + * @param {Object} options Options object. + * @param {String} options.url URL of the image + * @param {Boolean} [options.buildPyramid=true] If set to true (default), a + * pyramid will be built internally to provide a better downsampling. + * @param {String|Boolean} [options.crossOriginPolicy=false] Valid values are + * 'Anonymous', 'use-credentials', and false. If false, image requests will + * not use CORS preventing internal pyramid building for images from other + * domains. + * @param {String|Boolean} [options.ajaxWithCredentials=false] Whether to set + * the withCredentials XHR flag for AJAX requests (when loading tile sources). + * @param {Boolean} [options.useCanvas=true] Set to false to prevent any use + * of the canvas API. + */ +$.ImageTileSource = class extends $.TileSource { - options = $.extend({ + constructor(props) { + super($.extend({ buildPyramid: true, crossOriginPolicy: false, ajaxWithCredentials: false, useCanvas: true - }, options); - $.TileSource.apply(this, [options]); + }, props)); + } - }; + /** + * Determine if the data and/or url imply the image service is supported by + * this tile source. + * @function + * @param {Object|Array} data + * @param {String} url - optional + */ + supports(data, url) { + return data.type && data.type === "image"; + } + /** + * + * @function + * @param {Object} options - the options + * @param {String} dataUrl - the url the image was retrieved from, if any. + * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null + * @returns {Object} options - A dictionary of keyword arguments sufficient + * to configure this tile sources constructor. + */ + configure(options, dataUrl, postData) { + return options; + } + /** + * Responsible for retrieving, and caching the + * image metadata pertinent to this TileSources implementation. + * @function + * @param {String} url + * @throws {Error} + */ + getImageInfo(url) { + const image = new Image(), + _this = this; - $.extend($.ImageTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.ImageTileSource.prototype */{ - /** - * Determine if the data and/or url imply the image service is supported by - * this tile source. - * @function - * @param {Object|Array} data - * @param {String} optional - url - */ - supports: function (data, url) { - return data.type && data.type === "image"; - }, - /** - * - * @function - * @param {Object} options - the options - * @param {String} dataUrl - the url the image was retrieved from, if any. - * @param {String} postData - HTTP POST data in k=v&k2=v2... form or null - * @returns {Object} options - A dictionary of keyword arguments sufficient - * to configure this tile sources constructor. - */ - configure: function (options, dataUrl, postData) { - return options; - }, - /** - * Responsible for retrieving, and caching the - * image metadata pertinent to this TileSources implementation. - * @function - * @param {String} url - * @throws {Error} - */ - getImageInfo: function (url) { - var image = this._image = new Image(); - var _this = this; + if (this.crossOriginPolicy) { + image.crossOrigin = this.crossOriginPolicy; + } + if (this.ajaxWithCredentials) { + image.useCredentials = this.ajaxWithCredentials; + } - if (this.crossOriginPolicy) { - image.crossOrigin = this.crossOriginPolicy; - } - if (this.ajaxWithCredentials) { - image.useCredentials = this.ajaxWithCredentials; - } + $.addEvent(image, 'load', function () { + _this.width = image.naturalWidth; + _this.height = image.naturalHeight; + _this.aspectRatio = _this.width / _this.height; + _this.dimensions = new $.Point(_this.width, _this.height); + _this._tileWidth = _this.width; + _this._tileHeight = _this.height; + _this.tileOverlap = 0; + _this.minLevel = 0; + _this.image = image; + _this.levels = _this._buildLevels(image); + _this.maxLevel = _this.levels.length - 1; - $.addEvent(image, 'load', function () { - _this.width = image.naturalWidth; - _this.height = image.naturalHeight; - _this.aspectRatio = _this.width / _this.height; - _this.dimensions = new $.Point(_this.width, _this.height); - _this._tileWidth = _this.width; - _this._tileHeight = _this.height; - _this.tileOverlap = 0; - _this.minLevel = 0; - _this.levels = _this._buildLevels(); - _this.maxLevel = _this.levels.length - 1; + _this.ready = true; - _this.ready = true; + // Note: this event is documented elsewhere, in TileSource + _this.raiseEvent('ready', {tileSource: _this}); + }); - // Note: this event is documented elsewhere, in TileSource - _this.raiseEvent('ready', {tileSource: _this}); + $.addEvent(image, 'error', function () { + _this.image = null; + // Note: this event is documented elsewhere, in TileSource + _this.raiseEvent('open-failed', { + message: "Error loading image at " + url, + source: url }); + }); - $.addEvent(image, 'error', function () { - // Note: this event is documented elsewhere, in TileSource - _this.raiseEvent('open-failed', { - message: "Error loading image at " + url, - source: url - }); - }); + image.src = url; + } + /** + * @function + * @param {Number} level + */ + getLevelScale(level) { + let levelScale = NaN; + if (level >= this.minLevel && level <= this.maxLevel) { + levelScale = + this.levels[level].width / + this.levels[this.maxLevel].width; + } + return levelScale; + } + /** + * @function + * @param {Number} level + */ + getNumTiles(level) { + if (this.getLevelScale(level)) { + return new $.Point(1, 1); + } + return new $.Point(0, 0); + } + /** + * Retrieves a tile url + * @function + * @param {Number} level Level of the tile + * @param {Number} x x coordinate of the tile + * @param {Number} y y coordinate of the tile + */ + getTileUrl(level, x, y) { + if (level === this.maxLevel) { + return this.url; //for original image, preserve url + } + //make up url by positional args + return `${this.url}?l=${level}&x=${x}&y=${y}`; + } - image.src = url; - }, - /** - * @function - * @param {Number} level - */ - getLevelScale: function (level) { - var levelScale = NaN; - if (level >= this.minLevel && level <= this.maxLevel) { - levelScale = - this.levels[level].width / - this.levels[this.maxLevel].width; - } - return levelScale; - }, - /** - * @function - * @param {Number} level - */ - getNumTiles: function (level) { - var scale = this.getLevelScale(level); - if (scale) { - return new $.Point(1, 1); - } else { - return new $.Point(0, 0); - } - }, - /** - * Retrieves a tile url - * @function - * @param {Number} level Level of the tile - * @param {Number} x x coordinate of the tile - * @param {Number} y y coordinate of the tile - */ - getTileUrl: function (level, x, y) { - var url = null; - if (level >= this.minLevel && level <= this.maxLevel) { - url = this.levels[level].url; - } - return url; - }, - /** - * Retrieves a tile context 2D - * @function - * @param {Number} level Level of the tile - * @param {Number} x x coordinate of the tile - * @param {Number} y y coordinate of the tile - */ - getContext2D: function (level, x, y) { - var context = null; - if (level >= this.minLevel && level <= this.maxLevel) { - context = this.levels[level].context2D; - } - return context; - }, - /** - * Destroys ImageTileSource - * @function - */ - destroy: function () { - this._freeupCanvasMemory(); - }, + getTilePostData(level, x, y) { + return {level: level, x: x, y: y}; + } - // private - // - // Builds the different levels of the pyramid if possible - // (i.e. if canvas API enabled and no canvas tainting issue). - _buildLevels: function () { - var levels = [{ - url: this._image.src, - width: this._image.naturalWidth, - height: this._image.naturalHeight - }]; + /** + * Retrieves a tile context 2D + * @deprecated + */ + getContext2D(level, x, y) { + $.console.warn('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; + } - if (!this.buildPyramid || !$.supportsCanvas || !this.useCanvas) { - // We don't need the image anymore. Allows it to be GC. - delete this._image; - return levels; - } + downloadTileStart(job) { + const tileData = job.postData; + if (tileData.level === this.maxLevel) { + job.finish(this.image, null, "image"); + return; + } - var currentWidth = this._image.naturalWidth; - var currentHeight = this._image.naturalHeight; + if (tileData.level >= this.minLevel && tileData.level <= this.maxLevel) { + const levelData = this.levels[tileData.level]; + const context = this._createContext2D(this.image, levelData.width, levelData.height); + job.finish(context, null, "context2d"); + return; + } + job.fail(`Invalid level ${tileData.level} for plain image source. Did you forget to set buildPyramid=true?`); + } + downloadTileAbort(job) { + //no-op + } - var bigCanvas = document.createElement("canvas"); - var bigContext = bigCanvas.getContext("2d"); + // private + // + // Builds the different levels of the pyramid if possible + // (i.e. if canvas API enabled and no canvas tainting issue). + _buildLevels(image) { + const levels = [{ + url: image.src, + width: image.naturalWidth, + height: image.naturalHeight + }]; - bigCanvas.width = currentWidth; - bigCanvas.height = currentHeight; - bigContext.drawImage(this._image, 0, 0, currentWidth, currentHeight); - // 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 = bigContext; - // We don't need the image anymore. Allows it to be GC. - delete this._image; - - if ($.isCanvasTainted(bigCanvas)) { - // If the canvas is tainted, we can't compute the pyramid. - return levels; - } - - // We build smaller levels until either width or height becomes - // 1 pixel wide. - while (currentWidth >= 2 && currentHeight >= 2) { - currentWidth = Math.floor(currentWidth / 2); - currentHeight = Math.floor(currentHeight / 2); - var smallCanvas = document.createElement("canvas"); - var smallContext = smallCanvas.getContext("2d"); - smallCanvas.width = currentWidth; - smallCanvas.height = currentHeight; - smallContext.drawImage(bigCanvas, 0, 0, currentWidth, currentHeight); - - levels.splice(0, 0, { - context2D: smallContext, - width: currentWidth, - height: currentHeight - }); - - bigCanvas = smallCanvas; - bigContext = smallContext; - } + if (!this.buildPyramid || !$.supportsCanvas || !this.useCanvas) { return levels; - }, - /** - * Free up canvas memory - * (iOS 12 or higher on 2GB RAM device has only 224MB canvas memory, - * and Safari keeps canvas until its height and width will be set to 0). - * @function - */ - _freeupCanvasMemory: function () { - for (var i = 0; i < this.levels.length; i++) { - if(this.levels[i].context2D){ - this.levels[i].context2D.canvas.height = 0; - this.levels[i].context2D.canvas.width = 0; - } - } - }, - }); + } + + 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. + while (currentWidth >= 2 && currentHeight >= 2) { + currentWidth = Math.floor(currentWidth / 2); + currentHeight = Math.floor(currentHeight / 2); + + levels.push({ + width: currentWidth, + height: currentHeight, + }); + } + return levels.reverse(); + } + + _createContext2D(data, w, h) { + const canvas = document.createElement("canvas"), + context = canvas.getContext("2d"); + + canvas.width = w; + canvas.height = h; + context.drawImage(data, 0, 0, w, h); + return context; + } +}; }(OpenSeadragon)); diff --git a/src/openseadragon.js b/src/openseadragon.js index 2cb2ac0d..8fe17bea 100644 --- a/src/openseadragon.js +++ b/src/openseadragon.js @@ -838,16 +838,20 @@ function OpenSeadragon( options ){ * @private */ var class2type = { - '[object Boolean]': 'boolean', - '[object Number]': 'number', - '[object String]': 'string', - '[object Function]': 'function', - '[object AsyncFunction]': 'function', - '[object Promise]': 'promise', - '[object Array]': 'array', - '[object Date]': 'date', - '[object RegExp]': 'regexp', - '[object Object]': 'object' + '[object Boolean]': 'boolean', + '[object Number]': 'number', + '[object String]': 'string', + '[object Function]': 'function', + '[object AsyncFunction]': 'function', + '[object Promise]': 'promise', + '[object Array]': 'array', + '[object Date]': 'date', + '[object RegExp]': 'regexp', + '[object Object]': 'object', + '[object HTMLUnknownElement]': 'dom-node', + '[object HTMLImageElement]': 'image', + '[object HTMLCanvasElement]': 'canvas', + '[object CanvasRenderingContext2D]': 'context2d' }, // Save a reference to some core methods toString = Object.prototype.toString, @@ -2375,6 +2379,7 @@ function OpenSeadragon( options ){ // Note that our preferred API is that you pass in a single object; the named // arguments are for legacy support. + // FIXME ^ are we ready to drop legacy support? since we abandoned old ES... if( $.isPlainObject( url ) ){ onSuccess = url.success; onError = url.error; diff --git a/src/priorityqueue.js b/src/priorityqueue.js new file mode 100644 index 00000000..3e3bd02a --- /dev/null +++ b/src/priorityqueue.js @@ -0,0 +1,360 @@ +/* + * OpenSeadragon - Queue + * + * Copyright (C) 2023 OpenSeadragon contributors (modified) + * Copyright (C) Google Inc., The Closure Library Authors. + * https://github.com/google/closure-library + * + * SPDX-License-Identifier: Apache-2.0 + */ + +(function($) { + +/** + * @class PriorityQueue + * @classdesc Fast priority queue. Implemented as a Heap. + */ +$.PriorityQueue = class { + + /** + * @param {?OpenSeadragon.PriorityQueue} optHeap Optional Heap or + * Object to initialize heap with. + */ + constructor(optHeap = undefined) { + /** + * The nodes of the heap. + * + * This is a densely packed array containing all nodes of the heap, using + * the standard flat representation of a tree as an array (i.e. element [0] + * at the top, with [1] and [2] as the second row, [3] through [6] as the + * third, etc). Thus, the children of element `i` are `2i+1` and `2i+2`, and + * the parent of element `i` is `⌊(i-1)/2⌋`. + * + * The only invariant is that children's keys must be greater than parents'. + * + * @private @const {!Array} + */ + this.nodes_ = []; + + if (optHeap) { + this.insertAll(optHeap); + } + } + + /** + * Insert the given value into the heap with the given key. + * @param {K} key The key. + * @param {V} value The value. + */ + insert(key, value) { + this.insertNode(new Node(key, value)); + } + + /** + * Insert node item. + * @param node + */ + insertNode(node) { + const nodes = this.nodes_; + node.index = nodes.length; + nodes.push(node); + this.moveUp_(node.index); + } + + /** + * Adds multiple key-value pairs from another Heap or Object + * @param {?OpenSeadragon.PriorityQueue} heap Object containing the data to add. + */ + insertAll(heap) { + let keys, values; + if (heap instanceof $.PriorityQueue) { + keys = heap.getKeys(); + values = heap.getValues(); + + // If it is a heap and the current heap is empty, I can rely on the fact + // that the keys/values are in the correct order to put in the underlying + // structure. + if (this.getCount() <= 0) { + const nodes = this.nodes_; + for (let i = 0; i < keys.length; i++) { + const node = new Node(keys[i], values[i]); + node.index = nodes.length; + nodes.push(node); + } + return; + } + } else { + throw "insertAll supports only OpenSeadragon.PriorityQueue object!"; + } + + for (let i = 0; i < keys.length; i++) { + this.insert(keys[i], values[i]); + } + } + + /** + * Retrieves and removes the root value of this heap. + * @return {Node} The root node item removed from the root of the heap. Returns + * undefined if the heap is empty. + */ + remove() { + const nodes = this.nodes_; + const count = nodes.length; + const rootNode = nodes[0]; + if (count <= 0) { + return undefined; + } else if (count == 1) { // eslint-disable-line + nodes.length = 0; + } else { + nodes[0] = nodes.pop(); + if (nodes[0]) { + nodes[0].index = 0; + } + this.moveDown_(0); + } + if (rootNode) { + delete rootNode.index; + } + return rootNode; + } + + /** + * Retrieves but does not remove the root value of this heap. + * @return {V} The value at the root of the heap. Returns + * undefined if the heap is empty. + */ + peek() { + const nodes = this.nodes_; + if (nodes.length == 0) { // eslint-disable-line + return undefined; + } + return nodes[0].value; + } + + /** + * Retrieves but does not remove the key of the root node of this heap. + * @return {string} The key at the root of the heap. Returns undefined if the + * heap is empty. + */ + peekKey() { + return this.nodes_[0] && this.nodes_[0].key; + } + + /** + * Move the node up in hierarchy + * @param {Node} node the node + * @param {K} key new ley, must be smaller than current key + */ + decreaseKey(node, key) { + if (node.index === undefined) { + node.key = key; + this.insertNode(node); + } else { + node.key = key; + this.moveUp_(node.index); + } + } + + /** + * Moves the node at the given index down to its proper place in the heap. + * @param {number} index The index of the node to move down. + * @private + */ + moveDown_(index) { + const nodes = this.nodes_; + const count = nodes.length; + + // Save the node being moved down. + const node = nodes[index]; + // While the current node has a child. + while (index < (count >> 1)) { + const leftChildIndex = this.getLeftChildIndex_(index); + const rightChildIndex = this.getRightChildIndex_(index); + + // Determine the index of the smaller child. + const smallerChildIndex = rightChildIndex < count && + nodes[rightChildIndex].key < nodes[leftChildIndex].key ? + rightChildIndex : + leftChildIndex; + + // If the node being moved down is smaller than its children, the node + // has found the correct index it should be at. + if (nodes[smallerChildIndex].key > node.key) { + break; + } + + // If not, then take the smaller child as the current node. + nodes[index] = nodes[smallerChildIndex]; + nodes[index].index = index; + index = smallerChildIndex; + } + nodes[index] = node; + if (node) { + node.index = index; + } + } + + /** + * Moves the node at the given index up to its proper place in the heap. + * @param {number} index The index of the node to move up. + * @private + */ + moveUp_(index) { + const nodes = this.nodes_; + const node = nodes[index]; + + // While the node being moved up is not at the root. + while (index > 0) { + // If the parent is greater than the node being moved up, move the parent + // down. + const parentIndex = this.getParentIndex_(index); + if (nodes[parentIndex].key > node.key) { + nodes[index] = nodes[parentIndex]; + nodes[index].index = index; + index = parentIndex; + } else { + break; + } + } + nodes[index] = node; + if (node) { + node.index = index; + } + } + + /** + * Gets the index of the left child of the node at the given index. + * @param {number} index The index of the node to get the left child for. + * @return {number} The index of the left child. + * @private + */ + getLeftChildIndex_(index) { + return index * 2 + 1; + } + + /** + * Gets the index of the right child of the node at the given index. + * @param {number} index The index of the node to get the right child for. + * @return {number} The index of the right child. + * @private + */ + getRightChildIndex_(index) { + return index * 2 + 2; + } + + /** + * Gets the index of the parent of the node at the given index. + * @param {number} index The index of the node to get the parent for. + * @return {number} The index of the parent. + * @private + */ + getParentIndex_(index) { + return (index - 1) >> 1; + } + + /** + * Gets the values of the heap. + * @return {!Array<*>} The values in the heap. + */ + getValues() { + const nodes = this.nodes_; + const rv = []; + const l = nodes.length; + for (let i = 0; i < l; i++) { + rv.push(nodes[i].value); + } + return rv; + } + + /** + * Gets the keys of the heap. + * @return {!Array} The keys in the heap. + */ + getKeys() { + const nodes = this.nodes_; + const rv = []; + const l = nodes.length; + for (let i = 0; i < l; i++) { + rv.push(nodes[i].key); + } + return rv; + } + + /** + * Whether the heap contains the given value. + * @param {V} val The value to check for. + * @return {boolean} Whether the heap contains the value. + */ + containsValue(val) { + return this.nodes_.some((node) => node.value == val); // eslint-disable-line + } + + /** + * Whether the heap contains the given key. + * @param {string} key The key to check for. + * @return {boolean} Whether the heap contains the key. + */ + containsKey(key) { + return this.nodes_.some((node) => node.value == key); // eslint-disable-line + } + + /** + * Clones a heap and returns a new heap + * @return {!OpenSeadragon.PriorityQueue} A new Heap with the same key-value pairs. + */ + clone() { + return new $.PriorityQueue(this); + } + + /** + * The number of key-value pairs in the map + * @return {number} The number of pairs. + */ + getCount() { + return this.nodes_.length; + } + + /** + * Returns true if this heap contains no elements. + * @return {boolean} Whether this heap contains no elements. + */ + isEmpty() { + return this.nodes_.length === 0; + } + + /** + * Removes all elements from the heap. + */ + clear() { + this.nodes_.length = 0; + } +}; + +$.PriorityQueue.Node = class { + constructor(key, value) { + /** + * The key. + * @private {K} + */ + this.key = key; + + /** + * The value. + * @private {V} + */ + this.value = value; + + /** + * The node index value. Updated in the heap. + * @type {number} + * @private + */ + this.index = 0; + } + + clone() { + return new Node(this.key, this.value); + } +}; + +}(OpenSeadragon)); diff --git a/src/tile.js b/src/tile.js index 201034fe..a8a33c01 100644 --- a/src/tile.js +++ b/src/tile.js @@ -45,8 +45,8 @@ * @param {Boolean} exists Is this tile a part of a sparse image? ( Also has * this tile failed to load? ) * @param {String|Function} url The URL of this tile's image or a function that returns a url. - * @param {CanvasRenderingContext2D} context2D The context2D of this tile if it - * is provided directly by the tile source. + * @param {CanvasRenderingContext2D} [context2D=undefined] The context2D of this tile if it + * * is provided directly by the tile source. Deprecated: use Tile::setCache(...) instead. * @param {Boolean} loadWithAjax Whether this tile image should be loaded with an AJAX request . * @param {Object} ajaxHeaders The headers to send with this tile's AJAX request (if applicable). * @param {OpenSeadragon.Rect} sourceBounds The portion of the tile to use as the source of the @@ -115,7 +115,9 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @member {CanvasRenderingContext2D} context2D * @memberOf OpenSeadragon.Tile# */ - this.context2D = context2D; + if (context2D) { + this.context2D = context2D; + } /** * Whether to load this tile's image with an AJAX request. * @member {Boolean} loadWithAjax @@ -136,7 +138,8 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja cacheKey = $.TileSource.prototype.getTileHashKey(level, x, y, url, ajaxHeaders, postData); } /** - * The unique cache key for this tile. + * The unique main cache key for this tile. Created automatically + * from the given tiledImage.source.getTileHashKey(...) implementation. * @member {String} cacheKey * @memberof OpenSeadragon.Tile# */ @@ -252,6 +255,24 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @memberof OpenSeadragon.Tile# */ this.isBottomMost = false; + /** + * FIXME: I would like to remove this reference but there is no way + * to remove it since tile-unloaded event requires the tiledImage reference. + * And, unloadTilesFor(tiledImage) in cache uses it too. Storing the + * reference on a tile level rather than cache level is more efficient. + * + * Owner of this tile. + * @member {OpenSeadragon.TiledImage} + * @memberof OpenSeadragon.Tile# + */ + this.tiledImage = null; + + /** + * Array of cached tile data associated with the tile. + * @member {Object} _caches + * @private + */ + this._caches = {}; }; /** @lends OpenSeadragon.Tile.prototype */ @@ -267,26 +288,12 @@ $.Tile.prototype = { return this.level + "/" + this.x + "_" + this.y; }, - // private - _hasTransparencyChannel: function() { - console.warn("Tile.prototype._hasTransparencyChannel() has been " + - "deprecated and will be removed in the future. Use TileSource.prototype.hasTransparency() instead."); - return !!this.context2D || this.getUrl().match('.png'); - }, - /** * Renders the tile in an html container. * @function * @param {Element} container */ drawHTML: function( container ) { - if (!this.cacheImageRecord) { - $.console.warn( - '[Tile.drawHTML] attempting to draw tile %s when it\'s not cached', - this.toString()); - return; - } - if ( !this.loaded ) { $.console.warn( "Attempting to draw tile %s when it's not yet loaded.", @@ -297,10 +304,12 @@ $.Tile.prototype = { //EXPERIMENTAL - trying to figure out how to scale the container // content during animation of the container size. - if ( !this.element ) { - var image = this.getImage(); + const image = this.getImage(); if (!image) { + $.console.warn( + '[Tile.drawHTML] attempting to draw tile %s when it\'s not cached', + this.toString()); return; } @@ -358,10 +367,10 @@ $.Tile.prototype = { /** * Get the Image object for this tile. - * @returns {Image} + * @returns {?Image} */ getImage: function() { - return this.cacheImageRecord.getImage(); + return this.getData("image"); }, /** @@ -379,41 +388,144 @@ $.Tile.prototype = { /** * Get the CanvasRenderingContext2D instance for tile image data drawn * onto Canvas if enabled and available - * @returns {CanvasRenderingContext2D} + * @returns {?CanvasRenderingContext2D} */ getCanvasContext: function() { - return this.context2D || this.cacheImageRecord.getRenderedContext(); + return this.getData("context2d"); + }, + + /** + * The context2D of this tile if it is provided directly by the tile source. + * @deprecated + * @type {CanvasRenderingContext2D} context2D + */ + get context2D() { + $.console.error("[Tile.context2D] property has been deprecated. Use Tile::getCache()."); + return this.getData("context2d"); + }, + + /** + * The context2D of this tile if it is provided directly by the tile source. + * @deprecated + */ + set context2D(value) { + $.console.error("[Tile.context2D] property has been deprecated. Use Tile::setCache()."); + this.setData(value, "context2d"); + }, + + /** + * The default cache for this tile. + * @deprecated + * @type OpenSeadragon.CacheRecord + */ + get cacheImageRecord() { + $.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::getCache."); + return this.getCache(this.cacheKey); + }, + + /** + * The default cache for this tile. + * @deprecated + */ + set cacheImageRecord(value) { + $.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::setCache."); + this._caches[this.cacheKey] = value; + }, + + /** + * Get the default data for this tile + * @param {?string} [type=undefined] data type to require + */ + getData(type = undefined) { + const cache = this.getCache(this.cacheKey); + if (!cache) { + return undefined; + } + return cache.getData(type); + }, + + /** + * Invalidate the tile so that viewport gets updated. + */ + save() { + this._needsDraw = true; + }, + + /** + * Set cache data + * @param {*} value + * @param {?string} [type=undefined] data type to require + */ + setData(value, type = undefined) { + this.setCache(this.cacheKey, value, type); + }, + + /** + * Read tile cache data object (CacheRecord) + * @param {string} key cache key to read that belongs to this tile + * @return {OpenSeadragon.CacheRecord} + */ + getCache: function(key) { + return this._caches[key]; + }, + + /** + * Set tile cache, possibly multiple with custom key + * @param {string} key cache key, must be unique (we recommend re-using this.cacheTile + * value and extend it with some another unique content, by default overrides the existing + * main cache used for drawing, if not existing. + * @param {*} data data to cache - this data will be sent to the TileSource API for refinement. + * @param {?string} type data type, will be guessed if not provided + * @param [_safely=true] private + * @param [_cutoff=0] private + */ + setCache: function(key, data, type = undefined, _safely = true, _cutoff = 0) { + type = type || $.convertor.guessType(data); + + if (_safely && key === this.cacheKey) { + //todo later, we could have drawers register their supported rendering type + // and OpenSeadragon would check compatibility automatically, now we render + // using two main types so we check their ability + const conversion = $.convertor.getConversionPath(type, "canvas", "image"); + $.console.assert(conversion, "[Tile.setCache] data was set for the default tile cache we are unable" + + "to render. Make sure OpenSeadragon.convertor was taught to convert type: " + type); + } + + this.tiledImage._tileCache.cacheTile({ + data: data, + dataType: type, + tile: this, + cacheKey: key, + cutoff: _cutoff + }); }, /** * Renders the tile in a canvas-based context. * @function - * @param {Canvas} context + * @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. + * position and size of tiles supporting alpha channel in non-transparency context. * @param {OpenSeadragon.TileSource} source - The source specification of the tile. */ drawCanvas: function( context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source) { var position = this.position.times($.pixelDensityRatio), size = this.size.times($.pixelDensityRatio), - rendered; + rendered = this.getCanvasContext(); - if (!this.context2D && !this.cacheImageRecord) { + if (!rendered) { $.console.warn( '[Tile.drawCanvas] attempting to draw tile %s when it\'s not cached', this.toString()); return; } - rendered = this.getCanvasContext(); - if ( !this.loaded || !rendered ){ $.console.warn( "Attempting to draw tile %s when it's not yet loaded.", @@ -495,15 +607,11 @@ $.Tile.prototype = { /** * Get the ratio between current and original size. * @function - * @returns {Float} + * @returns {Number} */ getScaleForEdgeSmoothing: function() { - var context; - if (this.cacheImageRecord) { - context = this.cacheImageRecord.getRenderedContext(); - } else if (this.context2D) { - context = this.context2D; - } else { + const context = this.getCanvasContext(); + if (!context) { $.console.warn( '[Tile.drawCanvas] attempting to get tile scale %s when tile\'s not cached', this.toString()); @@ -548,6 +656,8 @@ $.Tile.prototype = { this.element.parentNode.removeChild( this.element ); } + this.tiledImage = null; + this._caches = []; this.element = null; this.imgElement = null; this.loaded = false; diff --git a/src/tilecache.js b/src/tilecache.js index d890b8a8..c191dbd9 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -34,55 +34,203 @@ (function( $ ){ -// private class -var TileRecord = function( options ) { - $.console.assert( options, "[TileCache.cacheTile] options is required" ); - $.console.assert( options.tile, "[TileCache.cacheTile] options.tile is required" ); - $.console.assert( options.tiledImage, "[TileCache.cacheTile] options.tiledImage is required" ); - this.tile = options.tile; - this.tiledImage = options.tiledImage; -}; +/** + * Cached Data Record, the cache object. + * Keeps only latest object type required. + * @typedef {{ + * getImage: function, + * getData: function, + * getRenderedContext: function + * }} OpenSeadragon.CacheRecord + */ +$.CacheRecord = class { + constructor() { + this._tiles = []; + } -// private class -var ImageRecord = function(options) { - $.console.assert( options, "[ImageRecord] options is required" ); - $.console.assert( options.data, "[ImageRecord] options.data is required" ); - this._tiles = []; - - options.create.apply(null, [this, options.data, options.ownerTile]); - this._destroyImplementation = options.destroy.bind(null, this); - this.getImage = options.getImage.bind(null, this); - this.getData = options.getData.bind(null, this); - this.getRenderedContext = options.getRenderedContext.bind(null, this); -}; - -ImageRecord.prototype = { - destroy: function() { - this._destroyImplementation(); + destroy() { this._tiles = null; - }, + this._data = null; + this._type = null; + } + + save() { + for (let tile of this._tiles) { + tile._needsDraw = true; + } + } + + get data() { + $.console.warn("[CacheRecord.data] is deprecated property. Use getData(...) instead!"); + return this._data; + } + + set data(value) { + //FIXME: addTile bit bad name, related to the issue mentioned elsewhere + $.console.warn("[CacheRecord.data] is deprecated property. Use addTile(...) instead!"); + this._data = value; + this._type = $.convertor.guessType(value); + } + + getImage() { + return this.getData("image"); + } + + getRenderedContext() { + return this.getData("context2d"); + } + + getData(type = this._type) { + if (type !== this._type) { + this._data = $.convertor.convert(this._data, this._type, type); + this._type = type; + } + return this._data; + } + + addTile(tile, data, type) { + $.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._type !== type) { + this._type = type; + this._data = data; + } - addTile: function(tile) { - $.console.assert(tile, '[ImageRecord.addTile] tile is required'); this._tiles.push(tile); - }, + } - removeTile: function(tile) { - for (var i = 0; i < this._tiles.length; i++) { + removeTile(tile) { + for (let i = 0; i < this._tiles.length; i++) { if (this._tiles[i] === tile) { this._tiles.splice(i, 1); return; } } - $.console.warn('[ImageRecord.removeTile] trying to remove unknown tile', tile); - }, + $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile); + } - getTileCount: function() { + getTileCount() { return this._tiles.length; } }; +//FIXME: really implement or throw away? new parameter would allow users to +// use this implementation isntead of the above to allow caching for old data +// (for example in the default use, the data is downloaded as an image, and +// converted to a canvas -> the image record gets thrown away) +$.MemoryCacheRecord = class extends $.CacheRecord { + constructor(memorySize) { + super(); + this.length = memorySize; + this.index = 0; + this.content = []; + this.types = []; + this.defaultType = "image"; + } + + // overrides: + + destroy() { + super.destroy(); + this.types = null; + this.content = null; + this.types = null; + this.defaultType = null; + } + + getData(type = this.defaultType) { + let item = this.add(type, undefined); + if (item === undefined) { + //no such type available, get if possible + //todo: possible unomptimal use, we could cache costs and re-use known paths, though it adds overhead... + item = $.convertor.convert(this.current(), this.currentType(), type); + this.add(type, item); + } + return item; + } + + /** + * @deprecated + */ + get data() { + $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use getData(...) instead!"); + return this.current(); + } + + /** + * @deprecated + * @param value + */ + set data(value) { + //FIXME: addTile bit bad name, related to the issue mentioned elsewhere + $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use addTile(...) instead!"); + this.defaultType = $.convertor.guessType(value); + this.add(this.defaultType, value); + } + + addTile(tile, data, type) { + $.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.defaultType !== type) { + this.defaultType = type; + this.add(type, data); + } + + this._tiles.push(tile); + } + + // extends: + + add(type, item) { + const index = this.hasIndex(type); + if (index > -1) { + //no index change, swap (optimally, move all by one - too expensive...) + item = this.content[index]; + this.content[index] = this.content[this.index]; + } else { + this.index = (this.index + 1) % this.length; + } + this.content[this.index] = item; + this.types[this.index] = type; + return item; + } + + has(type) { + for (let i = 0; i < this.types.length; i++) { + const t = this.types[i]; + if (t === type) { + return this.content[i]; + } + } + return undefined; + } + + hasIndex(type) { + for (let i = 0; i < this.types.length; i++) { + const t = this.types[i]; + if (t === type) { + return i; + } + } + return -1; + } + + current() { + return this.content[this.index]; + } + + currentType() { + return this.types[this.index]; + } +}; + /** * @class TileCache * @memberof OpenSeadragon @@ -92,24 +240,24 @@ ImageRecord.prototype = { * @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in * {@link OpenSeadragon.Options} for details. */ -$.TileCache = function( options ) { - options = options || {}; +$.TileCache = class { + constructor( options ) { + options = options || {}; - this._maxImageCacheCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount; - this._tilesLoaded = []; - this._imagesLoaded = []; - this._imagesLoadedCount = 0; -}; + this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount; + this._tilesLoaded = []; + this._cachesLoaded = []; + this._cachesLoadedCount = 0; + } -/** @lends OpenSeadragon.TileCache.prototype */ -$.TileCache.prototype = { /** * @returns {Number} The total number of tiles that have been loaded by - * this TileCache. + * this TileCache. Note that the tile might be recorded here mutliple times, + * once for each cache it uses. */ - numTilesLoaded: function() { + numTilesLoaded() { return this._tilesLoaded.length; - }, + } /** * Caches the specified tile, removing an old tile if necessary to stay under the @@ -119,24 +267,27 @@ $.TileCache.prototype = { * may temporarily surpass that number, but should eventually come back down to the max specified. * @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. - * @param {Image} options.image - The image of the tile to cache. - * @param {OpenSeadragon.TiledImage} options.tiledImage - The TiledImage that owns that tile. + * 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. + * function will release an old tile. The cutoff option specifies a tile level at or below which + * tiles will not be released. */ - cacheTile: function( options ) { + cacheTile( options ) { $.console.assert( options, "[TileCache.cacheTile] options is required" ); $.console.assert( options.tile, "[TileCache.cacheTile] options.tile is required" ); $.console.assert( options.tile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); - $.console.assert( options.tiledImage, "[TileCache.cacheTile] options.tiledImage is required" ); - var cutoff = options.cutoff || 0; - var insertionIndex = this._tilesLoaded.length; + let cutoff = options.cutoff || 0, + insertionIndex = this._tilesLoaded.length, + cacheKey = options.cacheKey || options.tile.cacheKey; - var imageRecord = this._imagesLoaded[options.tile.cacheKey]; - if (!imageRecord) { + let cacheRecord = this._cachesLoaded[options.tile.cacheKey]; + if (!cacheRecord) { if (!options.data) { $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " + @@ -144,41 +295,54 @@ $.TileCache.prototype = { options.data = options.image; } - $.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an ImageRecord" ); - imageRecord = this._imagesLoaded[options.tile.cacheKey] = new ImageRecord({ - data: options.data, - ownerTile: options.tile, - create: options.tiledImage.source.createTileCache, - destroy: options.tiledImage.source.destroyTileCache, - getImage: options.tiledImage.source.getTileCacheDataAsImage, - getData: options.tiledImage.source.getTileCacheData, - getRenderedContext: options.tiledImage.source.getTileCacheDataAsContext2D, - }); - - this._imagesLoadedCount++; + $.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an CacheRecord" ); + cacheRecord = this._cachesLoaded[options.tile.cacheKey] = new $.CacheRecord(); + this._cachesLoadedCount++; + } else if (cacheRecord.__zombie__) { + delete cacheRecord.__zombie__; + //revive cache, replace from _tilesLoaded so it won't get unloaded + this._tilesLoaded.splice( cacheRecord.__index__, 1 ); + delete cacheRecord.__index__; + insertionIndex--; } - imageRecord.addTile(options.tile); - options.tile.cacheImageRecord = imageRecord; + 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(options.tile, options.data, options.dataType); + options.tile._caches[ cacheKey ] = cacheRecord; // Note that just because we're unloading a tile doesn't necessarily mean - // we're unloading an image. With repeated calls it should sort itself out, though. - if ( this._imagesLoadedCount > this._maxImageCacheCount ) { - var worstTile = null; - var worstTileIndex = -1; - var worstTileRecord = null; - var prevTile, worstTime, worstLevel, prevTime, prevLevel, prevTileRecord; + // we're unloading its cache records. With repeated calls it should sort itself out, though. + if ( this._cachesLoadedCount > this._maxCacheItemCount ) { + let worstTile = null; + let worstTileIndex = -1; + let prevTile, worstTime, worstLevel, prevTime, prevLevel; - for ( var i = this._tilesLoaded.length - 1; i >= 0; i-- ) { - prevTileRecord = this._tilesLoaded[ i ]; - prevTile = prevTileRecord.tile; + for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { + prevTile = this._tilesLoaded[ i ]; + + //todo try different approach? the only ugly part, keep tilesLoaded array empty of unloaded tiles + if (!prevTile.loaded) { + //iterates from the array end, safe to remove + this._tilesLoaded.splice( i, 1 ); + continue; + } + + if ( prevTile.__zombie__ !== undefined ) { + //remove without hesitation, CacheRecord instance + worstTile = prevTile.__zombie__; + worstTileIndex = i; + break; + } if ( prevTile.level <= cutoff || prevTile.beingDrawn ) { continue; } else if ( !worstTile ) { worstTile = prevTile; worstTileIndex = i; - worstTileRecord = prevTileRecord; continue; } @@ -188,64 +352,91 @@ $.TileCache.prototype = { worstLevel = worstTile.level; if ( prevTime < worstTime || - ( prevTime === worstTime && prevLevel > worstLevel ) ) { + ( prevTime === worstTime && prevLevel > worstLevel )) { worstTile = prevTile; worstTileIndex = i; - worstTileRecord = prevTileRecord; } } if ( worstTile && worstTileIndex >= 0 ) { - this._unloadTile(worstTileRecord); + this._unloadTile(worstTile, true); insertionIndex = worstTileIndex; } } - this._tilesLoaded[ insertionIndex ] = new TileRecord({ - tile: options.tile, - tiledImage: options.tiledImage - }); - }, + this._tilesLoaded[ insertionIndex ] = options.tile; + } /** * Clears all tiles associated with the specified tiledImage. * @param {OpenSeadragon.TiledImage} tiledImage */ - clearTilesFor: function( tiledImage ) { + clearTilesFor( tiledImage ) { $.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required'); - var tileRecord; - for ( var i = 0; i < this._tilesLoaded.length; ++i ) { - tileRecord = this._tilesLoaded[ i ]; - if ( tileRecord.tiledImage === tiledImage ) { - this._unloadTile(tileRecord); + let tile; + for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { + tile = this._tilesLoaded[ i ]; + + //todo try different approach? the only ugly part, keep tilesLoaded array empty of unloaded tiles + if (!tile.loaded) { + //iterates from the array end, safe to remove this._tilesLoaded.splice( i, 1 ); i--; + } else if ( tile.tiledImage === tiledImage ) { + this._unloadTile(tile, !tiledImage._zombieCache || + this._cachesLoadedCount > this._maxCacheItemCount, i); } } - }, + } // private - getImageRecord: function(cacheKey) { - $.console.assert(cacheKey, '[TileCache.getImageRecord] cacheKey is required'); - return this._imagesLoaded[cacheKey]; - }, + getCacheRecord(cacheKey) { + $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required'); + return this._cachesLoaded[cacheKey]; + } - // private - _unloadTile: function(tileRecord) { - $.console.assert(tileRecord, '[TileCache._unloadTile] tileRecord is required'); - var tile = tileRecord.tile; - var tiledImage = tileRecord.tiledImage; + /** + * @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'); - tile.unload(); - tile.cacheImageRecord = null; + for (let key in tile._caches) { + const cacheRecord = this._cachesLoaded[key]; + if (cacheRecord) { + 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(); + delete this._cachesLoaded[tile.cacheKey]; + this._cachesLoadedCount--; - var imageRecord = this._imagesLoaded[tile.cacheKey]; - imageRecord.removeTile(tile); - if (!imageRecord.getTileCount()) { - imageRecord.destroy(); - delete this._imagesLoaded[tile.cacheKey]; - this._imagesLoadedCount--; + //delete also the tile record + if (deleteAtIndex !== undefined) { + this._tilesLoaded.splice( deleteAtIndex, 1 ); + } + } else if (deleteAtIndex !== undefined) { + // #2 Tile is a zombie. Do not delete record, reuse. + // a bit dirty but performant... -> we can remove later, or revive + // we can do this, in array the tile is once for each its cache object + this._tilesLoaded[ deleteAtIndex ] = cacheRecord; + cacheRecord.__zombie__ = tile; + cacheRecord.__index__ = deleteAtIndex; + } + } else if (deleteAtIndex !== undefined) { + // #3 Cache stays. Tile record needs to be removed anyway, since the tile is removed. + this._tilesLoaded.splice( deleteAtIndex, 1 ); + } + } else { + $.console.warn("[TileCache._unloadTile] Attempting to delete missing cache!"); + } } + const tiledImage = tile.tiledImage; + tile.unload(); /** * Triggered when a tile has just been unloaded from memory. @@ -263,4 +454,5 @@ $.TileCache.prototype = { } }; + }( OpenSeadragon )); diff --git a/src/tiledimage.js b/src/tiledimage.js index 0bedb701..0f9863bb 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -161,8 +161,9 @@ $.TiledImage = function( options ) { 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? + _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. //configurable settings springStiffness: $.DEFAULT_SETTINGS.springStiffness, animationTime: $.DEFAULT_SETTINGS.animationTime, @@ -337,6 +338,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this.reset(); if (this.source.destroy) { + $.console.warn("[TileSource.destroy] is deprecated. Use advanced data model API."); this.source.destroy(); } }, @@ -1094,6 +1096,18 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag } }, + /** + * Enable cache preservation even without this tile image, + * by default disabled. It means that upon removing, + * the tile cache does not get immediately erased but + * stays in the memory to be potentially re-used by other + * TiledImages. + * @param {boolean} allow + */ + allowZombieCache: function(allow) { + this._zombieCache = allow; + }, + // private _setScale: function(scale, immediately) { var sameTarget = (this._scaleSpring.target.value === scale); @@ -1277,11 +1291,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag // Load the new 'best' n tiles if (bestTiles && bestTiles.length > 0) { - bestTiles.forEach(function (tile) { - if (tile && !tile.context2D) { + for (let tile of bestTiles) { + if (tile) { this._loadTile(tile, currentTime); } - }, this); + } this._needsDraw = true; this._setFullyLoaded(false); } else { @@ -1460,12 +1474,12 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag levelVisibility, viewportCenter, numberOfTiles, currentTime, best){ var tile = this._getTile( - x, y, - level, - currentTime, - numberOfTiles, - this._worldWidthCurrent, - this._worldHeightCurrent + x, y, + level, + currentTime, + numberOfTiles, + this._worldWidthCurrent, + this._worldHeightCurrent ), drawTile = drawLevel; @@ -1517,10 +1531,12 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag ); if (!tile.loaded) { - if (tile.context2D) { - this._setTileLoaded(tile); + // Tile was created or its data removed: check whether cache has the data before downloading. + if (!tile.cacheKey) { + tile.cacheKey = ""; + this._setTileLoaded(tile, null); } else { - var imageRecord = this._tileCache.getImageRecord(tile.cacheKey); + const imageRecord = this._tileCache.getCacheRecord(tile.cacheKey); if (imageRecord) { this._setTileLoaded(tile, imageRecord.getData()); } @@ -1578,7 +1594,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag urlOrGetter, post, ajaxHeaders, - context2D, tile, tilesMatrix = this.tilesMatrix, tileSource = this.source; @@ -1610,9 +1625,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag ajaxHeaders = null; } - context2D = tileSource.getContext2D ? - tileSource.getContext2D(level, xMod, yMod) : undefined; - tile = new $.Tile( level, x, @@ -1620,12 +1632,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag bounds, exists, urlOrGetter, - context2D, + undefined, this.loadTilesWithAjax, ajaxHeaders, sourceBounds, post, - tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post) + tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post), + this ); if (this.getFlip()) { @@ -1672,8 +1685,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag ajaxHeaders: tile.ajaxHeaders, crossOriginPolicy: this.crossOriginPolicy, ajaxWithCredentials: this.ajaxWithCredentials, - callback: function( data, errorMsg, tileRequest ){ - _this._onTileLoad( tile, time, data, errorMsg, tileRequest ); + callback: function( data, errorMsg, tileRequest, dataType ){ + _this._onTileLoad( tile, time, data, errorMsg, tileRequest, dataType ); }, abort: function() { tile.loading = false; @@ -1690,9 +1703,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @param {*} data image data * @param {String} errorMsg * @param {XMLHttpRequest} tileRequest + * @param {String} [dataType=undefined] data type, derived automatically if not set */ - _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) { - if ( !data ) { + _onTileLoad: function( tile, time, data, errorMsg, tileRequest, dataType ) { + //data is set to null on error by image loader, allow custom falsey values (e.g. 0) + if ( data === null ) { $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg ); /** * Triggered when a tile fails to load. @@ -1730,8 +1745,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag finish = function() { var ccc = _this.source; var cutoff = ccc.getClosestLevel(); - _this._setTileLoaded(tile, data, cutoff, tileRequest); - }; + _this._setTileLoaded(tile, data, cutoff, tileRequest, dataType); + }; // Check if we're mid-update; this can happen on IE8 because image load events for // cached images happen immediately there @@ -1739,7 +1754,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag finish(); } else { // Wait until after the update, in case caching unloads any tiles - window.setTimeout( finish, 1); + window.setTimeout(finish, 1); } }, @@ -1748,14 +1763,19 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @inner * @param {OpenSeadragon.Tile} tile * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object - * @param {Number|undefined} cutoff - * @param {XMLHttpRequest|undefined} tileRequest + * @param {?Number} cutoff + * @param {?XMLHttpRequest} tileRequest + * @param {?String} [dataType=undefined] data type, derived automatically if not set */ - _setTileLoaded: function(tile, data, cutoff, tileRequest) { + _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) { var increment = 0, eventFinished = false, _this = this; + tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back + // -> reason why it is not in the constructor + tile.setCache(tile.cacheKey, data, dataType, false, cutoff); + function getCompletionCallback() { if (eventFinished) { $.console.error("Event 'tile-loaded' argument getCompletionCallback must be called synchronously. " + @@ -1770,21 +1790,19 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag if (increment === 0) { tile.loading = false; tile.loaded = true; - tile.hasTransparency = _this.source.hasTransparency( - tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData + //do not override true if set (false is default) + tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency( + undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData ); - if (!tile.context2D) { - _this._tileCache.cacheTile({ - data: data, - tile: tile, - cutoff: cutoff, - tiledImage: _this - }); - } - _this._needsDraw = true; + //FIXME: design choice: cache tile now set automatically so users can do + // tile.getCache(...) inside this event, but maybe we would like to have users + // freedom to decide on the cache creation (note, tiles now MUST have cache, e.g. + // it is no longer possible to store all tiles in the memory as it was with context2D prop) + tile.save(); } } + const fallbackCompletion = getCompletionCallback(); /** * Triggered when a tile has just been loaded in memory. That means that the * image has been downloaded and can be modified before being drawn to the canvas. @@ -1794,6 +1812,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @type {object} * @property {Image|*} image - The image (data) of the tile. Deprecated. * @property {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object + * @property {String} dataType type of the data * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. * @property {OpenSeadragon.Tile} tile - The tile which has been loaded. * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). @@ -1802,8 +1821,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * marked as entirely loaded when the callback has been called once for each * call to getCompletionCallback. */ - - var fallbackCompletion = getCompletionCallback(); this.viewer.raiseEvent("tile-loaded", { tile: tile, tiledImage: this, @@ -1813,6 +1830,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return data; }, data: data, + dataType: dataType, getCompletionCallback: getCompletionCallback }); eventFinished = true; @@ -1978,7 +1996,9 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag useSketch = this.opacity < 1 || (this.compositeOperation && this.compositeOperation !== 'source-over') || (!this._isBottomItem() && - this.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData)); + (tile.hasTransparency || this.source.hasTransparency( + undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData)) + ); } var sketchScale; diff --git a/src/tilesource.js b/src/tilesource.js index fefa0098..aefb9548 100644 --- a/src/tilesource.js +++ b/src/tilesource.js @@ -139,6 +139,12 @@ $.TileSource = function( width, height, tileSize, tileOverlap, minLevel, maxLeve } ); } + /** + * Retrieve context2D of this tile source + * @memberOf OpenSeadragon.TileSource + * @function getContext2D + */ + /** * Ratio of width to height * @member {Number} aspectRatio @@ -682,9 +688,12 @@ $.TileSource.prototype = { * The tile cache object is uniquely determined by this key and used to lookup * the image data in cache: keys should be different if images are different. * - * In case a tile has context2D property defined (TileSource.prototype.getContext2D) - * or its context2D is set manually; the cache is not used and this function - * is irrelevant. + * You can return falsey tile cache key, in which case the tile will + * be created without invoking ImageJob --- but with data=null. Then, + * you are responsible for manually creating the cache data. This is useful + * particularly if you want to use empty TiledImage with client-side derived data + * only. The default tile-cache key is then called "" - an empty string. + * * Note: default behaviour does not take into account post data. * @param {Number} level tile level it was fetched with * @param {Number} x x-coordinate in the pyramid level @@ -692,6 +701,9 @@ $.TileSource.prototype = { * @param {String} url the tile was fetched with * @param {Object} ajaxHeaders the tile was fetched with * @param {*} postData data the tile was fetched with (type depends on getTilePostData(..) return type) + * @return {?String} can return the cache key or null, in that case an empty cache is initialized + * without downloading any data for internal use: user has to define the cache contents manually, via + * the cache interface of this class. */ getTileHashKey: function(level, x, y, url, ajaxHeaders, postData) { function withHeaders(hash) { @@ -722,10 +734,15 @@ $.TileSource.prototype = { /** * Decide whether tiles have transparency: this is crucial for correct images blending. + * Overriden on a tile level by setting tile.hasTransparency = true; + * @param context2D unused, deprecated argument + * @param url tile.getUrl() value for given tile + * @param ajaxHeaders tile.ajaxHeaders value for given tile + * @param post tile.post value for given tile * @returns {boolean} true if the image has transparency */ hasTransparency: function(context2D, url, ajaxHeaders, post) { - return !!context2D || url.match('.png'); + return url.match('.png'); }, /** @@ -737,15 +754,18 @@ $.TileSource.prototype = { * @param {String} [context.ajaxHeaders] - Headers to add to the image request if using AJAX. * @param {Boolean} [context.ajaxWithCredentials] - Whether to set withCredentials on AJAX requests. * @param {String} [context.crossOriginPolicy] - CORS policy to use for downloads - * @param {String} [context.postData] - HTTP POST data (usually but not necessarily in k=v&k2=v2... form, - * see TileSource::getPostData) or null + * @param {?String|?Object} [context.postData] - HTTP POST data (usually but not necessarily + * in k=v&k2=v2... form, see TileSource::getPostData) or null * @param {*} [context.userData] - Empty object to attach your own data and helper variables to. - * @param {Function} [context.finish] - Should be called unless abort() was executed, e.g. on all occasions, - * be it successful or unsuccessful request. - * Usage: context.finish(data, request, errMessage). Pass the downloaded data object or null upon failure. - * Add also reference to an ajax request if used. Provide error message in case of failure. + * @param {Function} [context.finish] - Should be called unless abort() was executed upon successful + * data retrieval. + * Usage: context.finish(data, request, dataType=undefined). Pass the downloaded data object + * add also reference to an ajax request if used. Optionally, specify what data type the data is. + * @param {Function} [context.fail] - Should be called unless abort() was executed upon unsuccessful request. + * Usage: context.fail(errMessage, request). Provide error message in case of failure, + * add also reference to an ajax request if used. * @param {Function} [context.abort] - Called automatically when the job times out. - * Usage: context.abort(). + * Usage: if you decide to abort the request (no fail/finish will be called), call context.abort(). * @param {Function} [context.callback] @private - Called automatically once image has been downloaded * (triggered by finish). * @param {Number} [context.timeout] @private - The max number of milliseconds that @@ -753,25 +773,26 @@ $.TileSource.prototype = { * @param {string} [context.errorMsg] @private - The final error message, default null (set by finish). */ downloadTileStart: function (context) { - var dataStore = context.userData, + const dataStore = context.userData, image = new Image(); dataStore.image = image; dataStore.request = null; - var finish = function(error) { - if (!image) { - context.finish(null, dataStore.request, "Image load failed: undefined Image instance."); + const finalize = function(error) { + if (error || !image) { + context.fail(error || "[downloadTileStart] Image load failed: undefined Image instance.", + dataStore.request); return; } image.onload = image.onerror = image.onabort = null; - context.finish(error ? null : image, dataStore.request, error); + context.finish(image, dataStore.request); //dataType="image" recognized automatically }; image.onload = function () { - finish(); + finalize(); }; image.onabort = image.onerror = function() { - finish("Image load aborted."); + finalize("[downloadTileStart] Image load aborted."); }; // Load the tile with an AJAX request if the loadWithAjax option is @@ -791,21 +812,21 @@ $.TileSource.prototype = { try { blb = new window.Blob([request.response]); } catch (e) { - var BlobBuilder = ( + const BlobBuilder = ( window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder ); if (e.name === 'TypeError' && BlobBuilder) { - var bb = new BlobBuilder(); + const bb = new BlobBuilder(); bb.append(request.response); blb = bb.getBlob(); } } // If the blob is empty for some reason consider the image load a failure. if (blb.size === 0) { - finish("Empty image response."); + finalize("[downloadTileStart] Empty image response."); } else { // Create a URL for the blob data and make it the source of the image object. // This will still trigger Image.onload to indicate a successful tile load. @@ -813,7 +834,7 @@ $.TileSource.prototype = { } }, error: function(request) { - finish("Image load aborted - XHR error"); + finalize("[downloadTileStart] Image load aborted - XHR error"); } }); } else { @@ -827,6 +848,8 @@ $.TileSource.prototype = { /** * Provide means of aborting the execution. * Note that if you override this function, you should override also downloadTileStart(). + * Note that calling job.abort() would create an infinite loop! + * * @param {ImageJob} context job, the same object as with downloadTileStart(..) * @param {*} [context.userData] - Empty object to attach (and mainly read) your own data. */ @@ -845,33 +868,44 @@ $.TileSource.prototype = { * cacheObject parameter should be used to attach the data to, there are no * conventions on how it should be stored - all the logic is implemented within *TileCache() functions. * - * Note that if you override any of *TileCache() functions, you should override all of them. - * @param {object} cacheObject context cache object + * Note that + * - data is cached automatically as cacheObject.data + * - if you override any of *TileCache() functions, you should override all of them. + * - these functions might be called over shared cache object managed by other TileSources simultaneously. + * @param {OpenSeadragon.CacheRecord} cacheObject context cache object * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object - * @param {Tile} tile instance the cache was created with + * @param {OpenSeadragon.Tile} tile instance the cache was created with + * @deprecated */ createTileCache: function(cacheObject, data, tile) { - cacheObject._data = data; + $.console.error("[TileSource.createTileCache] has been deprecated. Use cache API of a tile instead."); + //no-op, we create the cache automatically }, /** * Cache object destructor, unset all properties you created to allow GC collection. * Note that if you override any of *TileCache() functions, you should override all of them. - * @param {object} cacheObject context cache object + * Note that these functions might be called over shared cache object managed by other TileSources simultaneously. + * Original cache data is cacheObject.data, but do not delete it manually! It is taken care for, + * you might break things. + * @param {OpenSeadragon.CacheRecord} cacheObject context cache object + * @deprecated */ destroyTileCache: function (cacheObject) { - cacheObject._data = null; - cacheObject._renderedContext = null; + // FIXME: still allow custom desctruction? probably not - cache system should handle all + $.console.error("[TileSource.destroyTileCache] has been deprecated. Use cache API of a tile instead."); + //no-op, handled internally }, /** - * Raw data getter - * Note that if you override any of *TileCache() functions, you should override all of them. - * @param {object} cacheObject context cache object + * Raw data getter, should return anything that is compatible with the system, or undefined + * if the system can handle it. + * @param {OpenSeadragon.CacheRecord} cacheObject context cache object * @returns {*} cache data + * @deprecated */ getTileCacheData: function(cacheObject) { - return cacheObject._data; + return cacheObject.getData(); }, /** @@ -879,11 +913,14 @@ $.TileSource.prototype = { * - plugins might need image representation of the data * - div HTML rendering relies on image element presence * Note that if you override any of *TileCache() functions, you should override all of them. - * @param {object} cacheObject context cache object + * Note that these functions might be called over shared cache object managed by other TileSources simultaneously. + * @param {OpenSeadragon.CacheRecord} cacheObject context cache object * @returns {Image} cache data as an Image + * @deprecated */ getTileCacheDataAsImage: function(cacheObject) { - return cacheObject._data; //the data itself by default is Image + $.console.error("[TileSource.getTileCacheDataAsImage] has been deprecated. Use cache API of a tile instead."); + return cacheObject.getData("image"); }, /** @@ -891,21 +928,13 @@ $.TileSource.prototype = { * - most heavily used rendering method is a canvas-based approach, * convert the data to a canvas and return it's 2D context * Note that if you override any of *TileCache() functions, you should override all of them. - * @param {object} cacheObject context cache object + * @param {OpenSeadragon.CacheRecord} cacheObject context cache object * @returns {CanvasRenderingContext2D} context of the canvas representation of the cache data + * @deprecated */ getTileCacheDataAsContext2D: function(cacheObject) { - if (!cacheObject._renderedContext) { - var canvas = document.createElement( 'canvas' ); - canvas.width = cacheObject._data.width; - canvas.height = cacheObject._data.height; - cacheObject._renderedContext = canvas.getContext('2d'); - cacheObject._renderedContext.drawImage( cacheObject._data, 0, 0 ); - //since we are caching the prerendered image on a canvas - //allow the image to not be held in memory - cacheObject._data = null; - } - return cacheObject._renderedContext; + $.console.error("[TileSource.getTileCacheDataAsContext2D] has been deprecated. Use cache API of a tile instead."); + return cacheObject.getData("context2d"); } }; diff --git a/src/viewer.js b/src/viewer.js index d82a2d77..3f77a12b 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -415,6 +415,7 @@ $.Viewer = function( options ) { // Create the tile cache this.tileCache = new $.TileCache({ + viewer: this, maxImageCacheCount: this.maxImageCacheCount }); @@ -1434,7 +1435,9 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, * (portions of the image outside of this area will not be visible). Only works on * browsers that support the HTML5 canvas. * @param {Number} [options.opacity=1] Proportional opacity of the tiled images (1=opaque, 0=hidden) - * @param {Boolean} [options.preload=false] Default switch for loading hidden images (true loads, false blocks) + * @param {Boolean} [options.preload=false] Default switch for loading hidden images (true loads, false blocks) + * @param {Boolean} [options.zombieCache] In the case that this method removes any TiledImage instance, + * allow the item-referenced cache to remain in memory even without active tiles. Default false. * @param {Number} [options.degrees=0] Initial rotation of the tiled image around * its top left corner in degrees. * @param {Boolean} [options.flipped=false] Whether to horizontally flip the image. @@ -1576,6 +1579,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, if (newIndex !== -1) { queueItem.options.index = newIndex; } + queueItem.options.replaceItem.allowZombieCache(queueItem.options.zombieCache || false); _this.world.removeItem(queueItem.options.replaceItem); } diff --git a/src/world.js b/src/world.js index a525997b..4a9712e1 100644 --- a/src/world.js +++ b/src/world.js @@ -69,6 +69,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W /** * Add the specified item. * @param {OpenSeadragon.TiledImage} item - The item to add. + * @param {Object} options - Options affecting insertion. * @param {Number} [options.index] - Index for the item. If not specified, goes at the top. * @fires OpenSeadragon.World.event:add-item * @fires OpenSeadragon.World.event:metrics-change diff --git a/test/helpers/test.js b/test/helpers/test.js index 60931bba..e16afe9b 100644 --- a/test/helpers/test.js +++ b/test/helpers/test.js @@ -176,10 +176,20 @@ for ( var i in testLog ) { if ( testLog.hasOwnProperty( i ) && testLog[i].push ) { + //Tile.tiledImage creates circular reference, copy object to avoid and allow JSON serialization + const tileCircularStructureReplacer = function (key, value) { + if (value instanceof OpenSeadragon.Tile) { + var instance = {}; + Object.assign(instance, value); + delete value.tiledImage; + } + return value; + }; + testConsole[i] = ( function ( arr ) { return function () { var args = Array.prototype.slice.call( arguments, 0 ); // Coerce to true Array - arr.push( JSON.stringify( args ) ); // Store as JSON to avoid tedious array-equality tests + arr.push( JSON.stringify( args, tileCircularStructureReplacer ) ); // Store as JSON to avoid tedious array-equality tests }; } )( testLog[i] ); diff --git a/test/modules/basic.js b/test/modules/basic.js index 49af7aba..9094ff62 100644 --- a/test/modules/basic.js +++ b/test/modules/basic.js @@ -224,12 +224,13 @@ }); QUnit.test('FullScreen', function(assert) { - const done = assert.async(); if (!OpenSeadragon.supportsFullScreen) { + const done = assert.async(); assert.expect(0); done(); return; } + var timeWatcher = Util.timeWatcher(assert, 7000); viewer.addHandler('open', function () { assert.ok(!OpenSeadragon.isFullScreen(), 'Started out not fullscreen'); @@ -244,7 +245,7 @@ viewer.removeHandler('full-screen', checkExitingFullScreen); assert.ok(!event.fullScreen, 'Disabling fullscreen'); assert.ok(!OpenSeadragon.isFullScreen(), 'Fullscreen disabled'); - done(); + timeWatcher.done(); } // The 'new' headless mode allows us to enter fullscreen, so verify @@ -254,11 +255,11 @@ viewer.removeHandler('full-screen', checkAcquiredFullScreen); viewer.addHandler('full-screen', checkExitingFullScreen); assert.ok(event.fullScreen, 'Acquired fullscreen'); - assert.ok(OpenSeadragon.isFullScreen(), 'Fullscreen enabled'); + assert.ok(OpenSeadragon.isFullScreen(), 'Fullscreen enabled. Note: this test might fail ' + + 'because fullscreen might be blocked by your browser - not a trusted event!'); viewer.setFullScreen(false); }; - viewer.addHandler('pre-full-screen', checkEnteringPreFullScreen); viewer.addHandler('full-screen', checkAcquiredFullScreen); viewer.setFullScreen(true); @@ -330,7 +331,7 @@ height: 155 } ] } ); - viewer.addOnceHandler('tile-drawn', function() { + viewer.addOnceHandler('tile-drawn', function(e) { assert.ok(OpenSeadragon.isCanvasTainted(viewer.drawer.context.canvas), "Canvas should be tainted."); done(); diff --git a/test/modules/multi-image.js b/test/modules/multi-image.js index 8fab4eee..01196a55 100644 --- a/test/modules/multi-image.js +++ b/test/modules/multi-image.js @@ -221,11 +221,12 @@ viewer.addHandler('open', function() { var firstImage = viewer.world.getItemAt(0); firstImage.addHandler('fully-loaded-change', function() { - var imageData = viewer.drawer.context.getImageData(0, 0, - 500 * density, 500 * density); + var aX = Math.round(500 * density), aY = Math.round(500 * density); + var imageData = viewer.drawer.context.getImageData(0, 0, aX, aY); // Pixel 250,250 will be in the hole of the A - var expectedVal = getPixelValue(imageData, 250 * density, 250 * density); + aX = Math.round(250 * density); aY = Math.round(250 * density); + var expectedVal = getPixelValue(imageData, aX, aY); assert.notEqual(expectedVal.r, 0, 'Red channel should not be 0'); assert.notEqual(expectedVal.g, 0, 'Green channel should not be 0'); @@ -237,8 +238,10 @@ success: function() { var secondImage = viewer.world.getItemAt(1); secondImage.addHandler('fully-loaded-change', function() { - var imageData = viewer.drawer.context.getImageData(0, 0, 500 * density, 500 * density); - var actualVal = getPixelValue(imageData, 250 * density, 250 * density); + var aX = Math.round(500 * density), aY = Math.round(500 * density); + var imageData = viewer.drawer.context.getImageData(0, 0, aX, aY); + aX = Math.round(250 * density); aY = Math.round(250 * density); + var actualVal = getPixelValue(imageData, aX, aY); assert.equal(actualVal.r, expectedVal.r, 'Red channel should not change in transparent part of the A'); @@ -249,11 +252,12 @@ assert.equal(actualVal.a, expectedVal.a, 'Alpha channel should not change in transparent part of the A'); - var onAVal = getPixelValue(imageData, 333 * density, 250 * density); - assert.equal(onAVal.r, 0, 'Red channel should be null on the A'); - assert.equal(onAVal.g, 0, 'Green channel should be null on the A'); - assert.equal(onAVal.b, 0, 'Blue channel should be null on the A'); - assert.equal(onAVal.a, 255, 'Alpha channel should be 255 on the A'); + aX = Math.round(333 * density); aY = Math.round(250 * density); + var onAVal = getPixelValue(imageData, aX, aY); + assert.equal(onAVal.r, 0, 'Red channel should be null on the A pixel (' + aX + ', ' + aY + ')'); + assert.equal(onAVal.g, 0, 'Green channel should be null on the A pixel (' + aX + ', ' + aY + ')'); + assert.equal(onAVal.b, 0, 'Blue channel should be null on the A pixel (' + aX + ', ' + aY + ')'); + assert.equal(onAVal.a, 255, 'Alpha channel should be 255 on the A pixel (' + aX + ', ' + aY + ')'); done(); }); diff --git a/test/modules/tilecache.js b/test/modules/tilecache.js index 26ee51ba..0dc603ef 100644 --- a/test/modules/tilecache.js +++ b/test/modules/tilecache.js @@ -31,6 +31,9 @@ url: 'foo.jpg', cacheKey: 'foo.jpg', image: {}, + loaded: true, + tiledImage: fakeTiledImage0, + _caches: [], unload: function() {} }; @@ -38,6 +41,9 @@ url: 'foo.jpg', cacheKey: 'foo.jpg', image: {}, + loaded: true, + tiledImage: fakeTiledImage1, + _caches: [], unload: function() {} }; @@ -84,6 +90,9 @@ url: 'different.jpg', cacheKey: 'different.jpg', image: {}, + loaded: true, + tiledImage: fakeTiledImage0, + _caches: [], unload: function() {} }; @@ -91,6 +100,9 @@ url: 'same.jpg', cacheKey: 'same.jpg', image: {}, + loaded: true, + tiledImage: fakeTiledImage0, + _caches: [], unload: function() {} }; @@ -98,6 +110,9 @@ url: 'same.jpg', cacheKey: 'same.jpg', image: {}, + loaded: true, + tiledImage: fakeTiledImage0, + _caches: [], unload: function() {} };