diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81c5c75c..28a4b346 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,10 @@ Our tests are based on [QUnit](https://qunitjs.com/) and [Puppeteer](https://git grunt test +To test a specific module (`navigator` here) only: + + grunt test --module="navigator" + If you wish to work interactively with the tests or test your changes: grunt connect watch @@ -69,6 +73,12 @@ and open `http://localhost:8000/test/test.html` in your browser. Another good page, if you want to interactively test out your changes, is `http://localhost:8000/test/demo/basic.html`. + +> Note: corresponding npm commands for the above are: +> - npm run test +> - npm run test -- --module="navigator" +> - npm run dev + You can also get a report of the tests' code coverage: grunt coverage diff --git a/Gruntfile.js b/Gruntfile.js index d526fabe..d01ac36b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -49,6 +49,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", @@ -79,6 +81,11 @@ module.exports = function(grunt) { grunt.config.set('gitInfo', rev); }); + let moduleFilter = ''; + if (grunt.option('module')) { + moduleFilter = '?module=' + grunt.option('module') + } + // ---------- // Project configuration. grunt.initConfig({ @@ -164,7 +171,7 @@ module.exports = function(grunt) { qunit: { normal: { options: { - urls: [ "http://localhost:8000/test/test.html" ], + urls: [ "http://localhost:8000/test/test.html" + moduleFilter ], timeout: 10000, puppeteer: { headless: 'new' @@ -173,7 +180,7 @@ module.exports = function(grunt) { }, coverage: { options: { - urls: [ "http://localhost:8000/test/coverage.html" ], + urls: [ "http://localhost:8000/test/coverage.html" + moduleFilter ], coverage: { src: ['src/*.js'], htmlReport: coverageDir + '/html/', @@ -194,7 +201,12 @@ module.exports = function(grunt) { server: { options: { port: 8000, - base: "." + base: { + path: ".", + options: { + stylesheet: 'style.css' + } + } } } }, diff --git a/README.md b/README.md index c9364219..775f3e72 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,9 @@ OpenSeadragon is released under the New BSD license. For details, see the [LICEN [github-releases]: https://github.com/openseadragon/openseadragon/releases [github-contributing]: https://github.com/openseadragon/openseadragon/blob/master/CONTRIBUTING.md [github-license]: https://github.com/openseadragon/openseadragon/blob/master/LICENSE.txt + +## Sponsors + +We are grateful for the (development or financial) contribution to the OpenSeadragon project. + +Logo BBMRI ERIC diff --git a/assets/logos/bbmri-logo.png b/assets/logos/bbmri-logo.png new file mode 100644 index 00000000..8484d73f Binary files /dev/null and b/assets/logos/bbmri-logo.png differ diff --git a/changelog.txt b/changelog.txt index 7635b993..b943536e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,6 +2,28 @@ OPENSEADRAGON CHANGELOG ======================= 6.0.0: (in progress...) +* NEW BEHAVIOR: OpenSeadragon Data Pipeline Overhaul + * DEPRECATION: Properties on tile that manage drawer data, or store data to draw: Tile.[element|imgElement|style|context2D|getImage|getCanvasContext] and transitively Tile.getScaleForEdgeSmoothing + * DEPRECATION: TileSource data lifecycle handlers: system manages these automatically: TileSource.[createTileCache|destroyTileCache|getTileCacheData|getTileCacheDataAsImage|getTileCacheDataAsContext2D] + * Tiles data is driven by caches: tiles can have multiple caches and cache can reference multiple tiles. + * Data types & conversion pipeline: caches support automated conversion between types, and call optionally destructors. These are asynchronous. + * Data conversion reasoning: the system keeps costs of convertors and seeks the cheapest conversion to a target format (using Dijkstra). + * Async support: events can now await handlers. Added OpenSeadragon.Promise proxy object. This object supports also synchronous mode. + * Drawers define what data they are able to work with, and receive automatically data from one of the declared types. + * Drawers now store data only inside cache, and provide optional type convertors to move data into a format they can work with. + * TileSource equality operator. TileSource destructor support. TileSources now must output type of the data they download [context.finish]. + * Zombies: data can outlive tiles, and be kept in the system to wait if they are not suddenly needed. Turned on automatically with TiledImage addition with `replace: true` and equality test success. + * ImagesLoadedPerFrame is boosted 10 times when system is fresh (reset / open) and then declines back to original value. + * CacheRecord supports 'internal cache' of 'SimpleCache' type. This cache can be used by drawers to hide complex types used for rendering. Such caches are stored internally on CacheRecord objects. + * CacheRecord drives asynchronous data management and ensures correct behavior through awaiting Promises. + * TileCache adds new methods for cache modification: renameCache, cloneCache, injectCache, replaceCache, restoreTilesThatShareOriginalCache, safeUnloadCache, unloadCacheForTile and more. Used internally within invalidation events + * Tiles have up to two 'originalCacheKey' and 'cacheKey' caches, which keep original data and target drawn data (if modified). + * Invalidation Pipeline: New event 'tile-invalidated' and requestInvalidate methods on World and TiledImage. Tiles get methods to modify data to draw, system prepares data for drawing and swaps them with the current main tile cache. + * New test suites for the new cache system, conversion pipeline and invalidation events. + * New testing/demo utilities (MockSeadragon, DrawerSwitcher for switching drawers in demos, getBuiltInDrawersForTest for testing all drawers), serialization guard in tests to remove circular references. + * New demos, demonstrating the new pipeline. New demos for older plugins to show how compatible new version is. + * Misc: updated CSS for dev server, new dev & test commands. + 5.0.1: diff --git a/package.json b/package.json index 7489ea2d..f858e5bc 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ }, "scripts": { "test": "grunt test", - "prepare": "grunt build" + "prepare": "grunt build", + "build": "grunt build", + "dev": "grunt dev" } } diff --git a/src/canvasdrawer.js b/src/canvasdrawer.js index e494d1f2..70d859dc 100644 --- a/src/canvasdrawer.js +++ b/src/canvasdrawer.js @@ -47,7 +47,7 @@ */ class CanvasDrawer extends OpenSeadragon.DrawerBase{ - constructor(options){ + constructor(options) { super(options); /** @@ -69,7 +69,7 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ * @memberof OpenSeadragon.CanvasDrawer# * @private */ - this.context = this.canvas.getContext( '2d' ); + this.context = this.canvas.getContext('2d'); // Sketch canvas used to temporarily draw tiles which cannot be drawn directly // to the main canvas due to opacity. Lazily initialized. @@ -97,6 +97,10 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ return 'canvas'; } + getSupportedDataFormats() { + return ["context2d"]; + } + /** * create the HTML element (e.g. canvas, div) that the image will be drawn into * @returns {Element} the canvas to draw into @@ -267,26 +271,26 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ * */ _drawTiles( tiledImage ) { - var lastDrawn = tiledImage.getTilesToDraw().map(info => info.tile); + const lastDrawn = tiledImage.getTilesToDraw().map(info => info.tile); if (tiledImage.opacity === 0 || (lastDrawn.length === 0 && !tiledImage.placeholderFillStyle)) { return; } - var tile = lastDrawn[0]; - var useSketch; + let tile = lastDrawn[0]; + let useSketch; if (tile) { useSketch = tiledImage.opacity < 1 || (tiledImage.compositeOperation && tiledImage.compositeOperation !== 'source-over') || (!tiledImage._isBottomItem() && - tiledImage.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData)); + tiledImage.source.hasTransparency(null, tile.getUrl(), tile.ajaxHeaders, tile.postData)); } - var sketchScale; - var sketchTranslate; + let sketchScale; + let sketchTranslate; - var zoom = this.viewport.getZoom(true); - var imageZoom = tiledImage.viewportToImageZoom(zoom); + const zoom = this.viewport.getZoom(true); + const imageZoom = tiledImage.viewportToImageZoom(zoom); if (lastDrawn.length > 1 && imageZoom > tiledImage.smoothTileEdgesMinZoom && @@ -296,13 +300,19 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ // So we have to composite them at ~100% and scale them up together. // Note: Disabled on iOS devices per default as it causes a native crash useSketch = true; - sketchScale = tile.getScaleForEdgeSmoothing(); + + const context = tile.length && this.getDataToDraw(tile); + if (context) { + sketchScale = context.canvas.width / (tile.size.x * $.pixelDensityRatio); + } else { + sketchScale = 1; + } sketchTranslate = tile.getTranslationForEdgeSmoothing(sketchScale, this._getCanvasSize(false), this._getCanvasSize(true)); } - var bounds; + let bounds; if (useSketch) { if (!sketchScale) { // Except when edge smoothing, we only clean the part of the @@ -322,13 +332,13 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ this._setRotations(tiledImage, useSketch); } - var usedClip = false; + let usedClip = false; if ( tiledImage._clip ) { this._saveContext(useSketch); - var box = tiledImage.imageToViewportRectangle(tiledImage._clip, true); + let box = tiledImage.imageToViewportRectangle(tiledImage._clip, true); box = box.rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true)); - var clipRect = this.viewportToDrawerRectangle(box); + let clipRect = this.viewportToDrawerRectangle(box); if (sketchScale) { clipRect = clipRect.times(sketchScale); } @@ -341,17 +351,17 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ } if (tiledImage._croppingPolygons) { - var self = this; + const self = this; if(!usedClip){ this._saveContext(useSketch); } try { - var polygons = tiledImage._croppingPolygons.map(function (polygon) { + const polygons = tiledImage._croppingPolygons.map(function (polygon) { return polygon.map(function (coord) { - var point = tiledImage + const point = tiledImage .imageToViewportCoordinates(coord.x, coord.y, true) .rotate(-tiledImage.getRotation(true), tiledImage._getRotationPoint(true)); - var clipPoint = self.viewportCoordToDrawerCoord(point); + let clipPoint = self.viewportCoordToDrawerCoord(point); if (sketchScale) { clipPoint = clipPoint.times(sketchScale); } @@ -388,19 +398,18 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ this._drawRectangle(placeholderRect, fillStyle, useSketch); } - var subPixelRoundingRule = determineSubPixelRoundingRule(tiledImage.subPixelRoundingForTransparency); + const subPixelRoundingRule = determineSubPixelRoundingRule(tiledImage.subPixelRoundingForTransparency); - var shouldRoundPositionAndSize = false; + let shouldRoundPositionAndSize = false; if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ALWAYS) { shouldRoundPositionAndSize = true; } else if (subPixelRoundingRule === $.SUBPIXEL_ROUNDING_OCCURRENCES.ONLY_AT_REST) { - var isAnimating = this.viewer && this.viewer.isAnimating(); - shouldRoundPositionAndSize = !isAnimating; + shouldRoundPositionAndSize = !(this.viewer && this.viewer.isAnimating()); } // Iterate over the tiles to draw, and draw them - for (var i = 0; i < lastDrawn.length; i++) { + for (let i = 0; i < lastDrawn.length; i++) { tile = lastDrawn[ i ]; this._drawTile( tile, tiledImage, useSketch, sketchScale, sketchTranslate, shouldRoundPositionAndSize, tiledImage.source ); @@ -462,9 +471,7 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ this._drawDebugInfo( tiledImage, lastDrawn ); // Fire tiled-image-drawn event. - this._raiseTiledImageDrawnEvent(tiledImage, lastDrawn); - } /** @@ -522,52 +529,25 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ $.console.assert(tile, '[Drawer._drawTile] tile is required'); $.console.assert(tiledImage, '[Drawer._drawTile] drawingHandler is required'); - var context = this._getContext(useSketch); - scale = scale || 1; - this._drawTileToCanvas(tile, context, tiledImage, scale, translate, shouldRoundPositionAndSize, source); - - } - - /** - * Renders the tile in a canvas-based context. - * @private - * @function - * @param {OpenSeadragon.Tile} tile - the tile to draw to the canvas - * @param {Canvas} context - * @param {OpenSeadragon.TiledImage} tiledImage - Method for firing the drawing event. - * drawingHandler({context, tile, rendered}) - * where rendered is the context with the pre-drawn image. - * @param {Number} [scale=1] - Apply a scale to position and size - * @param {OpenSeadragon.Point} [translate] - A translation vector - * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round - * position and size of tiles supporting alpha channel in non-transparency - * context. - * @param {OpenSeadragon.TileSource} source - The source specification of the tile. - */ - _drawTileToCanvas( tile, context, tiledImage, scale, translate, shouldRoundPositionAndSize, source) { - - var position = tile.position.times($.pixelDensityRatio), - size = tile.size.times($.pixelDensityRatio), - rendered; - - if (!tile.context2D && !tile.cacheImageRecord) { - $.console.warn( - '[Drawer._drawTileToCanvas] attempting to draw tile %s when it\'s not cached', - tile.toString()); - return; - } - - rendered = tile.getCanvasContext(); - - if ( !tile.loaded || !rendered ){ + if ( !tile.loaded ){ $.console.warn( "Attempting to draw tile %s when it's not yet loaded.", tile.toString() ); - return; } + const rendered = this.getDataToDraw(tile); + if (!rendered) { + return; + } + + const context = this._getContext(useSketch); + scale = scale || 1; + + let position = tile.position.times($.pixelDensityRatio), + size = tile.size.times($.pixelDensityRatio); + context.save(); if (typeof scale === 'number' && scale !== 1) { @@ -606,7 +586,7 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ this._raiseTileDrawingEvent(tiledImage, context, tile, rendered); - var sourceWidth, sourceHeight; + let sourceWidth, sourceHeight; if (tile.sourceBounds) { sourceWidth = Math.min(tile.sourceBounds.width, rendered.canvas.width); sourceHeight = Math.min(tile.sourceBounds.height, rendered.canvas.height); @@ -634,6 +614,8 @@ class CanvasDrawer extends OpenSeadragon.DrawerBase{ context.restore(); } + + /** * Get the context of the main or sketch canvas * @private diff --git a/src/datatypeconvertor.js b/src/datatypeconvertor.js new file mode 100644 index 00000000..ed6f0d8c --- /dev/null +++ b/src/datatypeconvertor.js @@ -0,0 +1,488 @@ +/* + * OpenSeadragon.convertor (static property) + * + * Copyright (C) 2009 CodePlex Foundation + * Copyright (C) 2010-2024 OpenSeadragon contributors + + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function($){ + +/** + * modified from https://gist.github.com/Prottoy2938/66849e04b0bac459606059f5f9f3aa1a + * @private + */ +class WeightedGraph { + constructor() { + this.adjacencyList = {}; + this.vertices = {}; + } + + /** + * Add vertex to graph + * @param vertex unique vertex ID + * @return {boolean} true if inserted, false if exists (no-op) + */ + addVertex(vertex) { + if (!this.vertices[vertex]) { + this.vertices[vertex] = new $.PriorityQueue.Node(0, vertex); + this.adjacencyList[vertex] = []; + return true; + } + return false; + } + + /** + * Add edge to graph + * @param vertex1 id, must exist by calling addVertex() + * @param vertex2 id, must exist by calling addVertex() + * @param weight + * @param transform function that transforms on path vertex1 -> vertex2 + * @return {boolean} true if new edge, false if replaced existing + */ + addEdge(vertex1, vertex2, weight, transform) { + if (weight < 0) { + $.console.error("WeightedGraph: negative weights will make for invalid shortest path computation!"); + } + const outgoingPaths = this.adjacencyList[vertex1], + replacedEdgeIndex = outgoingPaths.findIndex(edge => edge.target === this.vertices[vertex2]), + newEdge = { target: this.vertices[vertex2], origin: this.vertices[vertex1], weight, transform }; + if (replacedEdgeIndex < 0) { + this.adjacencyList[vertex1].push(newEdge); + return true; + } + this.adjacencyList[vertex1][replacedEdgeIndex] = newEdge; + return false; + } + + /** + * @return {{path: ConversionStep[], cost: number}|undefined} cheapest path from start to finish + */ + 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 || !smallestNode._previous || smallestNode.value !== finish) { + 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 + }; + } +} + +/** + * Edge.transform function on the conversion path in OpenSeadragon.converter.getConversionPath(). + * It can be also conversion to undefined if used as destructor implementation. + * + * @callback TypeConvertor + * @memberof OpenSeadragon + * @param {OpenSeadragon.Tile} tile reference tile that owns the data + * @param {any} data data in the input format + * @returns {any} data in the output format + */ + +/** + * Destructor called every time a data type is to be destroyed or converted to another type. + * + * @callback TypeDestructor + * @memberof OpenSeadragon + * @param {any} data data in the format the destructor is registered for + * @returns {any} can return any value that is carried over to the caller if desirable. + * Note: not used by the OSD cache system. + */ + +/** + * Node on the conversion path in OpenSeadragon.converter.getConversionPath(). + * + * @typedef {Object} ConversionStep + * @memberof OpenSeadragon + * @param {OpenSeadragon.PriorityQueue.Node} target - Target node of the conversion step. + * Its value is the target format. + * @param {OpenSeadragon.PriorityQueue.Node} origin - Origin node of the conversion step. + * Its value is the origin format. + * @param {number} weight cost of the conversion + * @param {TypeConvertor} transform the conversion itself + */ + +/** + * Class that orchestrates automated data types conversion. Do not instantiate + * this class, use OpenSeadragon.convertor - a global instance, instead. + * @class DataTypeConvertor + * @memberOf OpenSeadragon + */ +$.DataTypeConvertor = class { + + constructor() { + this.graph = new WeightedGraph(); + this.destructors = {}; + this.copyings = {}; + + // Teaching OpenSeadragon built-in conversions: + const imageCreator = (tile, url) => new $.Promise((resolve, reject) => { + if (!$.supportsAsync) { + throw "Not supported in sync mode!"; + } + const img = new Image(); + img.onerror = img.onabort = reject; + img.onload = () => resolve(img); + img.src = url; + }); + const canvasContextCreator = (tile, imageData) => { + const canvas = document.createElement( 'canvas' ); + canvas.width = imageData.width; + canvas.height = imageData.height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + context.drawImage( imageData, 0, 0 ); + return context; + }; + + this.learn("context2d", "webImageUrl", (tile, ctx) => ctx.canvas.toDataURL(), 1, 2); + this.learn("image", "webImageUrl", (tile, image) => image.url); + this.learn("image", "context2d", canvasContextCreator, 1, 1); + this.learn("url", "image", imageCreator, 1, 1); + + //Copies + this.learn("image", "image", (tile, image) => imageCreator(tile, image.src), 1, 1); + this.learn("url", "url", (tile, url) => url, 0, 1); //strings are immutable, no need to copy + this.learn("context2d", "context2d", (tile, ctx) => canvasContextCreator(tile, ctx.canvas)); + + /** + * 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). + */ + this.learnDestroy("context2d", ctx => { + ctx.canvas.width = 0; + ctx.canvas.height = 0; + }); + } + + /** + * Unique identifier (unlike toString.call(x)) to be guessed + * from the data value. This type guess is more strict than + * OpenSeadragon.type() implementation, but for most type recognition + * this test relies on the output of OpenSeadragon.type(). + * + * Note: although we try to implement the type guessing, do + * not rely on this functionality! Prefer explicit type declaration. + * + * @function guessType + * @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(); + } + + if (guessType === "object") { + if ($.isFunction(x.getType)) { + return x.getType(); + } + } + return guessType; + } + + /** + * Teach the system to convert data type 'from' -> 'to' + * @param {string} from unique ID of the data item 'from' + * @param {string} to unique ID of the data item 'to' + * @param {OpenSeadragon.TypeConvertor} callback convertor that takes two arguments: a tile reference, and + * a data object of a type 'from'; and converts this data object to type 'to'. It can return also the value + * wrapped in a Promise (returned in resolve) or it can be async function. + * @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!"); + + if (from === to) { + this.copyings[to] = callback; + } else { + //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 = {}; //invalidate precomputed paths :/ + } + } + + /** + * Teach the system to destroy data type 'type' + * for example, textures loaded to GPU have to be also manually removed when not needed anymore. + * Needs to be defined only when the created object has extra deletion process. + * @param {string} type + * @param {OpenSeadragon.TypeDestructor} callback destructor, receives the object created, + * it is basically a type conversion to 'undefined' - thus the type. + */ + learnDestroy(type, callback) { + this.destructors[type] = callback; + } + + /** + * Convert data item x of type 'from' to any of the 'to' types, chosen is the cheapest known conversion. + * Data is destroyed upon conversion. For different behavior, implement your conversion using the + * path rules obtained from getConversionPath(). + * Note: conversion DOES NOT COPY data if [to] contains type 'from' (e.g., the cheapest conversion is no conversion). + * It automatically calls destructor on immediate types, but NOT on the x and the result. You should call these + * manually if these should be destroyed. + * @param {OpenSeadragon.Tile} tile + * @param {any} data data item to convert + * @param {string} from data item type + * @param {string} to desired type(s) + * @return {OpenSeadragon.Promise} promise resolution with type 'to' or undefined if the conversion failed + */ + convert(tile, data, from, ...to) { + const conversionPath = this.getConversionPath(from, to); + if (!conversionPath) { + $.console.error(`[OpenSeadragon.convertor.convert] Conversion ${from} ---> ${to} cannot be done!`); + return $.Promise.resolve(); + } + + const stepCount = conversionPath.length, + _this = this; + const step = (x, i, destroy = true) => { + if (i >= stepCount) { + return $.Promise.resolve(x); + } + let edge = conversionPath[i]; + let y = edge.transform(tile, x); + if (y === undefined) { + $.console.error(`[OpenSeadragon.convertor.convert] data mid result undefined value (while converting using %s)`, edge); + return $.Promise.resolve(); + } + //node.value holds the type string + if (destroy) { + _this.destroy(x, edge.origin.value); + } + const result = $.type(y) === "promise" ? y : $.Promise.resolve(y); + return result.then(res => step(res, i + 1)); + }; + //destroy only mid-results, but not the original value + return step(data, 0, false); + } + + /** + * Copy the data item given. + * @param {OpenSeadragon.Tile} tile + * @param {any} data data item to convert + * @param {string} type data type + * @return {OpenSeadragon.Promise|undefined} promise resolution with data passed from constructor + */ + copy(tile, data, type) { + const copyTransform = this.copyings[type]; + if (copyTransform) { + const y = copyTransform(tile, data); + return $.type(y) === "promise" ? y : $.Promise.resolve(y); + } + $.console.warn(`[OpenSeadragon.convertor.copy] is not supported with type %s`, type); + return $.Promise.resolve(undefined); + } + + /** + * Destroy the data item given. + * @param {string} type data type + * @param {any} data + * @return {OpenSeadragon.Promise|undefined} promise resolution with data passed from constructor, or undefined + * if not such conversion exists + */ + destroy(data, type) { + const destructor = this.destructors[type]; + if (destructor) { + const y = destructor(data); + return $.type(y) === "promise" ? y : $.Promise.resolve(y); + } + return undefined; + } + + /** + * Get possible system type conversions and cache result. + * @param {string} from data item type + * @param {string|string[]} to array of accepted types + * @return {ConversionStep[]|undefined} array of required conversions (returns empty array + * for from===to), or undefined if the system cannot convert between given types. + * Each object has 'transform' function that converts between neighbouring types, such + * that x = arr[i].transform(x) is valid input for convertor arr[i+1].transform(), e.g. + * arr[i+1].transform(arr[i].transform( ... )) is a valid conversion procedure. + * + * Note: if a function is returned, it is a callback called once the data is ready. + */ + getConversionPath(from, to) { + let bestConvertorPath, selectedType; + let knownFrom = this._known[from]; + if (!knownFrom) { + this._known[from] = knownFrom = {}; + } + + if (Array.isArray(to)) { + $.console.assert(to.length > 0, "[getConversionPath] conversion 'to' type must be defined."); + let bestCost = Infinity; + + //FIXME: pre-compute all paths in 'to' array? could be efficient for multiple + // type system, but overhead for simple use cases... now we just use the first type if costs unknown + selectedType = to[0]; + + for (const outType of to) { + const conversion = knownFrom[outType]; + if (conversion && bestCost > conversion.cost) { + bestConvertorPath = conversion; + bestCost = conversion.cost; + selectedType = outType; + } + } + } else { + $.console.assert(typeof to === "string", "[getConversionPath] conversion 'to' type must be defined."); + bestConvertorPath = knownFrom[to]; + selectedType = to; + } + + if (!bestConvertorPath) { + bestConvertorPath = this.graph.dijkstra(from, selectedType); + this._known[from][selectedType] = bestConvertorPath; + } + return bestConvertorPath ? bestConvertorPath.path : undefined; + } + + /** + * Return a list of known conversion types + * @return {string[]} + */ + getKnownTypes() { + return Object.keys(this.graph.vertices); + } + + /** + * Check whether given type is known to the convertor + * @param {string} type type to test + * @return {boolean} + */ + existsType(type) { + return !!this.graph.vertices[type]; + } +}; + +/** + * Static convertor available throughout OpenSeadragon. + * + * Built-in conversions include types: + * - context2d canvas 2d context + * - image HTMLImage element + * - url url string carrying or pointing to 2D raster data + * - canvas HTMLCanvas element + * + * @type OpenSeadragon.DataTypeConvertor + * @memberOf OpenSeadragon + */ +$.convertor = new $.DataTypeConvertor(); + +}(OpenSeadragon)); diff --git a/src/drawerbase.js b/src/drawerbase.js index 083aba79..3355b45c 100644 --- a/src/drawerbase.js +++ b/src/drawerbase.js @@ -34,7 +34,14 @@ (function( $ ){ - const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc +/** + * @typedef BaseDrawerOptions + * @memberOf OpenSeadragon + * @property {boolean} [usePrivateCache=false] specify whether the drawer should use + * detached (=internal) cache object in case it has to perform type conversion + */ + +const OpenSeadragon = $; // (re)alias back to OpenSeadragon for JSDoc /** * @class OpenSeadragon.DrawerBase * @classdesc Base class for Drawers that handle rendering of tiles for an {@link OpenSeadragon.Viewer}. @@ -51,10 +58,11 @@ OpenSeadragon.DrawerBase = class DrawerBase{ $.console.assert( options.viewport, "[Drawer] options.viewport is required" ); $.console.assert( options.element, "[Drawer] options.element is required" ); + this._id = this.getType() + $.now(); this.viewer = options.viewer; this.viewport = options.viewport; this.debugGridColor = typeof options.debugGridColor === 'string' ? [options.debugGridColor] : options.debugGridColor || $.DEFAULT_SETTINGS.debugGridColor; - this.options = options.options || {}; + this.options = $.extend({}, this.defaultOptions, options.options); this.container = $.getElement( options.element ); @@ -77,18 +85,40 @@ OpenSeadragon.DrawerBase = class DrawerBase{ this.container.style.textAlign = "left"; this.container.appendChild( this.canvas ); - this._checkForAPIOverrides(); + this._checkInterfaceImplementation(); + } + + /** + * Retrieve default options for the current drawer. + * The base implementation provides default shared options. + * Overrides should enumerate all defaults or extend from this implementation. + * return $.extend({}, super.options, { ... custom drawer instance options ... }); + * @returns {BaseDrawerOptions} common options + */ + get defaultOptions() { + return { + usePrivateCache: false + }; } // protect the canvas member with a getter get canvas(){ return this._renderingTarget; } + get element(){ $.console.error('Drawer.element is deprecated. Use Drawer.container instead.'); return this.container; } + /** + * Get unique drawer ID + * @return {string} + */ + getId() { + return this._id; + } + /** * @abstract * @returns {String | undefined} What type of drawer this is. Must be overridden by extending classes. @@ -98,6 +128,43 @@ OpenSeadragon.DrawerBase = class DrawerBase{ return undefined; } + /** + * Retrieve required data formats the data must be converted to. + * This list MUST BE A VALID SUBSET OF getSupportedDataFormats() + * @abstract + * @return {string[]} + */ + getRequiredDataFormats() { + return this.getSupportedDataFormats(); + } + + /** + * Retrieve data types + * @abstract + * @return {string[]} + */ + getSupportedDataFormats() { + throw "Drawer.getSupportedDataFormats must define its supported rendering data types!"; + } + + /** + * Check a particular cache record is compatible. + * This function _MUST_ be called: if it returns a falsey + * value, the rendering _MUST NOT_ proceed. It should + * await next animation frames and check again for availability. + * @param {OpenSeadragon.Tile} tile + * @return {any|undefined} undefined if cache not available, compatible data otherwise. + */ + getDataToDraw(tile) { + const cache = tile.getCache(tile.cacheKey); + if (!cache) { + $.console.warn("Attempt to draw tile %s when not cached!", tile); + return undefined; + } + const dataCache = cache.getDataForRendering(this, tile); + return dataCache && dataCache.data; + } + /** * @abstract * @returns {Boolean} Whether the drawer implementation is supported by the browser. Must be overridden by extending classes. @@ -149,7 +216,6 @@ OpenSeadragon.DrawerBase = class DrawerBase{ return false; } - /** * @abstract * @param {Boolean} [imageSmoothingEnabled] - Whether or not the image is @@ -183,20 +249,20 @@ OpenSeadragon.DrawerBase = class DrawerBase{ * @private * */ - _checkForAPIOverrides(){ - if(this._createDrawingElement === $.DrawerBase.prototype._createDrawingElement){ + _checkInterfaceImplementation(){ + if (this._createDrawingElement === $.DrawerBase.prototype._createDrawingElement) { throw(new Error("[drawer]._createDrawingElement must be implemented by child class")); } - if(this.draw === $.DrawerBase.prototype.draw){ + if (this.draw === $.DrawerBase.prototype.draw) { throw(new Error("[drawer].draw must be implemented by child class")); } - if(this.canRotate === $.DrawerBase.prototype.canRotate){ + if (this.canRotate === $.DrawerBase.prototype.canRotate) { throw(new Error("[drawer].canRotate must be implemented by child class")); } - if(this.destroy === $.DrawerBase.prototype.destroy){ + if (this.destroy === $.DrawerBase.prototype.destroy) { throw(new Error("[drawer].destroy must be implemented by child class")); } - if(this.setImageSmoothingEnabled === $.DrawerBase.prototype.setImageSmoothingEnabled){ + if (this.setImageSmoothingEnabled === $.DrawerBase.prototype.setImageSmoothingEnabled) { throw(new Error("[drawer].setImageSmoothingEnabled must be implemented by child class")); } } @@ -302,7 +368,7 @@ OpenSeadragon.DrawerBase = class DrawerBase{ * @property {OpenSeadragon.DrawerBase} drawer - The drawer that raised the error. * @property {String} error - A message describing the error. * @property {?Object} userData - Arbitrary subscriber-defined object. - * @private + * @protected */ this.viewer.raiseEvent( 'drawer-error', { tiledImage: tiledImage, diff --git a/src/dzitilesource.js b/src/dzitilesource.js index 5ee28bfc..96be0453 100644 --- a/src/dzitilesource.js +++ b/src/dzitilesource.js @@ -167,6 +167,14 @@ $.extend( $.DziTileSource.prototype, $.TileSource.prototype, /** @lends OpenSead }, + /** + * Equality comparator + */ + equals: function(otherSource) { + return this.tilesUrl === otherSource.tilesUrl; + }, + + /** * @function * @param {Number} level diff --git a/src/eventsource.js b/src/eventsource.js index 0edea517..139acbba 100644 --- a/src/eventsource.js +++ b/src/eventsource.js @@ -37,9 +37,19 @@ /** * Event handler method signature used by all OpenSeadragon events. * - * @callback EventHandler + * @typedef {function(OpenSeadragon.Event): void} OpenSeadragon.EventHandler * @memberof OpenSeadragon - * @param {Object} event - See individual events for event-specific properties. + * @param {OpenSeadragon.Event} event - The event object containing event-specific properties. + * @returns {void} This handler does not return a value. + */ + +/** + * Event handler method signature used by all OpenSeadragon events. + * + * @typedef {function(OpenSeadragon.Event): Promise} OpenSeadragon.AsyncEventHandler + * @memberof OpenSeadragon + * @param {OpenSeadragon.Event} event - The event object containing event-specific properties. + * @returns {Promise} This handler does not return a value. */ @@ -62,7 +72,7 @@ $.EventSource.prototype = { * for a given event. It is not removable with removeHandler(). * @function * @param {String} eventName - Name of event to register. - * @param {OpenSeadragon.EventHandler} handler - Function to call when event + * @param {OpenSeadragon.EventHandler|OpenSeadragon.AsyncEventHandler} handler - Function to call when event * is triggered. * @param {Object} [userData=null] - Arbitrary object to be passed unchanged * to the handler. @@ -72,10 +82,10 @@ $.EventSource.prototype = { * @returns {Boolean} - True if the handler was added, false if it was rejected */ addOnceHandler: function(eventName, handler, userData, times, priority) { - var self = this; + const self = this; times = times || 1; - var count = 0; - var onceHandler = function(event) { + let count = 0; + const onceHandler = function(event) { count++; if (count === times) { self.removeHandler(eventName, onceHandler); @@ -89,7 +99,7 @@ $.EventSource.prototype = { * Add an event handler for a given event. * @function * @param {String} eventName - Name of event to register. - * @param {OpenSeadragon.EventHandler} handler - Function to call when event is triggered. + * @param {OpenSeadragon.EventHandler|OpenSeadragon.AsyncEventHandler} handler - Function to call when event is triggered. * @param {Object} [userData=null] - Arbitrary object to be passed unchanged to the handler. * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. * @returns {Boolean} - True if the handler was added, false if it was rejected @@ -101,12 +111,12 @@ $.EventSource.prototype = { return false; } - var events = this.events[ eventName ]; + let events = this.events[ eventName ]; if ( !events ) { this.events[ eventName ] = events = []; } if ( handler && $.isFunction( handler ) ) { - var index = events.length, + let index = events.length, event = { handler: handler, userData: userData || null, priority: priority || 0 }; events[ index ] = event; while ( index > 0 && events[ index - 1 ].priority < events[ index ].priority ) { @@ -122,17 +132,16 @@ $.EventSource.prototype = { * Remove a specific event handler for a given event. * @function * @param {String} eventName - Name of event for which the handler is to be removed. - * @param {OpenSeadragon.EventHandler} handler - Function to be removed. + * @param {OpenSeadragon.EventHandler|OpenSeadragon.AsyncEventHandler} handler - Function to be removed. */ removeHandler: function ( eventName, handler ) { - var events = this.events[ eventName ], - handlers = [], - i; + const events = this.events[ eventName ], + handlers = []; if ( !events ) { return; } if ( $.isArray( events ) ) { - for ( i = 0; i < events.length; i++ ) { + for ( let i = 0; i < events.length; i++ ) { if ( events[i].handler !== handler ) { handlers.push( events[ i ] ); } @@ -147,7 +156,7 @@ $.EventSource.prototype = { * @returns {number} amount of events */ numberOfHandlers: function (eventName) { - var events = this.events[ eventName ]; + const events = this.events[ eventName ]; if ( !events ) { return 0; } @@ -164,7 +173,7 @@ $.EventSource.prototype = { if ( eventName ){ this.events[ eventName ] = []; } else{ - for ( var eventType in this.events ) { + for ( let eventType in this.events ) { this.events[ eventType ] = []; } } @@ -176,7 +185,7 @@ $.EventSource.prototype = { * @param {String} eventName - Name of event to get handlers for. */ getHandler: function ( eventName) { - var events = this.events[ eventName ]; + let events = this.events[ eventName ]; if ( !events || !events.length ) { return null; } @@ -184,9 +193,8 @@ $.EventSource.prototype = { [ events[ 0 ] ] : Array.apply( null, events ); return function ( source, args ) { - var i, - length = events.length; - for ( i = 0; i < length; i++ ) { + let length = events.length; + for ( let i = 0; i < length; i++ ) { if ( events[ i ] ) { args.eventSource = source; args.userData = events[ i ].userData; @@ -197,7 +205,46 @@ $.EventSource.prototype = { }, /** - * Trigger an event, optionally passing additional information. + * Get a function which iterates the list of all handlers registered for a given event, + * calling the handler for each and awaiting async ones. + * @function + * @param {String} eventName - Name of event to get handlers for. + * @param {any} bindTarget - Bound target to return with the promise on finish + */ + getAwaitingHandler: function ( eventName, bindTarget ) { + let events = this.events[ eventName ]; + if ( !events || !events.length ) { + return null; + } + events = events.length === 1 ? + [ events[ 0 ] ] : + Array.apply( null, events ); + + return function ( source, args ) { + // We return a promise that gets resolved after all the events finish. + // Returning loop result is not correct, loop promises chain dynamically + // and outer code could process finishing logics in the middle of event loop. + return new $.Promise(resolve => { + const length = events.length; + function loop(index) { + if ( index >= length || !events[ index ] ) { + resolve(bindTarget); + return null; + } + args.eventSource = source; + args.userData = events[ index ].userData; + let result = events[ index ].handler( args ); + result = (!result || $.type(result) !== "promise") ? $.Promise.resolve() : result; + return result.then(() => loop(index + 1)); + } + loop(0); + }); + }; + }, + + /** + * Trigger an event, optionally passing additional information. Does not await async handlers, i.e. + * OpenSeadragon.AsyncEventHandler. * @function * @param {String} eventName - Name of event to register. * @param {Object} eventArgs - Event-specific data. @@ -205,20 +252,40 @@ $.EventSource.prototype = { */ raiseEvent: function( eventName, eventArgs ) { //uncomment if you want to get a log of all events - //$.console.log( eventName ); + //$.console.log( "Event fired:", eventName ); if(Object.prototype.hasOwnProperty.call(this._rejectedEventList, eventName)){ $.console.error(`Error adding handler for ${eventName}. ${this._rejectedEventList[eventName]}`); return false; } - var handler = this.getHandler( eventName ); + const handler = this.getHandler( eventName ); if ( handler ) { handler( this, eventArgs || {} ); } return true; }, + /** + * Trigger an event, optionally passing additional information. + * This events awaits every asynchronous or promise-returning function, i.e. + * OpenSeadragon.AsyncEventHandler. + * @param {String} eventName - Name of event to register. + * @param {Object} eventArgs - Event-specific data. + * @param {?} [bindTarget = null] - Promise-resolved value on the event finish + * @return {OpenSeadragon.Promise|undefined} - Promise resolved upon the event completion. + */ + raiseEventAwaiting: function ( eventName, eventArgs, bindTarget = null ) { + //uncomment if you want to get a log of all events + //$.console.log( "Awaiting event fired:", eventName ); + + const awaitingHandler = this.getAwaitingHandler(eventName, bindTarget); + if (awaitingHandler) { + return awaitingHandler(this, eventArgs || {}); + } + return $.Promise.resolve(bindTarget); + }, + /** * Set an event name as being disabled, and provide an optional error message * to be printed to the console @@ -239,7 +306,6 @@ $.EventSource.prototype = { allowEventHandler(eventName){ delete this._rejectedEventList[eventName]; } - }; }( OpenSeadragon )); diff --git a/src/htmldrawer.js b/src/htmldrawer.js index 8b2a9305..f13856de 100644 --- a/src/htmldrawer.js +++ b/src/htmldrawer.js @@ -68,12 +68,55 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{ this.viewer.rejectEventHandler("tile-drawing", "The HTMLDrawer does not raise the tile-drawing event"); // Since the tile-drawn event is fired by this drawer, make sure handlers can be added for it this.viewer.allowEventHandler("tile-drawn"); + + // works with canvas & image objects + function _prepareTile(tile, data) { + const element = $.makeNeutralElement( "div" ); + const imgElement = data.cloneNode(); + imgElement.style.msInterpolationMode = "nearest-neighbor"; + imgElement.style.width = "100%"; + imgElement.style.height = "100%"; + + const style = element.style; + style.position = "absolute"; + + return { + element, imgElement, style, data + }; + } + + // The actual placing logics will not happen at draw event, but when the cache is created: + $.convertor.learn("context2d", HTMLDrawer.canvasCacheType, (t, d) => _prepareTile(t, d.canvas), 1, 1); + $.convertor.learn("image", HTMLDrawer.imageCacheType, _prepareTile, 1, 1); + // Also learn how to move back, since these elements can be just used as-is + $.convertor.learn(HTMLDrawer.canvasCacheType, "context2d", (t, d) => d.data.getContext('2d'), 1, 3); + $.convertor.learn(HTMLDrawer.imageCacheType, "image", (t, d) => d.data, 1, 3); + + function _freeTile(data) { + if ( data.imgElement && data.imgElement.parentNode ) { + data.imgElement.parentNode.removeChild( data.imgElement ); + } + if ( data.element && data.element.parentNode ) { + data.element.parentNode.removeChild( data.element ); + } + } + + $.convertor.learnDestroy(HTMLDrawer.canvasCacheType, _freeTile); + $.convertor.learnDestroy(HTMLDrawer.imageCacheType, _freeTile); + } + + static get imageCacheType() { + return 'htmlDrawer[image]'; + } + + static get canvasCacheType() { + return 'htmlDrawer[canvas]'; } /** * @returns {Boolean} always true */ - static isSupported(){ + static isSupported() { return true; } @@ -85,6 +128,10 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{ return 'html'; } + getSupportedDataFormats() { + return [HTMLDrawer.imageCacheType, HTMLDrawer.canvasCacheType]; + } + /** * @param {TiledImage} tiledImage the tiled image that is calling the function * @returns {Boolean} Whether this drawer requires enforcing minimum tile overlap to avoid showing seams. @@ -99,8 +146,7 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{ * @returns {Element} the div to draw into */ _createDrawingElement(){ - let canvas = $.makeNeutralElement("div"); - return canvas; + return $.makeNeutralElement("div"); } /** @@ -200,13 +246,6 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{ let container = this.canvas; - if (!tile.cacheImageRecord) { - $.console.warn( - '[Drawer._drawTileToHTML] attempting to draw tile %s when it\'s not cached', - tile.toString()); - return; - } - if ( !tile.loaded ) { $.console.warn( "Attempting to draw tile %s when it's not yet loaded.", @@ -218,41 +257,29 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{ //EXPERIMENTAL - trying to figure out how to scale the container // content during animation of the container size. - if ( !tile.element ) { - var image = tile.getImage(); - if (!image) { - return; - } - - tile.element = $.makeNeutralElement( "div" ); - tile.imgElement = image.cloneNode(); - tile.imgElement.style.msInterpolationMode = "nearest-neighbor"; - tile.imgElement.style.width = "100%"; - tile.imgElement.style.height = "100%"; - - tile.style = tile.element.style; - tile.style.position = "absolute"; + const dataObject = this.getDataToDraw(tile); + if (!dataObject) { + return; } - if ( tile.element.parentNode !== container ) { - container.appendChild( tile.element ); + if ( dataObject.element.parentNode !== container ) { + container.appendChild( dataObject.element ); } - if ( tile.imgElement.parentNode !== tile.element ) { - tile.element.appendChild( tile.imgElement ); + if ( dataObject.imgElement.parentNode !== dataObject.element ) { + dataObject.element.appendChild( dataObject.imgElement ); } - tile.style.top = tile.position.y + "px"; - tile.style.left = tile.position.x + "px"; - tile.style.height = tile.size.y + "px"; - tile.style.width = tile.size.x + "px"; + dataObject.style.top = tile.position.y + "px"; + dataObject.style.left = tile.position.x + "px"; + dataObject.style.height = tile.size.y + "px"; + dataObject.style.width = tile.size.x + "px"; if (tile.flipped) { - tile.style.transform = "scaleX(-1)"; + dataObject.style.transform = "scaleX(-1)"; } - $.setElementOpacity( tile.element, tile.opacity ); + $.setElementOpacity( dataObject.element, tile.opacity ); } - } $.HTMLDrawer = HTMLDrawer; diff --git a/src/iiiftilesource.js b/src/iiiftilesource.js index 1433a968..100297ee 100644 --- a/src/iiiftilesource.js +++ b/src/iiiftilesource.js @@ -263,7 +263,7 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea if (data.preferredFormats) { for (var f = 0; f < data.preferredFormats.length; f++ ) { - if ( OpenSeadragon.imageFormatSupported(data.preferredFormats[f]) ) { + if ( $.imageFormatSupported(data.preferredFormats[f]) ) { data.tileFormat = data.preferredFormats[f]; break; } @@ -503,6 +503,13 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea return uri; }, + /** + * Equality comparator + */ + equals: function(otherSource) { + return this._id === otherSource._id; + }, + __testonly__: { canBeTiled: canBeTiled, constructLevels: constructLevels diff --git a/src/imageloader.js b/src/imageloader.js index 00e413c1..ab038a13 100644 --- a/src/imageloader.js +++ b/src/imageloader.js @@ -48,7 +48,7 @@ * @param {Boolean} [options.ajaxWithCredentials] - Whether to set withCredentials on AJAX requests. * @param {String} [options.crossOriginPolicy] - CORS policy to use for downloads * @param {String} [options.postData] - HTTP POST data (usually but not necessarily in k=v&k2=v2... form, - * see TileSource::getPostData) or null + * see TileSource::getTilePostData) or null * @param {Function} [options.callback] - Called once image has been downloaded. * @param {Function} [options.abort] - Called when this image job is aborted. * @param {Number} [options.timeout] - The max number of milliseconds that this image job may take to complete. @@ -98,7 +98,7 @@ $.ImageJob.prototype = { var selfAbort = this.abort; this.jobId = window.setTimeout(function () { - self.finish(null, null, "Image load exceeded timeout (" + self.timeout + " ms)"); + self.fail("Image load exceeded timeout (" + self.timeout + " ms)", null); }, this.timeout); this.abort = function() { @@ -115,18 +115,48 @@ $.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 + * fallback compatibility behavior: dataType treated as errorMessage if data is falsey value * @memberof OpenSeadragon.ImageJob# */ - finish: function(data, request, errorMessage ) { + finish: function(data, request, dataType) { + if (!this.jobId) { + return; + } + // 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 = errorMessage; + 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); + this.jobId = null; + } + this.callback(this); } }; @@ -167,11 +197,12 @@ $.ImageLoader.prototype = { * @param {String} [options.ajaxHeaders] - Headers to add to the image request if using AJAX. * @param {String|Boolean} [options.crossOriginPolicy] - CORS policy to use for downloads * @param {String} [options.postData] - POST parameters (usually but not necessarily in k=v&k2=v2... form, - * see TileSource::getPostData) or null + * see TileSource::getTilePostData) or null * @param {Boolean} [options.ajaxWithCredentials] - Whether to set withCredentials on AJAX * requests. * @param {Function} [options.callback] - Called once image has been downloaded. * @param {Function} [options.abort] - Called when this image job is aborted. + * @returns {boolean} true if job was immediatelly started, false if queued */ addJob: function(options) { if (!options.source) { @@ -184,10 +215,7 @@ $.ImageLoader.prototype = { }; } - var _this = this, - complete = function(job) { - completeJob(_this, job, options.callback); - }, + const _this = this, jobOptions = { src: options.src, tile: options.tile || {}, @@ -197,7 +225,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 }, @@ -206,10 +234,17 @@ $.ImageLoader.prototype = { if ( !this.jobLimit || this.jobsInProgress < this.jobLimit ) { newJob.start(); this.jobsInProgress++; + return true; } - else { - this.jobQueue.push( newJob ); - } + this.jobQueue.push( newJob ); + return false; + }, + + /** + * @returns {boolean} true if a job can be submitted + */ + canAcceptNewJob() { + return !this.jobLimit || this.jobsInProgress < this.jobLimit; }, /** @@ -238,30 +273,30 @@ $.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--; - if ((!loader.jobLimit || loader.jobsInProgress < loader.jobLimit) && loader.jobQueue.length > 0) { + if (loader.canAcceptNewJob() && loader.jobQueue.length > 0) { nextJob = loader.jobQueue.shift(); nextJob.start(); loader.jobsInProgress++; } 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++; - } - } + if (loader.canAcceptNewJob() && loader.failedTiles.length > 0) { + 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 ca5ef0e8..7b02bc0e 100644 --- a/src/imagetilesource.js +++ b/src/imagetilesource.js @@ -31,268 +31,235 @@ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ - (function ($) { +/** + * @class ImageTileSource + * @classdesc The ImageTileSource allows a simple image to be loaded + * 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 { - /** - * @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 and ajaxWithCredentials - * 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). - */ - $.ImageTileSource = function (options) { - - options = $.extend({ + constructor(props) { + super($.extend({ buildPyramid: true, crossOriginPolicy: false, - ajaxWithCredentials: false - }, options); - $.TileSource.apply(this, [options]); + ajaxWithCredentials: false, + }, 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 - * @param {OpenSeadragon.Viewer} viewer the viewer that is calling - * destroy on the ImageTileSource - */ - destroy: function (viewer) { - this._freeupCanvasMemory(viewer); - }, + /** + * Equality comparator + */ + equals(otherSource) { + return this.url === otherSource.url; + } - // 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 - }]; + getTilePostData(level, x, y) { + return {level: level, x: x, y: y}; + } - if (!this.buildPyramid || !$.supportsCanvas) { - // We don't need the image anymore. Allows it to be GC. - delete this._image; - return levels; - } + /** + * Retrieves a tile context 2D + * @deprecated + */ + getContext2D(level, x, y) { + $.console.error('Using [TiledImage.getContext2D] (for plain images only) is deprecated. ' + + 'Use overridden downloadTileStart (https://openseadragon.github.io/examples/advanced-data-model/) instead.'); + return this._createContext2D(); + } - var currentWidth = this._image.naturalWidth; - var currentHeight = this._image.naturalHeight; + downloadTileStart(job) { + const tileData = job.postData; + if (tileData.level === this.maxLevel) { + job.finish(this.image, null, "image"); + return; + } + 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?`); + } - var bigCanvas = document.createElement("canvas"); - var bigContext = bigCanvas.getContext("2d"); + downloadTileAbort(job) { + //no-op + } - 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; + // 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 + }]; - 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 (viewer) { - for (var i = 0; i < this.levels.length; i++) { - if(this.levels[i].context2D){ - this.levels[i].context2D.canvas.height = 0; - this.levels[i].context2D.canvas.width = 0; + } - if(viewer){ - /** - * Triggered when an image has just been unloaded - * - * @event image-unloaded - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {CanvasRenderingContext2D} context2D - The context that is being unloaded - * @private - */ - viewer.raiseEvent("image-unloaded", { - context2D: this.levels[i].context2D - }); - } + let currentWidth = image.naturalWidth, + currentHeight = image.naturalHeight; + // We build smaller levels until either width or height becomes + // 2 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/legacytilesource.js b/src/legacytilesource.js index f500d728..c51231c3 100644 --- a/src/legacytilesource.js +++ b/src/legacytilesource.js @@ -187,6 +187,21 @@ $.extend( $.LegacyTileSource.prototype, $.TileSource.prototype, /** @lends OpenS url = this.levels[ level ].url; } return url; + }, + + /** + * Equality comparator + */ + equals: function (otherSource) { + if (!otherSource.levels || otherSource.levels.length !== this.levels.length) { + return false; + } + for (let i = this.minLevel; i <= this.maxLevel; i++) { + if (this.levels[i].url !== otherSource.levels[i].url) { + return false; + } + } + return true; } } ); diff --git a/src/openseadragon.js b/src/openseadragon.js index eab9841a..981f8ae5 100644 --- a/src/openseadragon.js +++ b/src/openseadragon.js @@ -722,6 +722,12 @@ * NOTE: passing POST data from URL by this feature only supports string values, however, * TileSource can send any data using POST as long as the header is correct * (@see OpenSeadragon.TileSource.prototype.getTilePostData) + * + * @property {Boolean} [callTileLoadedWithCachedData=false] + * tile-loaded event is called only for tiles that downloaded new data or + * their data is stored in the original form in a suplementary cache object. + * Caches that render directly from re-used cache does not trigger this event again, + * as possible modifications would be applied twice. */ /** @@ -760,12 +766,16 @@ */ /** - * @typedef {Object} DrawerOptions + * @typedef {Object.} DrawerOptions - give the renderer options (both shared - BaseDrawerOptions, and custom). + * Supports arbitrary keys: you can register any drawer on the OpenSeadragon namespace, it will get automatically recognized + * and its getType() implementation will define what key to specify the options with. * @memberof OpenSeadragon - * @property {Object} webgl - options if the WebGLDrawer is used. No options are currently supported. - * @property {Object} canvas - options if the CanvasDrawer is used. No options are currently supported. - * @property {Object} html - options if the HTMLDrawer is used. No options are currently supported. - * @property {Object} custom - options if a custom drawer is used. No options are currently supported. + * @property {BaseDrawerOptions} [webgl] - options if the WebGLDrawer is used. + * @property {BaseDrawerOptions} [canvas] - options if the CanvasDrawer is used. + * @property {BaseDrawerOptions} [html] - options if the HTMLDrawer is used. + * @property {BaseDrawerOptions} [custom] - options if a custom drawer is used. + * + * //Note: if you want to add change options for target drawer change type to {BaseDrawerOptions & MyDrawerOpts} */ @@ -863,16 +873,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, @@ -1066,6 +1080,14 @@ function OpenSeadragon( options ){ return supported >= 3; }()); + /** + * If true, OpenSeadragon uses async execution, else it uses synchronous execution. + * Note that disabling async means no plugins that use Promises / async will work with OSD. + * @member {boolean} + * @memberof OpenSeadragon + */ + $.supportsAsync = true; + /** * A ratio comparing the device screen's pixel density to the canvas's backing store pixel density, * clamped to a minimum of 1. Defaults to 1 if canvas isn't supported by the browser. @@ -1231,6 +1253,7 @@ function OpenSeadragon( options ){ loadTilesWithAjax: false, ajaxHeaders: {}, splitHashDataForPost: false, + callTileLoadedWithCachedData: false, //PAN AND ZOOM SETTINGS AND CONSTRAINTS panHorizontal: true, @@ -2296,29 +2319,6 @@ function OpenSeadragon( options ){ event.stopPropagation(); }, - // Deprecated - createCallback: function( object, method ) { - //TODO: This pattern is painful to use and debug. It's much cleaner - // to use pinning plus anonymous functions. Get rid of this - // pattern! - console.error('The createCallback function is deprecated and will be removed in future versions. Please use alternativeFunction instead.'); - var initialArgs = [], - i; - for ( i = 2; i < arguments.length; i++ ) { - initialArgs.push( arguments[ i ] ); - } - - return function() { - var args = initialArgs.concat( [] ), - i; - for ( i = 0; i < arguments.length; i++ ) { - args.push( arguments[ i ] ); - } - - return method.apply( object, args ); - }; - }, - /** * Retrieves the value of a url parameter from the window.location string. @@ -2367,6 +2367,14 @@ function OpenSeadragon( options ){ }, /** + * Makes an AJAX request. + * @param {String} url - the url to request + * @param {Function} onSuccess + * @param {Function} onError + * @throws {Error} + * @returns {XMLHttpRequest} + * @deprecated deprecated way of calling this function + *//** * Makes an AJAX request. * @param {Object} options * @param {String} options.url - the url to request @@ -2375,7 +2383,7 @@ function OpenSeadragon( options ){ * @param {Object} options.headers - headers to add to the AJAX request * @param {String} options.responseType - the response type of the AJAX request * @param {String} options.postData - HTTP POST data (usually but not necessarily in k=v&k2=v2... form, - * see TileSource::getPostData), GET method used if null + * see TileSource::getTilePostData), GET method used if null * @param {Boolean} [options.withCredentials=false] - whether to set the XHR's withCredentials * @throws {Error} * @returns {XMLHttpRequest} @@ -2396,6 +2404,8 @@ function OpenSeadragon( options ){ responseType = url.responseType || null; postData = url.postData || null; url = url.url; + } else { + $.console.warn("OpenSeadragon.makeAjaxRequest() deprecated usage!"); } var protocol = $.getUrlProtocol( url ); @@ -2622,10 +2632,13 @@ function OpenSeadragon( options ){ * keys and booleans as values. */ setImageFormatsSupported: function(formats) { + //TODO: how to deal with this within the data pipeline? + // $.console.warn("setImageFormatsSupported method is deprecated. You should check that" + + // " the system supports your TileSources by implementing corresponding data type convertors."); + // eslint-disable-next-line no-use-before-define $.extend(FILEFORMATS, formats); - } - + }, }); @@ -2891,6 +2904,122 @@ function OpenSeadragon( options ){ } } + /** + * @template T + * @typedef {function(): OpenSeadragon.Promise} AsyncNullaryFunction + * Represents an asynchronous function that takes no arguments and returns a promise of type T. + */ + + /** + * @template T, A + * @typedef {function(A): OpenSeadragon.Promise} AsyncUnaryFunction + * Represents an asynchronous function that: + * @param {A} arg - The single argument of type A. + * @returns {OpenSeadragon.Promise} A promise that resolves to a value of type T. + */ + + /** + * @template T, A, B + * @typedef {function(A, B): OpenSeadragon.Promise} AsyncBinaryFunction + * Represents an asynchronous function that: + * @param {A} arg1 - The first argument of type A. + * @param {B} arg2 - The second argument of type B. + * @returns {OpenSeadragon.Promise} A promise that resolves to a value of type T. + */ + + /** + * Promise proxy in OpenSeadragon, enables $.supportsAsync feature. + * This proxy is also necessary because OperaMini does not implement Promises (checks fail). + * @type {PromiseConstructor} + */ + $.Promise = window["Promise"] && $.supportsAsync ? window["Promise"] : class { + constructor(handler) { + this._error = false; + this.__value = undefined; + + try { + // Make sure to unwrap all nested promises! + handler( + (value) => { + while (value instanceof $.Promise) { + value = value._value; + } + this._value = value; + }, + (error) => { + while (error instanceof $.Promise) { + error = error._value; + } + this._value = error; + this._error = true; + } + ); + } catch (e) { + this._value = e; + this._error = true; + } + } + + then(handler) { + if (!this._error) { + try { + this._value = handler(this._value); + } catch (e) { + this._value = e; + this._error = true; + } + } + return this; + } + + catch(handler) { + if (this._error) { + try { + this._value = handler(this._value); + this._error = false; + } catch (e) { + this._value = e; + this._error = true; + } + } + return this; + } + + get _value() { + return this.__value; + } + set _value(val) { + if (val && val.constructor === this.constructor) { + val = val._value; //unwrap + } + this.__value = val; + } + + static resolve(value) { + return new this((resolve) => resolve(value)); + } + + static reject(error) { + return new this((_, reject) => reject(error)); + } + + static all(functions) { + return new this((resolve) => { + // no async support, just execute them + return resolve(functions.map(fn => fn())); + }); + } + + static race(functions) { + if (functions.length < 1) { + return this.resolve(); + } + // no async support, just execute the first + return new this((resolve) => { + return resolve(functions[0]()); + }); + } + }; }(OpenSeadragon)); diff --git a/src/osmtilesource.js b/src/osmtilesource.js index 4f9d1f35..1dfeba52 100644 --- a/src/osmtilesource.js +++ b/src/osmtilesource.js @@ -139,6 +139,13 @@ $.extend( $.OsmTileSource.prototype, $.TileSource.prototype, /** @lends OpenSead */ getTileUrl: function( level, x, y ) { return this.tilesUrl + (level - 8) + "/" + x + "/" + y + ".png"; + }, + + /** + * Equality comparator + */ + equals: function(otherSource) { + return this.tilesUrl === otherSource.tilesUrl; } }); diff --git a/src/priorityqueue.js b/src/priorityqueue.js new file mode 100644 index 00000000..9c9d8481 --- /dev/null +++ b/src/priorityqueue.js @@ -0,0 +1,362 @@ +/* + * OpenSeadragon - Queue + * + * Copyright (C) 2024 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 + */ + 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. + * @type {K} + * @private + */ + this.key = key; + + /** + * The value. + * @type {V} + * @private + */ + 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 0b6d5fb2..b04dbc61 100644 --- a/src/tile.js +++ b/src/tile.js @@ -45,15 +45,15 @@ * @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::addCache(...) 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 * drawing operation, in pixels. Note that this only works when drawing with canvas; when drawing * with HTML the entire tile is always used. * @param {String} postData HTTP POST data (usually but not necessarily in k=v&k2=v2... form, - * see TileSource::getPostData) or null + * see TileSource::getTilePostData) or null * @param {String} cacheKey key to act as a tile cache, must be unique for tiles with unique image data */ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, ajaxHeaders, sourceBounds, postData, cacheKey) { @@ -103,16 +103,16 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja /** * Private property to hold string url or url retriever function. * Consumers should access via Tile.getUrl() - * @private * @member {String|Function} url * @memberof OpenSeadragon.Tile# + * @private */ this._url = url; /** * Post parameters for this tile. For example, it can be an URL-encoded string * in k1=v1&k2=v2... format, or a JSON, or a FormData instance... or null if no POST request used * @member {String} postData HTTP POST data (usually but not necessarily in k=v&k2=v2... form, - * see TileSource::getPostData) or null + * see TileSource::getTilePostData) or null * @memberof OpenSeadragon.Tile# */ this.postData = postData; @@ -121,7 +121,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 @@ -141,12 +143,10 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja " in Tile class is deprecated. TileSource.prototype.getTileHashKey will be used."); cacheKey = $.TileSource.prototype.getTileHashKey(level, x, y, url, ajaxHeaders, postData); } - /** - * The unique cache key for this tile. - * @member {String} cacheKey - * @memberof OpenSeadragon.Tile# - */ - this.cacheKey = cacheKey; + + this._cKey = cacheKey || ""; + this._ocKey = cacheKey || ""; + /** * Is this tile loaded? * @member {Boolean} loaded @@ -159,26 +159,6 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @memberof OpenSeadragon.Tile# */ this.loading = false; - - /** - * The HTML div element for this tile - * @member {Element} element - * @memberof OpenSeadragon.Tile# - */ - this.element = null; - /** - * The HTML img element for this tile. - * @member {Element} imgElement - * @memberof OpenSeadragon.Tile# - */ - this.imgElement = null; - - /** - * The alias of this.element.style. - * @member {String} style - * @memberof OpenSeadragon.Tile# - */ - this.style = null; /** * This tile's position on screen, in pixels. * @member {OpenSeadragon.Point} position @@ -212,9 +192,9 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja /** * The squared distance of this tile to the viewport center. * Use for comparing tiles. - * @private * @member {Number} squaredDistance * @memberof OpenSeadragon.Tile# + * @private */ this.squaredDistance = null; /** @@ -258,6 +238,32 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @memberof OpenSeadragon.Tile# */ this.isBottomMost = false; + + /** + * Owner of this tile. Do not change this property manually. + * @member {OpenSeadragon.TiledImage} + * @memberof OpenSeadragon.Tile# + */ + this.tiledImage = null; + /** + * Array of cached tile data associated with the tile. + * @member {Object} + * @private + */ + this._caches = {}; + /** + * Processing flag, exempt the tile from removal when there are ongoing updates + * @member {Boolean|Number} + * @private + */ + this.processing = false; + /** + * Processing promise, resolves when the tile exits processing, or + * resolves immediatelly if not in the processing state. + * @member {OpenSeadragon.Promise} + * @private + */ + this.processingPromise = $.Promise.resolve(); }; /** @lends OpenSeadragon.Tile.prototype */ @@ -273,11 +279,42 @@ $.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'); + /** + * The unique main cache key for this tile. Created automatically + * from the given tiledImage.source.getTileHashKey(...) implementation. + * @member {String} cacheKey + * @memberof OpenSeadragon.Tile# + */ + get cacheKey() { + return this._cKey; + }, + set cacheKey(value) { + if (value === this.cacheKey) { + return; + } + const cache = this.getCache(value); + if (!cache) { + // It's better to first set cache, then change the key to existing one. Warn if otherwise. + $.console.warn("[Tile.cacheKey] should not be set manually. Use addCache() with setAsMain=true."); + } + this._updateMainCacheKey(value); + }, + + /** + * By default equal to tile.cacheKey, marks a cache associated with this tile + * that holds the cache original data (it was loaded with). In case you + * change the tile data, the tile original data should be left with the cache + * 'originalCacheKey' and the new, modified data should be stored in cache 'cacheKey'. + * This key is used in cache resolution: in case new tile data is requested, if + * this cache key exists in the cache it is loaded. + * @member {String} originalCacheKey + * @memberof OpenSeadragon.Tile# + */ + set originalCacheKey(value) { + throw "Original Cache Key cannot be managed manually!"; + }, + get originalCacheKey() { + return this._ocKey; }, /** @@ -288,7 +325,7 @@ $.Tile.prototype = { * @returns {Image} */ get image() { - $.console.error("[Tile.image] property has been deprecated. Use [Tile.prototype.getImage] instead."); + $.console.error("[Tile.image] property has been deprecated. Use [Tile.getData] instead."); return this.getImage(); }, @@ -300,16 +337,80 @@ $.Tile.prototype = { * @returns {String} */ get url() { - $.console.error("[Tile.url] property has been deprecated. Use [Tile.prototype.getUrl] instead."); + $.console.error("[Tile.url] property has been deprecated. Use [Tile.getUrl] instead."); return this.getUrl(); }, + /** + * The HTML div element for this tile + * @member {Element} element + * @memberof OpenSeadragon.Tile# + * @deprecated + */ + get element() { + $.console.error("Tile::element property is deprecated. Use cache API instead. Moreover, this property might be unstable."); + const cache = this.getCache(); + if (!cache || !cache.loaded) { + return null; + } + if (cache.type !== OpenSeadragon.HTMLDrawer.canvasCacheType || cache.type !== OpenSeadragon.HTMLDrawer.imageCacheType) { + $.console.error("Access to HtmlDrawer property via Tile instance: HTMLDrawer must be used!"); + return null; + } + return cache.data.element; + }, + + /** + * The HTML img element for this tile. + * @member {Element} imgElement + * @memberof OpenSeadragon.Tile# + * @deprecated + */ + get imgElement() { + $.console.error("Tile::imgElement property is deprecated. Use cache API instead. Moreover, this property might be unstable."); + const cache = this.getCache(); + if (!cache || !cache.loaded) { + return null; + } + if (cache.type !== OpenSeadragon.HTMLDrawer.canvasCacheType || cache.type !== OpenSeadragon.HTMLDrawer.imageCacheType) { + $.console.error("Access to HtmlDrawer property via Tile instance: HTMLDrawer must be used!"); + return null; + } + return cache.data.imgElement; + }, + + /** + * The alias of this.element.style. + * @member {String} style + * @memberof OpenSeadragon.Tile# + * @deprecated + */ + get style() { + $.console.error("Tile::style property is deprecated. Use cache API instead. Moreover, this property might be unstable."); + const cache = this.getCache(); + if (!cache || !cache.loaded) { + return null; + } + if (cache.type !== OpenSeadragon.HTMLDrawer.canvasCacheType || cache.type !== OpenSeadragon.HTMLDrawer.imageCacheType) { + $.console.error("Access to HtmlDrawer property via Tile instance: HTMLDrawer must be used!"); + return null; + } + return cache.data.style; + }, + /** * Get the Image object for this tile. - * @returns {Image} + * @returns {?Image} */ getImage: function() { - return this.cacheImageRecord.getImage(); + $.console.error("[Tile.getImage] property has been deprecated. Use 'tile-invalidated' routine event instead."); + //this method used to ensure the underlying data model conformed to given type - convert instead of getData() + const cache = this.getCache(this.cacheKey); + if (!cache) { + return undefined; + } + cache.transformTo("image"); + return cache.data; }, /** @@ -327,24 +428,290 @@ $.Tile.prototype = { /** * Get the CanvasRenderingContext2D instance for tile image data drawn * onto Canvas if enabled and available - * @returns {CanvasRenderingContext2D} + * @returns {CanvasRenderingContext2D|undefined} */ getCanvasContext: function() { - return this.context2D || (this.cacheImageRecord && this.cacheImageRecord.getRenderedContext()); + $.console.error("[Tile.getCanvasContext] property has been deprecated. Use 'tile-invalidated' routine event instead."); + //this method used to ensure the underlying data model conformed to given type - convert instead of getData() + const cache = this.getCache(this.cacheKey); + if (!cache) { + return undefined; + } + cache.transformTo("context2d"); + return cache.data; + }, + + /** + * The context2D of this tile if it is provided directly by the tile source. + * @deprecated + * @type {CanvasRenderingContext2D} + */ + get context2D() { + $.console.error("[Tile.context2D] property has been deprecated. Use 'tile-invalidated' routine event instead."); + return this.getCanvasContext(); + }, + + /** + * 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-invalidated' routine event instead."); + const cache = this._caches[this.cacheKey]; + if (cache) { + this.removeCache(this.cacheKey); + } + this.addCache(this.cacheKey, value, 'context2d', true, false); + }, + + /** + * 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::addCache."); + const cache = this._caches[this.cacheKey]; + + if (cache) { + this.removeCache(this.cacheKey); + } + + if (value) { + if (value.loaded) { + this.addCache(this.cacheKey, value.data, value.type, true, false); + } else { + value.await().then(x => this.addCache(this.cacheKey, x, value.type, true, false)); + } + } + }, + + /** + * Cache key for main cache that is 'cache-equal', but different from original cache key + * @return {string} + * @private + */ + buildDistinctMainCacheKey: function () { + return this.cacheKey === this.originalCacheKey ? "mod://" + this.originalCacheKey : this.cacheKey; + }, + + /** + * Read tile cache data object (CacheRecord) + * @param {string} [key=this.cacheKey] cache key to read that belongs to this tile + * @return {OpenSeadragon.CacheRecord} + */ + getCache: function(key = this._cKey) { + const cache = this._caches[key]; + if (cache) { + cache.withTileReference(this); + } + return cache; + }, + + /** + * Create tile cache for given data object. + * + * Using `setAsMain` updates also main tile cache key - the main cache key used to draw this tile. + * In that case, the cache should be ready to be rendered immediatelly (converted to one of the supported formats + * of the currently employed drawer). + * + * NOTE: if the existing cache already exists, + * data parameter is ignored and inherited from the existing cache object. + * WARNING: if you override main tile cache key to point to a different cache, the invalidation routine + * will no longer work. If you need to modify tile main data, prefer to use invalidation routine instead. + * + * @param {string} key cache key, if unique, new cache object is created, else existing cache attached + * @param {*} data this data will be IGNORED if cache already exists; therefore if + * `typeof data === 'function'` holds (both async and normal functions), the data is called to obtain + * the data item: this is an optimization to load data only when necessary. + * @param {string} [type=undefined] data type, will be guessed if not provided (not recommended), + * if data is a callback the type is a mandatory field, not setting it results in undefined behaviour + * @param {boolean} [setAsMain=false] if true, the key will be set as the tile.cacheKey, + * no effect if key === this.cacheKey + * @param [_safely=true] private + * @returns {OpenSeadragon.CacheRecord|null} - The cache record the tile was attached to. + */ + addCache: function(key, data, type = undefined, setAsMain = false, _safely = true) { + const tiledImage = this.tiledImage; + if (!tiledImage) { + return null; //async can access outside its lifetime + } + + if (!type) { + if (!this.__typeWarningReported) { + $.console.warn(this, "[Tile.addCache] called without type specification. " + + "Automated deduction is potentially unsafe: prefer specification of data type explicitly."); + this.__typeWarningReported = true; + } + if (typeof data === 'function') { + $.console.error("[TileCache.cacheTile] options.data as a callback requires type argument! Current is " + type); + } + type = $.convertor.guessType(data); + } + + const overwritesMainCache = key === this.cacheKey; + if (_safely && (overwritesMainCache || setAsMain)) { + // Need to get the supported type for rendering out of the active drawer. + const supportedTypes = tiledImage.viewer.drawer.getSupportedDataFormats(); + const conversion = $.convertor.getConversionPath(type, supportedTypes); + $.console.assert(conversion, "[Tile.addCache] data was set for the default tile cache we are unable" + + `to render. Make sure OpenSeadragon.convertor was taught to convert ${type} to (one of): ${conversion.toString()}`); + } + + const cachedItem = tiledImage._tileCache.cacheTile({ + data: data, + dataType: type, + tile: this, + cacheKey: key, + cutoff: tiledImage.source.getClosestLevel(), + }); + const havingRecord = this._caches[key]; + if (havingRecord !== cachedItem) { + this._caches[key] = cachedItem; + if (havingRecord) { + havingRecord.removeTile(this); + tiledImage._tileCache.safeUnloadCache(havingRecord); + } + } + + // Update cache key if differs and main requested + if (!overwritesMainCache && setAsMain) { + this._updateMainCacheKey(key); + } + return cachedItem; + }, + + + /** + * Add cache object to the tile + * + * @param {string} key cache key, if unique, new cache object is created, else existing cache attached + * @param {OpenSeadragon.CacheRecord} cache the cache object to attach to this tile + * @param {boolean} [setAsMain=false] if true, the key will be set as the tile.cacheKey, + * no effect if key === this.cacheKey + * @param [_safely=true] private + * @returns {OpenSeadragon.CacheRecord|null} - Returns cache parameter reference if attached. + */ + setCache(key, cache, setAsMain = false, _safely = true) { + const tiledImage = this.tiledImage; + if (!tiledImage) { + return null; //async can access outside its lifetime + } + + const overwritesMainCache = key === this.cacheKey; + if (_safely) { + $.console.assert(cache instanceof $.CacheRecord, "[Tile.setCache] cache must be a CacheRecord object!"); + if (overwritesMainCache || setAsMain) { + // Need to get the supported type for rendering out of the active drawer. + const supportedTypes = tiledImage.viewer.drawer.getSupportedDataFormats(); + const conversion = $.convertor.getConversionPath(cache.type, supportedTypes); + $.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 ${cache.type} to (one of): ${conversion.toString()}`); + } + } + + const havingRecord = this._caches[key]; + if (havingRecord !== cache) { + this._caches[key] = cache; + cache.addTile(this); // keep reference bidirectional + if (havingRecord) { + havingRecord.removeTile(this); + tiledImage._tileCache.safeUnloadCache(havingRecord); + } + } + + // Update cache key if differs and main requested + if (!overwritesMainCache && setAsMain) { + this._updateMainCacheKey(key); + } + return cache; + }, + + /** + * Sets the main cache key for this tile and + * performs necessary updates + * @param value + * @private + */ + _updateMainCacheKey: function(value) { + let ref = this._caches[this._cKey]; + if (ref) { + // make sure we free drawer internal cache if people change cache key externally + ref.destroyInternalCache(); + } + this._cKey = value; + }, + + /** + * Get the number of caches available to this tile + * @returns {number} number of caches + */ + getCacheSize: function() { + return Object.values(this._caches).length; + }, + + /** + * Free tile cache. Removes by default the cache record if no other tile uses it. + * @param {string} key cache key, required + * @param {boolean} [freeIfUnused=true] set to false if zombie should be created + * @return {OpenSeadragon.CacheRecord|undefined} reference to the cache record if it was removed, + * undefined if removal was refused to perform (e.g. does not exist, it is an original data target etc.) + */ + removeCache: function(key, freeIfUnused = true) { + const deleteTarget = this._caches[key]; + if (!deleteTarget) { + // try to erase anyway in case the cache got stuck in memory + this.tiledImage._tileCache.unloadCacheForTile(this, key, freeIfUnused, true); + return undefined; + } + + const currentMainKey = this.cacheKey, + originalDataKey = this.originalCacheKey, + sameBuiltinKeys = currentMainKey === originalDataKey; + + if (!sameBuiltinKeys && originalDataKey === key) { + $.console.warn("[Tile.removeCache] original data must not be manually deleted: other parts of the code might rely on it!", + "If you want the tile not to preserve the original data, toggle of data perseverance in tile.setData()."); + return undefined; + } + + if (currentMainKey === key) { + if (!sameBuiltinKeys && this._caches[originalDataKey]) { + // if we have original data let's revert back + this._updateMainCacheKey(originalDataKey); + } else { + $.console.warn("[Tile.removeCache] trying to remove the only cache that can be used to draw the tile!", + "If you want to remove the main cache, first set different cache as main with tile.addCache()"); + return undefined; + } + } + if (this.tiledImage._tileCache.unloadCacheForTile(this, key, freeIfUnused, false)) { + //if we managed to free tile from record, we are sure we decreased cache count + delete this._caches[key]; + } + return deleteTarget; }, /** * Get the ratio between current and original size. * @function - * @returns {Float} + * @deprecated + * @returns {number} */ getScaleForEdgeSmoothing: function() { - var context; - if (this.cacheImageRecord) { - context = this.cacheImageRecord.getRenderedContext(); - } else if (this.context2D) { - context = this.context2D; - } else { + // getCanvasContext is deprecated and so should be this method. + $.console.warn("[Tile.getScaleForEdgeSmoothing] is deprecated, the following error is the consequence:"); + const context = this.getCanvasContext(); + if (!context) { $.console.warn( '[Tile.drawCanvas] attempting to get tile scale %s when tile\'s not cached', this.toString()); @@ -365,8 +732,8 @@ $.Tile.prototype = { // the sketch canvas to the top and left and we must use negative coordinates to repaint it // to the main canvas. In that case, some browsers throw: // INDEX_SIZE_ERR: DOM Exception 1: Index or size was negative, or greater than the allowed value. - var x = Math.max(1, Math.ceil((sketchCanvasSize.x - canvasSize.x) / 2)); - var y = Math.max(1, Math.ceil((sketchCanvasSize.y - canvasSize.y) / 2)); + const x = Math.max(1, Math.ceil((sketchCanvasSize.x - canvasSize.x) / 2)); + const y = Math.max(1, Math.ceil((sketchCanvasSize.y - canvasSize.y) / 2)); return new $.Point(x, y).minus( this.position .times($.pixelDensityRatio) @@ -378,21 +745,61 @@ $.Tile.prototype = { }, /** - * Removes tile from its container. + * Reflect that a cache object was renamed. Called internally from TileCache. + * Do NOT call manually. * @function + * @private */ - unload: function() { - if ( this.imgElement && this.imgElement.parentNode ) { - this.imgElement.parentNode.removeChild( this.imgElement ); + reflectCacheRenamed: function (oldKey, newKey) { + let cache = this._caches[oldKey]; + if (!cache) { + return; // nothing to fix } - if ( this.element && this.element.parentNode ) { - this.element.parentNode.removeChild( this.element ); + // Do update via private refs, old key no longer exists in cache + if (oldKey === this._ocKey) { + this._ocKey = newKey; } + if (oldKey === this._cKey) { + this._cKey = newKey; + } + // Working key is never updated, it will be invalidated (but do not dereference cache, just fix the pointers) + this._caches[newKey] = cache; + delete this._caches[oldKey]; + }, + /** + * Check if two tiles are data-equal + * @param {OpenSeadragon.Tile} tile + */ + equals(tile) { + return this._ocKey === tile._ocKey; + }, + + /** + * Removes tile from the system: it will still be present in the + * OSD memory, but marked as loaded=false, and its data will be erased. + * @param {boolean} [erase=false] + */ + unload: function(erase = false) { + if (!this.loaded) { + return; + } + this.tiledImage._tileCache.unloadTile(this, erase); + }, + + /** + * this method shall be called only by cache system when the tile is already empty of data + * @private + */ + _unload: function () { + this.tiledImage = null; + this._caches = {}; + this._cacheSize = 0; this.element = null; this.imgElement = null; this.loaded = false; this.loading = false; + this._cKey = this._ocKey; } }; diff --git a/src/tilecache.js b/src/tilecache.js index 103a02af..f23d5e26 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -34,267 +34,1262 @@ (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; -}; - -// 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(); - this._tiles = null; - }, - - 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++) { - if (this._tiles[i] === tile) { - this._tiles.splice(i, 1); - return; - } - } - - $.console.warn('[ImageRecord.removeTile] trying to remove unknown tile', tile); - }, - - getTileCount: function() { - return this._tiles.length; - } -}; - -/** - * @class TileCache - * @memberof OpenSeadragon - * @classdesc Stores all the tiles displayed in a {@link OpenSeadragon.Viewer}. - * You generally won't have to interact with the TileCache directly. - * @param {Object} options - Configuration for this TileCache. - * @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in - * {@link OpenSeadragon.Options} for details. - */ -$.TileCache = function( options ) { - options = options || {}; - - this._maxImageCacheCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount; - this._tilesLoaded = []; - this._imagesLoaded = []; - this._imagesLoadedCount = 0; -}; - -/** @lends OpenSeadragon.TileCache.prototype */ -$.TileCache.prototype = { - /** - * @returns {Number} The total number of tiles that have been loaded by - * this TileCache. - */ - numTilesLoaded: function() { - return this._tilesLoaded.length; - }, + const DRAWER_INTERNAL_CACHE = Symbol("DRAWER_INTERNAL_CACHE"); /** - * Caches the specified tile, removing an old tile if necessary to stay under the - * maxImageCacheCount specified on construction. Note that if multiple tiles reference - * the same image, there may be more tiles than maxImageCacheCount; the goal is to keep - * the number of images below that number. Note, as well, that even the number of images - * may temporarily surpass that number, but should eventually come back down to the max specified. - * @param {Object} options - Tile info. - * @param {OpenSeadragon.Tile} options.tile - The tile to cache. - * @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. - * @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. + * @class CacheRecord + * @memberof OpenSeadragon + * @classdesc Cached Data Record, the cache object. Keeps only latest object type required. + * + * This class acts like the Maybe type: + * - it has 'loaded' flag indicating whether the tile data is ready + * - it has 'data' property that has value if loaded=true + * + * Furthermore, it has a 'getData' function that returns a promise resolving + * with the value on the desired type passed to the function. */ - cacheTile: function( 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; - - var imageRecord = this._imagesLoaded[options.tile.cacheKey]; - if (!imageRecord) { - - if (!options.data) { - $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " + - "has been deprecated and will be removed in the future."); - options.data = options.image; - } - - $.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++; - } - - imageRecord.addTile(options.tile); - options.tile.cacheImageRecord = imageRecord; - - // 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; - - for ( var i = this._tilesLoaded.length - 1; i >= 0; i-- ) { - prevTileRecord = this._tilesLoaded[ i ]; - prevTile = prevTileRecord.tile; - - if ( prevTile.level <= cutoff || prevTile.beingDrawn ) { - continue; - } else if ( !worstTile ) { - worstTile = prevTile; - worstTileIndex = i; - worstTileRecord = prevTileRecord; - continue; - } - - prevTime = prevTile.lastTouchTime; - worstTime = worstTile.lastTouchTime; - prevLevel = prevTile.level; - worstLevel = worstTile.level; - - if ( prevTime < worstTime || - ( prevTime === worstTime && prevLevel > worstLevel ) ) { - worstTile = prevTile; - worstTileIndex = i; - worstTileRecord = prevTileRecord; - } - } - - if ( worstTile && worstTileIndex >= 0 ) { - this._unloadTile(worstTileRecord); - insertionIndex = worstTileIndex; - } - } - - this._tilesLoaded[ insertionIndex ] = new TileRecord({ - tile: options.tile, - tiledImage: options.tiledImage - }); - }, - - /** - * Clears all tiles associated with the specified tiledImage. - * @param {OpenSeadragon.TiledImage} tiledImage - */ - clearTilesFor: function( 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); - this._tilesLoaded.splice( i, 1 ); - i--; - } - } - }, - - // private - getImageRecord: function(cacheKey) { - $.console.assert(cacheKey, '[TileCache.getImageRecord] cacheKey is required'); - return this._imagesLoaded[cacheKey]; - }, - - // private - _unloadTile: function(tileRecord) { - $.console.assert(tileRecord, '[TileCache._unloadTile] tileRecord is required'); - var tile = tileRecord.tile; - var tiledImage = tileRecord.tiledImage; - - // tile.getCanvasContext should always exist in normal usage (with $.Tile) - // but the tile cache test passes in a dummy object - let context2D = tile.getCanvasContext && tile.getCanvasContext(); - - tile.unload(); - tile.cacheImageRecord = null; - - var imageRecord = this._imagesLoaded[tile.cacheKey]; - if(!imageRecord){ - return; - } - imageRecord.removeTile(tile); - if (!imageRecord.getTileCount()) { - - imageRecord.destroy(); - delete this._imagesLoaded[tile.cacheKey]; - this._imagesLoadedCount--; - - if(context2D){ - /** - * Free up canvas memory - * (iOS 12 or higher on 2GB RAM device has only 224MB canvas memory, - * and Safari keeps canvas until its height and width will be set to 0). - */ - context2D.canvas.width = 0; - context2D.canvas.height = 0; - - /** - * Triggered when an image has just been unloaded - * - * @event image-unloaded - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {CanvasRenderingContext2D} context2D - The context that is being unloaded - * @private - */ - tiledImage.viewer.raiseEvent("image-unloaded", { - context2D: context2D, - tile: tile - }); - } - + $.CacheRecord = class { + constructor() { + this.revive(); } /** - * Triggered when a tile has just been unloaded from the cache. - * - * @event tile-unloaded - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the unloaded tile. - * @property {OpenSeadragon.Tile} tile - The tile which has been unloaded. + * Access the cache record data directly. Preferred way of data access. + * Might be undefined if this.loaded = false. + * You can access the data in synchronous way, but the data might not be available. + * If you want to access the data indirectly (await), use this.transformTo or this.getDataAs + * @returns {any} */ - tiledImage.viewer.raiseEvent("tile-unloaded", { - tile: tile, - tiledImage: tiledImage - }); + get data() { + return this._data; + } - } -}; + /** + * Read the cache type. The type can dynamically change, but should be consistent at + * one point in the time. For available types see the OpenSeadragon.Convertor, or the tutorials. + * @returns {string} + */ + get type() { + return this._type; + } + + /** + * Await ongoing process so that we get cache ready on callback. + * @returns {OpenSeadragon.Promise} + */ + await() { + if (!this._promise) { //if not cache loaded, do not fail + return $.Promise.resolve(); + } + return this._promise; + } + + getImage() { + $.console.error("[CacheRecord.getImage] options.image is deprecated. Moreover, it might not work" + + " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); + this.transformTo("image"); + return this.data; + } + + getRenderedContext() { + $.console.error("[CacheRecord.getRenderedContext] options.getRenderedContext is deprecated. Moreover, it might not work" + + " correctly as the cache system performs conversion asynchronously in case the type needs to be converted."); + this.transformTo("context2d"); + return this.data; + } + + /** + * Set the cache data. Asynchronous. + * @param {any} data + * @param {string} type + * @returns {OpenSeadragon.Promise} the old cache data that has been overwritten + */ + setDataAs(data, type) { + //allow set data with destroyed state, destroys the data if necessary + $.console.assert(data !== undefined && data !== null, "[CacheRecord.setDataAs] needs valid data to set!"); + if (this._conversionJobQueue) { + //delay saving if ongiong conversion, these were registered first + let resolver = null; + const promise = new $.Promise((resolve, reject) => { + resolver = resolve; + }); + this._conversionJobQueue.push(() => resolver(this._overwriteData(data, type))); + return promise; + } + return this._overwriteData(data, type); + } + + /** + * Access the cache record data indirectly. Preferred way of data access. Asynchronous. + * @param {string} [type=undefined] + * @param {boolean} [copy=true] if false and same type is retrieved as the cache type, + * copy is not performed: note that this is potentially dangerous as it might + * introduce race conditions (you get a cache data direct reference you modify). + * @returns {OpenSeadragon.Promise} desired data type in promise, undefined if the cache was destroyed + */ + getDataAs(type = undefined, copy = true) { + if (this.loaded) { + if (type === this._type) { + return copy ? $.convertor.copy(this._tRef, this._data, type || this._type) : this._promise; + } + return this._transformDataIfNeeded(this._tRef, this._data, type || this._type, copy) || this._promise; + } + return this._promise.then(data => this._transformDataIfNeeded(this._tRef, data, type || this._type, copy) || data); + } + + _transformDataIfNeeded(referenceTile, data, type, copy) { + //might get destroyed in meanwhile + if (this._destroyed) { + return $.Promise.resolve(); + } + + let result; + if (type !== this._type) { + result = $.convertor.convert(referenceTile, data, this._type, type); + } else if (copy) { //convert does not copy data if same type, do explicitly + result = $.convertor.copy(referenceTile, data, type); + } + if (result) { + return result.then(finalData => { + if (this._destroyed) { + $.convertor.destroy(finalData, type); + return undefined; + } + return finalData; + }); + } + return false; // no conversion needed, parent function returns item as-is + } + + /** + * Access of the data by drawers, synchronous function. Should always access a valid main cache, e.g. + * cache swap performed on working cache (replaceCache()) must be synchronous such that cache is always + * ready to render, and swaps atomically between render calls. + * + * @param {OpenSeadragon.DrawerBase} drawer drawer reference which requests the data: the drawer + * defines the supported formats this cache should return **synchronously** + * @param {OpenSeadragon.Tile} tileToDraw reference to the tile that is in the process of drawing and + * for which we request the data; if we attempt to draw such tile while main cache target is destroyed, + * attempt to reset the tile state to force system to re-download it again + * @returns {OpenSeadragon.CacheRecord|OpenSeadragon.SimpleCacheRecord|undefined} desired data if available, + * wrapped in the cache container. This data is guaranteed to be loaded & in the type supported by the drawer. + * Returns undefined if the data is not ready for rendering. + * @private + */ + getDataForRendering(drawer, tileToDraw) { + const supportedTypes = drawer.getSupportedDataFormats(), + keepInternalCopy = drawer.options.usePrivateCache; + if (this.loaded && supportedTypes.includes(this.type)) { + return this; + } + + if (this._destroyed) { + $.console.error("Attempt to draw tile with destroyed main cache!"); + tileToDraw._unload(); // try to restore the state so that the tile is later on fetched again + return undefined; + } + + let internalCache = this[DRAWER_INTERNAL_CACHE]; + internalCache = internalCache && internalCache[drawer.getId()]; + if (keepInternalCopy && !internalCache) { + $.console.warn("Attempt to render cache that is not prepared for current drawer " + + "supported format: the preparation should've happened after tile processing has finished.", + this, tileToDraw); + + this.prepareForRendering(drawer.getId(), supportedTypes, keepInternalCopy) + .then(() => this._triggerNeedsDraw()); + return undefined; + } + + if (internalCache) { + internalCache.withTileReference(this._tRef); + } else { + internalCache = this; + } + + // Cache in the process of loading, no-op + if (!internalCache.loaded) { + $.console.warn("Attempt to render cache that is not prepared for current drawer: " + + "internal cache still loading: this should be awaited.", + this, tileToDraw); + this._triggerNeedsDraw(); + return undefined; + } + + if (!supportedTypes.includes(internalCache.type)) { + let logReference = this[DRAWER_INTERNAL_CACHE]; + logReference = logReference ? Object.entries(logReference) : this; + $.console.warn("Attempt to render cache that is not prepared for current drawer " + + "supported format: the preparation should've happened after tile processing has finished.", + logReference, tileToDraw); + + internalCache.transformTo(supportedTypes.length > 1 ? supportedTypes : supportedTypes[0]) + .then(() => this._triggerNeedsDraw()); + return undefined; // type is NOT compatible + } + return internalCache; + } + + /** + * Should not be called if cache type is already among supported types + * @private + * @param drawerId + * @param supportedTypes + * @param keepInternalCopy if a drawer requests internal copy, it means it can only use + * given cache for itself, cannot be shared -> initialize privately + * @return {OpenSeadragon.Promise | null} + * reference to the cache processed for drawer rendering requirements, or null on error + */ + prepareForRendering(drawerId, supportedTypes, keepInternalCopy = true) { + // if not internal copy and we have no data, or we are ready to render, exit + if (!this.loaded || supportedTypes.includes(this.type)) { + return $.Promise.resolve(this); + } + + if (!keepInternalCopy) { + return this.transformTo(supportedTypes); + } + + // we can get here only if we want to render incompatible type + let internalCache = this[DRAWER_INTERNAL_CACHE]; + if (!internalCache) { + internalCache = this[DRAWER_INTERNAL_CACHE] = {}; + } + + internalCache = internalCache[drawerId]; + if (internalCache && supportedTypes.includes(internalCache.type)) { + // already done + return $.Promise.resolve(this); + } + + const conversionPath = $.convertor.getConversionPath(this.type, supportedTypes); + if (!conversionPath) { + $.console.error(`[getDataForRendering] Conversion ${this.type} ---> ${supportedTypes} cannot be done!`); + return $.Promise.resolve(this); + } + const newInternalCache = new $.SimpleCacheRecord(); + + newInternalCache.withTileReference(this._tRef); + const selectedFormat = conversionPath[conversionPath.length - 1].target.value; + return $.convertor.convert(this._tRef, this.data, this.type, selectedFormat).then(data => { + newInternalCache.setDataAs(data, selectedFormat); // synchronous, SimpleCacheRecord call + + // if existed, delete + if (internalCache) { + internalCache.destroy(); + } + this[DRAWER_INTERNAL_CACHE][drawerId] = newInternalCache; + return newInternalCache; + }); + } + + /** + * Transform cache to desired type and get the data after conversion. + * Does nothing if the type equals to the current type. Asynchronous. + * Transformation is LAZY, meaning conversions are performed only to + * match the last conversion request target type. + * @param {string|string[]} type if array provided, the system will + * try to optimize for the best type to convert to. + * @return {OpenSeadragon.Promise} + */ + transformTo(type = this._type) { + if (!this.loaded) { + this._conversionJobQueue = this._conversionJobQueue || []; + let resolver = null; + const promise = new $.Promise((resolve, reject) => { + resolver = resolve; + }); + + // Todo consider submitting only single tranform job to queue: any other transform calls will have + // no effect, the last one decides the target format + this._conversionJobQueue.push(() => { + if (this._destroyed) { + return; + } + //must re-check types since we perform in a queue of conversion requests + if ((typeof type === "string" && type !== this._type) || (Array.isArray(type) && !type.includes(this._type))) { + //ensures queue gets executed after finish + this._convert(this._type, type); + this._promise.then(data => resolver(data)); + } else { + //must ensure manually, but after current promise finished, we won't wait for the following job + this._promise.then(data => { + this._checkAwaitsConvert(); + return resolver(data); + }); + } + }); + return promise; + } + + if ((typeof type === "string" && type !== this._type) || (Array.isArray(type) && !type.includes(this._type))) { + this._convert(this._type, type); + } + return this._promise; + } + + /** + * If cache ceases to be the primary one, free data + * @private + */ + destroyInternalCache() { + const internal = this[DRAWER_INTERNAL_CACHE]; + if (internal) { + for (let iCache in internal) { + internal[iCache].destroy(); + } + delete this[DRAWER_INTERNAL_CACHE]; + } + } + + /** + * Conversion requires tile references: + * keep the most 'up to date' ref here. It is called and managed automatically. + * @param {OpenSeadragon.Tile} ref + * @return {OpenSeadragon.CacheRecord} self reference for builder pattern + * @private + */ + withTileReference(ref) { + this._tRef = ref; + return this; + } + + /** + * Get cache description. Used for system messages and errors. + * @return {string} + */ + toString() { + const tile = this._tRef || (this._tiles.length && this._tiles[0]); + return tile ? `Cache ${this.type} [used e.g. by ${tile.toString()}]` : `Orphan cache!`; + } + + /** + * Set initial state, prepare for usage. + * Must not be called on active cache, e.g. first call destroy(). + */ + revive() { + $.console.assert(!this.loaded && !this._type, "[CacheRecord::revive] must not be called when loaded!"); + this._tiles = []; + this._data = null; + this._type = null; + this.loaded = false; + this._promise = null; + this._destroyed = false; + } + + /** + * Free all the data and call data destructors if defined. + */ + destroy() { + if (!this._destroyed) { + delete this._conversionJobQueue; + this._destroyed = true; + + // make sure this gets destroyed even if loaded=false + if (this.loaded) { + this._destroySelfUnsafe(this._data, this._type); + } else if (this._promise) { + const oldType = this._type; + this._promise.then(x => this._destroySelfUnsafe(x, oldType)); + } + } + + } + + _destroySelfUnsafe(data, type) { + // ensure old data destroyed + $.convertor.destroy(data, type); + this.destroyInternalCache(); + // might've got revived in meanwhile if async ... + if (!this._destroyed) { + return; + } + this.loaded = false; + this._tiles = null; + this._data = null; + this._type = null; + this._tRef = null; + this._promise = null; + } + + /** + * Add tile dependency on this record + * @param tile + * @param data can be null|undefined => optimization, will skip data initialization and just adds tile reference + * @param type + */ + addTile(tile, data, type) { + if (this._destroyed) { + return; + } + $.console.assert(tile, '[CacheRecord.addTile] tile is required'); + + // first come first served, data for existing tiles is NOT overridden + if (data !== undefined && data !== null && this._tiles.length < 1) { + // Since we IGNORE new data if already initialized, we support 'data getter' + if (typeof data === 'function') { + data = data(); + } + + // in case we attempt to write to existing data object + if (this.type && this._promise) { + if (data instanceof $.Promise) { + this._promise = data.then(d => { + this._overwriteData(d, type); + }); + } else { + this._overwriteData(data, type); + } + } else { + // If we receive async callback, we consume the async state + if (data instanceof $.Promise) { + this._promise = data.then(d => { + this._data = d; + this.loaded = true; + return d; + }); + this._data = null; + } else { + this._promise = $.Promise.resolve(data); + this._data = data; + this.loaded = true; + } + + this._type = type; + } + this._tiles.push(tile); + } else { + const tileExists = this._tiles.includes(tile); + if (!tileExists && this.type && this._promise) { + // here really check we are loaded, since if optimization allows sending no data and we add tile without + // proper initialization it is a bug + this._tiles.push(tile); + } else if (!tileExists) { + $.console.warn("Tile %s caching attempt without data argument on uninitialized cache entry!", tile); + } + } + } + + /** + * Remove tile dependency on this record. + * @param tile + * @returns {Boolean} true if record removed + */ + removeTile(tile) { + if (this._destroyed) { + return false; + } + for (let i = 0; i < this._tiles.length; i++) { + if (this._tiles[i] === tile) { + this._tiles.splice(i, 1); + if (this._tRef === tile) { + // keep fresh ref + this._tRef = this._tiles[i - 1]; + } + return true; + } + } + $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile); + return false; + } + + /** + * Get the amount of tiles sharing this record. + * @return {number} + */ + getTileCount() { + return this._tiles ? this._tiles.length : 0; + } + + /** + * Private conversion that makes sure collided requests are + * processed eventually + * @private + */ + _checkAwaitsConvert() { + if (!this._conversionJobQueue || this._destroyed) { + return; + } + //let other code finish first + setTimeout(() => { + //check again, meanwhile things might've changed + if (!this._conversionJobQueue || this._destroyed) { + return; + } + const job = this._conversionJobQueue[0]; + this._conversionJobQueue.splice(0, 1); + if (this._conversionJobQueue.length === 0) { + delete this._conversionJobQueue; + } + job(); + }); + } + + _triggerNeedsDraw() { + if (this._tiles.length > 0) { + this._tiles[0].tiledImage.viewer.forceRedraw(); + } + } + + /** + * Safely overwrite the cache data and return the old data + * @private + */ + _overwriteData(data, type) { + if (this._destroyed) { + //we have received the ownership of the data, destroy it too since we are destroyed + $.convertor.destroy(data, type); + return $.Promise.resolve(); + } + if (this.loaded) { + // No-op if attempt to replace with the same object + if (this._data === data && this._type === type) { + return this._promise; + } + $.convertor.destroy(this._data, this._type); + this._type = type; + this._data = data; + this._promise = $.Promise.resolve(data); + const internal = this[DRAWER_INTERNAL_CACHE]; + if (internal) { + for (let iCache in internal) { + internal[iCache].setDataAs(data, type); + } + } + this._triggerNeedsDraw(); + return this._promise; + } + return this._promise.then(() => { + // No-op if attempt to replace with the same object + if (this._data === data && this._type === type) { + return this._data; + } + $.convertor.destroy(this._data, this._type); + this._type = type; + this._data = data; + this._promise = $.Promise.resolve(data); + const internal = this[DRAWER_INTERNAL_CACHE]; + if (internal) { + for (let iCache in internal) { + internal[iCache].setDataAs(data, type); + } + } + this._triggerNeedsDraw(); + return this._data; + }); + } + + /** + * Private conversion that makes sure the cache knows its data is ready + * @param to array or a string - allowed types + * @param from string - type origin + * @private + */ + _convert(from, to) { + const convertor = $.convertor, + conversionPath = convertor.getConversionPath(from, to); + if (!conversionPath) { + $.console.error(`[CacheRecord._convert] Conversion ${from} ---> ${to} cannot be done!`); + return; //no-op + } + + const originalData = this._data, + stepCount = conversionPath.length, + _this = this, + convert = (x, i) => { + if (i >= stepCount) { + _this._data = x; + _this.loaded = true; + _this._checkAwaitsConvert(); + return $.Promise.resolve(x); + } + let edge = conversionPath[i]; + let y = edge.transform(_this._tRef, x); + if (y === undefined) { + _this.loaded = false; + throw `[CacheRecord._convert] data mid result undefined value (while converting using ${edge}})`; + } + convertor.destroy(x, edge.origin.value); + const result = $.type(y) === "promise" ? y : $.Promise.resolve(y); + return result.then(res => convert(res, i + 1)); + }; + + this.loaded = false; + this._data = undefined; + // Read target type from the conversion path: [edge.target] = Vertex, its value=type + this._type = conversionPath[stepCount - 1].target.value; + this._promise = convert(originalData, 0); + } + }; + + /** + * @class SimpleCacheRecord + * @memberof OpenSeadragon + * @classdesc Simple cache record without robust support for async access. Meant for internal use only. + * + * This class acts like the Maybe type: + * - it has 'loaded' flag indicating whether the tile data is ready + * - it has 'data' property that has value if loaded=true + * + * This class supposes synchronous access, no collision of transform calls. + * It also does not record tiles nor allows cache/tile sharing. + * @private + */ + $.SimpleCacheRecord = class { + constructor(preferredTypes) { + this._data = null; + this._type = null; + this.loaded = false; + this.format = Array.isArray(preferredTypes) ? preferredTypes : null; + } + + /** + * Sync access to the data + * @returns {any} + */ + get data() { + return this._data; + } + + /** + * Sync access to the current type + * @returns {string} + */ + get type() { + return this._type; + } + + /** + * Must be called before transformTo or setDataAs. To keep + * compatible api with CacheRecord where tile refs are known. + * @param {OpenSeadragon.Tile} referenceTile reference tile for conversion + * @return {OpenSeadragon.SimpleCacheRecord} self reference for builder pattern + */ + withTileReference(referenceTile) { + this._temporaryTileRef = referenceTile; + return this; + } + + /** + * Transform cache to desired type and get the data after conversion. + * Does nothing if the type equals to the current type. Asynchronous. + * @param {string|string[]} type if array provided, the system will + * try to optimize for the best type to convert to. + * @returns {OpenSeadragon.Promise} + */ + transformTo(type) { + $.console.assert(this._temporaryTileRef, "SimpleCacheRecord needs tile reference set before update operation!"); + const convertor = $.convertor, + conversionPath = convertor.getConversionPath(this._type, type); + if (!conversionPath) { + $.console.error(`[SimpleCacheRecord.transformTo] Conversion ${this._type} ---> ${type} cannot be done!`); + return $.Promise.resolve(); //no-op + } + + const stepCount = conversionPath.length, + _this = this, + convert = (x, i) => { + if (i >= stepCount) { + _this._data = x; + _this.loaded = true; + _this._temporaryTileRef = null; + return $.Promise.resolve(x); + } + let edge = conversionPath[i]; + try { + // no test for y - less robust approach + let y = edge.transform(this._temporaryTileRef, x); + convertor.destroy(x, edge.origin.value); + const result = $.type(y) === "promise" ? y : $.Promise.resolve(y); + return result.then(res => convert(res, i + 1)); + } catch (e) { + _this.loaded = false; + _this._temporaryTileRef = null; + throw e; + } + }; + + this.loaded = false; + // Read target type from the conversion path: [edge.target] = Vertex, its value=type + this._type = conversionPath[stepCount - 1].target.value; + const promise = convert(this._data, 0); + this._data = undefined; + return promise; + } + + /** + * Free all the data and call data destructors if defined. + */ + destroy() { + $.convertor.destroy(this._data, this._type); + this._data = null; + this._type = null; + } + + /** + * Safely overwrite the cache data and return the old data + * @private + */ + setDataAs(data, type) { + // no check for state, users must ensure compatibility manually + $.convertor.destroy(this._data, this._type); + this._type = type; + this._data = data; + this.loaded = true; + } + }; + + /** + * @class TileCache + * @memberof OpenSeadragon + * @classdesc Stores all the tiles displayed in a {@link OpenSeadragon.Viewer}. + * You generally won't have to interact with the TileCache directly. + * @param {Object} options - Configuration for this TileCache. + * @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in + * {@link OpenSeadragon.Options} for details. + */ + $.TileCache = class { + constructor( options ) { + options = options || {}; + + this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount; + this._tilesLoaded = []; + this._zombiesLoaded = []; + this._zombiesLoadedCount = 0; + this._cachesLoaded = []; + this._cachesLoadedCount = 0; + } + + /** + * @returns {Number} The total number of tiles that have been loaded by + * this TileCache. Note that the tile might be recorded here mutliple times, + * once for each cache it uses. + */ + numTilesLoaded() { + return this._tilesLoaded.length; + } + + /** + * @returns {Number} The total number of cached objects (+ zombies) + */ + numCachesLoaded() { + return this._zombiesLoadedCount + this._cachesLoadedCount; + } + + /** + * Caches the specified tile, removing an old tile if necessary to stay under the + * maxImageCacheCount specified on construction. Note that if multiple tiles reference + * the same image, there may be more tiles than maxImageCacheCount; the goal is to keep + * the number of images below that number. Note, as well, that even the number of images + * may temporarily surpass that number, but should eventually come back down to the max specified. + * @private + * @param {Object} options - Cache creation parameters. + * @param {OpenSeadragon.Tile} options.tile - The tile to cache. + * @param {?String} [options.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey + * @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache. + * Used if options.cacheKey not set. + * @param {Image} options.image - The image of the tile to cache. Deprecated. + * @param {*} options.data - The data of the tile to cache. If `typeof data === 'function'` holds, + * the data is called to obtain the data item: this is an optimization to load data only when necessary. + * @param {string} [options.dataType] - The data type of the tile to cache. Required. + * @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this + * function will release an old tile. The cutoff option specifies a tile level at or below which + * tiles will not be released. + * @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to. + */ + cacheTile( options ) { + $.console.assert( options, "[TileCache.cacheTile] options is required" ); + const theTile = options.tile; + $.console.assert( theTile, "[TileCache.cacheTile] options.tile is required" ); + $.console.assert( theTile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); + + if (options.image instanceof Image) { + $.console.warn("[TileCache.cacheTile] options.image is deprecated!" ); + options.data = options.image; + options.dataType = "image"; + } + + let cacheKey = options.cacheKey || theTile.cacheKey; + + let cacheRecord = this._cachesLoaded[cacheKey]; + if (!cacheRecord) { + if (options.data === undefined) { + $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " + + "has been deprecated and will be removed in the future."); + options.data = options.image; + } + + cacheRecord = this._zombiesLoaded[cacheKey]; + if (cacheRecord) { + // zombies should not be (yet) destroyed, but if we encounter one... + if (cacheRecord._destroyed) { + // if destroyed, invalidation routine will get triggered for us automatically + cacheRecord.revive(); + } else { + // if zombie ready, do not overwrite its data, in that case try to call + // we need to trigger invalidation routine, data was not part of the system! + if (typeof data === 'function') { + options.data(); + } + delete options.data; + } + delete this._zombiesLoaded[cacheKey]; + this._zombiesLoadedCount--; + this._cachesLoaded[cacheKey] = cacheRecord; + this._cachesLoadedCount++; + } else { + //allow anything but undefined, null, false (other values mean the data was set, for example '0') + const validData = options.data !== undefined && options.data !== null && options.data !== false; + $.console.assert( validData, "[TileCache.cacheTile] options.data is required to create an CacheRecord" ); + cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord(); + this._cachesLoadedCount++; + } + } + + if (!options.dataType) { + $.console.error("[TileCache.cacheTile] options.dataType is newly required. " + + "For easier use of the cache system, use the tile instance API."); + + // We need to force data acquisition now to guess the type + if (typeof options.data === 'function') { + $.console.error("[TileCache.cacheTile] options.dataType is mandatory " + + " when data item is a callback!"); + } + options.dataType = $.convertor.guessType(options.data); + } + + cacheRecord.addTile(theTile, options.data, options.dataType); + this._freeOldRecordRoutine(theTile, options.cutoff || 0); + return cacheRecord; + } + + /** + * Changes cache key + * @private + * @param {Object} options - Cache creation parameters. + * @param {String} options.oldCacheKey - Current key + * @param {String} options.newCacheKey - New key to set + * @return {OpenSeadragon.CacheRecord | null} + */ + renameCache( options ) { + const newKey = options.newCacheKey, + oldKey = options.oldCacheKey; + let originalCache = this._cachesLoaded[oldKey]; + + if (!originalCache) { + originalCache = this._zombiesLoaded[oldKey]; + $.console.assert( originalCache, "[TileCache.renameCache] oldCacheKey must reference existing cache!" ); + if (this._zombiesLoaded[newKey]) { + $.console.error("Cannot rename zombie cache %s to %s: the target cache is occupied!", + oldKey, newKey); + return null; + } + this._zombiesLoaded[newKey] = originalCache; + delete this._zombiesLoaded[oldKey]; + } else if (this._cachesLoaded[newKey]) { + $.console.error("Cannot rename cache %s to %s: the target cache is occupied!", + oldKey, newKey); + return null; // do not remove, we perform additional fixes on caches later on when swap occurred + } else { + this._cachesLoaded[newKey] = originalCache; + delete this._cachesLoaded[oldKey]; + } + + for (let tile of originalCache._tiles) { + tile.reflectCacheRenamed(oldKey, newKey); + } + + // do not call free old record routine, we did not increase cache size + return originalCache; + } + + /** + * Reads a cache if it exists and creates a new copy of a target, different cache if it does not + * @param {Object} options + * @param {OpenSeadragon.Tile} options.tile - The tile to own ot add record for the cache. + * @param {String} options.copyTargetKey - The unique key used to identify this tile in the cache. + * @param {String} options.newCacheKey - The unique key the copy will be created for. + * @param {String} [options.desiredType=undefined] - For optimization purposes, the desired type. Can + * be ignored. + * @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this + * function will release an old tile. The cutoff option specifies a tile level at or below which + * tiles will not be released. + * @returns {OpenSeadragon.Promise} - New record. + * @private + */ + cloneCache(options) { + const theTile = options.tile; + const cacheKey = options.copyTargetKey; + const cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; + $.console.assert(cacheRecord, "[TileCache.cloneCache] attempt to clone non-existent cache %s!", cacheKey); + $.console.assert(!this._cachesLoaded[options.newCacheKey], + "[TileCache.cloneCache] attempt to copy clone to existing cache %s!", options.newCacheKey); + + const desiredType = options.desiredType || undefined; + return cacheRecord.getDataAs(desiredType, true).then(data => { + let newRecord = this._cachesLoaded[options.newCacheKey] = new $.CacheRecord(); + newRecord.addTile(theTile, data, cacheRecord.type); + this._cachesLoadedCount++; + this._freeOldRecordRoutine(theTile, options.cutoff || 0); + return newRecord; + }); + } + + /** + * Inject new cache to the system + * @param {Object} options + * @param {OpenSeadragon.Tile} options.tile - Reference tile. All tiles sharing original data will be affected. + * @param {OpenSeadragon.CacheRecord} options.cache - Cache that will be injected. + * @param {String} options.targetKey - The target cache key to inhabit. Can replace existing cache. + * @param {Boolean} options.setAsMainCache - If true, tiles main cache gets updated to consumerKey. + * Otherwise, if consumerKey==tile.cacheKey the cache is set as main too. + * @param {Boolean} options.tileAllowNotLoaded - if true, tile that is not loaded is also processed, + * this is internal parameter used in tile-loaded completion routine, as we need to prepare tile but + * it is not yet loaded and cannot be marked as so (otherwise the system would think it is ready) + * @private + */ + injectCache(options) { + const targetKey = options.targetKey, + tile = options.tile; + if (!options.tileAllowNotLoaded && !tile.loaded && !tile.loading) { + $.console.warn("Attempt to inject cache on tile in invalid state: this is probably a bug!"); + return; + } + const consumer = this._cachesLoaded[targetKey]; + if (consumer) { + // We need to avoid async execution here: replace consumer instead of overwriting the data. + const iterateTiles = [...consumer._tiles]; // unloadCacheForTile() will modify the array, use a copy + for (let tile of iterateTiles) { + this.unloadCacheForTile(tile, targetKey, true, false); + } + } + if (this._cachesLoaded[targetKey]) { + $.console.error("The inject routine should've freed cache!"); + } + + const cache = options.cache; + this._cachesLoaded[targetKey] = cache; + + // Update cache: add the new cache, we must add since we removed above with unloadCacheForTile() + for (let t of tile.getCache(tile.originalCacheKey)._tiles) { // grab all cache-equal tiles + t.setCache(targetKey, cache, options.setAsMainCache, false); + } + } + + /** + * Replace cache (and update tile references) by another cache + * @param {Object} options + * @param {OpenSeadragon.Tile} options.tile - The tile to own ot add record for the cache. + * @param {String} options.victimKey - Cache that will be erased. In fact, the victim _replaces_ consumer, + * inheriting its tiles and key. + * @param {String} options.consumerKey - The cache that consumes the victim. In fact, it gets destroyed and + * replaced by victim, which inherits all its metadata. + * @param {Boolean} options.setAsMainCache - If true, tiles main cache gets updated to consumerKey. + * Otherwise, if consumerKey==tile.cacheKey the cache is set as main too. + * @param {Boolean} options.tileAllowNotLoaded - if true, tile that is not loaded is also processed, + * this is internal parameter used in tile-loaded completion routine, as we need to prepare tile but + * it is not yet loaded and cannot be marked as so (otherwise the system would think it is ready) + * @private + */ + replaceCache(options) { + const victimKey = options.victimKey, + consumerKey = options.consumerKey, + victim = this._cachesLoaded[victimKey], + tile = options.tile; + if (!victim || (!options.tileAllowNotLoaded && !tile.loaded && !tile.loading)) { + $.console.warn("Attempt to consume cache on tile in invalid state: this is probably a bug!"); + return; + } + const consumer = this._cachesLoaded[consumerKey]; + if (consumer) { + // We need to avoid async execution here: replace consumer instead of overwriting the data. + const iterateTiles = [...consumer._tiles]; // unloadCacheForTile() will modify the array, use a copy + for (let tile of iterateTiles) { + this.unloadCacheForTile(tile, consumerKey, true, false); + } + } + if (this._cachesLoaded[consumerKey]) { + $.console.error("The consume routine should've freed cache!"); + } + // Just swap victim to become new consumer + const resultCache = this.renameCache({ + oldCacheKey: victimKey, + newCacheKey: consumerKey + }); + + if (resultCache) { + // Only one cache got working item, other caches were idle: update cache: add the new cache + // we must add since we removed above with unloadCacheForTile() + for (let t of tile.getCache(tile.originalCacheKey)._tiles) { // grab all cache-equal tiles + t.setCache(consumerKey, resultCache, options.setAsMainCache, false); + } + } + } + + /** + * This method ensures other tiles are restored if one of the tiles + * was requested restore(). + * @param tile + * @param originalCache + * @param freeIfUnused if true, zombie is not created + * @private + */ + restoreTilesThatShareOriginalCache(tile, originalCache, freeIfUnused) { + for (let t of originalCache._tiles) { + if (t.cacheKey !== t.originalCacheKey) { + this.unloadCacheForTile(t, t.cacheKey, freeIfUnused, true); + delete t._caches[t.cacheKey]; + t.cacheKey = t.originalCacheKey; + } + } + } + + _freeOldRecordRoutine(theTile, cutoff) { + let insertionIndex = this._tilesLoaded.length, + worstTileIndex = -1; + + // Note that just because we're unloading a tile doesn't necessarily mean + // we're unloading its cache records. With repeated calls it should sort itself out, though. + if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) { + //prefer zombie deletion, faster, better + if (this._zombiesLoadedCount > 0) { + for (let zombie in this._zombiesLoaded) { + this._zombiesLoaded[zombie].destroy(); + delete this._zombiesLoaded[zombie]; + this._zombiesLoadedCount--; + break; + } + } else { + let worstTile = null; + let prevTile, worstTime, worstLevel, prevTime, prevLevel; + + for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { + prevTile = this._tilesLoaded[ i ]; + + if ( prevTile.level <= cutoff || + prevTile.beingDrawn || + prevTile.loading || + prevTile.processing ) { + continue; + } + if ( !worstTile ) { + worstTile = prevTile; + worstTileIndex = i; + continue; + } + + prevTime = prevTile.lastTouchTime; + worstTime = worstTile.lastTouchTime; + prevLevel = prevTile.level; + worstLevel = worstTile.level; + + if ( prevTime < worstTime || + ( prevTime === worstTime && prevLevel > worstLevel )) { + worstTile = prevTile; + worstTileIndex = i; + } + } + + if ( worstTile && worstTileIndex >= 0 ) { + this._unloadTile(worstTile, true); + insertionIndex = worstTileIndex; + } + } + } + + if (theTile.getCacheSize() === 0) { + this._tilesLoaded[ insertionIndex ] = theTile; + } else if (worstTileIndex >= 0) { + //tile is already recorded, do not add tile, but remove the tile at insertion index + this._tilesLoaded.splice(insertionIndex, 1); + } + } + + /** + * Clears all tiles associated with the specified tiledImage. + * @param {OpenSeadragon.TiledImage} tiledImage + */ + clearTilesFor( tiledImage ) { + $.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required'); + let tile; + + let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount; + if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) { + //prefer newer (fresh ;) zombies + for (let zombie in this._zombiesLoaded) { + this._zombiesLoaded[zombie].destroy(); + delete this._zombiesLoaded[zombie]; + } + this._zombiesLoadedCount = 0; + cacheOverflows = this._cachesLoadedCount > this._maxCacheItemCount; + } + for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { + tile = this._tilesLoaded[ i ]; + + if (tile.tiledImage === tiledImage) { + if (!tile.loaded) { + //iterates from the array end, safe to remove + this._tilesLoaded.splice( i, 1 ); + } else if ( tile.tiledImage === tiledImage ) { + this._unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i); + } + } + } + } + + /** + * Delete all data in the cache + * @param {boolean} withZombies + */ + clear(withZombies = true) { + for (let zombie in this._zombiesLoaded) { + this._zombiesLoaded[zombie].destroy(); + } + for (let tile in this._tilesLoaded) { + this._unloadTile(tile, true, undefined); + } + this._tilesLoaded = []; + this._zombiesLoaded = []; + this._zombiesLoadedCount = 0; + this._cachesLoaded = []; + this._cachesLoadedCount = 0; + } + + /** + * Returns reference to all tiles loaded by a particular + * tiled image item + * @param {OpenSeadragon.TiledImage|null} tiledImage if null, gets all tiles, else filters out tiles + * that belong to a specific image + */ + getLoadedTilesFor(tiledImage) { + if (!tiledImage) { + return [...this._tilesLoaded]; + } + return this._tilesLoaded.filter(tile => tile.tiledImage === tiledImage); + } + + /** + * Get cache record (might be a unattached record, i.e. a zombie) + * @param cacheKey + * @returns {OpenSeadragon.CacheRecord|undefined} + */ + getCacheRecord(cacheKey) { + $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required'); + return this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; + } + + /** + * Delete cache safely from the system if it is not needed + * @param {OpenSeadragon.CacheRecord} cache + */ + safeUnloadCache(cache) { + if (cache && !cache._destroyed && cache.getTileCount() < 1) { + for (let i in this._zombiesLoaded) { + const c = this._zombiesLoaded[i]; + if (c === cache) { + delete this._zombiesLoaded[i]; + c.destroy(); + return; + } + } + $.console.error("Attempt to delete an orphan cache that is not in zombie list: this could be a bug!", cache); + cache.destroy(); + } + } + + /** + * Delete cache record for a given til + * @param {OpenSeadragon.Tile} tile + * @param {string} key cache key + * @param {boolean} destroy if true, empty cache is destroyed, else left as a zombie + * @param {boolean} okIfNotExists sometimes we call destruction just to make sure, if true do not report as error + * @private + */ + unloadCacheForTile(tile, key, destroy, okIfNotExists) { + const cacheRecord = this._cachesLoaded[key]; + //unload record only if relevant - the tile exists in the record + if (cacheRecord) { + if (cacheRecord.removeTile(tile)) { + if (!cacheRecord.getTileCount()) { + if (destroy) { + // #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie) + cacheRecord.destroy(); + } else { + // #2 Tile is a zombie. Do not delete record, reuse. + this._zombiesLoaded[key] = cacheRecord; + this._zombiesLoadedCount++; + } + // Either way clear cache + delete this._cachesLoaded[key]; + this._cachesLoadedCount--; + } + return true; + } + $.console.error("[TileCache.unloadCacheForTile] System tried to delete tile from cache it " + + "does not belong to! This could mean a bug in the cache system."); + return false; + } + if (!okIfNotExists) { + $.console.warn("[TileCache.unloadCacheForTile] Attempting to delete missing cache!"); + } + return false; + } + + /** + * Unload tile: this will free the tile data and mark the tile as unloaded. + * @param {OpenSeadragon.Tile} tile + * @param {boolean} destroy if set to true, tile data is not preserved as zombies but deleted immediatelly + */ + unloadTile(tile, destroy = false) { + if (!tile.loaded) { + $.console.warn("Attempt to unload already unloaded tile."); + return; + } + const index = this._tilesLoaded.findIndex(x => x === tile); + this._unloadTile(tile, destroy, index); + } + + /** + * @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 _tilesLoaded if not set + * @private + */ + _unloadTile(tile, destroy, deleteAtIndex) { + $.console.assert(tile, '[TileCache._unloadTile] tile is required'); + + for (let key in tile._caches) { + //we are 'ok' to remove tile caches here since we later call destroy on tile, otherwise + //tile has count of its cache size --> would be inconsistent + this.unloadCacheForTile(tile, key, destroy, false); + } + //delete also the tile record + if (deleteAtIndex !== undefined) { + this._tilesLoaded.splice( deleteAtIndex, 1 ); + } + + // Possible error: it can happen that unloaded tile gets to this stage. Should it even be allowed to happen? + if (!tile.loaded) { + return; + } + + const tiledImage = tile.tiledImage; + tile._unload(); + + /** + * Triggered when a tile has just been unloaded from memory. + @@ -255,12 +668,15 @@ $.TileCache.prototype = { + * @type {object} + * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the unloaded tile. + * @property {OpenSeadragon.Tile} tile - The tile which has been unloaded. + * @property {boolean} destroyed - False if the tile data was kept in the system. + */ + tiledImage.viewer.raiseEvent("tile-unloaded", { + tile: tile, + tiledImage: tiledImage, + destroyed: destroy + }); + } + }; }( OpenSeadragon )); diff --git a/src/tiledimage.js b/src/tiledimage.js index 17a7abcf..163b3088 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -163,6 +163,7 @@ $.TiledImage = function( options ) { _needsUpdate: true, // Does the tiledImage need to update the viewport again? _hasOpaqueTile: false, // Do we have even one fully opaque tile? _tilesLoading: 0, // The number of pending tile requests. + _zombieCache: false, // Allow cache to stay in memory upon deletion. _tilesToDraw: [], // info about the tiles currently in the viewport, two deep: array[level][tile] _lastDrawn: [], // array of tiles that were last fetched by the drawer _isBlending: false, // Are any tiles still being blended? @@ -188,7 +189,8 @@ $.TiledImage = function( options ) { preload: $.DEFAULT_SETTINGS.preload, compositeOperation: $.DEFAULT_SETTINGS.compositeOperation, subPixelRoundingForTransparency: $.DEFAULT_SETTINGS.subPixelRoundingForTransparency, - maxTilesPerFrame: $.DEFAULT_SETTINGS.maxTilesPerFrame + maxTilesPerFrame: $.DEFAULT_SETTINGS.maxTilesPerFrame, + _currentMaxTilesPerFrame: (options.maxTilesPerFrame || $.DEFAULT_SETTINGS.maxTilesPerFrame) * 10 }, options ); this._preload = this.preload; @@ -229,6 +231,7 @@ $.TiledImage = function( options ) { this._ownAjaxHeaders = {}; this.setAjaxHeaders(ajaxHeaders, false); this._initialized = true; + // this.invalidatedAt = 0; }; $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.TiledImage.prototype */{ @@ -277,14 +280,30 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }); }, + /** + * Forces the system consider all tiles in this tiled image + * as outdated, and fire tile update event on relevant tiles + * Detailed description is available within the 'tile-invalidated' + * event. + * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data + * @param {boolean} [viewportOnly=false] optionally invalidate only viewport-visible tiles if true + * @param {number} [tStamp=OpenSeadragon.now()] optionally provide tStamp of the update event + */ + requestInvalidate: function (restoreTiles = true, viewportOnly = false, tStamp = $.now()) { + const tiles = viewportOnly ? this._lastDrawn.map(x => x.tile) : this._tileCache.getLoadedTilesFor(this); + return this.viewer.world.requestTileInvalidateEvent(tiles, tStamp, restoreTiles); + }, + /** * Clears all tiles and triggers an update on the next call to * {@link OpenSeadragon.TiledImage#update}. */ reset: function() { this._tileCache.clearTilesFor(this); + this._currentMaxTilesPerFrame = this.maxTilesPerFrame * 10; this.lastResetTime = $.now(); this._needsDraw = true; + this._fullyLoaded = false; }, /** @@ -325,7 +344,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @returns {Boolean} whether the item still needs to be drawn due to blending */ setDrawn: function(){ - this._needsDraw = this._isBlending || this._wasBlending; + this._needsDraw = this._isBlending || this._wasBlending || + (this.opacity > 0 && this._lastDrawn.length < 1); return this._needsDraw; }, @@ -353,10 +373,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag */ destroy: function() { this.reset(); - - if (this.source.destroy) { - this.source.destroy(this.viewer); - } + this.source.destroy(this.viewer); }, /** @@ -907,13 +924,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag this.flipped = flip; }, - get flipped(){ + get flipped() { return this._flipped; }, - set flipped(flipped){ + set flipped(flipped) { let changed = this._flipped !== !!flipped; this._flipped = !!flipped; - if(changed){ + if (changed && this._initialized) { this.update(true); this._needsDraw = true; this._raiseBoundsChange(); @@ -1162,7 +1179,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag ajaxHeaders = {}; } if (!$.isPlainObject(ajaxHeaders)) { - console.error('[TiledImage.setAjaxHeaders] Ignoring invalid headers, must be a plain object'); + $.console.error('[TiledImage.setAjaxHeaders] Ignoring invalid headers, must be a plain object'); return; } @@ -1227,6 +1244,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); @@ -1433,20 +1462,16 @@ $.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; return false; } else { return this._tilesLoading === 0; } - - // Update - }, /** @@ -1736,12 +1761,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag _updateTile: function( x, y, level, levelVisibility, viewportCenter, numberOfTiles, currentTime, best){ - var tile = this._getTile( + const tile = this._getTile( x, y, level, currentTime, numberOfTiles - ); + ); + if( this.viewer ){ /** @@ -1784,22 +1810,20 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag levelVisibility ); - if (!tile.loaded) { - if (tile.context2D) { - this._setTileLoaded(tile); - } else { - var imageRecord = this._tileCache.getImageRecord(tile.cacheKey); - if (imageRecord) { - this._setTileLoaded(tile, imageRecord.getData()); - } - } + // Try-find will populate tile with data if equal tile exists in system + if (!tile.loaded && !tile.loading && this._tryFindTileCacheRecord(tile)) { + loadingCoverage = true; } if ( tile.loading ) { // the tile is already in the download queue this._tilesLoading++; } else if (!loadingCoverage) { - best = this._compareTiles( best, tile, this.maxTilesPerFrame ); + // add tile to best tiles to load only when not loaded already + best = this._compareTiles( best, tile, this._currentMaxTilesPerFrame ); + if (this._currentMaxTilesPerFrame > this.maxTilesPerFrame) { + this._currentMaxTilesPerFrame = Math.max(Math.ceil(this.maxTilesPerFrame / 2), this.maxTilesPerFrame); + } } return { @@ -1850,6 +1874,25 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }, /** + * @private + * @inner + * Try to find existing cache of the tile + * @param {OpenSeadragon.Tile} tile + */ + _tryFindTileCacheRecord: function(tile) { + let record = this._tileCache.getCacheRecord(tile.originalCacheKey); + + if (!record) { + return false; + } + tile.loading = true; + this._setTileLoaded(tile, record.data, null, null, record.type); + return true; + }, + + /** + * @private + * @inner * Obtains a tile at the given location. * @private * @param {Number} x @@ -1873,7 +1916,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag urlOrGetter, post, ajaxHeaders, - context2D, tile, tilesMatrix = this.tilesMatrix, tileSource = this.source; @@ -1905,9 +1947,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag ajaxHeaders = null; } - context2D = tileSource.getContext2D ? - tileSource.getContext2D(level, xMod, yMod) : undefined; - tile = new $.Tile( level, x, @@ -1915,7 +1954,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag bounds, exists, urlOrGetter, - context2D, + undefined, this.loadTilesWithAjax, ajaxHeaders, sourceBounds, @@ -1957,7 +1996,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag _loadTile: function(tile, time ) { var _this = this; tile.loading = true; - this._imageLoader.addJob({ + tile.tiledImage = this; + if (!this._imageLoader.addJob({ src: tile.getUrl(), tile: tile, source: this.source, @@ -1966,13 +2006,29 @@ $.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; } - }); + })) { + /** + * Triggered if tile load job was added to a full queue. + * This allows to react upon e.g. network not being able to serve the tiles fast enough. + * @event job-queue-full + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Tile} tile - The tile that failed to load. + * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image the tile belongs to. + * @property {number} time - The time in milliseconds when the tile load began. + */ + this.viewer.raiseEvent("job-queue-full", { + tile: tile, + tiledImage: this, + time: time, + }); + } }, /** @@ -1983,9 +2039,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 || data === undefined ) { $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg ); /** * Triggered when a tile fails to load. @@ -2019,28 +2077,50 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return; } - var _this = this, - finish = function() { - var ccc = _this.source; - var cutoff = ccc.getClosestLevel(); - _this._setTileLoaded(tile, data, cutoff, tileRequest); - }; - - - finish(); + this._setTileLoaded(tile, data, null, tileRequest, dataType); }, /** * @private * @param {OpenSeadragon.Tile} tile - * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object - * @param {Number|undefined} cutoff - * @param {XMLHttpRequest|undefined} tileRequest + * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object, + * can be null: in that case, cache is assigned to a tile without further processing + * @param {?Number} cutoff ignored, @deprecated + * @param {?XMLHttpRequest} tileRequest + * @param {?String} [dataType=undefined] data type, derived automatically if not set */ - _setTileLoaded: function(tile, data, cutoff, tileRequest) { - var increment = 0, - eventFinished = false, - _this = this; + _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) { + tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back + // does nothing if tile.cacheKey already present + + let tileCacheCreated = false; + tile.addCache(tile.cacheKey, () => { + tileCacheCreated = true; + return data; + }, dataType, false, false); + + let resolver = null, + increment = 0, + eventFinished = false; + const _this = this, + now = $.now(); + + function completionCallback() { + increment--; + if (increment > 0) { + return; + } + eventFinished = true; + + //do not override true if set (false is default) + tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency( + undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData + ); + tile.loading = false; + tile.loaded = true; + _this.redraw(); + resolver(tile); + } function getCompletionCallback() { if (eventFinished) { @@ -2051,76 +2131,81 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag return completionCallback; } - function completionCallback() { - increment--; - if (increment === 0) { - tile.loading = false; - tile.loaded = true; - tile.hasTransparency = _this.source.hasTransparency( - tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData - ); - if (!tile.context2D) { - _this._tileCache.cacheTile({ - data: data, - tile: tile, - cutoff: cutoff, - tiledImage: _this - }); - } - /** - * Triggered when a tile is loaded and pre-processing is compelete, - * and the tile is ready to draw. - * - * @event tile-ready - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {OpenSeadragon.Tile} tile - The tile which has been loaded. - * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. - * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). - * @private - */ - _this.viewer.raiseEvent("tile-ready", { - tile: tile, - tiledImage: _this, - tileRequest: tileRequest - }); - _this._needsDraw = true; - } + function markTileAsReady() { + 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. + * This event is _awaiting_, it supports asynchronous functions or functions that return a promise. + * + * @event tile-loaded + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {Image|*} image - The image (data) of the tile. Deprecated. + * @property {*} data image data, the data sent to ImageJob.prototype.finish(), + * by default an Image object. Deprecated + * @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). + * @property {OpenSeadragon.Promise} - Promise resolved when the tile gets fully loaded. + * NOTE: do no await the promise in the handler: you will create a deadlock! + * @property {function} getCompletionCallback - deprecated + */ + _this.viewer.raiseEventAwaiting("tile-loaded", { + tile: tile, + tiledImage: _this, + tileRequest: tileRequest, + promise: new $.Promise(resolve => { + resolver = resolve; + }), + get image() { + $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'tile-invalidated' event to modify data instead."); + return data; + }, + get data() { + $.console.error("[tile-loaded] event 'data' has been deprecated. Use 'tile-invalidated' event to modify data instead."); + return data; + }, + getCompletionCallback: function () { + $.console.error("[tile-loaded] getCompletionCallback is deprecated: it introduces race conditions: " + + "use async event handlers instead, execution order is deducted by addHandler(...) priority argument."); + return getCompletionCallback(); + }, + }).catch(() => { + $.console.error("[tile-loaded] event finished with failure: there might be a problem with a plugin you are using."); + }).then(fallbackCompletion); } - /** - * Triggered when a tile has just been loaded in memory. That means that the - * image has been downloaded and can be modified before being drawn to the canvas. - * - * @event tile-loaded - * @memberof OpenSeadragon.Viewer - * @type {object} - * @property {Image|*} image - The image (data) of the tile. Deprecated. - * @property {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object - * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. - * @property {OpenSeadragon.Tile} tile - The tile which has been loaded. - * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). - * @property {function} getCompletionCallback - A function giving a callback to call - * when the asynchronous processing of the image is done. The image will be - * marked as entirely loaded when the callback has been called once for each - * call to getCompletionCallback. - */ - var fallbackCompletion = getCompletionCallback(); - this.viewer.raiseEvent("tile-loaded", { - tile: tile, - tiledImage: this, - tileRequest: tileRequest, - get image() { - $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead."); - return data; - }, - data: data, - getCompletionCallback: getCompletionCallback - }); - eventFinished = true; - // In case the completion callback is never called, we at least force it once. - fallbackCompletion(); + if (tileCacheCreated) { + _this.viewer.world.requestTileInvalidateEvent([tile], now, false, true).then(markTileAsReady); + } else { + // Tile-invalidated not called on each tile, but only on tiles with new data! Verify we share the main cache + const origCache = tile.getCache(tile.originalCacheKey); + for (let t of origCache._tiles) { + + // if there exists a tile that has different main cache, inherit it as a main cache + if (t.cacheKey !== tile.cacheKey) { + + // add reference also to the main cache, no matter what the other tile state has + // completion of the invaldate event should take care of all such tiles + const targetMainCache = t.getCache(); + tile.setCache(t.cacheKey, targetMainCache, true, false); + break; + } else if (t.processing) { + // Await once processing finishes - mark tile as loaded + t.processingPromise.then(t => { + const targetMainCache = t.getCache(); + tile.setCache(t.cacheKey, targetMainCache, true, false); + markTileAsReady(); + }); + return; + } + } + markTileAsReady(); + } }, @@ -2170,7 +2255,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag }); }, - /** * Returns true if the given tile provides coverage to lower-level tiles of * lower resolution representing the same content. If neither x nor y is diff --git a/src/tilesource.js b/src/tilesource.js index 2a5225c0..6228e50a 100644 --- a/src/tilesource.js +++ b/src/tilesource.js @@ -55,7 +55,8 @@ * @param {Object} options * You can either specify a URL, or literally define the TileSource (by specifying * width, height, tileSize, tileOverlap, minLevel, and maxLevel). For the former, - * the extending class is expected to implement 'getImageInfo' and 'configure'. + * the extending class is expected to implement 'supports' and 'configure'. + * Note that _in this case, the child class of getImageInfo() is ignored!_ * For the latter, the construction is assumed to occur through * the extending classes implementation of 'configure'. * @param {String} [options.url] @@ -72,6 +73,7 @@ * @param {Boolean} [options.splitHashDataForPost] * First occurrence of '#' in the options.url is used to split URL * and the latter part is treated as POST data (applies to getImageInfo(...)) + * Does not work if getImageInfo() is overridden and used (see the options description) * @param {Number} [options.width] * Width of the source image at max resolution in pixels. * @param {Number} [options.height] @@ -139,6 +141,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 @@ -170,6 +178,8 @@ $.TileSource = function( width, height, tileSize, tileOverlap, minLevel, maxLeve * @memberof OpenSeadragon.TileSource# */ + // TODO potentially buggy behavior: what if .url is used by child class before it calls super constructor? + // this can happen if old JS class definition is used if( 'string' === $.type( arguments[ 0 ] ) ){ this.url = arguments[0]; } @@ -425,6 +435,13 @@ $.TileSource.prototype = { /** * Responsible for retrieving, and caching the * image metadata pertinent to this TileSources implementation. + * There are three scenarios of opening a tile source: providing a parseable string, plain object, or an URL. + * This method is only called by OSD if the TileSource configuration is a non-parseable string (~url). + * + * The string can contain a hash `#` symbol, followed by + * key=value arguments. If this is the case, this method sends this + * data as a POST body. + * * @function * @param {String} url * @throws {Error} @@ -554,7 +571,7 @@ $.TileSource.prototype = { * @property {String} message * @property {String} source * @property {String} postData - HTTP POST data (usually but not necessarily in k=v&k2=v2... form, - * see TileSource::getPostData) or null + * see TileSource::getTilePostData) or null * @property {?Object} userData - Arbitrary subscriber-defined object. */ _this.raiseEvent( 'open-failed', { @@ -586,6 +603,17 @@ $.TileSource.prototype = { return false; }, + /** + * Check whether two tileSources are equal. This is used for example + * when replacing tile-sources, which turns on the zombie cache before + * old item removal. + * @param {OpenSeadragon.TileSource} otherSource + * @returns {Boolean} + */ + equals: function (otherSource) { + return false; + }, + /** * Responsible for parsing and configuring the * image metadata pertinent to this TileSources implementation. @@ -608,6 +636,16 @@ $.TileSource.prototype = { throw new Error( "Method not implemented." ); }, + /** + * Shall this source need to free some objects + * upon unloading, it must be done here. For example, canvas + * size must be set to 0 for safari to free. + * @param {OpenSeadragon.Viewer} viewer + */ + destroy: function ( viewer ) { + //no-op + }, + /** * Responsible for retrieving the url which will return an image for the * region specified by the given x, y, and level components. @@ -683,9 +721,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 @@ -693,6 +734,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) { @@ -723,10 +767,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'); }, /** @@ -738,41 +787,45 @@ $.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::getTilePostData) 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(). - * @param {Function} [context.callback] @private - Called automatically once image has been downloaded + * Usage: if you decide to abort the request (no fail/finish will be called), call context.abort(). + * @param {Function} [context.callback] Private parameter. Called automatically once image has been downloaded * (triggered by finish). - * @param {Number} [context.timeout] @private - The max number of milliseconds that + * @param {Number} [context.timeout] Private parameter. The max number of milliseconds that * this image job may take to complete. - * @param {string} [context.errorMsg] @private - The final error message, default null (set by finish). + * @param {string} [context.errorMsg] Private parameter. 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, "image"); }; 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 @@ -792,21 +845,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. @@ -814,7 +867,7 @@ $.TileSource.prototype = { } }, error: function(request) { - finish("Image load aborted - XHR error"); + finalize("[downloadTileStart] Image load aborted - XHR error"); } }); } else { @@ -828,6 +881,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. */ @@ -846,33 +901,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; + $.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 - * @returns {*} cache data + * 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 {OpenSeadragon.Promise} cache data + * @deprecated */ getTileCacheData: function(cacheObject) { - return cacheObject._data; + $.console.error("[TileSource.getTileCacheData] has been deprecated. Use cache API of a tile instead."); + return cacheObject.getDataAs(undefined, false); }, /** @@ -880,11 +946,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.getImage(); }, /** @@ -892,21 +961,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.getRenderedContext(); } }; diff --git a/src/tmstilesource.js b/src/tmstilesource.js index 5f8c999f..53a8b74a 100644 --- a/src/tmstilesource.js +++ b/src/tmstilesource.js @@ -131,6 +131,13 @@ $.extend( $.TmsTileSource.prototype, $.TileSource.prototype, /** @lends OpenSead var yTiles = this.getNumTiles( level ).y - 1; return this.tilesUrl + level + "/" + x + "/" + (yTiles - y) + ".png"; + }, + + /** + * Equality comparator + */ + equals: function (otherSource) { + return this.tilesUrl === otherSource.tilesUrl; } }); diff --git a/src/viewer.js b/src/viewer.js index e1ca0a1a..e8a373f3 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -432,6 +432,7 @@ $.Viewer = function( options ) { // Create the tile cache this.tileCache = new $.TileCache({ + viewer: this, maxImageCacheCount: this.maxImageCacheCount }); @@ -761,6 +762,30 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, return this; }, + /** + * Updates data within every tile in the viewer. Should be called + * when tiles are outdated and should be re-processed. Useful mainly + * for plugins that change tile data. + * @function + * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data + * @fires OpenSeadragon.Viewer.event:tile-invalidated + * @return {OpenSeadragon.Promise} + */ + requestInvalidate: function (restoreTiles = true) { + if ( !THIS[ this.hash ] ) { + //this viewer has already been destroyed: returning immediately + return $.Promise.resolve(); + } + + const tStamp = $.now(); + const worldPromise = this.world.requestInvalidate(restoreTiles, tStamp); + if (!this.navigator) { + return worldPromise; + } + const navigatorPromise = this.navigator.world.requestInvalidate(restoreTiles, tStamp); + return $.Promise.all([worldPromise, navigatorPromise]); + }, + /** * @function @@ -787,6 +812,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, THIS[ this.hash ].animating = false; this.world.removeAll(); + this.tileCache.clear(); this.imageLoader.clear(); /** @@ -1004,7 +1030,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, * @returns {Boolean} */ isMouseNavEnabled: function () { - return this.innerTracker.isTracking(); + return this.innerTracker.tracking; }, /** @@ -1110,7 +1136,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, ajaxHeaders = {}; } if (!$.isPlainObject(ajaxHeaders)) { - console.error('[Viewer.setAjaxHeaders] Ignoring invalid headers, must be a plain object'); + $.console.error('[Viewer.setAjaxHeaders] Ignoring invalid headers, must be a plain object'); return; } if (propagate === undefined) { @@ -1545,7 +1571,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. @@ -1683,11 +1711,15 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, _this._loadQueue.splice(0, 1); if (queueItem.options.replace) { - var newIndex = _this.world.getIndexOfItem(queueItem.options.replaceItem); + const replaced = queueItem.options.replaceItem; + const newIndex = _this.world.getIndexOfItem(replaced); if (newIndex !== -1) { queueItem.options.index = newIndex; } - _this.world.removeItem(queueItem.options.replaceItem); + if (!replaced._zombieCache && replaced.source.equals(queueItem.tileSource)) { + replaced.allowZombieCache(true); + } + _this.world.removeItem(replaced); } tiledImage = new $.TiledImage({ @@ -1727,7 +1759,8 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype, loadTilesWithAjax: queueItem.options.loadTilesWithAjax, ajaxHeaders: queueItem.options.ajaxHeaders, debugMode: _this.debugMode, - subPixelRoundingForTransparency: _this.subPixelRoundingForTransparency + subPixelRoundingForTransparency: _this.subPixelRoundingForTransparency, + callTileLoadedWithCachedData: _this.callTileLoadedWithCachedData, }); if (_this.collectionMode) { @@ -2697,7 +2730,7 @@ function getTileSourceImplementation( viewer, tileSource, imgOptions, successCal waitUntilReady(new $TileSource(options), tileSource); } } else { - //can assume it's already a tile source implementation + //can assume it's already a tile source implementation, force inheritance waitUntilReady(tileSource, tileSource); } }); diff --git a/src/webgldrawer.js b/src/webgldrawer.js index 52c4d109..eaa26623 100644 --- a/src/webgldrawer.js +++ b/src/webgldrawer.js @@ -40,23 +40,23 @@ /** * @class OpenSeadragon.WebGLDrawer * @classdesc Default implementation of WebGLDrawer for an {@link OpenSeadragon.Viewer}. The WebGLDrawer - * loads tile data as textures to the graphics card as soon as it is available (via the tile-ready event), - * and unloads the data (via the image-unloaded event). The drawer utilizes a context-dependent two pass drawing pipeline. - * For the first pass, tile composition for a given TiledImage is always done using a canvas with a WebGL context. - * This allows tiles to be stitched together without seams or artifacts, without requiring a tile source with overlap. If overlap is present, - * overlapping pixels are discarded. The second pass copies all pixel data from the WebGL context onto an output canvas - * with a Context2d context. This allows applications to have access to pixel data and other functionality provided by - * Context2d, regardless of whether the CanvasDrawer or the WebGLDrawer is used. Certain options, including compositeOperation, - * clip, croppingPolygons, and debugMode are implemented using Context2d operations; in these scenarios, each TiledImage is - * drawn onto the output canvas immediately after the tile composition step (pass 1). Otherwise, for efficiency, all TiledImages - * are copied over to the output canvas at once, after all tiles have been composited for all images. + * defines its own data type that ensures textures are correctly loaded to and deleted from the GPU memory. + * The drawer utilizes a context-dependent two pass drawing pipeline. For the first pass, tile composition + * for a given TiledImage is always done using a canvas with a WebGL context. This allows tiles to be stitched + * together without seams or artifacts, without requiring a tile source with overlap. If overlap is present, + * overlapping pixels are discarded. The second pass copies all pixel data from the WebGL context onto an output + * canvas with a Context2d context. This allows applications to have access to pixel data and other functionality + * provided by Context2d, regardless of whether the CanvasDrawer or the WebGLDrawer is used. Certain options, + * including compositeOperation, clip, croppingPolygons, and debugMode are implemented using Context2d operations; + * in these scenarios, each TiledImage is drawn onto the output canvas immediately after the tile composition step + * (pass 1). Otherwise, for efficiency, all TiledImages are copied over to the output canvas at once, after all + * tiles have been composited for all images. * @param {Object} options - Options for this Drawer. * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. * @param {Element} options.element - Parent element. * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. */ - OpenSeadragon.WebGLDrawer = class WebGLDrawer extends OpenSeadragon.DrawerBase{ constructor(options){ super(options); @@ -76,15 +76,11 @@ // private members this._destroyed = false; - this._TextureMap = new Map(); - this._TileMap = new Map(); - this._gl = null; this._firstPass = null; this._secondPass = null; this._glFrameBuffer = null; this._renderToTexture = null; - this._glFramebufferToCanvasTransform = null; this._outputCanvas = null; this._outputContext = null; this._clippingCanvas = null; @@ -94,12 +90,6 @@ this._imageSmoothingEnabled = true; // will be updated by setImageSmoothingEnabled - // Add listeners for events that require modifying the scene or camera - this._boundToTileReady = ev => this._tileReadyHandler(ev); - this._boundToImageUnloaded = ev => this._imageUnloadedHandler(ev); - this.viewer.addHandler("tile-ready", this._boundToTileReady); - this.viewer.addHandler("image-unloaded", this._boundToImageUnloaded); - // Reject listening for the tile-drawing and tile-drawn events, which this drawer does not fire this.viewer.rejectEventHandler("tile-drawn", "The WebGLDrawer does not raise the tile-drawn event"); this.viewer.rejectEventHandler("tile-drawing", "The WebGLDrawer does not raise the tile-drawing event"); @@ -110,9 +100,23 @@ this._setupCanvases(); this._setupRenderer(); - this.context = this._outputContext; // API required by tests + this._supportedFormats = this._setupTextureHandlers(); + this._requiredFormats = this._supportedFormats; + this._setupCallCount = 1; - } + this.context = this._outputContext; // API required by tests + } + + get defaultOptions() { + return { + // use detached cache: our type conversion will not collide (and does not have to preserve CPU data ref) + usePrivateCache: true + }; + } + + getSupportedDataFormats() { + return this._supportedFormats; + } // Public API required by all Drawer implementations /** @@ -137,8 +141,6 @@ gl.bindRenderbuffer(gl.RENDERBUFFER, null); gl.bindFramebuffer(gl.FRAMEBUFFER, null); - this._unloadTextures(); - // Delete all our created resources gl.deleteBuffer(this._secondPass.bufferOutputPosition); gl.deleteFramebuffer(this._glFrameBuffer); @@ -156,11 +158,6 @@ ext.loseContext(); } - // unbind our event listeners from the viewer - this.viewer.removeHandler("tile-ready", this._boundToTileReady); - this.viewer.removeHandler("image-unloaded", this._boundToImageUnloaded); - this.viewer.removeHandler("resize", this._resizeHandler); - // set our webgl context reference to null to enable garbage collection this._gl = null; @@ -204,7 +201,7 @@ /** * - * @returns 'webgl' + * @returns {string} 'webgl' */ getType(){ return 'webgl'; @@ -243,6 +240,17 @@ if(!this._backupCanvasDrawer){ this._backupCanvasDrawer = this.viewer.requestDrawer('canvas', {mainDrawer: false}); this._backupCanvasDrawer.canvas.style.setProperty('visibility', 'hidden'); + this._backupCanvasDrawer.getSupportedDataFormats = () => this._supportedFormats; + this._backupCanvasDrawer.getDataToDraw = (tile) => { + const cache = tile.getCache(tile.cacheKey); + if (!cache) { + $.console.warn("Attempt to draw tile %s when not cached!", tile); + return undefined; + } + const dataCache = cache.getDataForRendering(this, tile); + // Use CPU Data for the drawer instead + return dataCache && dataCache.cpuData; + }; } return this._backupCanvasDrawer; @@ -379,23 +387,14 @@ let tile = tilesToDraw[tileIndex].tile; let indexInDrawArray = tileIndex % maxTextures; let numTilesToDraw = indexInDrawArray + 1; - let tileContext = tile.getCanvasContext(); + const textureInfo = this.getDataToDraw(tile); - let textureInfo = tileContext ? this._TextureMap.get(tileContext.canvas) : null; - if(!textureInfo){ - // tile was not processed in the tile-ready event (this can happen - // if this drawer was created after the tile was downloaded) - this._tileReadyHandler({tile: tile, tiledImage: tiledImage}); - - // retry getting textureInfo - textureInfo = tileContext ? this._TextureMap.get(tileContext.canvas) : null; - } - - if(textureInfo){ + if (textureInfo) { this._getTileData(tile, tiledImage, textureInfo, overallMatrix, indexInDrawArray, texturePositionArray, textureDataArray, matrixArray, opacityArray); } else { // console.log('No tile info', tile); } + if( (numTilesToDraw === maxTextures) || (tileIndex === tilesToDraw.length - 1)){ // We've filled up the buffers: time to draw this set of tiles @@ -473,8 +472,6 @@ } } - - }); if(renderingBufferHasImageData){ @@ -483,6 +480,10 @@ } + getRequiredDataFormats() { + return this._requiredFormats; + } + // Public API required by all Drawer implementations /** * Sets whether image smoothing is enabled or disabled @@ -491,9 +492,15 @@ setImageSmoothingEnabled(enabled){ if( this._imageSmoothingEnabled !== enabled ){ this._imageSmoothingEnabled = enabled; - this._unloadTextures(); - this.viewer.world.draw(); + + // Todo consider removing old type handlers if _supportedFormats had already types defined, + // and remove support for rendering old types... + const newFormats = this._setupTextureHandlers(); // re-sets the type to enforce re-initialization + this._supportedFormats.push(...newFormats); + this._requiredFormats = newFormats; + return this.viewer.requestInvalidate(); } + return $.Promise.resolve(); } /** @@ -876,6 +883,97 @@ this.viewer.addHandler("resize", this._resizeHandler); } + _setupTextureHandlers() { + const tex2DCompatibleLoader = (tile, data) => { + let tiledImage = tile.tiledImage; + let gl = this._gl; + let texture; + let position; + + if (!tiledImage.isTainted()) { + if((data instanceof CanvasRenderingContext2D) && $.isCanvasTainted(data.canvas)){ + tiledImage.setTainted(true); + $.console.warn('WebGL cannot be used to draw this TiledImage because it has tainted data. Does crossOriginPolicy need to be set?'); + this._raiseDrawerErrorEvent(tiledImage, 'Tainted data cannot be used by the WebGLDrawer. Falling back to CanvasDrawer for this TiledImage.'); + } else { + let sourceWidthFraction, sourceHeightFraction; + if (tile.sourceBounds) { + sourceWidthFraction = Math.min(tile.sourceBounds.width, data.width) / data.width; + sourceHeightFraction = Math.min(tile.sourceBounds.height, data.height) / data.height; + } else { + sourceWidthFraction = 1; + sourceHeightFraction = 1; + } + + // create a gl Texture for this tile and bind the canvas with the image data + texture = gl.createTexture(); + let overlap = tiledImage.source.tileOverlap; + if( overlap > 0){ + // calculate the normalized position of the rect to actually draw + // discarding overlap. + let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); + + let left = (tile.x === 0 ? 0 : overlapFraction.x) * sourceWidthFraction; + let top = (tile.y === 0 ? 0 : overlapFraction.y) * sourceHeightFraction; + let right = (tile.isRightMost ? 1 : 1 - overlapFraction.x) * sourceWidthFraction; + let bottom = (tile.isBottomMost ? 1 : 1 - overlapFraction.y) * sourceHeightFraction; + position = this._makeQuadVertexBuffer(left, right, top, bottom); + } else if (sourceWidthFraction === 1 && sourceHeightFraction === 1) { + // no overlap and no padding: this texture can use the unit quad as its position data + position = this._unitQuad; + } else { + position = this._makeQuadVertexBuffer(0, sourceWidthFraction, 0, sourceHeightFraction); + } + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + // Set the parameters so we can render any size image. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this._textureFilter()); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this._textureFilter()); + + try { + // This depends on gl.TEXTURE_2D being bound to the texture + // associated with this canvas before calling this function + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data); + } catch (e){ + // Todo a bit dirty re-use of the tainted flag, but makes the code more stable + tiledImage.setTainted(true); + $.console.error('Error uploading image data to WebGL. Falling back to canvas renderer.', e); + this._raiseDrawerErrorEvent(tiledImage, 'Unknown error when uploading texture. Falling back to CanvasDrawer for this TiledImage.'); + } + } + } + + // TextureInfo stored in the cache + return { + texture: texture, + position: position, + cpuData: data // Reference to the outer cache data, used to draw if webgl canont be used + }; + }; + const tex2DCompatibleDestructor = textureInfo => { + if (textureInfo) { + this._gl.deleteTexture(textureInfo.texture); + } + }; + + const thisType = `${this.getId()}_${this._setupCallCount++}_TEX_2D`; + // Differentiate type also based on type used to upload data: we can support bidirectional conversion. + const c2dTexType = thisType + ":context2d", + imageTexType = thisType + ":image"; + + // We should be OK uploading any of these types. The complexity is selected to be O(3n), should be + // more than linear pass over pixels + $.convertor.learn("context2d", c2dTexType, (t, d) => tex2DCompatibleLoader(t, d.canvas), 1, 3); + $.convertor.learn("image", imageTexType, tex2DCompatibleLoader, 1, 3); + + $.convertor.learnDestroy(c2dTexType, tex2DCompatibleDestructor); + $.convertor.learnDestroy(imageTexType, tex2DCompatibleDestructor); + return [c2dTexType, imageTexType]; + } + // private _makeQuadVertexBuffer(left, right, top, bottom){ return new Float32Array([ @@ -887,92 +985,6 @@ right, top]); } - // private - _tileReadyHandler(event){ - let tile = event.tile; - let tiledImage = event.tiledImage; - - // If a tiledImage is already known to be tainted, don't try to upload any - // textures to webgl, because they won't be used even if it succeeds - if(tiledImage.isTainted()){ - return; - } - - let tileContext = tile.getCanvasContext(); - let canvas = tileContext && tileContext.canvas; - // if the tile doesn't provide a canvas, or is tainted by cross-origin - // data, marked the TiledImage as tainted so the canvas drawer can be - // used instead, and return immediately - tainted data cannot be uploaded to webgl - if(!canvas || $.isCanvasTainted(canvas)){ - const wasTainted = tiledImage.isTainted(); - if(!wasTainted){ - tiledImage.setTainted(true); - $.console.warn('WebGL cannot be used to draw this TiledImage because it has tainted data. Does crossOriginPolicy need to be set?'); - this._raiseDrawerErrorEvent(tiledImage, 'Tainted data cannot be used by the WebGLDrawer. Falling back to CanvasDrawer for this TiledImage.'); - } - return; - } - - let textureInfo = this._TextureMap.get(canvas); - - // if this is a new image for us, create a texture - if(!textureInfo){ - let gl = this._gl; - - // create a gl Texture for this tile and bind the canvas with the image data - let texture = gl.createTexture(); - let position; - let overlap = tiledImage.source.tileOverlap; - - // deal with tiles where there is padding, i.e. the pixel data doesn't take up the entire provided canvas - let sourceWidthFraction, sourceHeightFraction; - if (tile.sourceBounds) { - sourceWidthFraction = Math.min(tile.sourceBounds.width, canvas.width) / canvas.width; - sourceHeightFraction = Math.min(tile.sourceBounds.height, canvas.height) / canvas.height; - } else { - sourceWidthFraction = 1; - sourceHeightFraction = 1; - } - - if( overlap > 0){ - // calculate the normalized position of the rect to actually draw - // discarding overlap. - let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); - - let left = (tile.x === 0 ? 0 : overlapFraction.x) * sourceWidthFraction; - let top = (tile.y === 0 ? 0 : overlapFraction.y) * sourceHeightFraction; - let right = (tile.isRightMost ? 1 : 1 - overlapFraction.x) * sourceWidthFraction; - let bottom = (tile.isBottomMost ? 1 : 1 - overlapFraction.y) * sourceHeightFraction; - position = this._makeQuadVertexBuffer(left, right, top, bottom); - } else if (sourceWidthFraction === 1 && sourceHeightFraction === 1) { - // no overlap and no padding: this texture can use the unit quad as its position data - position = this._unitQuad; - } else { - position = this._makeQuadVertexBuffer(0, sourceWidthFraction, 0, sourceHeightFraction); - } - - let textureInfo = { - texture: texture, - position: position, - }; - - // add it to our _TextureMap - this._TextureMap.set(canvas, textureInfo); - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, texture); - // Set the parameters so we can render any size image. - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this._textureFilter()); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this._textureFilter()); - - // Upload the image into the texture. - this._uploadImageData(tileContext); - - } - - } - // private _calculateOverlapFraction(tile, tiledImage){ let overlap = tiledImage.source.tileOverlap; @@ -989,51 +1001,13 @@ } // private - _unloadTextures(){ - let canvases = Array.from(this._TextureMap.keys()); - canvases.forEach(canvas => { - this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap - }); - } +// _unloadTextures(){ +// let canvases = Array.from(this._TextureMap.keys()); +// canvases.forEach(canvas => { +// this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap +// }); +// } - // private - _uploadImageData(tileContext){ - - let gl = this._gl; - let canvas = tileContext.canvas; - - try{ - if(!canvas){ - throw('Tile context does not have a canvas', tileContext); - } - // This depends on gl.TEXTURE_2D being bound to the texture - // associated with this canvas before calling this function - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); - } catch (e){ - $.console.error('Error uploading image data to WebGL', e); - } - } - - // private - _imageUnloadedHandler(event){ - let canvas = event.context2D.canvas; - this._cleanupImageData(canvas); - } - - // private - _cleanupImageData(tileCanvas){ - let textureInfo = this._TextureMap.get(tileCanvas); - //remove from the map - this._TextureMap.delete(tileCanvas); - - //release the texture from the GPU - if(textureInfo){ - this._gl.deleteTexture(textureInfo.texture); - } - - } - - // private _setClip(){ // no-op: called by _renderToClippingCanvas when tiledImage._clip is truthy // so that tests will pass. @@ -1340,9 +1314,7 @@ return shaderProgram; } - }; - }( OpenSeadragon )); diff --git a/src/world.js b/src/world.js index 6049bb06..3530cf05 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 @@ -231,6 +232,209 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W } }, + /** + * Forces the system consider all tiles across all tiled images + * as outdated, and fire tile update event on relevant tiles + * Detailed description is available within the 'tile-invalidated' + * event. + * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data + * @param {number} [tStamp=OpenSeadragon.now()] optionally provide tStamp of the update event + * @function + * @fires OpenSeadragon.Viewer.event:tile-invalidated + * @return {OpenSeadragon.Promise} + */ + requestInvalidate: function (restoreTiles = true, tStamp = $.now()) { + $.__updated = tStamp; + // const priorityTiles = this._items.map(item => item._lastDrawn.map(x => x.tile)).flat(); + // const promise = this.requestTileInvalidateEvent(priorityTiles, tStamp, restoreTiles); + // return promise.then(() => this.requestTileInvalidateEvent(this.viewer.tileCache.getLoadedTilesFor(null), tStamp, restoreTiles)); + + // Tile-first retrieval fires computation on tiles that share cache, which are filtered out by processing property + return this.requestTileInvalidateEvent(this.viewer.tileCache.getLoadedTilesFor(null), tStamp, restoreTiles); + + // Cache-first update tile retrieval is nicer since there might be many tiles sharing + // return this.requestTileInvalidateEvent(new Set(Object.values(this.viewer.tileCache._cachesLoaded) + // .map(c => !c._destroyed && c._tiles[0])), tStamp, restoreTiles); + }, + + /** + * Requests tile data update. + * @function OpenSeadragon.Viewer.prototype._updateSequenceButtons + * @private + * @param {Iterable} tilesToProcess tiles to update + * @param {Number} tStamp timestamp in milliseconds, if active timestamp of the same value is executing, + * changes are added to the cycle, else they await next iteration + * @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data + * @param {Boolean} [_allowTileUnloaded=false] internal flag for calling on tiles that come new to the system + * @fires OpenSeadragon.Viewer.event:tile-invalidated + * @return {OpenSeadragon.Promise} + */ + requestTileInvalidateEvent: function(tilesToProcess, tStamp, restoreTiles = true, _allowTileUnloaded = false) { + if (!this.viewer.isOpen()) { + return $.Promise.resolve(); + } + + const tileList = [], + tileFinishResolvers = []; + for (const tile of tilesToProcess) { + // We allow re-execution on tiles that are in process but have too low processing timestamp, + // which must be solved by ensuring subsequent data calls in the suddenly outdated processing + // pipeline take no effect. + if (!tile || (!_allowTileUnloaded && !tile.loaded)) { + continue; + } + const tileCache = tile.getCache(tile.originalCacheKey); + if (tileCache.__invStamp && tileCache.__invStamp >= tStamp) { + continue; + } + + for (let t of tileCache._tiles) { + // Mark all related tiles as processing and register callback to unmark later on + t.processing = tStamp; + t.processingPromise = new $.Promise((resolve) => { + tileFinishResolvers.push(() => { + t.processing = false; + resolve(t); + }); + }); + } + + tileCache.__invStamp = tStamp; + tileList.push(tile); + } + + if (!tileList.length) { + return $.Promise.resolve(); + } + + // We call the event on the parent viewer window no matter what + const eventTarget = this.viewer.viewer || this.viewer; + // However, we must pick the correct drawer reference (navigator VS viewer) + const supportedFormats = this.viewer.drawer.getRequiredDataFormats(); + const keepInternalCacheCopy = this.viewer.drawer.options.usePrivateCache; + const drawerId = this.viewer.drawer.getId(); + + const jobList = tileList.map(tile => { + const tiledImage = tile.tiledImage; + const originalCache = tile.getCache(tile.originalCacheKey); + let workingCache = null; + const getWorkingCacheData = (type) => { + if (workingCache) { + return workingCache.getDataAs(type, false); + } + + const targetCopyKey = restoreTiles ? tile.originalCacheKey : tile.cacheKey; + const origCache = tile.getCache(targetCopyKey); + if (!origCache) { + $.console.error("[Tile::getData] There is no cache available for tile with key %s", targetCopyKey); + return $.Promise.reject(); + } + // Here ensure type is defined, rquired by data callbacks + type = type || origCache.type; + workingCache = new $.CacheRecord().withTileReference(tile); + return origCache.getDataAs(type, true).then(data => { + workingCache.addTile(tile, data, type); + return workingCache.data; + }); + }; + const setWorkingCacheData = (value, type) => { + if (!workingCache) { + workingCache = new $.CacheRecord().withTileReference(tile); + workingCache.addTile(tile, value, type); + return $.Promise.resolve(); + } + return workingCache.setDataAs(value, type); + }; + const atomicCacheSwap = () => { + if (workingCache) { + let newCacheKey = tile.buildDistinctMainCacheKey(); + tiledImage._tileCache.injectCache({ + tile: tile, + cache: workingCache, + targetKey: newCacheKey, + setAsMainCache: true, + tileAllowNotLoaded: tile.loading + }); + } else if (restoreTiles) { + // If we requested restore, perform now + tiledImage._tileCache.restoreTilesThatShareOriginalCache(tile, tile.getCache(tile.originalCacheKey), true); + } + }; + + /** + * @event tile-invalidated + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {OpenSeadragon.Tile} tile + * @property {AsyncNullaryFunction} outdated - predicate that evaluates to true if the event + * is outdated and should not be longer processed (has no effect) + * @property {AsyncUnaryFunction} getData - get data of desired type (string argument) + * @property {AsyncBinaryFunction} setData - set data (any) + * and the type of the data (string) + * @property {function} resetData - function that deletes any previous data modification in the current + * execution pipeline + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + return eventTarget.raiseEventAwaiting('tile-invalidated', { + tile: tile, + tiledImage: tiledImage, + outdated: () => originalCache.__invStamp !== tStamp || (!tile.loaded && !tile.loading), + getData: getWorkingCacheData, + setData: setWorkingCacheData, + resetData: () => { + if (workingCache) { + workingCache.destroy(); + workingCache = null; + } + } + }).then(_ => { + if (originalCache.__invStamp === tStamp && (tile.loaded || tile.loading)) { + if (workingCache) { + return workingCache.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then(c => { + if (c && originalCache.__invStamp === tStamp) { + atomicCacheSwap(); + originalCache.__invStamp = null; + } + }); + } + + // If we requested restore, perform now + if (restoreTiles) { + const freshOriginalCacheRef = tile.getCache(tile.originalCacheKey); + return freshOriginalCacheRef.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then((c) => { + if (c && originalCache.__invStamp === tStamp) { + atomicCacheSwap(); + originalCache.__invStamp = null; + } + }); + } + + // Preventive call to ensure we stay compatible + const freshMainCacheRef = tile.getCache(); + return freshMainCacheRef.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then(() => { + atomicCacheSwap(); + originalCache.__invStamp = null; + }); + + } else if (workingCache) { + workingCache.destroy(); + workingCache = null; + } + return null; + }).catch(e => { + $.console.error("Update routine error:", e); + }); + }); + + return $.Promise.all(jobList).then(() => { + for (let resolve of tileFinishResolvers) { + resolve(); + } + this.draw(); + }); + }, + /** * Clears all tiles and triggers updates for all items. */ @@ -261,9 +465,9 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W draw: function() { this.viewer.drawer.draw(this._items); this._needsDraw = false; - this._items.forEach((item) => { + for (let item of this._items) { this._needsDraw = item.setDrawn() || this._needsDraw; - }); + } }, /** diff --git a/src/zoomifytilesource.js b/src/zoomifytilesource.js index 5798d8eb..9f39dc04 100644 --- a/src/zoomifytilesource.js +++ b/src/zoomifytilesource.js @@ -77,7 +77,7 @@ options.minLevel = 0; options.maxLevel = options.gridSize.length - 1; - OpenSeadragon.TileSource.apply(this, [options]); + $.TileSource.apply(this, [options]); }; $.extend($.ZoomifyTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.ZoomifyTileSource.prototype */ { @@ -143,6 +143,13 @@ result = Math.floor(num / 256); return this.tilesUrl + 'TileGroup' + result + '/' + level + '-' + x + '-' + y + '.' + this.fileFormat; + }, + + /** + * Equality comparator + */ + equals: function (otherSource) { + return this.tilesUrl === otherSource.tilesUrl; } }); diff --git a/style.css b/style.css new file mode 100644 index 00000000..e9c4c8d1 --- /dev/null +++ b/style.css @@ -0,0 +1,300 @@ +* { + margin: 0; + padding: 0; + outline: 0; +} + +body { + padding: 80px 100px; + font: 13px "Helvetica Neue", "Lucida Grande", "Arial"; + background: #ECE9E9 -webkit-gradient(linear, 0% 0%, 0% 100%, from(#fff), to(#ECE9E9)); + background: #ECE9E9 -moz-linear-gradient(top, #fff, #ECE9E9); + background-repeat: no-repeat; + color: #555; + -webkit-font-smoothing: antialiased; +} +h1, h2, h3 { + font-size: 22px; + color: #343434; +} +h1 em, h2 em { + padding: 0 5px; + font-weight: normal; +} +h1 { + font-size: 60px; +} +h2 { + margin-top: 10px; +} +h3 { + margin: 5px 0 10px 0; + padding-bottom: 5px; + border-bottom: 1px solid #eee; + font-size: 18px; +} +ul li { + list-style: none; +} +ul li:hover { + cursor: pointer; + color: #2e2e2e; +} +ul li .path { + padding-left: 5px; + font-weight: bold; +} +ul li .line { + padding-right: 5px; + font-style: italic; +} +ul li:first-child .path { + padding-left: 0; +} +p { + line-height: 1.5; +} +a { + color: #555; + text-decoration: none; +} +a:hover { + color: #303030; +} +#stacktrace { + margin-top: 15px; +} +.directory h1 { + margin-bottom: 15px; + font-size: 18px; +} +ul#files { + width: 100%; + height: 100%; + overflow: hidden; +} +ul#files li { + float: left; + width: 30%; + line-height: 25px; + margin: 1px; +} +ul#files li a { + display: block; + height: 25px; + border: 1px solid transparent; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + overflow: hidden; + white-space: nowrap; +} +ul#files li a:focus, +ul#files li a:hover { + background: rgba(255,255,255,0.65); + border: 1px solid #ececec; +} +ul#files li a.highlight { + -webkit-transition: background .4s ease-in-out; + background: #ffff4f; + border-color: #E9DC51; +} +#search { + display: block; + position: fixed; + top: 20px; + right: 20px; + width: 90px; + -webkit-transition: width ease 0.2s, opacity ease 0.4s; + -moz-transition: width ease 0.2s, opacity ease 0.4s; + -webkit-border-radius: 32px; + -moz-border-radius: 32px; + -webkit-box-shadow: inset 0px 0px 3px rgba(0, 0, 0, 0.25), inset 0px 1px 3px rgba(0, 0, 0, 0.7), 0px 1px 0px rgba(255, 255, 255, 0.03); + -moz-box-shadow: inset 0px 0px 3px rgba(0, 0, 0, 0.25), inset 0px 1px 3px rgba(0, 0, 0, 0.7), 0px 1px 0px rgba(255, 255, 255, 0.03); + -webkit-font-smoothing: antialiased; + text-align: left; + font: 13px "Helvetica Neue", Arial, sans-serif; + padding: 4px 10px; + border: none; + background: transparent; + margin-bottom: 0; + outline: none; + opacity: 0.7; + color: #888; +} +#search:focus { + width: 120px; + opacity: 1.0; +} + +/*views*/ +#files span { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + text-indent: 10px; +} +#files .name { + background-repeat: no-repeat; +} +#files .icon .name { + text-indent: 28px; +} + +/*tiles*/ +.view-tiles .name { + width: 100%; + background-position: 8px 5px; + margin-left: 30px; +} +.view-tiles .size, +.view-tiles .date { + display: none; +} + +.view-tiles a { + position: relative; +} +/*hack: reuse empty to find folders*/ +#files .size:empty { + width: 20px; + height: 14px; + background-color: #f9d342; /* Folder color */ + position: absolute; + border-radius: 4px; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); /* Optional shadow for effect */ + display: block !important; + float: left; + left: 13px; + top: 5px; +} +#files .size:empty:before { + content: ''; + position: absolute; + top: -2px; + left: 2px; + width: 12px; + height: 4px; + background-color: #f9d342; + border-top-left-radius: 2px; + border-top-right-radius: 2px; +} +#files .size:empty:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 8px; + height: 4px; + background-color: #e8c233; /* Slightly darker shade for the tab */ + border-top-left-radius: 2px; + border-bottom-right-radius: 2px; +} +/*details*/ +ul#files.view-details li { + float: none; + display: block; + width: 90%; +} +ul#files.view-details li.header { + height: 25px; + background: #000; + color: #fff; + font-weight: bold; +} +.view-details .header { + border-radius: 5px; +} +.view-details .name { + width: 60%; + background-position: 8px 5px; +} +.view-details .size { + width: 10%; +} +.view-details .date { + width: 30%; +} +.view-details .size, +.view-details .date { + text-align: right; + direction: rtl; +} + +/*mobile*/ +@media (max-width: 768px) { + body { + font-size: 13px; + line-height: 16px; + padding: 0; + } + #search { + position: static; + width: 100%; + font-size: 2em; + line-height: 1.8em; + text-indent: 10px; + border: 0; + border-radius: 0; + padding: 10px 0; + margin: 0; + } + #search:focus { + width: 100%; + border: 0; + opacity: 1; + } + .directory h1 { + font-size: 2em; + line-height: 1.5em; + color: #fff; + background: #000; + padding: 15px 10px; + margin: 0; + } + ul#files { + border-top: 1px solid #cacaca; + } + ul#files li { + float: none; + width: auto !important; + display: block; + border-bottom: 1px solid #cacaca; + font-size: 2em; + line-height: 1.2em; + text-indent: 0; + margin: 0; + } + ul#files li:nth-child(odd) { + background: #e0e0e0; + } + ul#files li a { + height: auto; + border: 0; + border-radius: 0; + padding: 15px 10px; + } + ul#files li a:focus, + ul#files li a:hover { + border: 0; + } + #files .header, + #files .size, + #files .date { + display: none !important; + } + #files .name { + float: none; + display: inline-block; + width: 100%; + text-indent: 0; + background-position: 0 50%; + } + #files .icon .name { + text-indent: 41px; + } + #files .size:empty { + top: 23px; + left: 5px; + } +} diff --git a/test/coverage.html b/test/coverage.html index e4046459..30b27ab2 100644 --- a/test/coverage.html +++ b/test/coverage.html @@ -58,6 +58,7 @@ + diff --git a/test/demo/basic-html-drawer.html b/test/demo/basic-html-drawer.html new file mode 100644 index 00000000..b5757f8d --- /dev/null +++ b/test/demo/basic-html-drawer.html @@ -0,0 +1,33 @@ + + + + OpenSeadragon Basic Demo + + + + + +
+ Simple demo page to show a default OpenSeadragon viewer. +
+
+ + + diff --git a/test/demo/constrainedpan.html b/test/demo/constrainedpan.html index 1cd7578a..0c594463 100644 --- a/test/demo/constrainedpan.html +++ b/test/demo/constrainedpan.html @@ -41,8 +41,9 @@ constrainDuringPan: true, visibilityRatio: 1, prefixUrl: "../../build/openseadragon/images/", - minZoomImageRatio: 1 + minZoomImageRatio: 1, + crossOriginPolicy: 'Anonymous', }); - \ No newline at end of file + diff --git a/test/demo/filtering-plugin/demo.js b/test/demo/filtering-plugin/demo.js new file mode 100644 index 00000000..1da03828 --- /dev/null +++ b/test/demo/filtering-plugin/demo.js @@ -0,0 +1,843 @@ +/* + * Modified and maintained by the OpenSeadragon Community. + * + * This software was orignally developed at the National Institute of Standards and + * Technology by employees of the Federal Government. NIST assumes + * no responsibility whatsoever for its use by other parties, and makes no + * guarantees, expressed or implied, about its quality, reliability, or + * any other characteristic. + * @author Antoine Vandecreme + */ + +/** + * This class is an improvement over the basic jQuery spinner to support + * 'Enter' to update the value (with validity checks). + * @param {Object} options Options object + * @return {Spinner} A spinner object + */ +class Spinner { + constructor(options) { + options.$element.html(''); + + const self = this, + $spinner = options.$element.find('input'); + this.value = options.init; + $spinner.spinner({ + min: options.min, + max: options.max, + step: options.step, + spin: function(event, ui) { + /*jshint unused:true */ + self.value = ui.value; + options.updateCallback(self.value); + } + }); + $spinner.val(this.value); + $spinner.keyup(function(e) { + if (e.which === 13) { + if (!this.value.match(/^-?\d?\.?\d*$/)) { + this.value = options.init; + } else if (options.min !== undefined && + this.value < options.min) { + this.value = options.min; + } else if (options.max !== undefined && + this.value > options.max) { + this.value = options.max; + } + self.value = this.value; + options.updateCallback(self.value); + } + }); + + } + getValue() { + return this.value; + } +} + +class SpinnerSlider { + + constructor(options) { + let idIncrement = 0; + + this.hash = idIncrement++; + + const spinnerId = 'wdzt-spinner-slider-spinner-' + this.hash; + const sliderId = 'wdzt-spinner-slider-slider-' + this.hash; + + this.value = options.init; + + const self = this; + + options.$element.html(` +
+
+
+ +
+
+
+
+
+
+
+ `); + + const $slider = options.$element.find('#' + sliderId) + .slider({ + min: options.min, + max: options.sliderMax !== undefined ? + options.sliderMax : options.max, + step: options.step, + value: this.value, + slide: function (event, ui) { + /*jshint unused:true */ + self.value = ui.value; + $spinner.spinner('value', self.value); + options.updateCallback(self.value); + } + }); + const $spinner = options.$element.find('#' + spinnerId) + .spinner({ + min: options.min, + max: options.max, + step: options.step, + spin: function (event, ui) { + /*jshint unused:true */ + self.value = ui.value; + $slider.slider('value', self.value); + options.updateCallback(self.value); + } + }); + $spinner.val(this.value); + $spinner.keyup(function (e) { + if (e.which === 13) { + self.value = $spinner.spinner('value'); + $slider.slider('value', self.value); + options.updateCallback(self.value); + } + }); + } + + getValue () { + return this.value; + }; +} + + +const switcher = new DrawerSwitcher(); +switcher.addDrawerOption("drawer"); +$("#title-drawer").html(switcher.activeName("drawer")); +switcher.render("#title-banner"); +const sources = { + 'Highsmith': "https://openseadragon.github.io/example-images/highsmith/highsmith.dzi", + 'Rainbow Grid': "../../data/testpattern.dzi", + 'Leaves': "../../data/iiif_2_0_sizes/info.json", + "Duomo":"https://openseadragon.github.io/example-images/duomo/duomo.dzi", +} +const url = new URL(window.location); +const targetSource = url.searchParams.get("image") || Object.values(sources)[0]; +const viewer = window.viewer = new OpenSeadragon({ + id: 'openseadragon', + prefixUrl: '/build/openseadragon/images/', + tileSources: targetSource, + crossOriginPolicy: 'Anonymous', + drawer: switcher.activeImplementation("drawer"), + showNavigator: true, + wrapHorizontal: true, + gestureSettingsMouse: { + clickToZoom: false + } +}); + +$("#image-select") + .html(Object.entries(sources).map(([k, v]) => + ``).join("\n")) + .on('change', e => { + url.searchParams.set('image', e.target.value); + window.history.pushState(null, '', url.toString()); + viewer.addTiledImage({tileSource: e.target.value, index: 0, replace: true}); + }); + + +// Prevent Caman from caching the canvas because without this: +// 1. We have a memory leak +// 2. Non-caman filters in between 2 camans filters get ignored. +Caman.Store.put = function() {}; + +// List of filters with their templates. +const availableFilters = [ + { + name: 'Invert', + generate: function() { + return { + html: '', + getParams: function() { + return ''; + }, + getFilter: function() { + /*eslint new-cap: 0*/ + return OpenSeadragon.Filters.INVERT(); + } + }; + } + }, { + name: 'Colormap', + generate: function(updateCallback) { + const cmaps = { + aCm: [ [0,0,0], [0,4,0], [0,8,0], [0,12,0], [0,16,0], [0,20,0], [0,24,0], [0,28,0], [0,32,0], [0,36,0], [0,40,0], [0,44,0], [0,48,0], [0,52,0], [0,56,0], [0,60,0], [0,64,0], [0,68,0], [0,72,0], [0,76,0], [0,80,0], [0,85,0], [0,89,0], [0,93,0], [0,97,0], [0,101,0], [0,105,0], [0,109,0], [0,113,0], [0,117,0], [0,121,0], [0,125,0], [0,129,2], [0,133,5], [0,137,7], [0,141,10], [0,145,13], [0,149,15], [0,153,18], [0,157,21], [0,161,23], [0,165,26], [0,170,29], [0,174,31], [0,178,34], [0,182,37], [0,186,39], [0,190,42], [0,194,45], [0,198,47], [0,202,50], [0,206,53], [0,210,55], [0,214,58], [0,218,61], [0,222,63], [0,226,66], [0,230,69], [0,234,71], [0,238,74], [0,242,77], [0,246,79], [0,250,82], [0,255,85], [3,251,87], [7,247,90], [11,243,92], [15,239,95], [19,235,98], [23,231,100], [27,227,103], [31,223,106], [35,219,108], [39,215,111], [43,211,114], [47,207,116], [51,203,119], [55,199,122], [59,195,124], [63,191,127], [67,187,130], [71,183,132], [75,179,135], [79,175,138], [83,171,140], [87,167,143], [91,163,146], [95,159,148], [99,155,151], [103,151,154], [107,147,156], [111,143,159], [115,139,162], [119,135,164], [123,131,167], [127,127,170], [131,123,172], [135,119,175], [139,115,177], [143,111,180], [147,107,183], [151,103,185], [155,99,188], [159,95,191], [163,91,193], [167,87,196], [171,83,199], [175,79,201], [179,75,204], [183,71,207], [187,67,209], [191,63,212], [195,59,215], [199,55,217], [203,51,220], [207,47,223], [211,43,225], [215,39,228], [219,35,231], [223,31,233], [227,27,236], [231,23,239], [235,19,241], [239,15,244], [243,11,247], [247,7,249], [251,3,252], [255,0,255], [255,0,251], [255,0,247], [255,0,244], [255,0,240], [255,0,237], [255,0,233], [255,0,230], [255,0,226], [255,0,223], [255,0,219], [255,0,216], [255,0,212], [255,0,208], [255,0,205], [255,0,201], [255,0,198], [255,0,194], [255,0,191], [255,0,187], [255,0,184], [255,0,180], [255,0,177], [255,0,173], [255,0,170], [255,0,166], [255,0,162], [255,0,159], [255,0,155], [255,0,152], [255,0,148], [255,0,145], [255,0,141], [255,0,138], [255,0,134], [255,0,131], [255,0,127], [255,0,123], [255,0,119], [255,0,115], [255,0,112], [255,0,108], [255,0,104], [255,0,100], [255,0,96], [255,0,92], [255,0,88], [255,0,85], [255,0,81], [255,0,77], [255,0,73], [255,0,69], [255,0,65], [255,0,61], [255,0,57], [255,0,54], [255,0,50], [255,0,46], [255,0,42], [255,0,38], [255,0,34], [255,0,30], [255,0,27], [255,0,23], [255,0,19], [255,0,15], [255,0,11], [255,0,7], [255,0,3], [255,0,0], [255,4,0], [255,8,0], [255,12,0], [255,17,0], [255,21,0], [255,25,0], [255,30,0], [255,34,0], [255,38,0], [255,43,0], [255,47,0], [255,51,0], [255,56,0], [255,60,0], [255,64,0], [255,69,0], [255,73,0], [255,77,0], [255,82,0], [255,86,0], [255,90,0], [255,95,0], [255,99,0], [255,103,0], [255,108,0], [255,112,0], [255,116,0], [255,121,0], [255,125,0], [255,129,0], [255,133,0], [255,138,0], [255,142,0], [255,146,0], [255,151,0], [255,155,0], [255,159,0], [255,164,0], [255,168,0], [255,172,0], [255,177,0], [255,181,0], [255,185,0], [255,190,0], [255,194,0], [255,198,0], [255,203,0], [255,207,0], [255,211,0], [255,216,0], [255,220,0], [255,224,0], [255,229,0], [255,233,0], [255,237,0], [255,242,0], [255,246,0], [255,250,0], [255,255,0]], + bCm: [ [0,0,0], [0,0,4], [0,0,8], [0,0,12], [0,0,16], [0,0,20], [0,0,24], [0,0,28], [0,0,32], [0,0,36], [0,0,40], [0,0,44], [0,0,48], [0,0,52], [0,0,56], [0,0,60], [0,0,64], [0,0,68], [0,0,72], [0,0,76], [0,0,80], [0,0,85], [0,0,89], [0,0,93], [0,0,97], [0,0,101], [0,0,105], [0,0,109], [0,0,113], [0,0,117], [0,0,121], [0,0,125], [0,0,129], [0,0,133], [0,0,137], [0,0,141], [0,0,145], [0,0,149], [0,0,153], [0,0,157], [0,0,161], [0,0,165], [0,0,170], [0,0,174], [0,0,178], [0,0,182], [0,0,186], [0,0,190], [0,0,194], [0,0,198], [0,0,202], [0,0,206], [0,0,210], [0,0,214], [0,0,218], [0,0,222], [0,0,226], [0,0,230], [0,0,234], [0,0,238], [0,0,242], [0,0,246], [0,0,250], [0,0,255], [3,0,251], [7,0,247], [11,0,243], [15,0,239], [19,0,235], [23,0,231], [27,0,227], [31,0,223], [35,0,219], [39,0,215], [43,0,211], [47,0,207], [51,0,203], [55,0,199], [59,0,195], [63,0,191], [67,0,187], [71,0,183], [75,0,179], [79,0,175], [83,0,171], [87,0,167], [91,0,163], [95,0,159], [99,0,155], [103,0,151], [107,0,147], [111,0,143], [115,0,139], [119,0,135], [123,0,131], [127,0,127], [131,0,123], [135,0,119], [139,0,115], [143,0,111], [147,0,107], [151,0,103], [155,0,99], [159,0,95], [163,0,91], [167,0,87], [171,0,83], [175,0,79], [179,0,75], [183,0,71], [187,0,67], [191,0,63], [195,0,59], [199,0,55], [203,0,51], [207,0,47], [211,0,43], [215,0,39], [219,0,35], [223,0,31], [227,0,27], [231,0,23], [235,0,19], [239,0,15], [243,0,11], [247,0,7], [251,0,3], [255,0,0], [255,3,0], [255,7,0], [255,11,0], [255,15,0], [255,19,0], [255,23,0], [255,27,0], [255,31,0], [255,35,0], [255,39,0], [255,43,0], [255,47,0], [255,51,0], [255,55,0], [255,59,0], [255,63,0], [255,67,0], [255,71,0], [255,75,0], [255,79,0], [255,83,0], [255,87,0], [255,91,0], [255,95,0], [255,99,0], [255,103,0], [255,107,0], [255,111,0], [255,115,0], [255,119,0], [255,123,0], [255,127,0], [255,131,0], [255,135,0], [255,139,0], [255,143,0], [255,147,0], [255,151,0], [255,155,0], [255,159,0], [255,163,0], [255,167,0], [255,171,0], [255,175,0], [255,179,0], [255,183,0], [255,187,0], [255,191,0], [255,195,0], [255,199,0], [255,203,0], [255,207,0], [255,211,0], [255,215,0], [255,219,0], [255,223,0], [255,227,0], [255,231,0], [255,235,0], [255,239,0], [255,243,0], [255,247,0], [255,251,0], [255,255,0], [255,255,3], [255,255,7], [255,255,11], [255,255,15], [255,255,19], [255,255,23], [255,255,27], [255,255,31], [255,255,35], [255,255,39], [255,255,43], [255,255,47], [255,255,51], [255,255,55], [255,255,59], [255,255,63], [255,255,67], [255,255,71], [255,255,75], [255,255,79], [255,255,83], [255,255,87], [255,255,91], [255,255,95], [255,255,99], [255,255,103], [255,255,107], [255,255,111], [255,255,115], [255,255,119], [255,255,123], [255,255,127], [255,255,131], [255,255,135], [255,255,139], [255,255,143], [255,255,147], [255,255,151], [255,255,155], [255,255,159], [255,255,163], [255,255,167], [255,255,171], [255,255,175], [255,255,179], [255,255,183], [255,255,187], [255,255,191], [255,255,195], [255,255,199], [255,255,203], [255,255,207], [255,255,211], [255,255,215], [255,255,219], [255,255,223], [255,255,227], [255,255,231], [255,255,235], [255,255,239], [255,255,243], [255,255,247], [255,255,251], [255,255,255]], + bbCm: [ [0,0,0], [2,0,0], [4,0,0], [6,0,0], [8,0,0], [10,0,0], [12,0,0], [14,0,0], [16,0,0], [18,0,0], [20,0,0], [22,0,0], [24,0,0], [26,0,0], [28,0,0], [30,0,0], [32,0,0], [34,0,0], [36,0,0], [38,0,0], [40,0,0], [42,0,0], [44,0,0], [46,0,0], [48,0,0], [50,0,0], [52,0,0], [54,0,0], [56,0,0], [58,0,0], [60,0,0], [62,0,0], [64,0,0], [66,0,0], [68,0,0], [70,0,0], [72,0,0], [74,0,0], [76,0,0], [78,0,0], [80,0,0], [82,0,0], [84,0,0], [86,0,0], [88,0,0], [90,0,0], [92,0,0], [94,0,0], [96,0,0], [98,0,0], [100,0,0], [102,0,0], [104,0,0], [106,0,0], [108,0,0], [110,0,0], [112,0,0], [114,0,0], [116,0,0], [118,0,0], [120,0,0], [122,0,0], [124,0,0], [126,0,0], [128,1,0], [130,3,0], [132,5,0], [134,7,0], [136,9,0], [138,11,0], [140,13,0], [142,15,0], [144,17,0], [146,19,0], [148,21,0], [150,23,0], [152,25,0], [154,27,0], [156,29,0], [158,31,0], [160,33,0], [162,35,0], [164,37,0], [166,39,0], [168,41,0], [170,43,0], [172,45,0], [174,47,0], [176,49,0], [178,51,0], [180,53,0], [182,55,0], [184,57,0], [186,59,0], [188,61,0], [190,63,0], [192,65,0], [194,67,0], [196,69,0], [198,71,0], [200,73,0], [202,75,0], [204,77,0], [206,79,0], [208,81,0], [210,83,0], [212,85,0], [214,87,0], [216,89,0], [218,91,0], [220,93,0], [222,95,0], [224,97,0], [226,99,0], [228,101,0], [230,103,0], [232,105,0], [234,107,0], [236,109,0], [238,111,0], [240,113,0], [242,115,0], [244,117,0], [246,119,0], [248,121,0], [250,123,0], [252,125,0], [255,127,0], [255,129,1], [255,131,3], [255,133,5], [255,135,7], [255,137,9], [255,139,11], [255,141,13], [255,143,15], [255,145,17], [255,147,19], [255,149,21], [255,151,23], [255,153,25], [255,155,27], [255,157,29], [255,159,31], [255,161,33], [255,163,35], [255,165,37], [255,167,39], [255,169,41], [255,171,43], [255,173,45], [255,175,47], [255,177,49], [255,179,51], [255,181,53], [255,183,55], [255,185,57], [255,187,59], [255,189,61], [255,191,63], [255,193,65], [255,195,67], [255,197,69], [255,199,71], [255,201,73], [255,203,75], [255,205,77], [255,207,79], [255,209,81], [255,211,83], [255,213,85], [255,215,87], [255,217,89], [255,219,91], [255,221,93], [255,223,95], [255,225,97], [255,227,99], [255,229,101], [255,231,103], [255,233,105], [255,235,107], [255,237,109], [255,239,111], [255,241,113], [255,243,115], [255,245,117], [255,247,119], [255,249,121], [255,251,123], [255,253,125], [255,255,127], [255,255,129], [255,255,131], [255,255,133], [255,255,135], [255,255,137], [255,255,139], [255,255,141], [255,255,143], [255,255,145], [255,255,147], [255,255,149], [255,255,151], [255,255,153], [255,255,155], [255,255,157], [255,255,159], [255,255,161], [255,255,163], [255,255,165], [255,255,167], [255,255,169], [255,255,171], [255,255,173], [255,255,175], [255,255,177], [255,255,179], [255,255,181], [255,255,183], [255,255,185], [255,255,187], [255,255,189], [255,255,191], [255,255,193], [255,255,195], [255,255,197], [255,255,199], [255,255,201], [255,255,203], [255,255,205], [255,255,207], [255,255,209], [255,255,211], [255,255,213], [255,255,215], [255,255,217], [255,255,219], [255,255,221], [255,255,223], [255,255,225], [255,255,227], [255,255,229], [255,255,231], [255,255,233], [255,255,235], [255,255,237], [255,255,239], [255,255,241], [255,255,243], [255,255,245], [255,255,247], [255,255,249], [255,255,251], [255,255,253], [255,255,255]], + blueCm: [ [0,0,0], [0,0,1], [0,0,2], [0,0,3], [0,0,4], [0,0,5], [0,0,6], [0,0,7], [0,0,8], [0,0,9], [0,0,10], [0,0,11], [0,0,12], [0,0,13], [0,0,14], [0,0,15], [0,0,16], [0,0,17], [0,0,18], [0,0,19], [0,0,20], [0,0,21], [0,0,22], [0,0,23], [0,0,24], [0,0,25], [0,0,26], [0,0,27], [0,0,28], [0,0,29], [0,0,30], [0,0,31], [0,0,32], [0,0,33], [0,0,34], [0,0,35], [0,0,36], [0,0,37], [0,0,38], [0,0,39], [0,0,40], [0,0,41], [0,0,42], [0,0,43], [0,0,44], [0,0,45], [0,0,46], [0,0,47], [0,0,48], [0,0,49], [0,0,50], [0,0,51], [0,0,52], [0,0,53], [0,0,54], [0,0,55], [0,0,56], [0,0,57], [0,0,58], [0,0,59], [0,0,60], [0,0,61], [0,0,62], [0,0,63], [0,0,64], [0,0,65], [0,0,66], [0,0,67], [0,0,68], [0,0,69], [0,0,70], [0,0,71], [0,0,72], [0,0,73], [0,0,74], [0,0,75], [0,0,76], [0,0,77], [0,0,78], [0,0,79], [0,0,80], [0,0,81], [0,0,82], [0,0,83], [0,0,84], [0,0,85], [0,0,86], [0,0,87], [0,0,88], [0,0,89], [0,0,90], [0,0,91], [0,0,92], [0,0,93], [0,0,94], [0,0,95], [0,0,96], [0,0,97], [0,0,98], [0,0,99], [0,0,100], [0,0,101], [0,0,102], [0,0,103], [0,0,104], [0,0,105], [0,0,106], [0,0,107], [0,0,108], [0,0,109], [0,0,110], [0,0,111], [0,0,112], [0,0,113], [0,0,114], [0,0,115], [0,0,116], [0,0,117], [0,0,118], [0,0,119], [0,0,120], [0,0,121], [0,0,122], [0,0,123], [0,0,124], [0,0,125], [0,0,126], [0,0,127], [0,0,128], [0,0,129], [0,0,130], [0,0,131], [0,0,132], [0,0,133], [0,0,134], [0,0,135], [0,0,136], [0,0,137], [0,0,138], [0,0,139], [0,0,140], [0,0,141], [0,0,142], [0,0,143], [0,0,144], [0,0,145], [0,0,146], [0,0,147], [0,0,148], [0,0,149], [0,0,150], [0,0,151], [0,0,152], [0,0,153], [0,0,154], [0,0,155], [0,0,156], [0,0,157], [0,0,158], [0,0,159], [0,0,160], [0,0,161], [0,0,162], [0,0,163], [0,0,164], [0,0,165], [0,0,166], [0,0,167], [0,0,168], [0,0,169], [0,0,170], [0,0,171], [0,0,172], [0,0,173], [0,0,174], [0,0,175], [0,0,176], [0,0,177], [0,0,178], [0,0,179], [0,0,180], [0,0,181], [0,0,182], [0,0,183], [0,0,184], [0,0,185], [0,0,186], [0,0,187], [0,0,188], [0,0,189], [0,0,190], [0,0,191], [0,0,192], [0,0,193], [0,0,194], [0,0,195], [0,0,196], [0,0,197], [0,0,198], [0,0,199], [0,0,200], [0,0,201], [0,0,202], [0,0,203], [0,0,204], [0,0,205], [0,0,206], [0,0,207], [0,0,208], [0,0,209], [0,0,210], [0,0,211], [0,0,212], [0,0,213], [0,0,214], [0,0,215], [0,0,216], [0,0,217], [0,0,218], [0,0,219], [0,0,220], [0,0,221], [0,0,222], [0,0,223], [0,0,224], [0,0,225], [0,0,226], [0,0,227], [0,0,228], [0,0,229], [0,0,230], [0,0,231], [0,0,232], [0,0,233], [0,0,234], [0,0,235], [0,0,236], [0,0,237], [0,0,238], [0,0,239], [0,0,240], [0,0,241], [0,0,242], [0,0,243], [0,0,244], [0,0,245], [0,0,246], [0,0,247], [0,0,248], [0,0,249], [0,0,250], [0,0,251], [0,0,252], [0,0,253], [0,0,254], [0,0,255]], + coolCm: [ [0,0,0], [0,0,1], [0,0,3], [0,0,5], [0,0,7], [0,0,9], [0,0,11], [0,0,13], [0,0,15], [0,0,17], [0,0,18], [0,0,20], [0,0,22], [0,0,24], [0,0,26], [0,0,28], [0,0,30], [0,0,32], [0,0,34], [0,0,35], [0,0,37], [0,0,39], [0,0,41], [0,0,43], [0,0,45], [0,0,47], [0,0,49], [0,0,51], [0,0,52], [0,0,54], [0,0,56], [0,0,58], [0,0,60], [0,0,62], [0,0,64], [0,0,66], [0,0,68], [0,0,69], [0,0,71], [0,0,73], [0,0,75], [0,0,77], [0,0,79], [0,0,81], [0,0,83], [0,0,85], [0,0,86], [0,0,88], [0,0,90], [0,0,92], [0,0,94], [0,0,96], [0,0,98], [0,0,100], [0,0,102], [0,0,103], [0,0,105], [0,1,107], [0,2,109], [0,4,111], [0,5,113], [0,6,115], [0,8,117], [0,9,119], [0,10,120], [0,12,122], [0,13,124], [0,14,126], [0,16,128], [0,17,130], [0,18,132], [0,20,134], [0,21,136], [0,23,137], [0,24,139], [0,25,141], [0,27,143], [0,28,145], [1,29,147], [1,31,149], [1,32,151], [1,33,153], [1,35,154], [2,36,156], [2,37,158], [2,39,160], [2,40,162], [2,42,164], [3,43,166], [3,44,168], [3,46,170], [3,47,171], [4,48,173], [4,50,175], [4,51,177], [4,52,179], [4,54,181], [5,55,183], [5,56,185], [5,58,187], [5,59,188], [5,61,190], [6,62,192], [6,63,194], [6,65,196], [6,66,198], [7,67,200], [7,69,202], [7,70,204], [7,71,205], [7,73,207], [8,74,209], [8,75,211], [8,77,213], [8,78,215], [8,80,217], [9,81,219], [9,82,221], [9,84,222], [9,85,224], [9,86,226], [10,88,228], [10,89,230], [10,90,232], [10,92,234], [11,93,236], [11,94,238], [11,96,239], [11,97,241], [11,99,243], [12,100,245], [12,101,247], [12,103,249], [12,104,251], [12,105,253], [13,107,255], [13,108,255], [13,109,255], [13,111,255], [14,112,255], [14,113,255], [14,115,255], [14,116,255], [14,118,255], [15,119,255], [15,120,255], [15,122,255], [15,123,255], [15,124,255], [16,126,255], [16,127,255], [16,128,255], [16,130,255], [17,131,255], [17,132,255], [17,134,255], [17,135,255], [17,136,255], [18,138,255], [18,139,255], [18,141,255], [18,142,255], [18,143,255], [19,145,255], [19,146,255], [19,147,255], [19,149,255], [19,150,255], [20,151,255], [20,153,255], [20,154,255], [20,155,255], [21,157,255], [21,158,255], [21,160,255], [21,161,255], [21,162,255], [22,164,255], [22,165,255], [22,166,255], [22,168,255], [22,169,255], [23,170,255], [23,172,255], [23,173,255], [23,174,255], [24,176,255], [24,177,255], [24,179,255], [24,180,255], [24,181,255], [25,183,255], [25,184,255], [25,185,255], [29,187,255], [32,188,255], [36,189,255], [40,191,255], [44,192,255], [47,193,255], [51,195,255], [55,196,255], [58,198,255], [62,199,255], [66,200,255], [69,202,255], [73,203,255], [77,204,255], [81,206,255], [84,207,255], [88,208,255], [92,210,255], [95,211,255], [99,212,255], [103,214,255], [106,215,255], [110,217,255], [114,218,255], [118,219,255], [121,221,255], [125,222,255], [129,223,255], [132,225,255], [136,226,255], [140,227,255], [143,229,255], [147,230,255], [151,231,255], [155,233,255], [158,234,255], [162,236,255], [166,237,255], [169,238,255], [173,240,255], [177,241,255], [180,242,255], [184,244,255], [188,245,255], [192,246,255], [195,248,255], [199,249,255], [203,250,255], [206,252,255], [210,253,255], [214,255,255], [217,255,255], [221,255,255], [225,255,255], [229,255,255], [232,255,255], [236,255,255], [240,255,255], [243,255,255], [247,255,255], [251,255,255], [255,255,255]], + cubehelix0Cm: [ [0,0,0], [2,1,2], [5,2,5], [5,2,5], [6,2,6], [7,2,7], [10,3,10], [12,5,12], [13,5,14], [14,5,16], [15,5,17], [16,6,20], [17,7,22], [18,8,24], [19,9,26], [20,10,28], [21,11,30], [22,12,33], [22,13,34], [22,14,36], [22,15,38], [24,16,40], [25,17,43], [25,18,45], [25,19,46], [25,20,48], [25,22,50], [25,23,51], [25,25,53], [25,26,54], [25,28,56], [25,28,57], [25,29,59], [25,30,61], [25,33,62], [25,35,63], [25,36,65], [25,37,67], [25,38,68], [25,40,70], [25,43,71], [24,45,72], [23,46,73], [22,48,73], [22,49,75], [22,51,76], [22,52,76], [22,54,76], [22,56,76], [22,57,77], [22,59,78], [22,61,79], [21,63,79], [20,66,79], [20,67,79], [20,68,79], [20,68,79], [20,71,79], [20,73,79], [20,75,78], [20,77,77], [20,79,76], [20,80,76], [20,81,76], [21,83,75], [22,85,74], [22,86,73], [22,89,72], [22,91,71], [23,92,71], [24,93,71], [25,94,71], [26,96,70], [28,99,68], [28,100,68], [29,101,67], [30,102,66], [31,102,65], [32,103,64], [33,104,63], [35,105,62], [38,107,61], [39,107,60], [39,108,59], [40,109,58], [43,110,57], [45,112,56], [47,113,55], [49,113,54], [51,114,53], [54,116,52], [58,117,51], [60,117,50], [62,117,49], [63,117,48], [66,118,48], [68,119,48], [71,119,48], [73,119,48], [76,119,48], [79,120,47], [81,121,46], [84,122,45], [87,122,45], [91,122,45], [94,122,46], [96,122,47], [99,122,48], [103,122,48], [107,122,48], [109,122,49], [112,122,50], [114,122,51], [118,122,52], [122,122,53], [124,122,54], [127,122,55], [130,122,56], [133,122,57], [137,122,58], [140,122,60], [142,122,62], [145,122,63], [149,122,66], [153,122,68], [155,121,70], [158,120,72], [160,119,73], [162,119,75], [164,119,77], [165,119,79], [169,119,81], [173,119,84], [175,119,86], [176,119,89], [178,119,91], [181,119,95], [183,119,99], [186,120,102], [188,121,104], [191,122,107], [192,122,110], [193,122,114], [195,122,117], [197,122,119], [198,122,122], [200,122,126], [201,122,130], [202,123,132], [203,124,135], [204,124,137], [204,125,141], [205,126,144], [206,127,147], [207,127,151], [209,127,155], [209,128,158], [210,129,160], [211,130,163], [211,131,167], [211,132,170], [211,133,173], [211,134,175], [211,135,178], [211,136,182], [211,137,186], [211,138,188], [211,139,191], [211,140,193], [211,142,196], [211,145,198], [210,146,201], [209,147,204], [209,147,206], [209,150,209], [209,153,211], [208,153,213], [207,154,215], [206,155,216], [206,157,218], [206,158,220], [206,160,221], [205,163,224], [204,165,226], [203,167,228], [202,169,230], [201,170,232], [201,172,233], [201,173,234], [200,175,235], [199,176,236], [198,178,237], [197,181,238], [196,183,239], [196,185,239], [196,187,239], [196,188,239], [195,191,240], [193,193,242], [193,194,242], [193,195,242], [193,196,242], [193,198,242], [193,199,242], [193,201,242], [193,204,242], [193,206,242], [193,208,242], [193,209,242], [193,211,242], [193,212,242], [193,214,242], [194,215,242], [195,217,242], [196,219,242], [196,220,242], [196,221,242], [197,223,241], [198,225,240], [198,226,239], [200,228,239], [201,229,239], [202,230,239], [203,231,239], [204,232,239], [205,233,239], [206,234,239], [207,235,239], [208,236,239], [209,237,239], [210,238,239], [212,238,239], [214,239,239], [215,240,239], [216,242,239], [218,243,239], [220,243,239], [221,244,239], [224,246,239], [226,247,239], [228,247,240], [230,247,241], [232,247,242], [234,248,242], [237,249,242], [238,249,243], [240,249,243], [242,249,244], [243,251,246], [244,252,247], [246,252,249], [248,252,250], [249,252,252], [251,253,253], [253,254,254], [255,255,255]], + cubehelix1Cm: [ [0,0,0], [2,0,2], [5,0,5], [6,0,7], [8,0,10], [10,0,12], [12,1,15], [15,2,17], [17,2,20], [18,2,22], [20,2,25], [21,2,29], [22,2,33], [23,3,35], [24,4,38], [25,5,40], [25,6,44], [25,7,48], [26,8,51], [27,9,53], [28,10,56], [28,11,59], [28,12,63], [27,14,66], [26,16,68], [25,17,71], [25,18,73], [25,19,74], [25,20,76], [24,22,80], [22,25,84], [22,27,85], [21,28,87], [20,30,89], [19,33,91], [17,35,94], [16,37,95], [14,39,96], [12,40,96], [11,43,99], [10,45,102], [8,47,102], [6,49,103], [5,51,104], [2,54,104], [0,58,104], [0,60,104], [0,62,104], [0,63,104], [0,66,104], [0,68,104], [0,71,104], [0,73,104], [0,76,104], [0,79,103], [0,81,102], [0,84,102], [0,86,99], [0,89,96], [0,91,96], [0,94,95], [0,96,94], [0,99,91], [0,102,89], [0,103,86], [0,105,84], [0,107,81], [0,109,79], [0,112,76], [0,113,73], [0,115,71], [0,117,68], [0,119,65], [0,122,61], [0,124,58], [0,125,56], [0,127,53], [0,128,51], [0,129,48], [0,130,45], [0,132,42], [0,135,38], [0,136,35], [0,136,33], [0,137,30], [3,138,26], [7,140,22], [10,140,21], [12,140,19], [15,140,17], [19,141,14], [22,142,10], [26,142,8], [29,142,6], [33,142,5], [38,142,2], [43,142,0], [46,142,0], [50,142,0], [53,142,0], [57,141,0], [62,141,0], [66,140,0], [72,140,0], [79,140,0], [83,139,0], [87,138,0], [91,137,0], [98,136,0], [104,135,0], [108,134,0], [113,133,0], [117,132,0], [123,131,0], [130,130,0], [134,129,0], [138,128,0], [142,127,0], [149,126,0], [155,124,0], [159,123,0], [164,121,1], [168,119,2], [174,118,6], [181,117,10], [185,116,12], [189,115,15], [193,114,17], [197,113,21], [200,113,24], [204,112,28], [209,110,33], [214,109,38], [217,108,41], [221,107,45], [224,107,48], [228,105,54], [232,104,61], [234,103,64], [237,102,68], [239,102,71], [243,102,77], [247,102,84], [249,101,89], [250,100,94], [252,99,99], [253,99,105], [255,99,112], [255,99,117], [255,99,122], [255,99,127], [255,99,131], [255,99,136], [255,99,140], [255,100,147], [255,102,155], [255,102,159], [255,102,164], [255,102,168], [255,103,174], [255,104,181], [255,105,185], [255,106,189], [255,107,193], [255,108,200], [255,109,206], [255,111,210], [255,113,215], [255,114,219], [253,117,224], [252,119,229], [250,120,232], [249,121,236], [247,122,239], [244,124,244], [242,127,249], [240,130,251], [238,132,253], [237,135,255], [234,136,255], [232,138,255], [229,140,255], [226,142,255], [224,145,255], [222,147,255], [221,150,255], [219,153,255], [215,156,255], [211,160,255], [209,162,255], [208,164,255], [206,165,255], [204,169,255], [201,173,255], [199,175,255], [198,178,255], [196,181,255], [193,183,255], [191,186,255], [189,188,255], [187,191,255], [186,193,255], [185,195,255], [184,197,255], [183,198,255], [182,202,255], [181,206,255], [180,208,255], [179,209,255], [178,211,255], [177,214,255], [175,216,255], [175,218,255], [175,220,255], [175,221,255], [175,224,255], [175,226,255], [176,228,255], [177,230,255], [178,232,255], [179,234,255], [181,237,255], [181,238,255], [182,238,255], [183,239,255], [184,240,252], [186,242,249], [187,243,249], [189,243,248], [191,244,247], [192,245,246], [194,246,245], [196,247,244], [198,248,243], [201,249,242], [203,250,242], [204,251,242], [206,252,242], [210,252,240], [214,252,239], [216,252,240], [219,252,241], [221,252,242], [224,253,242], [226,255,242], [229,255,243], [232,255,243], [234,255,244], [238,255,246], [242,255,247], [243,255,248], [245,255,249], [247,255,249], [249,255,251], [252,255,253], [255,255,255]], + greenCm: [ [0,0,0], [0,1,0], [0,2,0], [0,3,0], [0,4,0], [0,5,0], [0,6,0], [0,7,0], [0,8,0], [0,9,0], [0,10,0], [0,11,0], [0,12,0], [0,13,0], [0,14,0], [0,15,0], [0,16,0], [0,17,0], [0,18,0], [0,19,0], [0,20,0], [0,21,0], [0,22,0], [0,23,0], [0,24,0], [0,25,0], [0,26,0], [0,27,0], [0,28,0], [0,29,0], [0,30,0], [0,31,0], [0,32,0], [0,33,0], [0,34,0], [0,35,0], [0,36,0], [0,37,0], [0,38,0], [0,39,0], [0,40,0], [0,41,0], [0,42,0], [0,43,0], [0,44,0], [0,45,0], [0,46,0], [0,47,0], [0,48,0], [0,49,0], [0,50,0], [0,51,0], [0,52,0], [0,53,0], [0,54,0], [0,55,0], [0,56,0], [0,57,0], [0,58,0], [0,59,0], [0,60,0], [0,61,0], [0,62,0], [0,63,0], [0,64,0], [0,65,0], [0,66,0], [0,67,0], [0,68,0], [0,69,0], [0,70,0], [0,71,0], [0,72,0], [0,73,0], [0,74,0], [0,75,0], [0,76,0], [0,77,0], [0,78,0], [0,79,0], [0,80,0], [0,81,0], [0,82,0], [0,83,0], [0,84,0], [0,85,0], [0,86,0], [0,87,0], [0,88,0], [0,89,0], [0,90,0], [0,91,0], [0,92,0], [0,93,0], [0,94,0], [0,95,0], [0,96,0], [0,97,0], [0,98,0], [0,99,0], [0,100,0], [0,101,0], [0,102,0], [0,103,0], [0,104,0], [0,105,0], [0,106,0], [0,107,0], [0,108,0], [0,109,0], [0,110,0], [0,111,0], [0,112,0], [0,113,0], [0,114,0], [0,115,0], [0,116,0], [0,117,0], [0,118,0], [0,119,0], [0,120,0], [0,121,0], [0,122,0], [0,123,0], [0,124,0], [0,125,0], [0,126,0], [0,127,0], [0,128,0], [0,129,0], [0,130,0], [0,131,0], [0,132,0], [0,133,0], [0,134,0], [0,135,0], [0,136,0], [0,137,0], [0,138,0], [0,139,0], [0,140,0], [0,141,0], [0,142,0], [0,143,0], [0,144,0], [0,145,0], [0,146,0], [0,147,0], [0,148,0], [0,149,0], [0,150,0], [0,151,0], [0,152,0], [0,153,0], [0,154,0], [0,155,0], [0,156,0], [0,157,0], [0,158,0], [0,159,0], [0,160,0], [0,161,0], [0,162,0], [0,163,0], [0,164,0], [0,165,0], [0,166,0], [0,167,0], [0,168,0], [0,169,0], [0,170,0], [0,171,0], [0,172,0], [0,173,0], [0,174,0], [0,175,0], [0,176,0], [0,177,0], [0,178,0], [0,179,0], [0,180,0], [0,181,0], [0,182,0], [0,183,0], [0,184,0], [0,185,0], [0,186,0], [0,187,0], [0,188,0], [0,189,0], [0,190,0], [0,191,0], [0,192,0], [0,193,0], [0,194,0], [0,195,0], [0,196,0], [0,197,0], [0,198,0], [0,199,0], [0,200,0], [0,201,0], [0,202,0], [0,203,0], [0,204,0], [0,205,0], [0,206,0], [0,207,0], [0,208,0], [0,209,0], [0,210,0], [0,211,0], [0,212,0], [0,213,0], [0,214,0], [0,215,0], [0,216,0], [0,217,0], [0,218,0], [0,219,0], [0,220,0], [0,221,0], [0,222,0], [0,223,0], [0,224,0], [0,225,0], [0,226,0], [0,227,0], [0,228,0], [0,229,0], [0,230,0], [0,231,0], [0,232,0], [0,233,0], [0,234,0], [0,235,0], [0,236,0], [0,237,0], [0,238,0], [0,239,0], [0,240,0], [0,241,0], [0,242,0], [0,243,0], [0,244,0], [0,245,0], [0,246,0], [0,247,0], [0,248,0], [0,249,0], [0,250,0], [0,251,0], [0,252,0], [0,253,0], [0,254,0], [0,255,0]], + greyCm: [ [0,0,0], [1,1,1], [2,2,2], [3,3,3], [4,4,4], [5,5,5], [6,6,6], [7,7,7], [8,8,8], [9,9,9], [10,10,10], [11,11,11], [12,12,12], [13,13,13], [14,14,14], [15,15,15], [16,16,16], [17,17,17], [18,18,18], [19,19,19], [20,20,20], [21,21,21], [22,22,22], [23,23,23], [24,24,24], [25,25,25], [26,26,26], [27,27,27], [28,28,28], [29,29,29], [30,30,30], [31,31,31], [32,32,32], [33,33,33], [34,34,34], [35,35,35], [36,36,36], [37,37,37], [38,38,38], [39,39,39], [40,40,40], [41,41,41], [42,42,42], [43,43,43], [44,44,44], [45,45,45], [46,46,46], [47,47,47], [48,48,48], [49,49,49], [50,50,50], [51,51,51], [52,52,52], [53,53,53], [54,54,54], [55,55,55], [56,56,56], [57,57,57], [58,58,58], [59,59,59], [60,60,60], [61,61,61], [62,62,62], [63,63,63], [64,64,64], [65,65,65], [66,66,66], [67,67,67], [68,68,68], [69,69,69], [70,70,70], [71,71,71], [72,72,72], [73,73,73], [74,74,74], [75,75,75], [76,76,76], [77,77,77], [78,78,78], [79,79,79], [80,80,80], [81,81,81], [82,82,82], [83,83,83], [84,84,84], [85,85,85], [86,86,86], [87,87,87], [88,88,88], [89,89,89], [90,90,90], [91,91,91], [92,92,92], [93,93,93], [94,94,94], [95,95,95], [96,96,96], [97,97,97], [98,98,98], [99,99,99], [100,100,100], [101,101,101], [102,102,102], [103,103,103], [104,104,104], [105,105,105], [106,106,106], [107,107,107], [108,108,108], [109,109,109], [110,110,110], [111,111,111], [112,112,112], [113,113,113], [114,114,114], [115,115,115], [116,116,116], [117,117,117], [118,118,118], [119,119,119], [120,120,120], [121,121,121], [122,122,122], [123,123,123], [124,124,124], [125,125,125], [126,126,126], [127,127,127], [128,128,128], [129,129,129], [130,130,130], [131,131,131], [132,132,132], [133,133,133], [134,134,134], [135,135,135], [136,136,136], [137,137,137], [138,138,138], [139,139,139], [140,140,140], [141,141,141], [142,142,142], [143,143,143], [144,144,144], [145,145,145], [146,146,146], [147,147,147], [148,148,148], [149,149,149], [150,150,150], [151,151,151], [152,152,152], [153,153,153], [154,154,154], [155,155,155], [156,156,156], [157,157,157], [158,158,158], [159,159,159], [160,160,160], [161,161,161], [162,162,162], [163,163,163], [164,164,164], [165,165,165], [166,166,166], [167,167,167], [168,168,168], [169,169,169], [170,170,170], [171,171,171], [172,172,172], [173,173,173], [174,174,174], [175,175,175], [176,176,176], [177,177,177], [178,178,178], [179,179,179], [180,180,180], [181,181,181], [182,182,182], [183,183,183], [184,184,184], [185,185,185], [186,186,186], [187,187,187], [188,188,188], [189,189,189], [190,190,190], [191,191,191], [192,192,192], [193,193,193], [194,194,194], [195,195,195], [196,196,196], [197,197,197], [198,198,198], [199,199,199], [200,200,200], [201,201,201], [202,202,202], [203,203,203], [204,204,204], [205,205,205], [206,206,206], [207,207,207], [208,208,208], [209,209,209], [210,210,210], [211,211,211], [212,212,212], [213,213,213], [214,214,214], [215,215,215], [216,216,216], [217,217,217], [218,218,218], [219,219,219], [220,220,220], [221,221,221], [222,222,222], [223,223,223], [224,224,224], [225,225,225], [226,226,226], [227,227,227], [228,228,228], [229,229,229], [230,230,230], [231,231,231], [232,232,232], [233,233,233], [234,234,234], [235,235,235], [236,236,236], [237,237,237], [238,238,238], [239,239,239], [240,240,240], [241,241,241], [242,242,242], [243,243,243], [244,244,244], [245,245,245], [246,246,246], [247,247,247], [248,248,248], [249,249,249], [250,250,250], [251,251,251], [252,252,252], [253,253,253], [254,254,254], [255,255,255]], + heCm: [ [0,0,0], [42,0,10], [85,0,21], [127,0,31], [127,0,47], [127,0,63], [127,0,79], [127,0,95], [127,0,102], [127,0,109], [127,0,116], [127,0,123], [127,0,131], [127,0,138], [127,0,145], [127,0,152], [127,0,159], [127,8,157], [127,17,155], [127,25,153], [127,34,151], [127,42,149], [127,51,147], [127,59,145], [127,68,143], [127,76,141], [127,85,139], [127,93,136], [127,102,134], [127,110,132], [127,119,130], [127,127,128], [127,129,126], [127,131,124], [127,133,122], [127,135,120], [127,137,118], [127,139,116], [127,141,114], [127,143,112], [127,145,110], [127,147,108], [127,149,106], [127,151,104], [127,153,102], [127,155,100], [127,157,98], [127,159,96], [127,161,94], [127,163,92], [127,165,90], [127,167,88], [127,169,86], [127,171,84], [127,173,82], [127,175,80], [127,177,77], [127,179,75], [127,181,73], [127,183,71], [127,185,69], [127,187,67], [127,189,65], [127,191,63], [128,191,64], [129,191,65], [130,191,66], [131,192,67], [132,192,68], [133,192,69], [134,192,70], [135,193,71], [136,193,72], [137,193,73], [138,193,74], [139,194,75], [140,194,76], [141,194,77], [142,194,78], [143,195,79], [144,195,80], [145,195,81], [146,195,82], [147,196,83], [148,196,84], [149,196,85], [150,196,86], [151,196,87], [152,197,88], [153,197,89], [154,197,90], [155,197,91], [156,198,92], [157,198,93], [158,198,94], [159,198,95], [160,199,96], [161,199,97], [162,199,98], [163,199,99], [164,200,100], [165,200,101], [166,200,102], [167,200,103], [168,201,104], [169,201,105], [170,201,106], [171,201,107], [172,202,108], [173,202,109], [174,202,110], [175,202,111], [176,202,112], [177,203,113], [178,203,114], [179,203,115], [180,203,116], [181,204,117], [182,204,118], [183,204,119], [184,204,120], [185,205,121], [186,205,122], [187,205,123], [188,205,124], [189,206,125], [190,206,126], [191,206,127], [191,206,128], [192,207,129], [192,207,130], [193,208,131], [193,208,132], [194,208,133], [194,209,134], [195,209,135], [195,209,136], [196,210,137], [196,210,138], [197,211,139], [197,211,140], [198,211,141], [198,212,142], [199,212,143], [199,212,144], [200,213,145], [200,213,146], [201,214,147], [201,214,148], [202,214,149], [202,215,150], [203,215,151], [203,216,152], [204,216,153], [204,216,154], [205,217,155], [205,217,156], [206,217,157], [206,218,158], [207,218,159], [207,219,160], [208,219,161], [208,219,162], [209,220,163], [209,220,164], [210,220,165], [210,221,166], [211,221,167], [211,222,168], [212,222,169], [212,222,170], [213,223,171], [213,223,172], [214,223,173], [214,224,174], [215,224,175], [215,225,176], [216,225,177], [216,225,178], [217,226,179], [217,226,180], [218,226,181], [218,227,182], [219,227,183], [219,228,184], [220,228,185], [220,228,186], [221,229,187], [221,229,188], [222,230,189], [222,230,190], [223,230,191], [223,231,192], [224,231,193], [224,231,194], [225,232,195], [225,232,196], [226,233,197], [226,233,198], [227,233,199], [227,234,200], [228,234,201], [228,234,202], [229,235,203], [229,235,204], [230,236,205], [230,236,206], [231,236,207], [231,237,208], [232,237,209], [232,237,210], [233,238,211], [233,238,212], [234,239,213], [234,239,214], [235,239,215], [235,240,216], [236,240,217], [236,240,218], [237,241,219], [237,241,220], [238,242,221], [238,242,222], [239,242,223], [239,243,224], [240,243,225], [240,244,226], [241,244,227], [241,244,228], [242,245,229], [242,245,230], [243,245,231], [243,246,232], [244,246,233], [244,247,234], [245,247,235], [245,247,236], [246,248,237], [246,248,238], [247,248,239], [247,249,240], [248,249,241], [248,250,242], [249,250,243], [249,250,244], [250,251,245], [250,251,246], [251,251,247], [251,252,248], [252,252,249], [252,253,250], [253,253,251], [253,253,252], [254,254,253], [254,254,254], [255,255,255]], + heatCm: [ [0,0,0], [2,1,0], [5,2,0], [8,3,0], [11,4,0], [14,5,0], [17,6,0], [20,7,0], [23,8,0], [26,9,0], [29,10,0], [32,11,0], [35,12,0], [38,13,0], [41,14,0], [44,15,0], [47,16,0], [50,17,0], [53,18,0], [56,19,0], [59,20,0], [62,21,0], [65,22,0], [68,23,0], [71,24,0], [74,25,0], [77,26,0], [80,27,0], [83,28,0], [85,29,0], [88,30,0], [91,31,0], [94,32,0], [97,33,0], [100,34,0], [103,35,0], [106,36,0], [109,37,0], [112,38,0], [115,39,0], [118,40,0], [121,41,0], [124,42,0], [127,43,0], [130,44,0], [133,45,0], [136,46,0], [139,47,0], [142,48,0], [145,49,0], [148,50,0], [151,51,0], [154,52,0], [157,53,0], [160,54,0], [163,55,0], [166,56,0], [169,57,0], [171,58,0], [174,59,0], [177,60,0], [180,61,0], [183,62,0], [186,63,0], [189,64,0], [192,65,0], [195,66,0], [198,67,0], [201,68,0], [204,69,0], [207,70,0], [210,71,0], [213,72,0], [216,73,0], [219,74,0], [222,75,0], [225,76,0], [228,77,0], [231,78,0], [234,79,0], [237,80,0], [240,81,0], [243,82,0], [246,83,0], [249,84,0], [252,85,0], [255,86,0], [255,87,0], [255,88,0], [255,89,0], [255,90,0], [255,91,0], [255,92,0], [255,93,0], [255,94,0], [255,95,0], [255,96,0], [255,97,0], [255,98,0], [255,99,0], [255,100,0], [255,101,0], [255,102,0], [255,103,0], [255,104,0], [255,105,0], [255,106,0], [255,107,0], [255,108,0], [255,109,0], [255,110,0], [255,111,0], [255,112,0], [255,113,0], [255,114,0], [255,115,0], [255,116,0], [255,117,0], [255,118,0], [255,119,0], [255,120,0], [255,121,0], [255,122,0], [255,123,0], [255,124,0], [255,125,0], [255,126,0], [255,127,0], [255,128,0], [255,129,0], [255,130,0], [255,131,0], [255,132,0], [255,133,0], [255,134,0], [255,135,0], [255,136,0], [255,137,0], [255,138,0], [255,139,0], [255,140,0], [255,141,0], [255,142,0], [255,143,0], [255,144,0], [255,145,0], [255,146,0], [255,147,0], [255,148,0], [255,149,0], [255,150,0], [255,151,0], [255,152,0], [255,153,0], [255,154,0], [255,155,0], [255,156,0], [255,157,0], [255,158,0], [255,159,0], [255,160,0], [255,161,0], [255,162,0], [255,163,0], [255,164,0], [255,165,0], [255,166,3], [255,167,6], [255,168,9], [255,169,12], [255,170,15], [255,171,18], [255,172,21], [255,173,24], [255,174,27], [255,175,30], [255,176,33], [255,177,36], [255,178,39], [255,179,42], [255,180,45], [255,181,48], [255,182,51], [255,183,54], [255,184,57], [255,185,60], [255,186,63], [255,187,66], [255,188,69], [255,189,72], [255,190,75], [255,191,78], [255,192,81], [255,193,85], [255,194,88], [255,195,91], [255,196,94], [255,197,97], [255,198,100], [255,199,103], [255,200,106], [255,201,109], [255,202,112], [255,203,115], [255,204,118], [255,205,121], [255,206,124], [255,207,127], [255,208,130], [255,209,133], [255,210,136], [255,211,139], [255,212,142], [255,213,145], [255,214,148], [255,215,151], [255,216,154], [255,217,157], [255,218,160], [255,219,163], [255,220,166], [255,221,170], [255,222,173], [255,223,176], [255,224,179], [255,225,182], [255,226,185], [255,227,188], [255,228,191], [255,229,194], [255,230,197], [255,231,200], [255,232,203], [255,233,206], [255,234,209], [255,235,212], [255,236,215], [255,237,218], [255,238,221], [255,239,224], [255,240,227], [255,241,230], [255,242,233], [255,243,236], [255,244,239], [255,245,242], [255,246,245], [255,247,248], [255,248,251], [255,249,255], [255,250,255], [255,251,255], [255,252,255], [255,253,255], [255,254,255], [255,255,255]], + rainbowCm: [ [255,0,255], [250,0,255], [245,0,255], [240,0,255], [235,0,255], [230,0,255], [225,0,255], [220,0,255], [215,0,255], [210,0,255], [205,0,255], [200,0,255], [195,0,255], [190,0,255], [185,0,255], [180,0,255], [175,0,255], [170,0,255], [165,0,255], [160,0,255], [155,0,255], [150,0,255], [145,0,255], [140,0,255], [135,0,255], [130,0,255], [125,0,255], [120,0,255], [115,0,255], [110,0,255], [105,0,255], [100,0,255], [95,0,255], [90,0,255], [85,0,255], [80,0,255], [75,0,255], [70,0,255], [65,0,255], [60,0,255], [55,0,255], [50,0,255], [45,0,255], [40,0,255], [35,0,255], [30,0,255], [25,0,255], [20,0,255], [15,0,255], [10,0,255], [5,0,255], [0,0,255], [0,5,255], [0,10,255], [0,15,255], [0,20,255], [0,25,255], [0,30,255], [0,35,255], [0,40,255], [0,45,255], [0,50,255], [0,55,255], [0,60,255], [0,65,255], [0,70,255], [0,75,255], [0,80,255], [0,85,255], [0,90,255], [0,95,255], [0,100,255], [0,105,255], [0,110,255], [0,115,255], [0,120,255], [0,125,255], [0,130,255], [0,135,255], [0,140,255], [0,145,255], [0,150,255], [0,155,255], [0,160,255], [0,165,255], [0,170,255], [0,175,255], [0,180,255], [0,185,255], [0,190,255], [0,195,255], [0,200,255], [0,205,255], [0,210,255], [0,215,255], [0,220,255], [0,225,255], [0,230,255], [0,235,255], [0,240,255], [0,245,255], [0,250,255], [0,255,255], [0,255,250], [0,255,245], [0,255,240], [0,255,235], [0,255,230], [0,255,225], [0,255,220], [0,255,215], [0,255,210], [0,255,205], [0,255,200], [0,255,195], [0,255,190], [0,255,185], [0,255,180], [0,255,175], [0,255,170], [0,255,165], [0,255,160], [0,255,155], [0,255,150], [0,255,145], [0,255,140], [0,255,135], [0,255,130], [0,255,125], [0,255,120], [0,255,115], [0,255,110], [0,255,105], [0,255,100], [0,255,95], [0,255,90], [0,255,85], [0,255,80], [0,255,75], [0,255,70], [0,255,65], [0,255,60], [0,255,55], [0,255,50], [0,255,45], [0,255,40], [0,255,35], [0,255,30], [0,255,25], [0,255,20], [0,255,15], [0,255,10], [0,255,5], [0,255,0], [5,255,0], [10,255,0], [15,255,0], [20,255,0], [25,255,0], [30,255,0], [35,255,0], [40,255,0], [45,255,0], [50,255,0], [55,255,0], [60,255,0], [65,255,0], [70,255,0], [75,255,0], [80,255,0], [85,255,0], [90,255,0], [95,255,0], [100,255,0], [105,255,0], [110,255,0], [115,255,0], [120,255,0], [125,255,0], [130,255,0], [135,255,0], [140,255,0], [145,255,0], [150,255,0], [155,255,0], [160,255,0], [165,255,0], [170,255,0], [175,255,0], [180,255,0], [185,255,0], [190,255,0], [195,255,0], [200,255,0], [205,255,0], [210,255,0], [215,255,0], [220,255,0], [225,255,0], [230,255,0], [235,255,0], [240,255,0], [245,255,0], [250,255,0], [255,255,0], [255,250,0], [255,245,0], [255,240,0], [255,235,0], [255,230,0], [255,225,0], [255,220,0], [255,215,0], [255,210,0], [255,205,0], [255,200,0], [255,195,0], [255,190,0], [255,185,0], [255,180,0], [255,175,0], [255,170,0], [255,165,0], [255,160,0], [255,155,0], [255,150,0], [255,145,0], [255,140,0], [255,135,0], [255,130,0], [255,125,0], [255,120,0], [255,115,0], [255,110,0], [255,105,0], [255,100,0], [255,95,0], [255,90,0], [255,85,0], [255,80,0], [255,75,0], [255,70,0], [255,65,0], [255,60,0], [255,55,0], [255,50,0], [255,45,0], [255,40,0], [255,35,0], [255,30,0], [255,25,0], [255,20,0], [255,15,0], [255,10,0], [255,5,0], [255,0,0]], + redCm: [ [0,0,0], [1,0,0], [2,0,0], [3,0,0], [4,0,0], [5,0,0], [6,0,0], [7,0,0], [8,0,0], [9,0,0], [10,0,0], [11,0,0], [12,0,0], [13,0,0], [14,0,0], [15,0,0], [16,0,0], [17,0,0], [18,0,0], [19,0,0], [20,0,0], [21,0,0], [22,0,0], [23,0,0], [24,0,0], [25,0,0], [26,0,0], [27,0,0], [28,0,0], [29,0,0], [30,0,0], [31,0,0], [32,0,0], [33,0,0], [34,0,0], [35,0,0], [36,0,0], [37,0,0], [38,0,0], [39,0,0], [40,0,0], [41,0,0], [42,0,0], [43,0,0], [44,0,0], [45,0,0], [46,0,0], [47,0,0], [48,0,0], [49,0,0], [50,0,0], [51,0,0], [52,0,0], [53,0,0], [54,0,0], [55,0,0], [56,0,0], [57,0,0], [58,0,0], [59,0,0], [60,0,0], [61,0,0], [62,0,0], [63,0,0], [64,0,0], [65,0,0], [66,0,0], [67,0,0], [68,0,0], [69,0,0], [70,0,0], [71,0,0], [72,0,0], [73,0,0], [74,0,0], [75,0,0], [76,0,0], [77,0,0], [78,0,0], [79,0,0], [80,0,0], [81,0,0], [82,0,0], [83,0,0], [84,0,0], [85,0,0], [86,0,0], [87,0,0], [88,0,0], [89,0,0], [90,0,0], [91,0,0], [92,0,0], [93,0,0], [94,0,0], [95,0,0], [96,0,0], [97,0,0], [98,0,0], [99,0,0], [100,0,0], [101,0,0], [102,0,0], [103,0,0], [104,0,0], [105,0,0], [106,0,0], [107,0,0], [108,0,0], [109,0,0], [110,0,0], [111,0,0], [112,0,0], [113,0,0], [114,0,0], [115,0,0], [116,0,0], [117,0,0], [118,0,0], [119,0,0], [120,0,0], [121,0,0], [122,0,0], [123,0,0], [124,0,0], [125,0,0], [126,0,0], [127,0,0], [128,0,0], [129,0,0], [130,0,0], [131,0,0], [132,0,0], [133,0,0], [134,0,0], [135,0,0], [136,0,0], [137,0,0], [138,0,0], [139,0,0], [140,0,0], [141,0,0], [142,0,0], [143,0,0], [144,0,0], [145,0,0], [146,0,0], [147,0,0], [148,0,0], [149,0,0], [150,0,0], [151,0,0], [152,0,0], [153,0,0], [154,0,0], [155,0,0], [156,0,0], [157,0,0], [158,0,0], [159,0,0], [160,0,0], [161,0,0], [162,0,0], [163,0,0], [164,0,0], [165,0,0], [166,0,0], [167,0,0], [168,0,0], [169,0,0], [170,0,0], [171,0,0], [172,0,0], [173,0,0], [174,0,0], [175,0,0], [176,0,0], [177,0,0], [178,0,0], [179,0,0], [180,0,0], [181,0,0], [182,0,0], [183,0,0], [184,0,0], [185,0,0], [186,0,0], [187,0,0], [188,0,0], [189,0,0], [190,0,0], [191,0,0], [192,0,0], [193,0,0], [194,0,0], [195,0,0], [196,0,0], [197,0,0], [198,0,0], [199,0,0], [200,0,0], [201,0,0], [202,0,0], [203,0,0], [204,0,0], [205,0,0], [206,0,0], [207,0,0], [208,0,0], [209,0,0], [210,0,0], [211,0,0], [212,0,0], [213,0,0], [214,0,0], [215,0,0], [216,0,0], [217,0,0], [218,0,0], [219,0,0], [220,0,0], [221,0,0], [222,0,0], [223,0,0], [224,0,0], [225,0,0], [226,0,0], [227,0,0], [228,0,0], [229,0,0], [230,0,0], [231,0,0], [232,0,0], [233,0,0], [234,0,0], [235,0,0], [236,0,0], [237,0,0], [238,0,0], [239,0,0], [240,0,0], [241,0,0], [242,0,0], [243,0,0], [244,0,0], [245,0,0], [246,0,0], [247,0,0], [248,0,0], [249,0,0], [250,0,0], [251,0,0], [252,0,0], [253,0,0], [254,0,0], [255,0,0]], + standardCm: [ [0,0,0], [0,0,3], [1,1,6], [2,2,9], [3,3,12], [4,4,15], [5,5,18], [6,6,21], [7,7,24], [8,8,27], [9,9,30], [10,10,33], [10,10,36], [11,11,39], [12,12,42], [13,13,45], [14,14,48], [15,15,51], [16,16,54], [17,17,57], [18,18,60], [19,19,63], [20,20,66], [20,20,69], [21,21,72], [22,22,75], [23,23,78], [24,24,81], [25,25,85], [26,26,88], [27,27,91], [28,28,94], [29,29,97], [30,30,100], [30,30,103], [31,31,106], [32,32,109], [33,33,112], [34,34,115], [35,35,118], [36,36,121], [37,37,124], [38,38,127], [39,39,130], [40,40,133], [40,40,136], [41,41,139], [42,42,142], [43,43,145], [44,44,148], [45,45,151], [46,46,154], [47,47,157], [48,48,160], [49,49,163], [50,50,166], [51,51,170], [51,51,173], [52,52,176], [53,53,179], [54,54,182], [55,55,185], [56,56,188], [57,57,191], [58,58,194], [59,59,197], [60,60,200], [61,61,203], [61,61,206], [62,62,209], [63,63,212], [64,64,215], [65,65,218], [66,66,221], [67,67,224], [68,68,227], [69,69,230], [70,70,233], [71,71,236], [71,71,239], [72,72,242], [73,73,245], [74,74,248], [75,75,251], [76,76,255], [0,78,0], [1,80,1], [2,82,2], [3,84,3], [4,87,4], [5,89,5], [6,91,6], [7,93,7], [8,95,8], [9,97,9], [9,99,9], [10,101,10], [11,103,11], [12,105,12], [13,108,13], [14,110,14], [15,112,15], [16,114,16], [17,116,17], [18,118,18], [18,120,18], [19,122,19], [20,124,20], [21,126,21], [22,129,22], [23,131,23], [24,133,24], [25,135,25], [26,137,26], [27,139,27], [27,141,27], [28,143,28], [29,145,29], [30,147,30], [31,150,31], [32,152,32], [33,154,33], [34,156,34], [35,158,35], [36,160,36], [36,162,36], [37,164,37], [38,166,38], [39,168,39], [40,171,40], [41,173,41], [42,175,42], [43,177,43], [44,179,44], [45,181,45], [45,183,45], [46,185,46], [47,187,47], [48,189,48], [49,192,49], [50,194,50], [51,196,51], [52,198,52], [53,200,53], [54,202,54], [54,204,54], [55,206,55], [56,208,56], [57,210,57], [58,213,58], [59,215,59], [60,217,60], [61,219,61], [62,221,62], [63,223,63], [63,225,63], [64,227,64], [65,229,65], [66,231,66], [67,234,67], [68,236,68], [69,238,69], [70,240,70], [71,242,71], [72,244,72], [72,246,72], [73,248,73], [74,250,74], [75,252,75], [76,255,76], [78,0,0], [80,1,1], [82,2,2], [84,3,3], [86,4,4], [88,5,5], [91,6,6], [93,7,7], [95,8,8], [97,8,8], [99,9,9], [101,10,10], [103,11,11], [105,12,12], [107,13,13], [109,14,14], [111,15,15], [113,16,16], [115,16,16], [118,17,17], [120,18,18], [122,19,19], [124,20,20], [126,21,21], [128,22,22], [130,23,23], [132,24,24], [134,24,24], [136,25,25], [138,26,26], [140,27,27], [142,28,28], [144,29,29], [147,30,30], [149,31,31], [151,32,32], [153,32,32], [155,33,33], [157,34,34], [159,35,35], [161,36,36], [163,37,37], [165,38,38], [167,39,39], [169,40,40], [171,40,40], [174,41,41], [176,42,42], [178,43,43], [180,44,44], [182,45,45], [184,46,46], [186,47,47], [188,48,48], [190,48,48], [192,49,49], [194,50,50], [196,51,51], [198,52,52], [201,53,53], [203,54,54], [205,55,55], [207,56,56], [209,56,56], [211,57,57], [213,58,58], [215,59,59], [217,60,60], [219,61,61], [221,62,62], [223,63,63], [225,64,64], [228,64,64], [230,65,65], [232,66,66], [234,67,67], [236,68,68], [238,69,69], [240,70,70], [242,71,71], [244,72,72], [246,72,72], [248,73,73], [250,74,74], [252,75,75], [255,76,76]] + }; + let cmapOptions = ''; + Object.keys(cmaps).forEach(function(c) { + cmapOptions += ''; + }); + const $html = $('
' + + ' Colormap:
' + + ' Center: ' + + '
'); + const cmapUpdate = function() { + const val = $('#cmapSelect').val(); + $('#cmapSelect').change(function() { + updateCallback(val); + }); + return cmaps[val]; + }; + const spinnerSlider = new Spinner({ + $element: $html.find('#cmapCenter'), + init: 128, + min: 1, + sliderMax: 254, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + /*eslint new-cap: 0*/ + return OpenSeadragon.Filters.COLORMAP(cmapUpdate(), spinnerSlider.getValue()); + } + }; + } + }, { + name: 'Colorize', + help: 'The adjustment range (strength) is from 0 to 100.' + + 'The higher the value, the closer the colors in the ' + + 'image shift towards the given adjustment color.' + + 'Color values are between 0 to 255', + generate: function(updateCallback) { + const redSpinnerId = 'redSpinner-' + idIncrement; + const greenSpinnerId = 'greenSpinner-' + idIncrement; + const blueSpinnerId = 'blueSpinner-' + idIncrement; + const strengthSpinnerId = 'strengthSpinner-' + idIncrement; + /*eslint max-len: 0*/ + const $html = $('
' + + '
' + + '
' + + ' Red: ' + + '
' + + '
' + + ' Green: ' + + '
' + + '
' + + ' Blue: ' + + '
' + + '
' + + ' Strength: ' + + '
' + + '
' + + '
'); + const redSpinner = new Spinner({ + $element: $html.find('#' + redSpinnerId), + init: 100, + min: 0, + max: 255, + step: 1, + updateCallback: updateCallback + }); + const greenSpinner = new Spinner({ + $element: $html.find('#' + greenSpinnerId), + init: 20, + min: 0, + max: 255, + step: 1, + updateCallback: updateCallback + }); + const blueSpinner = new Spinner({ + $element: $html.find('#' + blueSpinnerId), + init: 20, + min: 0, + max: 255, + step: 1, + updateCallback: updateCallback + }); + const strengthSpinner = new Spinner({ + $element: $html.find('#' + strengthSpinnerId), + init: 50, + min: 0, + max: 100, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + const red = redSpinner.getValue(); + const green = greenSpinner.getValue(); + const blue = blueSpinner.getValue(); + const strength = strengthSpinner.getValue(); + return 'R: ' + red + ' G: ' + green + ' B: ' + blue + + ' S: ' + strength; + }, + getFilter: function() { + const red = redSpinner.getValue(); + const green = greenSpinner.getValue(); + const blue = blueSpinner.getValue(); + const strength = strengthSpinner.getValue(); + return function(context) { + const promise = getPromiseResolver(); + Caman(context.canvas, function() { + this.colorize(red, green, blue, strength); + this.render(promise.call.back); + }); + return promise.promise; + }; + } + }; + } + }, { + name: 'Contrast', + help: 'Range is from 0 to infinity, although sane values are from 0 ' + + 'to 4 or 5. Values between 0 and 1 will lessen the contrast ' + + 'while values greater than 1 will increase it.', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 1.3, + min: 0, + sliderMax: 4, + step: 0.1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + return OpenSeadragon.Filters.CONTRAST( + spinnerSlider.getValue()); + } + }; + } + }, { + name: 'Exposure', + help: 'Range is -100 to 100. Values < 0 will decrease ' + + 'exposure while values > 0 will increase exposure', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 10, + min: -100, + max: 100, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + const value = spinnerSlider.getValue(); + return function(context) { + const promise = getPromiseResolver(); + Caman(context.canvas, function() { + this.exposure(value); + this.render(promise.call.back); + }); + return promise.promise; + }; + } + }; + } + }, { + name: 'Gamma', + help: 'Range is from 0 to infinity, although sane values ' + + 'are from 0 to 4 or 5. Values between 0 and 1 will ' + + 'lessen the contrast while values greater than 1 will increase it.', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 0.5, + min: 0, + sliderMax: 5, + step: 0.1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + const value = spinnerSlider.getValue(); + return OpenSeadragon.Filters.GAMMA(value); + } + }; + } + }, { + name: 'Hue', + help: 'hue value is between 0 to 100 representing the ' + + 'percentage of Hue shift in the 0 to 360 range', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 20, + min: 0, + max: 100, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + const value = spinnerSlider.getValue(); + return function(context) { + const promise = getPromiseResolver(); + Caman(context.canvas, function() { + this.hue(value); + this.render(promise.call.back); + }); + return promise.promise; + }; + } + }; + } + }, { + name: 'Saturation', + help: 'saturation value has to be between -100 and 100', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 50, + min: -100, + max: 100, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + const value = spinnerSlider.getValue(); + return function(context) { + const promise = getPromiseResolver(); + Caman(context.canvas, function() { + this.saturation(value); + this.render(promise.call.back); + }); + return promise.promise; + }; + } + }; + } + }, { + name: 'Vibrance', + help: 'vibrance value has to be between -100 and 100', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 50, + min: -100, + max: 100, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + const value = spinnerSlider.getValue(); + return function(context) { + const promise = getPromiseResolver(); + Caman(context.canvas, function() { + this.vibrance(value); + this.render(promise.call.back); + }); + return promise.promise; + }; + } + }; + } + }, { + name: 'Sepia', + help: 'sepia value has to be between 0 and 100', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 50, + min: 0, + max: 100, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + const value = spinnerSlider.getValue(); + return function(context) { + const promise = getPromiseResolver(); + Caman(context.canvas, function() { + this.sepia(value); + this.render(promise.call.back); + }); + return promise.promise; + }; + } + }; + } + }, { + name: 'Noise', + help: 'Noise cannot be smaller than 0', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 50, + min: 0, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + const value = spinnerSlider.getValue(); + return function(context) { + const promise = getPromiseResolver(); + Caman(context.canvas, function() { + this.noise(value); + this.render(promise.call.back); + }); + return promise.promise; + }; + } + }; + } + }, { + name: 'Greyscale', + generate: function() { + return { + html: '', + getParams: function() { + return ''; + }, + getFilter: function() { + return OpenSeadragon.Filters.GREYSCALE(); + } + }; + } + }, { + name: 'Sobel Edge', + generate: function() { + return { + html: '', + getParams: function() { + return ''; + }, + getFilter: function() { + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pixels = imgData.data; + const originalPixels = context.getImageData(0, 0, context.canvas.width, context.canvas.height).data; + const oneRowOffset = context.canvas.width * 4; + const onePixelOffset = 4; + let Gy, Gx, idx = 0; + for (let i = 1; i < context.canvas.height - 1; i += 1) { + idx = oneRowOffset * i + 4; + for (let j = 1; j < context.canvas.width - 1; j += 1) { + Gy = originalPixels[idx - onePixelOffset + oneRowOffset] + 2 * originalPixels[idx + oneRowOffset] + originalPixels[idx + onePixelOffset + oneRowOffset]; + Gy = Gy - (originalPixels[idx - onePixelOffset - oneRowOffset] + 2 * originalPixels[idx - oneRowOffset] + originalPixels[idx + onePixelOffset - oneRowOffset]); + Gx = originalPixels[idx + onePixelOffset - oneRowOffset] + 2 * originalPixels[idx + onePixelOffset] + originalPixels[idx + onePixelOffset + oneRowOffset]; + Gx = Gx - (originalPixels[idx - onePixelOffset - oneRowOffset] + 2 * originalPixels[idx - onePixelOffset] + originalPixels[idx - onePixelOffset + oneRowOffset]); + pixels[idx] = Math.sqrt(Gx * Gx + Gy * Gy); // 0.5*Math.abs(Gx) + 0.5*Math.abs(Gy);//100*Math.atan(Gy,Gx); + pixels[idx + 1] = 0; + pixels[idx + 2] = 0; + idx += 4; + } + } + context.putImageData(imgData, 0, 0); + }; + } + }; + } + }, { + name: 'Brightness', + help: 'Brightness must be between -255 (darker) and 255 (brighter).', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 50, + min: -255, + max: 255, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + return OpenSeadragon.Filters.BRIGHTNESS( + spinnerSlider.getValue()); + } + }; + } + }, { + name: 'Erosion', + help: 'The erosion kernel size must be an odd number.', + generate: function(updateCallback) { + const $html = $('
'); + const spinner = new Spinner({ + $element: $html, + init: 3, + min: 3, + step: 2, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinner.getValue(); + }, + getFilter: function() { + return OpenSeadragon.Filters.MORPHOLOGICAL_OPERATION( + spinner.getValue(), Math.min); + } + }; + } + }, { + name: 'Dilation', + help: 'The dilation kernel size must be an odd number.', + generate: function(updateCallback) { + const $html = $('
'); + const spinner = new Spinner({ + $element: $html, + init: 3, + min: 3, + step: 2, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinner.getValue(); + }, + getFilter: function() { + return OpenSeadragon.Filters.MORPHOLOGICAL_OPERATION( + spinner.getValue(), Math.max); + } + }; + } + }, { + name: 'Thresholding', + help: 'The threshold must be between 0 and 255.', + generate: function(updateCallback) { + const $html = $('
'); + const spinnerSlider = new SpinnerSlider({ + $element: $html, + init: 127, + min: 0, + max: 255, + step: 1, + updateCallback: updateCallback + }); + return { + html: $html, + getParams: function() { + return spinnerSlider.getValue(); + }, + getFilter: function() { + return OpenSeadragon.Filters.THRESHOLDING( + spinnerSlider.getValue()); + } + }; + } + }]; +availableFilters.sort(function(f1, f2) { + return f1.name.localeCompare(f2.name); +}); + +let idIncrement = 0; +const hashTable = {}; + +availableFilters.forEach(function(filter) { + const $li = $('
  • '); + const $plus = $('+'); + $li.append($plus); + $li.append(filter.name); + $li.appendTo($('#available')); + $plus.click(function() { + const id = 'selected_' + idIncrement++; + const generatedFilter = filter.generate(updateFilters); + hashTable[id] = { + name: filter.name, + generatedFilter: generatedFilter + }; + const $li = $('
  • '); + const $minus = $('
    -
    '); + $li.find('.wdzt-row-layout').append($minus); + $li.find('.wdzt-row-layout').append('
    ' + filter.name + '
    '); + if (filter.help) { + const $help = $('
     ? 
    '); + $help.tooltip(); + $li.find('.wdzt-row-layout').append($help); + } + $li.find('.wdzt-row-layout').append( + $('
    ') + .append(generatedFilter.html)); + $minus.click(function() { + delete hashTable[id]; + $li.remove(); + updateFilters(); + }); + $li.appendTo($('#selected')); + updateFilters(); + }); +}); + +$('#selected').sortable({ + containment: 'parent', + axis: 'y', + tolerance: 'pointer', + update: updateFilters +}); + +function getPromiseResolver() { + let call = {}; + let promise = new OpenSeadragon.Promise(resolve => { + call.back = resolve; + }); + return {call, promise}; +} + +function updateFilters() { + const filters = []; + $('#selected li').each(function() { + const id = this.id; + const filter = hashTable[id]; + filters.push(filter.generatedFilter.getFilter()); + }); + viewer.setFilterOptions({ + filters: { + processors: filters + } + }); +} + +window.debugCache = function () { + for (let cacheKey in viewer.tileCache._cachesLoaded) { + let cache = viewer.tileCache._cachesLoaded[cacheKey]; + if (!cache.loaded) { + console.log(cacheKey, "skipping..."); + } + if (cache.type === "context2d") { + console.log(cacheKey, cache.data.canvas.width, cache.data.canvas.height); + } else { + console.log(cacheKey, cache.data); + } + } +} + + +// Monitoring of tiles: +let monitoredTile = null; +async function updateCanvas(node, tile, targetCacheKey) { + const data = await tile.getCache(targetCacheKey)?.getDataAs('context2d', true); + if (!data) { + const text = document.createElement("span"); + text.innerHTML = targetCacheKey + "
    empty"; + node.replaceChildren(text); + } else { + node.replaceChildren(data.canvas); + } +} +async function processTile(tile) { + console.log("Selected tile", tile); + await Promise.all([ + updateCanvas(document.getElementById("tile-original"), tile, tile.originalCacheKey), + updateCanvas(document.getElementById("tile-main"), tile, tile.cacheKey), + ]); +} +viewer.addHandler('tile-invalidated', async event => { + if (event.tile === monitoredTile) { + await processTile(monitoredTile); + } +}, null, -Infinity); // as a last handler + +// When testing code, you can call in OSD $.debugTile(message, tile) and it will log only for selected tiles on the canvas +OpenSeadragon.debugTile = function (msg, t) { + if (monitoredTile && monitoredTile.x === t.x && monitoredTile.y === t.y && monitoredTile.level === t.level) { + console.log(msg, t); + } +} + +viewer.addHandler("canvas-release", e => { + const tiledImage = viewer.world.getItemAt(viewer.world.getItemCount()-1); + if (!tiledImage) { + monitoredTile = null; + return; + } + + const position = viewer.viewport.windowToViewportCoordinates(e.position); + + let tiles = tiledImage._lastDrawn; + for (let i = 0; i < tiles.length; i++) { + if (tiles[i].tile.bounds.containsPoint(position)) { + monitoredTile = tiles[i].tile; + return processTile(monitoredTile); + } + } + monitoredTile = null; +}); diff --git a/test/demo/filtering-plugin/index.html b/test/demo/filtering-plugin/index.html new file mode 100644 index 00000000..e8b9632e --- /dev/null +++ b/test/demo/filtering-plugin/index.html @@ -0,0 +1,82 @@ + + + + + + + + OpenSeadragon Filtering Plugin Demo + + + + + + + + + + + + + + + + + +
    +

    OpenSeadragon filtering plugin demo:

    +

    You might want to check the plugin repository to see if the plugin code is up to date.

    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +

    Available filters

    +
      +
    + +

    Selected filters

    +
      + +

      Drag and drop the selected filters to set their order.

      + +
      + +
      +
      +
      +
      + +
      + Monitoring of a tile lifecycle: (use filters and click on a tile to start monitoring) + +
      +
      +
      +
      +
      + + + + diff --git a/test/demo/filtering-plugin/plugin.js b/test/demo/filtering-plugin/plugin.js new file mode 100644 index 00000000..0a5dd27e --- /dev/null +++ b/test/demo/filtering-plugin/plugin.js @@ -0,0 +1,337 @@ +/* + * Modified and maintained by the OpenSeadragon Community. + * + * This software was orignally developed at the National Institute of Standards and + * Technology by employees of the Federal Government. NIST assumes + * no responsibility whatsoever for its use by other parties, and makes no + * guarantees, expressed or implied, about its quality, reliability, or + * any other characteristic. + * @author Antoine Vandecreme + */ + +(function() { + + 'use strict'; + + const $ = window.OpenSeadragon; + if (!$) { + throw new Error('OpenSeadragon is missing.'); + } + + $.Viewer.prototype.setFilterOptions = function(options) { + if (!this.filterPluginInstance) { + options = options || {}; + options.viewer = this; + this.filterPluginInstance = new $.FilterPlugin(options); + } else { + setOptions(this.filterPluginInstance, options); + } + }; + + /** + * @class FilterPlugin + * @param {Object} options The options + * @param {OpenSeadragon.Viewer} options.viewer The viewer to attach this + * plugin to. + * @param {Object[]} options.filters The filters to apply to the images. + * @param {OpenSeadragon.TiledImage[]} options.filters[x].items The tiled images + * on which to apply the filter. + * @param {function|function[]} options.filters[x].processors The processing + * function(s) to apply to the images. The parameter of this function is + * the context to modify. + */ + $.FilterPlugin = function(options) { + options = options || {}; + if (!options.viewer) { + throw new Error('A viewer must be specified.'); + } + const self = this; + this.viewer = options.viewer; + this.viewer.addHandler('tile-invalidated', applyFilters); + + setOptions(this, options); + + async function applyFilters(e) { + const tiledImage = e.tiledImage, + processors = getFiltersProcessors(self, tiledImage); + + if (processors.length === 0) { + return; + } + + const contextCopy = await e.getData('context2d'); + if (!contextCopy) return; + + for (let i = 0; i < processors.length; i++) { + if (e.outdated()) return; + await processors[i](contextCopy); + } + if (e.outdated()) return; + await e.setData(contextCopy, 'context2d'); + } + }; + + function setOptions(instance, options) { + options = options || {}; + const filters = options.filters; + instance.filters = !filters ? [] : + $.isArray(filters) ? filters : [filters]; + for (let i = 0; i < instance.filters.length; i++) { + const filter = instance.filters[i]; + if (!filter.processors) { + throw new Error('Filter processors must be specified.'); + } + filter.processors = $.isArray(filter.processors) ? + filter.processors : [filter.processors]; + } + instance.viewer.requestInvalidate(); + } + + function getFiltersProcessors(instance, item) { + if (instance.filters.length === 0) { + return []; + } + + let globalProcessors = null; + for (let i = 0; i < instance.filters.length; i++) { + const filter = instance.filters[i]; + if (!filter.items) { + globalProcessors = filter.processors; + } else if (filter.items === item || + $.isArray(filter.items) && filter.items.indexOf(item) >= 0) { + return filter.processors; + } + } + return globalProcessors ? globalProcessors : []; + } + + $.Filters = { + THRESHOLDING: function(threshold) { + if (threshold < 0 || threshold > 255) { + throw new Error('Threshold must be between 0 and 255.'); + } + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pixels = imgData.data; + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const v = (r + g + b) / 3; + pixels[i] = pixels[i + 1] = pixels[i + 2] = + v < threshold ? 0 : 255; + } + context.putImageData(imgData, 0, 0); + }; + }, + BRIGHTNESS: function(adjustment) { + if (adjustment < -255 || adjustment > 255) { + throw new Error( + 'Brightness adjustment must be between -255 and 255.'); + } + const precomputedBrightness = []; + for (let i = 0; i < 256; i++) { + precomputedBrightness[i] = i + adjustment; + } + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pixels = imgData.data; + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = precomputedBrightness[pixels[i]]; + pixels[i + 1] = precomputedBrightness[pixels[i + 1]]; + pixels[i + 2] = precomputedBrightness[pixels[i + 2]]; + } + context.putImageData(imgData, 0, 0); + }; + }, + CONTRAST: function(adjustment) { + if (adjustment < 0) { + throw new Error('Contrast adjustment must be positive.'); + } + const precomputedContrast = []; + for (let i = 0; i < 256; i++) { + precomputedContrast[i] = i * adjustment; + } + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pixels = imgData.data; + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = precomputedContrast[pixels[i]]; + pixels[i + 1] = precomputedContrast[pixels[i + 1]]; + pixels[i + 2] = precomputedContrast[pixels[i + 2]]; + } + context.putImageData(imgData, 0, 0); + }; + }, + GAMMA: function(adjustment) { + if (adjustment < 0) { + throw new Error('Gamma adjustment must be positive.'); + } + const precomputedGamma = []; + for (let i = 0; i < 256; i++) { + precomputedGamma[i] = Math.pow(i / 255, adjustment) * 255; + } + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pixels = imgData.data; + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = precomputedGamma[pixels[i]]; + pixels[i + 1] = precomputedGamma[pixels[i + 1]]; + pixels[i + 2] = precomputedGamma[pixels[i + 2]]; + } + context.putImageData(imgData, 0, 0); + }; + }, + GREYSCALE: function() { + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pixels = imgData.data; + for (let i = 0; i < pixels.length; i += 4) { + const val = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3; + pixels[i] = val; + pixels[i + 1] = val; + pixels[i + 2] = val; + } + context.putImageData(imgData, 0, 0); + }; + }, + INVERT: function() { + const precomputedInvert = []; + for (let i = 0; i < 256; i++) { + precomputedInvert[i] = 255 - i; + } + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pixels = imgData.data; + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = precomputedInvert[pixels[i]]; + pixels[i + 1] = precomputedInvert[pixels[i + 1]]; + pixels[i + 2] = precomputedInvert[pixels[i + 2]]; + } + context.putImageData(imgData, 0, 0); + }; + }, + MORPHOLOGICAL_OPERATION: function(kernelSize, comparator) { + if (kernelSize % 2 === 0) { + throw new Error('The kernel size must be an odd number.'); + } + const kernelHalfSize = Math.floor(kernelSize / 2); + + if (!comparator) { + throw new Error('A comparator must be defined.'); + } + + return function(context) { + const width = context.canvas.width; + const height = context.canvas.height; + const imgData = context.getImageData(0, 0, width, height); + const originalPixels = context.getImageData(0, 0, width, height) + .data; + let offset; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + offset = (y * width + x) * 4; + let r = originalPixels[offset], + g = originalPixels[offset + 1], + b = originalPixels[offset + 2]; + for (let j = 0; j < kernelSize; j++) { + for (let i = 0; i < kernelSize; i++) { + const pixelX = x + i - kernelHalfSize; + const pixelY = y + j - kernelHalfSize; + if (pixelX >= 0 && pixelX < width && + pixelY >= 0 && pixelY < height) { + offset = (pixelY * width + pixelX) * 4; + r = comparator(originalPixels[offset], r); + g = comparator( + originalPixels[offset + 1], g); + b = comparator( + originalPixels[offset + 2], b); + } + } + } + imgData.data[offset] = r; + imgData.data[offset + 1] = g; + imgData.data[offset + 2] = b; + } + } + context.putImageData(imgData, 0, 0); + }; + }, + CONVOLUTION: function(kernel) { + if (!$.isArray(kernel)) { + throw new Error('The kernel must be an array.'); + } + const kernelSize = Math.sqrt(kernel.length); + if ((kernelSize + 1) % 2 !== 0) { + throw new Error('The kernel must be a square matrix with odd' + + 'width and height.'); + } + const kernelHalfSize = (kernelSize - 1) / 2; + + return function(context) { + const width = context.canvas.width; + const height = context.canvas.height; + const imgData = context.getImageData(0, 0, width, height); + const originalPixels = context.getImageData(0, 0, width, height) + .data; + let offset; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let r = 0, g = 0, b = 0; + for (let j = 0; j < kernelSize; j++) { + for (let i = 0; i < kernelSize; i++) { + const pixelX = x + i - kernelHalfSize; + const pixelY = y + j - kernelHalfSize; + if (pixelX >= 0 && pixelX < width && + pixelY >= 0 && pixelY < height) { + offset = (pixelY * width + pixelX) * 4; + const weight = kernel[j * kernelSize + i]; + r += originalPixels[offset] * weight; + g += originalPixels[offset + 1] * weight; + b += originalPixels[offset + 2] * weight; + } + } + } + offset = (y * width + x) * 4; + imgData.data[offset] = r; + imgData.data[offset + 1] = g; + imgData.data[offset + 2] = b; + } + } + context.putImageData(imgData, 0, 0); + }; + }, + COLORMAP: function(cmap, ctr) { + const resampledCmap = cmap.slice(0); + const diff = 255 - ctr; + for (let i = 0; i < 256; i++) { + let position = i > ctr ? + Math.min((i - ctr) / diff * 128 + 128,255) | 0 : + Math.max(0, i / (ctr / 128)) | 0; + resampledCmap[i] = cmap[position]; + } + return function(context) { + const imgData = context.getImageData( + 0, 0, context.canvas.width, context.canvas.height); + const pxl = imgData.data; + for (let i = 0; i < pxl.length; i += 4) { + const v = (pxl[i] + pxl[i + 1] + pxl[i + 2]) / 3 | 0; + const c = resampledCmap[v]; + pxl[i] = c[0]; + pxl[i + 1] = c[1]; + pxl[i + 2] = c[2]; + } + context.putImageData(imgData, 0, 0); + }; + } + }; + +}()); diff --git a/test/demo/filtering-plugin/static/minus.png b/test/demo/filtering-plugin/static/minus.png new file mode 100644 index 00000000..2977e59f Binary files /dev/null and b/test/demo/filtering-plugin/static/minus.png differ diff --git a/test/demo/filtering-plugin/static/plus.png b/test/demo/filtering-plugin/static/plus.png new file mode 100644 index 00000000..614754c2 Binary files /dev/null and b/test/demo/filtering-plugin/static/plus.png differ diff --git a/test/demo/filtering-plugin/style.css b/test/demo/filtering-plugin/style.css new file mode 100644 index 00000000..fa66afa5 --- /dev/null +++ b/test/demo/filtering-plugin/style.css @@ -0,0 +1,81 @@ +/* + * Modified and maintained by the OpenSeadragon Community. + * + * This software was orignally developed at the National Institute of Standards and + * Technology by employees of the Federal Government. NIST assumes + * no responsibility whatsoever for its use by other parties, and makes no + * guarantees, expressed or implied, about its quality, reliability, or + * any other characteristic. + */ +.demo { + line-height: normal; +} + +.demo h3 { + margin-top: 5px; + margin-bottom: 5px; +} + +#openseadragon { + width: 100%; + height: 700px; + background-color: black; +} + +.wdzt-table-layout { + display: table; +} + +.wdzt-row-layout { + display: table-row; +} + +.wdzt-cell-layout { + display: table-cell; +} + +.wdzt-full-width { + width: 100%; +} + +.wdzt-menu-slider { + margin-left: 10px; + margin-right: 10px; +} + +.column-2 { + width: 50%; + vertical-align: top; + padding: 3px; +} + +#available { + list-style-type: none; +} + +ul { + padding: 0; + border: 1px solid black; + min-height: 25px; +} + +li { + padding: 3px; +} + +#selected { + list-style-type: none; +} + +.button { + cursor: pointer; + vertical-align: text-top; +} + +.filterLabel { + min-width: 120px; +} + +#selected .filterLabel { + cursor: move; +} diff --git a/test/demo/item-animation.html b/test/demo/item-animation.html index 254458a6..19bcd129 100644 --- a/test/demo/item-animation.html +++ b/test/demo/item-animation.html @@ -59,7 +59,8 @@ this.viewer = OpenSeadragon({ id: "contentDiv", prefixUrl: "../../build/openseadragon/images/", - tileSources: tileSources + tileSources: tileSources, + crossOriginPolicy: 'Anonymous', }); this.viewer.addHandler('open', function() { diff --git a/test/demo/layers.html b/test/demo/layers.html index c4777f55..ce25ff1d 100644 --- a/test/demo/layers.html +++ b/test/demo/layers.html @@ -109,14 +109,14 @@ opacity: getOpacity( layerName ) }; var addLayerHandler = function( event ) { - if ( event.options === options ) { - viewer.removeHandler( "add-layer", addLayerHandler ); - layers[layerName] = event.drawer; + if ( event.item.source.levels[0].url.includes(layerName) ) { + viewer.world.removeHandler( "add-item", addLayerHandler ); + layers[layerName] = event.item; updateOrder(); } }; - viewer.addHandler( "add-layer", addLayerHandler ); - viewer.addLayer( options ); + viewer.world.addHandler( "add-item", addLayerHandler ); + viewer.addTiledImage( options ); } function left() { @@ -146,13 +146,15 @@ } function updateOrder() { - var nbLayers = viewer.getLayersCount(); + var nbLayers = viewer.world.getItemCount(); if ( nbLayers < 2 ) { return; } $.each( $( "#used select option" ), function( index, value ) { var layer = value.innerHTML; - viewer.setLayerLevel( layers[layer], nbLayers -1 - index ); + if (layers[layer]) { + viewer.world.setItemIndex( layers[layer], nbLayers -1 - index ); + } } ); } diff --git a/test/demo/max-tiles-per-frame.html b/test/demo/max-tiles-per-frame.html index 2c2bad9b..6e43203d 100644 --- a/test/demo/max-tiles-per-frame.html +++ b/test/demo/max-tiles-per-frame.html @@ -26,7 +26,7 @@ prefixUrl: "../../build/openseadragon/images/", tileSources: "https://openseadragon.github.io/example-images/duomo/duomo.dzi", showNavigator:true, - debugMode:true, + crossOriginPolicy: 'Anonymous', maxTilesPerFrame:3, }); diff --git a/test/demo/plugin-data-modification-interaction.html b/test/demo/plugin-data-modification-interaction.html new file mode 100644 index 00000000..07682d4b --- /dev/null +++ b/test/demo/plugin-data-modification-interaction.html @@ -0,0 +1,143 @@ + + + + + + + OpenSeadragon Filtering Plugin Demo + + + + + + + + + + + + + + +
      +

      OpenSeadragon plugin demo

      +

      You should see two plugins interacting. You can change the order of plugins and the logics!

      +
      + +
      +
      + +
      + + + + +
      + +
      + + + + diff --git a/test/demo/timeout-certain.html b/test/demo/timeout-certain.html index 9e177500..e5f4bde1 100644 --- a/test/demo/timeout-certain.html +++ b/test/demo/timeout-certain.html @@ -25,8 +25,9 @@ // debugMode: true, id: "contentDiv", prefixUrl: "../../build/openseadragon/images/", - tileSources: "http://wellcomelibrary.org/iiif-img/b11768265-0/a6801943-b8b4-4674-908c-7d5b27e70569/info.json", + tileSources: "https://openseadragon.github.io/example-images/highsmith/highsmith.dzi", showNavigator:true, + crossOriginPolicy: 'Anonymous', timeout: 0 }); diff --git a/test/demo/timeout-unlikely.html b/test/demo/timeout-unlikely.html index 284f4297..dbf46d33 100644 --- a/test/demo/timeout-unlikely.html +++ b/test/demo/timeout-unlikely.html @@ -25,8 +25,9 @@ // debugMode: true, id: "contentDiv", prefixUrl: "../../build/openseadragon/images/", - tileSources: "http://wellcomelibrary.org/iiif-img/b11768265-0/a6801943-b8b4-4674-908c-7d5b27e70569/info.json", + tileSources: "https://openseadragon.github.io/example-images/highsmith/highsmith.dzi", showNavigator:true, + crossOriginPolicy: 'Anonymous', timeout: 1000 * 60 * 60 * 24 }); diff --git a/test/helpers/drawer-switcher.js b/test/helpers/drawer-switcher.js new file mode 100644 index 00000000..6913c0eb --- /dev/null +++ b/test/helpers/drawer-switcher.js @@ -0,0 +1,76 @@ +/** + * Ability to switch between different drawers. + * Usage: with two viewers, we would do + * + * const switcher = new DrawerSwitcher(); + * switcher.addDrawerOption("drawer_left", "Select drawer for the left viewer", "canvas"); + * switcher.addDrawerOption("drawer_right", "Select drawer for the right viewer", "webgl"); + * const viewer1 = window.viewer1 = new OpenSeadragon({ + * id: 'openseadragon', + * ... + * drawer:switcher.activeImplementation("drawer_left"), + * }); + * $("#my-title-for-left-drawer").html(`Viewer using drawer ${switcher.activeName("drawer_left")}`); + * $("#container").html(switcher.render()); + * // OR switcher.render("#container") + * // ..do the same for the second viewer + */ +class DrawerSwitcher { + url = new URL(window.location.href); + drawers = { + canvas: "Context2d drawer (default in OSD <= 4.1.0)", + webgl: "New WebGL drawer" + }; + _data = {} + + addDrawerOption(urlQueryName, title="Select drawer:", defaultDrawerImplementation="canvas") { + const drawer = this.url.searchParams.get(urlQueryName) || defaultDrawerImplementation; + if (!this.drawers[drawer]) throw "Unsupported drawer implementation: " + drawer; + + let context = this._data[urlQueryName] = { + query: urlQueryName, + implementation: drawer, + title: title + }; + } + + activeName(urlQueryName) { + return this.drawers[this.activeImplementation(urlQueryName)]; + } + + activeImplementation(urlQueryName) { + return this._data[urlQueryName].implementation; + } + + _getFormData(useNewline=true) { + return Object.values(this._data).map(ctx => `${ctx.title}  +`).join(useNewline ? "
      " : ""); + } + + _preserveOtherSeachParams() { + let res = [], registered = Object.keys(this._data); + for (let [k, v] of this.url.searchParams.entries()) { + if (!registered.includes(k)) { + res.push(``); + } + } + return res.join('\n'); + } + + render(selector, useNewline=undefined) { + useNewline = typeof useNewline === "boolean" ? useNewline : Object.keys(this._data).length > 1; + const html = `
      +
      + ${this._preserveOtherSeachParams()} + ${this._getFormData()}${useNewline ? "
      ":""} +
      +
      `; + if (selector) $(selector).append(html); + return html; + } +} diff --git a/test/helpers/mocks.js b/test/helpers/mocks.js new file mode 100644 index 00000000..2958fe19 --- /dev/null +++ b/test/helpers/mocks.js @@ -0,0 +1,95 @@ +// Test-wide mocks for more test stability: tests might require calling functions that expect +// presence of certain mock properties. It is better to include maintened mock props than to copy +// over all the place + +window.MockSeadragon = { + /** + * Get mocked tile: loaded state, cutoff such that it is not kept in cache by force, + * level: 1, x: 0, y: 0, all coords: [x0 y0 w0 h0] + * + * Requires TiledImage referece (mock or real) + * @return {OpenSeadragon.Tile} + */ + getTile(url, tiledImage, props={}) { + const dummyRect = new OpenSeadragon.Rect(0, 0, 0, 0, 0); + //default cutoof = 0 --> use level 1 to not to keep caches from unloading (cutoff = navigator data, kept in cache) + const dummyTile = new OpenSeadragon.Tile(1, 0, 0, dummyRect, true, url, + undefined, true, null, dummyRect, null, url); + dummyTile.tiledImage = tiledImage; + //by default set as ready + dummyTile.loaded = true; + dummyTile.loading = false; + //override anything we need + OpenSeadragon.extend(tiledImage, props); + return dummyTile; + }, + + /** + * Get mocked viewer: it has not all props that might be required. If your + * tests fails because they do not find some props on a viewer, add them here. + * + * Requires a drawer reference (mock or real). Automatically created if not provided. + * @return {OpenSeadragon.Viewer} + */ + getViewer(drawer=null, props={}) { + drawer = drawer || this.getDrawer(); + return OpenSeadragon.extend(new class extends OpenSeadragon.EventSource { + forceRedraw () {} + drawer = drawer + tileCache = new OpenSeadragon.TileCache() + }, props); + }, + + /** + * Get mocked viewer: it has not all props that might be required. If your + * tests fails because they do not find some props on a viewer, add them here. + * @return {OpenSeadragon.Viewer} + */ + getDrawer(props={}) { + return OpenSeadragon.extend({ + getType: function () { + return "mock"; + } + }, props); + }, + + /** + * Get mocked tiled image: it has not all props that might be required. If your + * tests fails because they do not find some props on a tiled image, add them here. + * + * Requires viewer reference (mock or real). Automatically created if not provided. + * @return {OpenSeadragon.TiledImage} + */ + getTiledImage(viewer=null, props={}) { + viewer = viewer || this.getViewer(); + return OpenSeadragon.extend({ + viewer: viewer, + source: OpenSeadragon.TileSource.prototype, + redraw: function() {}, + _tileCache: viewer.tileCache + }, props); + }, + + /** + * Get mocked tile source + * @return {OpenSeadragon.TileSource} + */ + getTileSource(props={}) { + return new OpenSeadragon.TileSource(OpenSeadragon.extend({ + width: 1500, + height: 1000, + tileWidth: 200, + tileHeight: 150, + tileOverlap: 0 + }, props)); + }, + + /** + * Get mocked cache record + * @return {OpenSeadragon.CacheRecord} + */ + getCacheRecord(props={}) { + return OpenSeadragon.extend(new OpenSeadragon.CacheRecord(), props); + } +}; + diff --git a/test/helpers/test.js b/test/helpers/test.js index 033c4c4d..dcc6c128 100644 --- a/test/helpers/test.js +++ b/test/helpers/test.js @@ -180,12 +180,45 @@ } }; + // OSD has circular references, if a console log tries to serialize + // certain object, remove these references from a clone (do not delete prop + // on the original object). + // NOTE: this does not work if someone replaces the original class with + // a mock object! Try to mock functions only, or ensure mock objects + // do not hold circular references. + const circularOSDReferences = { + 'Tile': 'tiledImage', + 'CacheRecord': ['_tRef', '_tiles'], + 'World': 'viewer', + 'DrawerBase': ['viewer', 'viewport'], + 'CanvasDrawer': ['viewer', 'viewport'], + 'WebGLDrawer': ['viewer', 'viewport'], + 'TiledImage': ['viewer', '_drawer'], + }; for ( var i in testLog ) { if ( testLog.hasOwnProperty( i ) && testLog[i].push ) { + // Circular reference removal + const osdCircularStructureReplacer = function (key, value) { + for (let ClassType in circularOSDReferences) { + if (value instanceof OpenSeadragon[ClassType]) { + const instance = {}; + Object.assign(instance, value); + + let circProps = circularOSDReferences[ClassType]; + if (!Array.isArray(circProps)) circProps = [circProps]; + for (let prop of circProps) { + instance[prop] = '__circular_reference__'; + } + return instance; + } + } + 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, osdCircularStructureReplacer ) ); // Store as JSON to avoid tedious array-equality tests }; } )( testLog[i] ); @@ -207,5 +240,29 @@ }; OpenSeadragon.console = testConsole; + + OpenSeadragon.getBuiltInDrawersForTest = function() { + const drawers = []; + for (let property in OpenSeadragon) { + const drawer = OpenSeadragon[ property ], + proto = drawer.prototype; + if( proto && + proto instanceof OpenSeadragon.DrawerBase && + $.isFunction( proto.getType )){ + drawers.push(proto.getType.call( drawer )); + } + } + return drawers; + }; + + OpenSeadragon.Viewer.prototype.waitForFinishedJobsForTest = function () { + let finish; + let int = setInterval(() => { + if (this.imageLoader.jobsInProgress < 1) { + finish(); + } + }, 50); + return new OpenSeadragon.Promise((resolve) => finish = resolve); + }; } )(); diff --git a/test/modules/ajax-post-data.js b/test/modules/ajax-post-data.js index a4e16dfb..fc6b072e 100644 --- a/test/modules/ajax-post-data.js +++ b/test/modules/ajax-post-data.js @@ -81,7 +81,7 @@ tileExists: function ( level, x, y ) { return true; - } + }, }); var Loader = function(options) { @@ -97,7 +97,9 @@ OriginalLoader.prototype.addJob.apply(this, [options]); } else { //no ajax means we would wait for invalid image link to load, close - passed - viewer.close(); + setTimeout(() => { + viewer.close(); + }); } } }); @@ -139,7 +141,9 @@ //first AJAX firing is the image info getter, second is the first tile request: can exit ajaxCounter++; if (ajaxCounter > 1) { - viewer.close(); + setTimeout(() => { + viewer.close(); + }); return null; } @@ -184,33 +188,34 @@ }); var failHandler = function (event) { - testPostData(event.postData, "event: 'open-failed'"); - viewer.removeHandler('open-failed', failHandler); - viewer.close(); + ASSERT.ok(false, 'Open-failed shoud not be called. We have custom function of fetching the data that succeeds.'); }; viewer.addHandler('open-failed', failHandler); - var readyHandler = function (event) { - //relies on Tilesource contructor extending itself with options object - testPostData(event.postData, "event: 'ready'"); - viewer.removeHandler('ready', readyHandler); - }; - viewer.addHandler('ready', readyHandler); - - + var openHandlerCalled = false; var openHandler = function(event) { viewer.removeHandler('open', openHandler); - ASSERT.ok(true, 'Open event was sent'); + openHandlerCalled = true; + }; + + var readyHandler = function (event) { + testPostData(event.item.source.getTilePostData(0, 0, 0), "event: 'add-item'"); + viewer.world.removeHandler('add-item', readyHandler); viewer.addHandler('close', closeHandler); - viewer.world.draw(); }; var closeHandler = function(event) { + ASSERT.ok(openHandlerCalled, 'Open event was sent.'); + viewer.removeHandler('close', closeHandler); $('#example').empty(); ASSERT.ok(true, 'Close event was sent'); timeWatcher.done(); }; + + //make sure we call add-item before the system default 0 priority, it fires download on tiles and removes + // which calls internally viewer.close + viewer.world.addHandler('add-item', readyHandler, null, Infinity); viewer.addHandler('open', openHandler); }; diff --git a/test/modules/ajax-tiles.js b/test/modules/ajax-tiles.js index fc0b0545..0b1b21f0 100644 --- a/test/modules/ajax-tiles.js +++ b/test/modules/ajax-tiles.js @@ -49,7 +49,8 @@ loadTilesWithAjax: true, ajaxHeaders: { 'X-Viewer-Header': 'ViewerHeaderValue' - } + }, + callTileLoadedWithCachedData: true }); }, afterEach: function() { diff --git a/test/modules/basic.js b/test/modules/basic.js index bf2fc077..23b0378e 100644 --- a/test/modules/basic.js +++ b/test/modules/basic.js @@ -223,50 +223,50 @@ viewer.open('/test/data/testpattern.dzi'); }); - // TODO: can this be enabled without breaking tests due to lack of short-duration user interaction? - // QUnit.test('FullScreen', function(assert) { - // const done = assert.async(); - // if (!OpenSeadragon.supportsFullScreen) { - // assert.expect(0); - // done(); - // return; - // } + QUnit.test('FullScreen', function(assert) { + 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'); + viewer.addHandler('open', function () { + assert.ok(!OpenSeadragon.isFullScreen(), 'Started out not fullscreen'); - // const checkEnteringPreFullScreen = (event) => { - // viewer.removeHandler('pre-full-screen', checkEnteringPreFullScreen); - // assert.ok(event.fullScreen, 'Switching to fullscreen'); - // assert.ok(!OpenSeadragon.isFullScreen(), 'Not yet fullscreen'); - // }; + const checkEnteringPreFullScreen = (event) => { + viewer.removeHandler('pre-full-screen', checkEnteringPreFullScreen); + assert.ok(event.fullScreen, 'Switching to fullscreen'); + assert.ok(!OpenSeadragon.isFullScreen(), 'Not yet fullscreen'); + }; - // const checkExitingFullScreen = (event) => { - // viewer.removeHandler('full-screen', checkExitingFullScreen); - // assert.ok(!event.fullScreen, 'Disabling fullscreen'); - // assert.ok(!OpenSeadragon.isFullScreen(), 'Fullscreen disabled'); - // done(); - // } + const checkExitingFullScreen = (event) => { + viewer.removeHandler('full-screen', checkExitingFullScreen); + assert.ok(!event.fullScreen, 'Disabling fullscreen'); + assert.ok(!OpenSeadragon.isFullScreen(), 'Fullscreen disabled'); + timeWatcher.done(); + } - // // The 'new' headless mode allows us to enter fullscreen, so verify - // // that we see the correct values returned. We will then close out - // // of fullscreen to check the same values when exiting. - // const checkAcquiredFullScreen = (event) => { - // viewer.removeHandler('full-screen', checkAcquiredFullScreen); - // viewer.addHandler('full-screen', checkExitingFullScreen); - // assert.ok(event.fullScreen, 'Acquired fullscreen'); - // assert.ok(OpenSeadragon.isFullScreen(), 'Fullscreen enabled'); - // viewer.setFullScreen(false); - // }; + // The 'new' headless mode allows us to enter fullscreen, so verify + // that we see the correct values returned. We will then close out + // of fullscreen to check the same values when exiting. + const checkAcquiredFullScreen = (event) => { + viewer.removeHandler('full-screen', checkAcquiredFullScreen); + viewer.addHandler('full-screen', checkExitingFullScreen); + assert.ok(event.fullScreen, 'Acquired fullscreen'); + 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); + }); - // viewer.addHandler('pre-full-screen', checkEnteringPreFullScreen); - // viewer.addHandler('full-screen', checkAcquiredFullScreen); - // viewer.setFullScreen(true); - // }); - - // viewer.open('/test/data/testpattern.dzi'); - // }); + viewer.open('/test/data/testpattern.dzi'); + }); QUnit.test('Close', function(assert) { var done = assert.async(); @@ -332,9 +332,10 @@ } ] } ); viewer.addOnceHandler('tiled-image-drawn', function(event) { - assert.ok(OpenSeadragon.isCanvasTainted(event.tiles[0].getCanvasContext().canvas), - "Canvas should be tainted."); - done(); + event.tiles[0].getCache().getDataAs("context2d", false).then(context => + assert.ok(OpenSeadragon.isCanvasTainted(context.canvas), + "Canvas should be tainted.") + ).then(done); }); } ); @@ -352,9 +353,10 @@ } ] } ); viewer.addOnceHandler('tiled-image-drawn', function(event) { - assert.ok(!OpenSeadragon.isCanvasTainted(event.tiles[0].getCanvasContext().canvas), - "Canvas should not be tainted."); - done(); + event.tiles[0].getCache().getDataAs("context2d", false).then(context => + assert.notOk(OpenSeadragon.isCanvasTainted(context.canvas), + "Canvas should be tainted.") + ).then(done); }); } ); @@ -376,9 +378,10 @@ crossOriginPolicy : false } ); viewer.addOnceHandler('tiled-image-drawn', function(event) { - assert.ok(OpenSeadragon.isCanvasTainted(event.tiles[0].getCanvasContext().canvas), - "Canvas should be tainted."); - done(); + event.tiles[0].getCache().getDataAs("context2d", false).then(context => + assert.ok(OpenSeadragon.isCanvasTainted(context.canvas), + "Canvas should be tainted.") + ).then(done); }); } ); @@ -400,9 +403,10 @@ } } ); viewer.addOnceHandler('tiled-image-drawn', function(event) { - assert.ok(!OpenSeadragon.isCanvasTainted(event.tiles[0].getCanvasContext().canvas), - "Canvas should not be tainted."); - done(); + event.tiles[0].getCache().getDataAs("context2d", false).then(context => + assert.notOk(OpenSeadragon.isCanvasTainted(context.canvas), + "Canvas should be tainted.") + ).then(done); }); } ); diff --git a/test/modules/data-manipulation.js b/test/modules/data-manipulation.js new file mode 100644 index 00000000..fb4a4f5d --- /dev/null +++ b/test/modules/data-manipulation.js @@ -0,0 +1,260 @@ +/* global QUnit, testLog */ + +(function() { + + let viewer; + QUnit.module(`Data Manipulation Across Drawers`, { + beforeEach: function () { + $('
      ').appendTo("#qunit-fixture"); + testLog.reset(); + }, + afterEach: function () { + if (viewer && viewer.close) { + viewer.close(); + } + + viewer = null; + } + }); + + const PROMISE_REF_KEY = Symbol("_private_test_ref"); + + OpenSeadragon.getBuiltInDrawersForTest().forEach(testDrawer); + // If you want to debug a specific drawer, use instead: + // ['webgl'].forEach(testDrawer); + + function getPluginCode(overlayColor = "rgba(0,0,255,0.5)") { + return async function(e) { + const ctx = await e.getData('context2d'); + if (ctx) { + const canvas = ctx.canvas; + ctx.fillStyle = overlayColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + await e.setData(ctx, 'context2d'); + } + }; + } + + function getResetTileDataCode() { + return async function(e) { + e.resetData(); + }; + } + + function getTileDescription(t) { + return `${t.level}/${t.x}-${t.y}`; + } + + + function testDrawer(type) { + + function whiteViewport() { + viewer = OpenSeadragon({ + id: 'example', + prefixUrl: '/build/openseadragon/images/', + maxImageCacheCount: 200, + springStiffness: 100, + drawer: type + }); + + viewer.open({ + width: 24, + height: 24, + tileSize: 24, + minLevel: 1, + + // This is a crucial test feature: all tiles share the same URL, so there are plenty collisions + getTileUrl: (x, y, l) => "", + getTilePostData: () => "", + downloadTileStart: (context) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + canvas.width = context.tile.size.x; + canvas.height = context.tile.size.y; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, context.tile.size.x, context.tile.size.y); + + context.finish(ctx, null, "context2d"); + } + }); + + // Get promise reference to wait for tile ready + viewer.addHandler('tile-loaded', e => { + e.tile[PROMISE_REF_KEY] = e.promise; + }); + } + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + + // we test middle of the canvas, so that we can test both tiles or the output canvas of canvas drawer :) + async function readTileData(tileRef = null) { + // Get some time for viewer to load data + await sleep(50); + // make sure at least one tile loaded + const tile = tileRef || viewer.world.getItemAt(0).getTilesToDraw()[0]; + await tile[PROMISE_REF_KEY]; + // Get some time for viewer to load data + await sleep(50); + + if (type === "canvas") { + //test with the underlying canvas instead + const canvas = viewer.drawer.canvas; + return viewer.drawer.canvas.getContext("2d").getImageData(canvas.width/2, canvas.height/2, 1, 1); + } + + //else incompatible drawer for data getting + const cache = tile.tile.getCache(); + if (!cache || !cache.loaded) return null; + + const ctx = await cache.getDataAs("context2d"); + if (!ctx) return null; + return ctx.getImageData(ctx.canvas.width/2, ctx.canvas.height/2, 1, 1) + } + + QUnit.test(type + ' drawer: basic scenario.', function(assert) { + whiteViewport(); + const done = assert.async(); + const fnA = getPluginCode("rgba(0,0,255,1)"); + const fnB = getPluginCode("rgba(255,0,0,1)"); + + viewer.addHandler('tile-invalidated', fnA); + viewer.addHandler('tile-invalidated', fnB); + + viewer.addHandler('open', async () => { + await viewer.waitForFinishedJobsForTest(); + let data = await readTileData(); + assert.equal(data.data[0], 255); + assert.equal(data.data[1], 0); + assert.equal(data.data[2], 0); + assert.equal(data.data[3], 255); + + // Thorough testing of the cache state + for (let tile of viewer.tileCache._tilesLoaded) { + await tile[PROMISE_REF_KEY]; // to be sure all tiles has finished before checking + + const caches = Object.entries(tile._caches); + assert.equal(caches.length, 2, `Tile ${getTileDescription(tile)} has only two caches - main & original`); + for (let [key, value] of caches) { + assert.ok(value.loaded, `Attached cache '${key}' is ready.`); + assert.notOk(value._destroyed, `Attached cache '${key}' is not destroyed.`); + assert.ok(value._tiles.includes(tile), `Attached cache '${key}' reference is bidirectional.`); + } + } + + done(); + }); + }); + + QUnit.test(type + ' drawer: basic scenario with priorities + events addition.', function(assert) { + whiteViewport(); + const done = assert.async(); + // FNA gets applied last since it has low priority + const fnA = getPluginCode("rgba(0,0,255,1)"); + const fnB = getPluginCode("rgba(255,0,0,1)"); + + viewer.addHandler('tile-invalidated', fnA); + viewer.addHandler('tile-invalidated', fnB, null, 1); + // const promise = viewer.requestInvalidate(); + + viewer.addHandler('open', async () => { + await viewer.waitForFinishedJobsForTest(); + + let data = await readTileData(); + assert.equal(data.data[0], 0); + assert.equal(data.data[1], 0); + assert.equal(data.data[2], 255); + assert.equal(data.data[3], 255); + + // Test swap + viewer.addHandler('tile-invalidated', fnB); + await viewer.requestInvalidate(); + + data = await readTileData(); + // suddenly B is applied since it was added with same priority but later + assert.equal(data.data[0], 255); + assert.equal(data.data[1], 0); + assert.equal(data.data[2], 0); + assert.equal(data.data[3], 255); + + // Now B gets applied last! Red + viewer.addHandler('tile-invalidated', fnB, null, -1); + await viewer.requestInvalidate(); + // no change + data = await readTileData(); + assert.equal(data.data[0], 255); + assert.equal(data.data[1], 0); + assert.equal(data.data[2], 0); + assert.equal(data.data[3], 255); + + // Thorough testing of the cache state + for (let tile of viewer.tileCache._tilesLoaded) { + await tile[PROMISE_REF_KEY]; // to be sure all tiles has finished before checking + + const caches = Object.entries(tile._caches); + assert.equal(caches.length, 2, `Tile ${getTileDescription(tile)} has only two caches - main & original`); + for (let [key, value] of caches) { + assert.ok(value.loaded, `Attached cache '${key}' is ready.`); + assert.notOk(value._destroyed, `Attached cache '${key}' is not destroyed.`); + assert.ok(value._tiles.includes(tile), `Attached cache '${key}' reference is bidirectional.`); + } + } + + done(); + }); + }); + + QUnit.test(type + ' drawer: one calls tile restore.', function(assert) { + whiteViewport(); + + const done = assert.async(); + const fnA = getPluginCode("rgba(0,255,0,1)"); + const fnB = getResetTileDataCode(); + + viewer.addHandler('tile-invalidated', fnA); + viewer.addHandler('tile-invalidated', fnB, null, 1); + // const promise = viewer.requestInvalidate(); + + viewer.addHandler('open', async () => { + await viewer.waitForFinishedJobsForTest(); + + let data = await readTileData(); + assert.equal(data.data[0], 0); + assert.equal(data.data[1], 255); + assert.equal(data.data[2], 0); + assert.equal(data.data[3], 255); + + // Test swap - suddenly B applied since it was added later + viewer.addHandler('tile-invalidated', fnB); + await viewer.requestInvalidate(); + data = await readTileData(); + assert.equal(data.data[0], 255); + assert.equal(data.data[1], 255); + assert.equal(data.data[2], 255); + assert.equal(data.data[3], 255); + + viewer.addHandler('tile-invalidated', fnB, null, -1); + await viewer.requestInvalidate(); + data = await readTileData(); + //Erased! + assert.equal(data.data[0], 255); + assert.equal(data.data[1], 255); + assert.equal(data.data[2], 255); + assert.equal(data.data[3], 255); + + // Thorough testing of the cache state + for (let tile of viewer.tileCache._tilesLoaded) { + await tile[PROMISE_REF_KEY]; // to be sure all tiles has finished before checking + + const caches = Object.entries(tile._caches); + assert.equal(caches.length, 1, `Tile ${getTileDescription(tile)} has only single, original cache`); + for (let [key, value] of caches) { + assert.ok(value.loaded, `Attached cache '${key}' is ready.`); + assert.notOk(value._destroyed, `Attached cache '${key}' is not destroyed.`); + assert.ok(value._tiles.includes(tile), `Attached cache '${key}' reference is bidirectional.`); + } + } + + done(); + }); + }); + } +}()); diff --git a/test/modules/drawer.js b/test/modules/drawer.js index e7b8e8cf..45b3398d 100644 --- a/test/modules/drawer.js +++ b/test/modules/drawer.js @@ -2,8 +2,7 @@ (function() { var viewer; - const drawerTypes = ['webgl','canvas','html']; - drawerTypes.forEach(runDrawerTests); + OpenSeadragon.getBuiltInDrawersForTest().forEach(runDrawerTests); function runDrawerTests(drawerType){ diff --git a/test/modules/event-source.js b/test/modules/event-source.js index 2f61e5ee..b1e622e8 100644 --- a/test/modules/event-source.js +++ b/test/modules/event-source.js @@ -33,10 +33,14 @@ } } - function runTest(e) { + function runTest(e, async=false) { context.raiseEvent(eName, e); } + function runTestAwaiting(e, async=false) { + context.raiseEventAwaiting(eName, e); + } + QUnit.module( 'EventSource', { beforeEach: function () { context = new OpenSeadragon.EventSource(); @@ -82,4 +86,58 @@ message: 'Prioritized callback order should follow [2,1,4,5,3].' }); }); + + QUnit.test('EventSource: async non-synchronized order', function(assert) { + context.addHandler(eName, executor(1, 5)); + context.addHandler(eName, executor(2, 50)); + context.addHandler(eName, executor(3)); + context.addHandler(eName, executor(4)); + runTest({ + assert: assert, + done: assert.async(), + expected: [3, 4, 1, 2], + message: 'Async callback order should follow [3,4,1,2].' + }); + }); + + QUnit.test('EventSource: async non-synchronized priority order', function(assert) { + context.addHandler(eName, executor(1, 5)); + context.addHandler(eName, executor(2, 50), undefined, -100); + context.addHandler(eName, executor(3), undefined, -500); + context.addHandler(eName, executor(4), undefined, 675); + runTest({ + assert: assert, + done: assert.async(), + expected: [4, 3, 1, 2], + message: 'Async callback order with priority should follow [4,3,1,2]. Async functions do not respect priority.' + }); + }); + + QUnit.test('EventSource: async synchronized order', function(assert) { + context.addHandler(eName, executor(1, 5)); + context.addHandler(eName, executor(2, 50)); + context.addHandler(eName, executor(3)); + context.addHandler(eName, executor(4)); + runTestAwaiting({ + waitForPromiseHandlers: true, + assert: assert, + done: assert.async(), + expected: [1, 2, 3, 4], + message: 'Async callback order should follow [1,2,3,4], since it is synchronized.' + }); + }); + + QUnit.test('EventSource: async synchronized priority order', function(assert) { + context.addHandler(eName, executor(1, 5)); + context.addHandler(eName, executor(2), undefined, -500); + context.addHandler(eName, executor(3, 50), undefined, -200); + context.addHandler(eName, executor(4), undefined, 675); + runTestAwaiting({ + waitForPromiseHandlers: true, + assert: assert, + done: assert.async(), + expected: [4, 1, 3, 2], + message: 'Async callback order with priority should follow [4,1,3,2], since priority is respected when synchronized.' + }); + }); } )(); diff --git a/test/modules/events.js b/test/modules/events.js index 8075869d..2c26c0f5 100644 --- a/test/modules/events.js +++ b/test/modules/events.js @@ -2,6 +2,7 @@ (function () { var viewer; + var sleep = time => new Promise(res => setTimeout(res, time)); QUnit.module( 'Events', { beforeEach: function () { @@ -1222,11 +1223,12 @@ var tile = event.tile; assert.ok( tile.loading, "The tile should be marked as loading."); assert.notOk( tile.loaded, "The tile should not be marked as loaded."); - setTimeout(function() { + //make sure we require tile loaded status once the data is ready + event.promise.then(function() { assert.notOk( tile.loading, "The tile should not be marked as loading."); assert.ok( tile.loaded, "The tile should be marked as loaded."); done(); - }, 0); + }); } viewer.addHandler( 'tile-loaded', tileLoaded); @@ -1238,72 +1240,85 @@ function tileLoaded ( event ) { viewer.removeHandler( 'tile-loaded', tileLoaded); var tile = event.tile; - var callback = event.getCompletionCallback(); assert.ok( tile.loading, "The tile should be marked as loading."); assert.notOk( tile.loaded, "The tile should not be marked as loaded."); - assert.ok( callback, "The event should have a callback."); - setTimeout(function() { - assert.ok( tile.loading, "The tile should be marked as loading."); - assert.notOk( tile.loaded, "The tile should not be marked as loaded."); - callback(); + event.promise.then( _ => { assert.notOk( tile.loading, "The tile should not be marked as loading."); assert.ok( tile.loaded, "The tile should be marked as loaded."); done(); - }, 0); + }); } viewer.addHandler( 'tile-loaded', tileLoaded); viewer.open( '/test/data/testpattern.dzi' ); } ); - QUnit.test( 'Viewer: tile-loaded event with 2 callbacks.', function (assert) { - var done = assert.async(); - function tileLoaded ( event ) { - viewer.removeHandler( 'tile-loaded', tileLoaded); - var tile = event.tile; - var callback1 = event.getCompletionCallback(); - var callback2 = event.getCompletionCallback(); + QUnit.test( 'Viewer: asynchronous tile processing.', function (assert) { + var done = assert.async(), + handledOnce = false; + + const tileLoaded1 = async (event) => { + assert.ok( handledOnce, "tileLoaded1 with priority 5 should be called second."); + const tile = event.tile; + handledOnce = true; assert.ok( tile.loading, "The tile should be marked as loading."); assert.notOk( tile.loaded, "The tile should not be marked as loaded."); - setTimeout(function() { - assert.ok( tile.loading, "The tile should be marked as loading."); - assert.notOk( tile.loaded, "The tile should not be marked as loaded."); - callback1(); - assert.ok( tile.loading, "The tile should be marked as loading."); - assert.notOk( tile.loaded, "The tile should not be marked as loaded."); - setTimeout(function() { - assert.ok( tile.loading, "The tile should be marked as loading."); - assert.notOk( tile.loaded, "The tile should not be marked as loaded."); - callback2(); - assert.notOk( tile.loading, "The tile should not be marked as loading."); - assert.ok( tile.loaded, "The tile should be marked as loaded."); - done(); - }, 0); - }, 0); - } - viewer.addHandler( 'tile-loaded', tileLoaded); + event.promise.then(() => { + assert.notOk( tile.loading, "The tile should not be marked as loading."); + assert.ok( tile.loaded, "The tile should be marked as loaded."); + done(); + done = null; + }); + await sleep(10); + }; + const tileLoaded2 = async (event) => { + assert.notOk( handledOnce, "TileLoaded2 with priority 10 should be called first."); + const tile = event.tile; + + //remove handlers immediatelly, processing is async -> removing in the second function could + //get after a different tile gets processed + viewer.removeHandler( 'tile-loaded', tileLoaded1); + viewer.removeHandler( 'tile-loaded', tileLoaded2); + + handledOnce = true; + assert.ok( tile.loading, "The tile should be marked as loading."); + assert.notOk( tile.loaded, "The tile should not be marked as loaded."); + + event.promise.then(() => { + assert.notOk( tile.loading, "The tile should not be marked as loading."); + assert.ok( tile.loaded, "The tile should be marked as loaded."); + }); + await sleep(30); + }; + + //first will get called tileLoaded2 although registered later + viewer.addHandler( 'tile-loaded', tileLoaded1, null, 5); + viewer.addHandler( 'tile-loaded', tileLoaded2, null, 10); viewer.open( '/test/data/testpattern.dzi' ); } ); QUnit.test( 'Viewer: tile-unloaded event.', function(assert) { var tiledImage; - var tile; + var tiles = []; var done = assert.async(); function tileLoaded( event ) { - viewer.removeHandler( 'tile-loaded', tileLoaded); tiledImage = event.tiledImage; - tile = event.tile; - setTimeout(function() { - tiledImage.reset(); - }, 0); + tiles.push(event.tile); + if (tiles.length === 1) { + setTimeout(function() { + tiledImage.reset(); + }, 0); + } } function tileUnloaded( event ) { + viewer.removeHandler( 'tile-loaded', tileLoaded); viewer.removeHandler( 'tile-unloaded', tileUnloaded ); - assert.equal( tile, event.tile, - "The unloaded tile should be the same than the loaded one." ); + + assert.equal( tiles.find(t => t === event.tile), event.tile, + "The unloaded tile should be one of the loaded tiles." ); assert.equal( tiledImage, event.tiledImage, "The tiledImage of the unloaded tile should be the same than the one of the loaded one." ); done(); diff --git a/test/modules/formats.js b/test/modules/formats.js index 6b5c7fb6..bd851b92 100644 --- a/test/modules/formats.js +++ b/test/modules/formats.js @@ -29,7 +29,7 @@ }; var testOpen = function(tileSource, assert) { - var timeWatcher = Util.timeWatcher(assert, 7000); + const done = assert.async(); viewer = OpenSeadragon({ id: 'example', @@ -56,7 +56,7 @@ viewer.removeHandler('close', closeHandler); $('#example').empty(); assert.ok(true, 'Close event was sent'); - timeWatcher.done(); + done(); }; viewer.addHandler('open', openHandler); }; diff --git a/test/modules/multi-image.js b/test/modules/multi-image.js index 7edf894f..27dc92a4 100644 --- a/test/modules/multi-image.js +++ b/test/modules/multi-image.js @@ -201,7 +201,6 @@ var done = assert.async(); viewer.addHandler("open", function openHandler() { viewer.removeHandler("open", openHandler); - viewer.world.addHandler('add-item', function itemAdded(event) { viewer.world.removeHandler('add-item', itemAdded); assert.equal(event.item.opacity, 0.5, @@ -221,17 +220,23 @@ var done = assert.async(); viewer.open('/test/data/testpattern.dzi'); - var density = OpenSeadragon.pixelDensityRatio; + function getPixelFromViewerScreenCoords(x, y) { + const density = OpenSeadragon.pixelDensityRatio; + const imageData = viewer.drawer.context.getImageData(x * density, y * density, 1, 1); + return { + r: imageData.data[0], + g: imageData.data[1], + b: imageData.data[2], + a: imageData.data[3] + }; + } viewer.addHandler('open', function() { var firstImage = viewer.world.getItemAt(0); firstImage.addHandler('fully-loaded-change', function() { viewer.addOnceHandler('update-viewport', function(){ - var imageData = viewer.drawer.context.getImageData(0, 0, - 500 * density, 500 * density); - // Pixel 250,250 will be in the hole of the A - var expectedVal = getPixelValue(imageData, 250 * density, 250 * density); + var expectedVal = getPixelFromViewerScreenCoords(250, 250); assert.notEqual(expectedVal.r, 0, 'Red channel should not be 0'); assert.notEqual(expectedVal.g, 0, 'Green channel should not be 0'); @@ -242,10 +247,9 @@ url: '/test/data/A.png', success: function() { var secondImage = viewer.world.getItemAt(1); - secondImage.addHandler('fully-loaded-change', function() { - viewer.addOnceHandler('update-viewport',function(){ - var imageData = viewer.drawer.context.getImageData(0, 0, 500 * density, 500 * density); - var actualVal = getPixelValue(imageData, 250 * density, 250 * density); + secondImage.addHandler('fully-loaded-change', function() { + viewer.addOnceHandler('update-viewport', function(){ + var actualVal = getPixelFromViewerScreenCoords(250, 250); assert.equal(actualVal.r, expectedVal.r, 'Red channel should not change in transparent part of the A'); @@ -256,10 +260,10 @@ 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'); + var onAVal = getPixelFromViewerScreenCoords(333 , 250); + assert.equal(onAVal.r, 0, 'Red channel should be 0 on the A'); + assert.equal(onAVal.g, 0, 'Green channel should be 0 on the A'); + assert.equal(onAVal.b, 0, 'Blue channel should be 0 on the A'); assert.equal(onAVal.a, 255, 'Alpha channel should be 255 on the A'); done(); @@ -272,17 +276,6 @@ }); }); }); - - function getPixelValue(imageData, x, y) { - var offset = 4 * (y * imageData.width + x); - return { - r: imageData.data[offset], - g: imageData.data[offset + 1], - b: imageData.data[offset + 2], - a: imageData.data[offset + 3] - }; - } }); } - })(); diff --git a/test/modules/tilecache.js b/test/modules/tilecache.js index 26ee51ba..accbdec5 100644 --- a/test/modules/tilecache.js +++ b/test/modules/tilecache.js @@ -1,61 +1,250 @@ /* global QUnit, testLog */ (function() { + const Convertor = OpenSeadragon.convertor, + T_A = "__TEST__typeA", T_B = "__TEST__typeB", T_C = "__TEST__typeC", T_D = "__TEST__typeD", T_E = "__TEST__typeE"; + + let viewer; + + //we override jobs: remember original function + const originalJob = OpenSeadragon.ImageLoader.prototype.addJob; + + //event awaiting + function waitFor(predicate) { + const time = setInterval(() => { + if (predicate()) { + clearInterval(time); + } + }, 20); + } + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + // Replace conversion with our own system and test: __TEST__ prefix must be used, otherwise + // other tests will interfere + let typeAtoB = 0, typeBtoC = 0, typeCtoA = 0, typeDtoA = 0, typeCtoE = 0; + //set all same costs to get easy testing, know which path will be taken + Convertor.learn(T_A, T_B, (tile, x) => { + typeAtoB++; + return x+1; + }); + // Costly conversion to C simulation + Convertor.learn(T_B, T_C, async (tile, x) => { + typeBtoC++; + await sleep(5); + return x+1; + }); + Convertor.learn(T_C, T_A, (tile, x) => { + typeCtoA++; + return x+1; + }); + Convertor.learn(T_D, T_A, (tile, x) => { + typeDtoA++; + return x+1; + }); + Convertor.learn(T_C, T_E, (tile, x) => { + typeCtoE++; + return x+1; + }); + //'Copy constructors' + let copyA = 0, copyB = 0, copyC = 0, copyD = 0, copyE = 0; + //also learn destructors + Convertor.learn(T_A, T_A,(tile, x) => { + copyA++; + return x+1; + }); + Convertor.learn(T_B, T_B,(tile, x) => { + copyB++; + return x+1; + }); + Convertor.learn(T_C, T_C,(tile, x) => { + copyC++; + return x-1; + }); + Convertor.learn(T_D, T_D,(tile, x) => { + copyD++; + return x+1; + }); + Convertor.learn(T_E, T_E,(tile, x) => { + copyE++; + return x+1; + }); + let destroyA = 0, destroyB = 0, destroyC = 0, destroyD = 0, destroyE = 0; + //also learn destructors + Convertor.learnDestroy(T_A, () => { + destroyA++; + }); + Convertor.learnDestroy(T_B, () => { + destroyB++; + }); + Convertor.learnDestroy(T_C, () => { + destroyC++; + }); + Convertor.learnDestroy(T_D, () => { + destroyD++; + }); + Convertor.learnDestroy(T_E, () => { + destroyE++; + }); + + OpenSeadragon.TestCacheDrawer = class extends OpenSeadragon.DrawerBase { + constructor(opts) { + super(opts); + this.testEvents = new OpenSeadragon.EventSource(); + } + + getType() { + return "test-cache-drawer"; + } + + // Make test use private cache + get defaultOptions() { + return { + usePrivateCache: true + }; + } + + getSupportedDataFormats() { + return [T_C, T_E]; + } + + static isSupported() { + return true; + } + + _createDrawingElement() { + return document.createElement("div"); + } + + draw(tiledImages) { + for (let image of tiledImages) { + const tilesDoDraw = image.getTilesToDraw().map(info => info.tile); + for (let tile of tilesDoDraw) { + const data = this.getDataToDraw(tile); + this.testEvents.raiseEvent('test-tile', { + tile: tile, + dataToDraw: data, + }); + } + } + } + + canRotate() { + return true; + } + + destroy() { + //noop + } + + setImageSmoothingEnabled(imageSmoothingEnabled){ + //noop + } + + drawDebuggingRect(rect) { + //noop + } + + clear(){ + //noop + } + } + + OpenSeadragon.EmptyTestT_ATileSource = class extends OpenSeadragon.TileSource { + + supports( data, url ){ + return data && data.isTestSource; + } + + configure( data, url, postData ){ + return { + width: 512, /* width *required */ + height: 512, /* height *required */ + tileSize: 128, /* tileSize *required */ + tileOverlap: 0, /* tileOverlap *required */ + minLevel: 0, /* minLevel */ + maxLevel: 3, /* maxLevel */ + tilesUrl: "", /* tilesUrl */ + fileFormat: "", /* fileFormat */ + displayRects: null /* displayRects */ + } + } + + getTileUrl(level, x, y) { + return String(level); //treat each tile on level same to introduce cache overlaps + } + + downloadTileStart(context) { + context.finish(0, null, T_A); + } + } // ---------- QUnit.module('TileCache', { beforeEach: function () { + $('
      ').appendTo("#qunit-fixture"); + testLog.reset(); + + viewer = OpenSeadragon({ + id: 'example', + prefixUrl: '/build/openseadragon/images/', + maxImageCacheCount: 200, //should be enough to fit test inside the cache + springStiffness: 100, // Faster animation = faster tests + drawer: 'test-cache-drawer', + }); + OpenSeadragon.ImageLoader.prototype.addJob = originalJob; + + // Reset counters + typeAtoB = 0, typeBtoC = 0, typeCtoA = 0, typeDtoA = 0, typeCtoE = 0; + copyA = 0, copyB = 0, copyC = 0, copyD = 0, copyE = 0; + destroyA = 0, destroyB = 0, destroyC = 0, destroyD = 0, destroyE = 0; }, afterEach: function () { + if (viewer && viewer.close) { + viewer.close(); + } + + viewer = null; } }); // ---------- - // TODO: this used to be async QUnit.test('basics', function(assert) { - var done = assert.async(); - var fakeViewer = { - raiseEvent: function() {} - }; - var fakeTiledImage0 = { - viewer: fakeViewer, - source: OpenSeadragon.TileSource.prototype - }; - var fakeTiledImage1 = { - viewer: fakeViewer, - source: OpenSeadragon.TileSource.prototype - }; + const done = assert.async(); + const fakeViewer = MockSeadragon.getViewer( + MockSeadragon.getDrawer({ + // tile in safe mode inspects the supported formats upon cache set + getSupportedDataFormats() { + return [T_A, T_B, T_C, T_D, T_E]; + } + }) + ); + const fakeTiledImage0 = MockSeadragon.getTiledImage(fakeViewer); + const fakeTiledImage1 = MockSeadragon.getTiledImage(fakeViewer); - var fakeTile0 = { - url: 'foo.jpg', - cacheKey: 'foo.jpg', - image: {}, - unload: function() {} - }; + const tile0 = MockSeadragon.getTile('foo.jpg', fakeTiledImage0); + const tile1 = MockSeadragon.getTile('foo.jpg', fakeTiledImage1); - var fakeTile1 = { - url: 'foo.jpg', - cacheKey: 'foo.jpg', - image: {}, - unload: function() {} - }; - - var cache = new OpenSeadragon.TileCache(); + const cache = new OpenSeadragon.TileCache(); assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with'); - cache.cacheTile({ - tile: fakeTile0, - tiledImage: fakeTiledImage0 + tile0._caches[tile0.cacheKey] = cache.cacheTile({ + tile: tile0, + tiledImage: fakeTiledImage0, + data: 3, + dataType: T_A }); + tile0._cacheSize++; assert.equal(cache.numTilesLoaded(), 1, 'tile count after cache'); - cache.cacheTile({ - tile: fakeTile1, - tiledImage: fakeTiledImage1 + tile1._caches[tile1.cacheKey] = cache.cacheTile({ + tile: tile1, + tiledImage: fakeTiledImage1, + data: 55, + dataType: T_B }); - + tile1._cacheSize++; assert.equal(cache.numTilesLoaded(), 2, 'tile count after second cache'); cache.clearTilesFor(fakeTiledImage0); @@ -71,64 +260,520 @@ // ---------- QUnit.test('maxImageCacheCount', function(assert) { - var done = assert.async(); - var fakeViewer = { - raiseEvent: function() {} - }; - var fakeTiledImage0 = { - viewer: fakeViewer, - source: OpenSeadragon.TileSource.prototype - }; + const done = assert.async(); + const fakeViewer = MockSeadragon.getViewer( + MockSeadragon.getDrawer({ + // tile in safe mode inspects the supported formats upon cache set + getSupportedDataFormats() { + return [T_A, T_B, T_C, T_D, T_E]; + } + }) + ); + const fakeTiledImage0 = MockSeadragon.getTiledImage(fakeViewer); + const tile0 = MockSeadragon.getTile('different.jpg', fakeTiledImage0); + const tile1 = MockSeadragon.getTile('same.jpg', fakeTiledImage0); + const tile2 = MockSeadragon.getTile('same.jpg', fakeTiledImage0); - var fakeTile0 = { - url: 'different.jpg', - cacheKey: 'different.jpg', - image: {}, - unload: function() {} - }; - - var fakeTile1 = { - url: 'same.jpg', - cacheKey: 'same.jpg', - image: {}, - unload: function() {} - }; - - var fakeTile2 = { - url: 'same.jpg', - cacheKey: 'same.jpg', - image: {}, - unload: function() {} - }; - - var cache = new OpenSeadragon.TileCache({ + const cache = new OpenSeadragon.TileCache({ maxImageCacheCount: 1 }); assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with'); - cache.cacheTile({ - tile: fakeTile0, - tiledImage: fakeTiledImage0 + tile0._caches[tile0.cacheKey] = cache.cacheTile({ + tile: tile0, + tiledImage: fakeTiledImage0, + data: 55, + dataType: T_B }); + tile0._cacheSize++; assert.equal(cache.numTilesLoaded(), 1, 'tile count after add'); - cache.cacheTile({ - tile: fakeTile1, - tiledImage: fakeTiledImage0 + tile1._caches[tile1.cacheKey] = cache.cacheTile({ + tile: tile1, + tiledImage: fakeTiledImage0, + data: 55, + dataType: T_B }); + tile1._cacheSize++; assert.equal(cache.numTilesLoaded(), 1, 'tile count after add of second image'); - cache.cacheTile({ - tile: fakeTile2, - tiledImage: fakeTiledImage0 + tile2._caches[tile2.cacheKey] = cache.cacheTile({ + tile: tile2, + tiledImage: fakeTiledImage0, + data: 55, + dataType: T_B }); + tile2._cacheSize++; assert.equal(cache.numTilesLoaded(), 2, 'tile count after additional same image'); done(); }); + //Tile API and cache interaction + QUnit.test('Tile: basic rendering & test setup', function(test) { + const done = test.async(); + + const tileCache = viewer.tileCache; + const drawer = viewer.drawer; + + let testTileCalled = false; + drawer.testEvents.addHandler('test-tile', e => { + testTileCalled = true; + test.ok(e.dataToDraw, "Tile data is ready to be drawn"); + }); + + viewer.addHandler('open', async () => { + await viewer.waitForFinishedJobsForTest(); + await sleep(1); // necessary to make space for a draw call + + test.ok(viewer.world.getItemAt(0).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A."); + test.ok(viewer.world.getItemAt(1).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A."); + test.ok(testTileCalled, "Drawer tested at least one tile."); + + test.ok(typeAtoB > 1, "At least one conversion was triggered."); + test.equal(typeAtoB, typeBtoC, "A->B = B->C, since we need to move all data to T_C for the drawer."); + + for (let tile of tileCache._tilesLoaded) { + const cache = tile.getCache(); + test.equal(cache.type, T_A, "Cache data was not affected, the drawer uses internal cache."); + + const internalCache = cache.getDataForRendering(drawer, tile); + test.equal(internalCache.type, T_C, "Conversion A->C ready, since there is no way to get to T_E."); + test.ok(internalCache.loaded, "Internal cache ready."); + } + + done(); + }); + viewer.open([ + {isTestSource: true}, + {isTestSource: true}, + ]); + }); + + QUnit.test('Tile & Invalidation API: basic conversion & preprocessing', function(test) { + const done = test.async(); + + const tileCache = viewer.tileCache; + const drawer = viewer.drawer; + + let testTileCalled = false; + + let _currentTestVal = undefined; + let previousTestValue = undefined; + drawer.testEvents.addHandler('test-tile', e => { + test.ok(e.dataToDraw, "Tile data is ready to be drawn"); + if (_currentTestVal !== undefined) { + testTileCalled = true; + test.equal(e.dataToDraw, _currentTestVal, "Value is correct on the drawn data."); + } + }); + + function testDrawingRoutine(value) { + _currentTestVal = value; + viewer.world.needsDraw(); + viewer.world.draw(); + previousTestValue = value; + _currentTestVal = undefined; + } + + viewer.addHandler('open', async () => { + await viewer.waitForFinishedJobsForTest(); + await sleep(1); // necessary to make space for a draw call + + // Test simple data set -> creates main cache + + let testHandler = async e => { + // data comes in as T_A + test.equal(typeDtoA, 0, "No conversion needed to get type A."); + test.equal(typeCtoA, 0, "No conversion needed to get type A."); + + const data = await e.getData(T_A); + test.equal(data, 1, "Copy: creation of a working cache."); + e.tile.__TEST_PROCESSED = true; + + // Test value 2 since we set T_C no need to convert + await e.setData(2, T_C); + test.notOk(e.outdated(), "Event is still valid."); + }; + + viewer.addHandler('tile-invalidated', testHandler); + await viewer.world.requestInvalidate(true); + await sleep(1); // necessary to make space for internal updates + testDrawingRoutine(2); + + //test for each level only single cache was processed + const processedLevels = {}; + for (let tile of tileCache._tilesLoaded) { + const level = tile.level; + + if (tile.__TEST_PROCESSED) { + test.ok(!processedLevels[level], "Only single tile processed per level."); + processedLevels[level] = true; + delete tile.__TEST_PROCESSED; + } + + const origCache = tile.getCache(tile.originalCacheKey); + test.equal(origCache.type, T_A, "Original cache data was not affected, the drawer uses internal cache."); + test.equal(origCache.data, 0, "Original cache data was not affected, the drawer uses internal cache."); + + const cache = tile.getCache(); + test.equal(cache.type, T_C, "Main Cache Updated (suite 1)"); + test.equal(cache.data, previousTestValue, "Main Cache Updated (suite 1)"); + + const internalCache = cache.getDataForRendering(drawer, tile); + test.equal(T_C, internalCache.type, "Conversion A->C ready, since there is no way to get to T_E."); + test.ok(internalCache.loaded, "Internal cache ready."); + } + + // Test that basic scenario with reset data false starts from the main cache data of previous round + const modificationConstant = 50; + viewer.removeHandler('tile-invalidated', testHandler); + testHandler = async e => { + const data = await e.getData(T_B); + test.equal(data, previousTestValue + 2, "C -> A -> B conversion happened."); + await e.setData(data + modificationConstant, T_B); + console.log(data + modificationConstant); + test.notOk(e.outdated(), "Event is still valid."); + }; + console.log(previousTestValue, modificationConstant) + + viewer.addHandler('tile-invalidated', testHandler); + await viewer.world.requestInvalidate(false); + await sleep(1); // necessary to make space for a draw call + // We set data as TB - there is T_C -> T_A -> T_B -> T_C conversion round + let newValue = previousTestValue + modificationConstant + 3; + testDrawingRoutine(newValue); + + newValue--; // intenrla cache performed +1 conversion, but here we have main cache with one step less + for (let tile of tileCache._tilesLoaded) { + const cache = tile.getCache(); + test.equal(cache.type, T_B, "Main Cache Updated (suite 2)."); + test.equal(cache.data, newValue, "Main Cache Updated (suite 2)."); + } + + // Now test whether data reset works, value 1 -> copy perfomed due to internal cache cration + viewer.removeHandler('tile-invalidated', testHandler); + testHandler = async e => { + const data = await e.getData(T_B); + test.equal(data, 1, "Copy: creation of a working cache."); + await e.setData(-8, T_E); + e.resetData(); + }; + viewer.addHandler('tile-invalidated', testHandler); + await viewer.world.requestInvalidate(true); + await sleep(1); // necessary to make space for a draw call + testDrawingRoutine(2); // Value +2 rendering from original data + + for (let tile of tileCache._tilesLoaded) { + const origCache = tile.getCache(tile.originalCacheKey); + test.ok(tile.getCache() === origCache, "Main cache is now original cache."); + } + + // Now force main cache creation that differs + viewer.removeHandler('tile-invalidated', testHandler); + testHandler = async e => { + await e.setData(41, T_B); + }; + viewer.addHandler('tile-invalidated', testHandler); + await viewer.world.requestInvalidate(true); + + // Now test whether data reset works, even with non-original data + viewer.removeHandler('tile-invalidated', testHandler); + testHandler = async e => { + const data = await e.getData(T_B); + test.equal(data, 42, "Copy: 41 + 1."); + await e.setData(data, T_E); + e.resetData(); + }; + viewer.addHandler('tile-invalidated', testHandler); + await viewer.world.requestInvalidate(false); + await sleep(1); // necessary to make space for a draw call + testDrawingRoutine(42); + + for (let tile of tileCache._tilesLoaded) { + const origCache = tile.getCache(tile.originalCacheKey); + test.equal(origCache.type, T_A, "Original cache data was not affected, the drawer uses main cache even after refresh."); + test.equal(origCache.data, 0, "Original cache data was not affected, the drawer uses main cache even after refresh."); + } + + test.ok(testTileCalled, "Drawer tested at least one tile."); + done(); + }); + viewer.open([ + {isTestSource: true}, + {isTestSource: true}, + ]); + }); + + //Tile API and cache interaction + QUnit.test('Tile API Cache Interaction', function(test) { + const done = test.async(); + const fakeViewer = MockSeadragon.getViewer( + MockSeadragon.getDrawer({ + // tile in safe mode inspects the supported formats upon cache set + getSupportedDataFormats() { + return [T_A, T_B, T_C, T_D, T_E]; + } + }) + ); + const tileCache = fakeViewer.tileCache; + const fakeTiledImage0 = MockSeadragon.getTiledImage(fakeViewer); + const fakeTiledImage1 = MockSeadragon.getTiledImage(fakeViewer); + + //load data + const tile00 = MockSeadragon.getTile('foo.jpg', fakeTiledImage0); + tile00.addCache(tile00.cacheKey, 0, T_A, false, false); + const tile01 = MockSeadragon.getTile('foo2.jpg', fakeTiledImage0); + tile01.addCache(tile01.cacheKey, 0, T_B, false, false); + const tile10 = MockSeadragon.getTile('foo3.jpg', fakeTiledImage1); + tile10.addCache(tile10.cacheKey, 0, T_C, false, false); + const tile11 = MockSeadragon.getTile('foo3.jpg', fakeTiledImage1); + tile11.addCache(tile11.cacheKey, 0, T_C, false, false); + const tile12 = MockSeadragon.getTile('foo.jpg', fakeTiledImage1); + tile12.addCache(tile12.cacheKey, 0, T_A, false, false); + + //test set/get data in async env + (async function() { + test.equal(tileCache.numTilesLoaded(), 5, "We loaded 5 tiles"); + test.equal(tileCache.numCachesLoaded(), 3, "We loaded 3 cache objects - three different urls"); + + const c00 = tile00.getCache(tile00.cacheKey); + const c12 = tile12.getCache(tile12.cacheKey); + + //now test multi-cache within tile + const theTileKey = tile00.cacheKey; + tile00.addCache(tile00.buildDistinctMainCacheKey(), 42, T_E, true, false); + test.notEqual(tile00.cacheKey, tile00.originalCacheKey, "New cache rendered."); + + //now add artifically another record + tile00.addCache("my_custom_cache", 128, T_C); + test.equal(tileCache.numTilesLoaded(), 5, "We still loaded only 5 tiles."); + test.equal(tileCache.numCachesLoaded(), 5, "The cache has now 5 items (two new added already)."); + + test.equal(c00.getTileCount(), 2, "The cache still has only two tiles attached."); + test.equal(tile00.getCacheSize(), 3, "The tile has three cache objects (original data, main cache & custom."); + //related tile not affected + test.equal(tile12.cacheKey, tile12.originalCacheKey, "Original cache change not reflected on shared caches."); + test.equal(tile12.originalCacheKey, theTileKey, "Original cache key also preserved."); + test.equal(c12.getTileCount(), 2, "The original data cache still has only two tiles attached."); + + //add and delete cache nothing changes (+1 destroy T_C) + tile00.addCache("my_custom_cache2", 128, T_C); + tile00.removeCache("my_custom_cache2"); + test.equal(tileCache.numTilesLoaded(), 5, "We still loaded only 5 tiles."); + test.equal(tileCache.numCachesLoaded(), 5, "The cache has now 5 items."); + test.equal(tile00.getCacheSize(), 3, "The tile has three cache objects."); + + //delete cache as a zombie (+0 destroy) + tile00.addCache("my_custom_cache2", 17, T_D); + //direct access shoes correct value although we set key! + const myCustomCache2Data = tile00.getCache("my_custom_cache2").data; + test.equal(myCustomCache2Data, 17, "Previously defined cache does not intervene."); + test.equal(tileCache.numCachesLoaded(), 6, "The cache size is 6."); + //keep zombie + tile00.removeCache("my_custom_cache2", false); + test.equal(tileCache.numCachesLoaded(), 6, "The cache is 5 + 1 zombie, no change."); + test.equal(tile00.getCacheSize(), 3, "The tile has three cache objects."); + test.equal(tileCache._zombiesLoadedCount, 1, "One zombie."); + + //revive zombie + tile01.addCache("my_custom_cache2", 18, T_D); + const myCustomCache2OtherData = tile01.getCache("my_custom_cache2").data; + test.equal(myCustomCache2OtherData, myCustomCache2Data, "Caches are equal because revived."); + test.equal(tileCache._cachesLoadedCount, 6, "Zombie revived, original state restored."); + test.equal(tileCache._zombiesLoadedCount, 0, "No zombies."); + + //again, keep zombie + tile01.removeCache("my_custom_cache2", false); + + //first create additional cache so zombie is not the youngest + tile01.addCache("some weird cache", 11, T_A); + test.ok(tile01.cacheKey === tile01.originalCacheKey, "Custom cache does not touch tile cache keys."); + + //insertion aadditional cache clears the zombie first although it is not the youngest one + test.equal(tileCache.numCachesLoaded(), 7, "The cache has now 7 items."); + test.equal(tileCache._cachesLoadedCount, 6, "New cache created -> 5+1."); + test.equal(tileCache._zombiesLoadedCount, 1, "One zombie remains."); + + //Test CAP + tileCache._maxCacheItemCount = 7; + + // Zombie destroyed before other caches (+1 destroy T_D) + tile12.addCache("someKey", 43, T_B); + test.equal(tileCache.numCachesLoaded(), 7, "The cache has now 7 items."); + test.equal(tileCache._zombiesLoadedCount, 0, "One zombie sacrificed, preferred over living cache."); + test.notOk([tile00, tile01, tile10, tile11, tile12].find(x => !x.loaded), "All tiles sill loaded since zombie was sacrificed."); + + // test destructors called as expected + test.equal(destroyA, 0, "No destructors for A called."); + test.equal(destroyB, 0, "No destructors for B called."); + test.equal(destroyC, 1, "One destruction for C called."); + test.equal(destroyD, 1, "One destruction for D called."); + test.equal(destroyE, 0, "No destructors for E called."); + + + //try to revive zombie will fail: the zombie was deleted, we will find new vaue there + tile01.addCache("my_custom_cache2", -849613, T_C); + const myCustomCache2RecreatedData = tile01.getCache("my_custom_cache2").data; + test.notEqual(myCustomCache2RecreatedData, myCustomCache2Data, "Caches are not equal because zombie was killed."); + test.equal(myCustomCache2RecreatedData, -849613, "Cache data is actually as set to 18."); + test.equal(tileCache.numCachesLoaded(), 7, "The cache has still 7 items."); + + // some tile has been selected as a sacrifice since we triggered cap control + test.ok([tile00, tile01, tile10, tile11, tile12].find(x => !x.loaded), "One tile has been sacrificed."); + done(); + })(); + }); + + QUnit.test('Zombie Cache', function(test) { + const done = test.async(); + + //test jobs by coverage: fail if cached coverage not fully re-stored without jobs + let jobCounter = 0, coverage = undefined; + OpenSeadragon.ImageLoader.prototype.addJob = function (options) { + jobCounter++; + if (coverage) { + //old coverage of previous tiled image: if loaded, fail --> should be in cache + const coverageItem = coverage[options.tile.level][options.tile.x][options.tile.y]; + test.ok(!coverageItem, "Attempt to add job for tile that should be already in memory."); + } + return originalJob.call(this, options); + }; + + let tilesFinished = 0; + const tileCounter = function (event) {tilesFinished++;} + + const openHandler = function(event) { + event.item.allowZombieCache(true); + + viewer.world.removeHandler('add-item', openHandler); + test.ok(jobCounter === 0, 'Initial state, no images loaded'); + + waitFor(() => { + if (tilesFinished === jobCounter && event.item._fullyLoaded) { + coverage = $.extend(true, {}, event.item.coverage); + viewer.world.removeAll(); + return true; + } + return false; + }); + }; + + let jobsAfterRemoval = 0; + const removalHandler = function (event) { + viewer.world.removeHandler('remove-item', removalHandler); + test.ok(jobCounter > 0, 'Tiled image removed after 100 ms, should load some images.'); + jobsAfterRemoval = jobCounter; + + viewer.world.addHandler('add-item', reopenHandler); + viewer.addTiledImage({ + tileSource: '/test/data/testpattern.dzi' + }); + } + + const reopenHandler = function (event) { + event.item.allowZombieCache(true); + + viewer.removeHandler('add-item', reopenHandler); + test.equal(jobCounter, jobsAfterRemoval, 'Reopening image does not fetch any tiles imemdiatelly.'); + + waitFor(() => { + if (event.item._fullyLoaded) { + viewer.removeHandler('tile-unloaded', unloadTileHandler); + viewer.removeHandler('tile-loaded', tileCounter); + coverage = undefined; + + //console test needs here explicit removal to finish correctly + OpenSeadragon.ImageLoader.prototype.addJob = originalJob; + done(); + return true; + } + return false; + }); + }; + + const unloadTileHandler = function (event) { + test.equal(event.destroyed, false, "Tile unload event should not delete with zombies!"); + } + + viewer.world.addHandler('add-item', openHandler); + viewer.world.addHandler('remove-item', removalHandler); + viewer.addHandler('tile-unloaded', unloadTileHandler); + viewer.addHandler('tile-loaded', tileCounter); + + viewer.open('/test/data/testpattern.dzi'); + }); + + QUnit.test('Zombie Cache Replace Item', function(test) { + const done = test.async(); + + let jobCounter = 0, coverage = undefined; + OpenSeadragon.ImageLoader.prototype.addJob = function (options) { + jobCounter++; + if (coverage) { + //old coverage of previous tiled image: if loaded, fail --> should be in cache + const coverageItem = coverage[options.tile.level][options.tile.x][options.tile.y]; + if (!coverageItem) { + console.warn(coverage, coverage[options.tile.level][options.tile.x], options.tile); + } + test.ok(!coverageItem, "Attempt to add job for tile data that was previously loaded."); + } + return originalJob.call(this, options); + }; + + let tilesFinished = 0; + const tileCounter = function (event) {tilesFinished++;} + + const openHandler = function(event) { + event.item.allowZombieCache(true); + viewer.world.removeHandler('add-item', openHandler); + viewer.world.addHandler('add-item', reopenHandler); + + waitFor(() => { + if (tilesFinished === jobCounter && event.item._fullyLoaded) { + coverage = $.extend(true, {}, event.item.coverage); + viewer.addTiledImage({ + tileSource: '/test/data/testpattern.dzi', + index: 0, + replace: true + }); + return true; + } + return false; + }); + }; + + const reopenHandler = function (event) { + event.item.allowZombieCache(true); + + viewer.removeHandler('add-item', reopenHandler); + waitFor(() => { + if (event.item._fullyLoaded) { + viewer.removeHandler('tile-unloaded', unloadTileHandler); + viewer.removeHandler('tile-loaded', tileCounter); + + //console test needs here explicit removal to finish correctly + OpenSeadragon.ImageLoader.prototype.addJob = originalJob; + done(); + return true; + } + return false; + }); + }; + + const unloadTileHandler = function (event) { + test.equal(event.destroyed, false, "Tile unload event should not delete with zombies!"); + } + + viewer.world.addHandler('add-item', openHandler); + viewer.addHandler('tile-unloaded', unloadTileHandler); + viewer.addHandler('tile-loaded', tileCounter); + + viewer.open('/test/data/testpattern.dzi'); + }); + })(); diff --git a/test/modules/tiledimage.js b/test/modules/tiledimage.js index 6c33b752..bbee01ce 100644 --- a/test/modules/tiledimage.js +++ b/test/modules/tiledimage.js @@ -558,17 +558,17 @@ }); QUnit.test('_getCornerTiles without wrapping', function(assert) { - var tiledImageMock = { + var tiledImageMock = MockSeadragon.getTiledImage(null, { wrapHorizontal: false, wrapVertical: false, - source: new OpenSeadragon.TileSource({ + source: MockSeadragon.getTileSource({ width: 1500, height: 1000, tileWidth: 200, tileHeight: 150, tileOverlap: 1, - }), - }; + }) + }); var _getCornerTiles = OpenSeadragon.TiledImage.prototype._getCornerTiles.bind(tiledImageMock); function assertCornerTiles(topLeftBound, bottomRightBound, @@ -606,17 +606,13 @@ }); QUnit.test('_getCornerTiles with horizontal wrapping', function(assert) { - var tiledImageMock = { + var tiledImageMock = MockSeadragon.getTiledImage(null, { wrapHorizontal: true, wrapVertical: false, - source: new OpenSeadragon.TileSource({ - width: 1500, - height: 1000, - tileWidth: 200, - tileHeight: 150, - tileOverlap: 1, - }), - }; + source: MockSeadragon.getTileSource({ + tileOverlap: 1 + }) + }); var _getCornerTiles = OpenSeadragon.TiledImage.prototype._getCornerTiles.bind(tiledImageMock); function assertCornerTiles(topLeftBound, bottomRightBound, @@ -653,17 +649,13 @@ }); QUnit.test('_getCornerTiles with vertical wrapping', function(assert) { - var tiledImageMock = { + var tiledImageMock = MockSeadragon.getTiledImage(null, { wrapHorizontal: false, wrapVertical: true, - source: new OpenSeadragon.TileSource({ - width: 1500, - height: 1000, - tileWidth: 200, - tileHeight: 150, - tileOverlap: 1, - }), - }; + source: MockSeadragon.getTileSource({ + tileOverlap: 1 + }) + }); var _getCornerTiles = OpenSeadragon.TiledImage.prototype._getCornerTiles.bind(tiledImageMock); function assertCornerTiles(topLeftBound, bottomRightBound, diff --git a/test/modules/tilesource-dynamic-url.js b/test/modules/tilesource-dynamic-url.js index 099530af..59e33c1f 100644 --- a/test/modules/tilesource-dynamic-url.js +++ b/test/modules/tilesource-dynamic-url.js @@ -8,7 +8,7 @@ var DYNAMIC_URL = ""; var viewer = null; var OriginalAjax = OpenSeadragon.makeAjaxRequest; - var OriginalTile = OpenSeadragon.Tile; + var OriginalTileGetUrl = OpenSeadragon.Tile.prototype.getUrl; // These variables allow tracking when the first request for data has finished var firstUrlPromise = null; var isFirstUrlPromiseResolved = false; @@ -115,22 +115,15 @@ return request; }; - // Override Tile to ensure getUrl is called successfully. - var Tile = function(...params) { - OriginalTile.apply(this, params); - }; - - OpenSeadragon.extend( Tile.prototype, OpenSeadragon.Tile.prototype, { - getUrl: function() { - // if ASSERT is still truthy, call ASSERT.ok. If the viewer - // has already been destroyed and ASSERT has set to null, ignore this - if(ASSERT){ - ASSERT.ok(true, 'Tile.getUrl called'); - } - return OriginalTile.prototype.getUrl.apply(this); + // Override Tile::getUrl to ensure getUrl is called successfully. + OpenSeadragon.Tile.prototype.getUrl = function () { + // if ASSERT is still truthy, call ASSERT.ok. If the viewer + // has already been destroyed and ASSERT has set to null, ignore this + if (ASSERT) { + ASSERT.ok(true, 'Tile.getUrl called'); } - }); - OpenSeadragon.Tile = Tile; + return OriginalTileGetUrl.apply(this, arguments); + }; }, afterEach: function () { @@ -143,7 +136,7 @@ viewer = null; OpenSeadragon.makeAjaxRequest = OriginalAjax; - OpenSeadragon.Tile = OriginalTile; + OpenSeadragon.Tile.prototype.getUrl = OriginalTileGetUrl; } }); diff --git a/test/modules/type-conversion.js b/test/modules/type-conversion.js new file mode 100644 index 00000000..4ca56cdc --- /dev/null +++ b/test/modules/type-conversion.js @@ -0,0 +1,389 @@ +/* global QUnit, $, Util, testLog */ + +(function() { + const Convertor = OpenSeadragon.convertor; + + let viewer; + + //we override jobs: remember original function + const originalJob = OpenSeadragon.ImageLoader.prototype.addJob; + + //event awaiting + function waitFor(predicate) { + const time = setInterval(() => { + if (predicate()) { + clearInterval(time); + } + }, 20); + } + + //hijack conversion paths + //count jobs: how many items we process? + let jobCounter = 0; + OpenSeadragon.ImageLoader.prototype.addJob = function (options) { + jobCounter++; + return originalJob.call(this, options); + }; + + // Replace conversion with our own system and test: __TEST__ prefix must be used, otherwise + // other tests will interfere + // Note: this is not the same as in the production conversion, where CANVAS on its own does not exist + let imageToCanvas = 0, srcToImage = 0, context2DtoImage = 0, canvasToContext2D = 0, imageToUrl = 0, + canvasToUrl = 0; + //set all same costs to get easy testing, know which path will be taken + Convertor.learn("__TEST__canvas", "__TEST__url", (tile, canvas) => { + canvasToUrl++; + return canvas.toDataURL(); + }, 1, 1); + Convertor.learn("__TEST__image", "__TEST__url", (tile,image) => { + imageToUrl++; + return image.url; + }, 1, 1); + Convertor.learn("__TEST__canvas", "__TEST__context2d", (tile,canvas) => { + canvasToContext2D++; + return canvas.getContext("2d"); + }, 1, 1); + Convertor.learn("__TEST__context2d", "__TEST__canvas", (tile,context2D) => { + context2DtoImage++; + return context2D.canvas; + }, 1, 1); + Convertor.learn("__TEST__image", "__TEST__canvas", (tile,image) => { + imageToCanvas++; + 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); + Convertor.learn("__TEST__url", "__TEST__image", (tile, url) => { + return new Promise((resolve, reject) => { + srcToImage++; + const img = new Image(); + img.onerror = img.onabort = reject; + img.onload = () => resolve(img); + img.src = url; + }); + }, 1, 1); + + let canvasDestroy = 0, imageDestroy = 0, contex2DDestroy = 0, urlDestroy = 0; + //also learn destructors + Convertor.learnDestroy("__TEST__canvas", canvas => { + canvas.width = canvas.height = 0; + canvasDestroy++; + }); + Convertor.learnDestroy("__TEST__image", () => { + imageDestroy++; + }); + Convertor.learnDestroy("__TEST__context2d", () => { + contex2DDestroy++; + }); + Convertor.learnDestroy("__TEST__url", () => { + urlDestroy++; + }); + + + + QUnit.module('TypeConversion', { + beforeEach: function () { + $('
      ').appendTo("#qunit-fixture"); + + testLog.reset(); + + viewer = OpenSeadragon({ + id: 'example', + prefixUrl: '/build/openseadragon/images/', + maxImageCacheCount: 200, //should be enough to fit test inside the cache + springStiffness: 100 // Faster animation = faster tests + }); + OpenSeadragon.ImageLoader.prototype.addJob = originalJob; + }, + afterEach: function () { + if (viewer && viewer.close) { + viewer.close(); + } + + viewer = null; + imageToCanvas = 0; srcToImage = 0; context2DtoImage = 0; + canvasToContext2D = 0; imageToUrl = 0; canvasToUrl = 0; + canvasDestroy = 0; imageDestroy = 0; contex2DDestroy = 0; urlDestroy = 0; + } + }); + + + QUnit.test('Conversion path deduction', function (test) { + const done = test.async(); + + test.ok(Convertor.getConversionPath("__TEST__url", "__TEST__image"), + "Type conversion ok between TEST types."); + test.ok(Convertor.getConversionPath("url", "context2d"), + "Type conversion ok between real types."); + + test.equal(Convertor.getConversionPath("url", "__TEST__image"), undefined, + "Type conversion not possible between TEST and real types."); + test.equal(Convertor.getConversionPath("__TEST__canvas", "context2d"), undefined, + "Type conversion not possible between TEST and real types."); + + done(); + }); + + QUnit.test('Copy of build-in types', function (test) { + const done = test.async(); + + //prepare data + const URL = "/test/data/A.png"; + const image = new Image(); + image.onerror = image.onabort = () => { + test.ok(false, "Image data preparation failed to load!"); + done(); + }; + const canvas = document.createElement( 'canvas' ); + //test when ready + image.onload = async () => { + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + context.drawImage( image, 0, 0 ); + + //copy URL + const URL2 = await Convertor.copy(null, URL, "url"); + //we cannot check if they are not the same object, strings are immutable (and we don't copy anyway :D ) + test.equal(URL, URL2, "String copy is equal in data."); + test.equal(typeof URL, typeof URL2, "Type of copies equals."); + test.equal(URL.length, URL2.length, "Data length is also equal."); + + //copy context + const context2 = await Convertor.copy(null, context, "context2d"); + test.notEqual(context, context2, "Copy is not the same as original canvas."); + test.equal(typeof context, typeof context2, "Type of copies equals."); + test.equal(context.canvas.toDataURL(), context2.canvas.toDataURL(), "Data is equal."); + + //copy image + const image2 = await Convertor.copy(null, image, "image"); + test.notEqual(image, image2, "Copy is not the same as original image."); + test.equal(typeof image, typeof image2, "Type of copies equals."); + test.equal(image.src, image2.src, "Data is equal."); + + done(); + }; + image.src = URL; + }); + + // ---------- + QUnit.test('Manual Data Convertors: testing conversion, copies & destruction', function (test) { + const done = test.async(); + + //load image object: url -> image + Convertor.convert(null, "/test/data/A.png", "__TEST__url", "__TEST__image").then(i => { + test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion."); + test.equal(srcToImage, 1, "Conversion happened."); + + test.equal(urlDestroy, 0, "Url destructor not called automatically."); + Convertor.destroy("/test/data/A.png", "__TEST__url"); + test.equal(urlDestroy, 1, "Url destructor called."); + + test.equal(imageDestroy, 0, "Image destructor not called."); + return Convertor.convert(null, i, "__TEST__image", "__TEST__canvas"); + }).then(c => { //path image -> canvas + test.equal(OpenSeadragon.type(c), "canvas", "Got canvas object after conversion."); + test.equal(srcToImage, 1, "Conversion ulr->image did not happen."); + test.equal(imageToCanvas, 1, "Conversion image->canvas happened."); + test.equal(urlDestroy, 1, "Url destructor not called."); + test.equal(imageDestroy, 0, "Image destructor not called unless we ask it."); + return Convertor.convert(null, c, "__TEST__canvas", "__TEST__image"); + }).then(i => { //path canvas, image: canvas -> url -> image + test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion."); + test.equal(srcToImage, 2, "Conversion ulr->image happened."); + test.equal(imageToCanvas, 1, "Conversion image->canvas did not happened."); + test.equal(context2DtoImage, 0, "Conversion c2d->image did not happened."); + test.equal(canvasToContext2D, 0, "Conversion canvas->c2d did not happened."); + test.equal(canvasToUrl, 1, "Conversion canvas->url happened."); + test.equal(imageToUrl, 0, "Conversion image->url did not happened."); + + test.equal(urlDestroy, 2, "Url destructor called."); + test.equal(imageDestroy, 0, "Image destructor not called."); + test.equal(canvasDestroy, 0, "Canvas destructor called."); + test.equal(contex2DDestroy, 0, "Image destructor not called."); + done(); + }); + }); + + QUnit.test('Data Convertors via Cache object: testing conversion & destruction', function (test) { + const done = test.async(); + const dummyTile = MockSeadragon.getTile("", MockSeadragon.getTiledImage(), {cacheKey: "key"}); + const cache = MockSeadragon.getCacheRecord(); + cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); + + //load image object: url -> image + cache.transformTo("__TEST__image").then(_ => { + test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion."); + test.equal(srcToImage, 1, "Conversion happened."); + test.equal(urlDestroy, 1, "Url destructor called."); + test.equal(imageDestroy, 0, "Image destructor not called."); + return cache.transformTo("__TEST__canvas"); + }).then(_ => { //path image -> canvas + test.equal(OpenSeadragon.type(cache.data), "canvas", "Got canvas object after conversion."); + test.equal(srcToImage, 1, "Conversion ulr->image did not happen."); + test.equal(imageToCanvas, 1, "Conversion image->canvas happened."); + test.equal(urlDestroy, 1, "Url destructor not called."); + test.equal(imageDestroy, 1, "Image destructor called."); + return cache.transformTo("__TEST__image"); + }).then(_ => { //path canvas, image: canvas -> url -> image + test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion."); + test.equal(srcToImage, 2, "Conversion ulr->image happened."); + test.equal(imageToCanvas, 1, "Conversion image->canvas did not happened."); + test.equal(context2DtoImage, 0, "Conversion c2d->image did not happened."); + test.equal(canvasToContext2D, 0, "Conversion canvas->c2d did not happened."); + test.equal(canvasToUrl, 1, "Conversion canvas->url happened."); + test.equal(imageToUrl, 0, "Conversion image->url did not happened."); + + test.equal(urlDestroy, 2, "Url destructor called."); + test.equal(imageDestroy, 1, "Image destructor not called."); + test.equal(canvasDestroy, 1, "Canvas destructor called."); + test.equal(contex2DDestroy, 0, "Image destructor not called."); + }).then(_ => { + cache.destroy(); + + test.equal(urlDestroy, 2, "Url destructor not called."); + test.equal(imageDestroy, 2, "Image destructor called."); + test.equal(canvasDestroy, 1, "Canvas destructor not called."); + test.equal(contex2DDestroy, 0, "Image destructor not called."); + + done(); + }); + }); + + QUnit.test('Data Convertors via Cache object: testing set/get', function (test) { + const done = test.async(); + + const dummyTile = MockSeadragon.getTile("", MockSeadragon.getTiledImage(), {cacheKey: "key"}); + const cache = MockSeadragon.getCacheRecord({ + testGetSet: async function(type) { + const value = await cache.getDataAs(type, false); + await cache.setDataAs(value, type); + return value; + } + }); + cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); + + //load image object: url -> image + cache.testGetSet("__TEST__image").then(_ => { + test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion."); + test.equal(srcToImage, 1, "Conversion happened."); + test.equal(urlDestroy, 1, "Url destructor called."); + test.equal(imageDestroy, 0, "Image destructor not called."); + return cache.testGetSet("__TEST__canvas"); + }).then(_ => { //path image -> canvas + test.equal(OpenSeadragon.type(cache.data), "canvas", "Got canvas object after conversion."); + test.equal(srcToImage, 1, "Conversion ulr->image did not happen."); + test.equal(imageToCanvas, 1, "Conversion image->canvas happened."); + test.equal(urlDestroy, 1, "Url destructor not called."); + test.equal(imageDestroy, 1, "Image destructor called."); + return cache.testGetSet("__TEST__image"); + }).then(_ => { //path canvas, image: canvas -> url -> image + test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion."); + test.equal(srcToImage, 2, "Conversion ulr->image happened."); + test.equal(imageToCanvas, 1, "Conversion image->canvas did not happened."); + test.equal(context2DtoImage, 0, "Conversion c2d->image did not happened."); + test.equal(canvasToContext2D, 0, "Conversion canvas->c2d did not happened."); + test.equal(canvasToUrl, 1, "Conversion canvas->url happened."); + test.equal(imageToUrl, 0, "Conversion image->url did not happened."); + + test.equal(urlDestroy, 2, "Url destructor called."); + test.equal(imageDestroy, 1, "Image destructor not called."); + test.equal(canvasDestroy, 1, "Canvas destructor called."); + test.equal(contex2DDestroy, 0, "Image destructor not called."); + }).then(_ => { + cache.destroy(); + + test.equal(urlDestroy, 2, "Url destructor not called."); + test.equal(imageDestroy, 2, "Image destructor called."); + test.equal(canvasDestroy, 1, "Canvas destructor not called."); + test.equal(contex2DDestroy, 0, "Image destructor not called."); + + done(); + }); + }); + + QUnit.test('Deletion cache after a copy was requested but not yet processed.', function (test) { + const done = test.async(); + + let conversionHappened = false; + Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", (tile, value) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + conversionHappened = true; + resolve("modified " + value); + }, 20); + }); + }, 1, 1); + let longConversionDestroy = 0; + Convertor.learnDestroy("__TEST__longConversionProcessForTesting", _ => { + longConversionDestroy++; + }); + + const dummyTile = MockSeadragon.getTile("", MockSeadragon.getTiledImage(), {cacheKey: "key"}); + const cache = MockSeadragon.getCacheRecord(); + cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); + cache.getDataAs("__TEST__longConversionProcessForTesting").then(convertedData => { + test.equal(longConversionDestroy, 1, "Copy already destroyed."); + test.notOk(cache.loaded, "Cache was destroyed."); + test.equal(cache.data, undefined, "Already destroyed cache does not return data."); + test.equal(urlDestroy, 1, "Url was destroyed."); + test.ok(conversionHappened, "Conversion was fired."); + //destruction will likely happen after we finish current async callback + setTimeout(async () => { + test.equal(longConversionDestroy, 1, "Copy destroyed."); + done(); + }, 25); + }); + test.ok(cache.loaded, "Cache is still not loaded."); + test.equal(cache.data, "/test/data/A.png", "Get data does not override cache."); + test.equal(cache.type, "__TEST__url", "Cache did not change its type."); + cache.destroy(); + test.notOk(cache.type, "Type erased immediatelly as the data copy is out."); + test.equal(urlDestroy, 1, "We destroyed cache before copy conversion finished."); + }); + + QUnit.test('Deletion cache while being in the conversion process', function (test) { + const done = test.async(); + + let conversionHappened = false; + Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", (tile, value) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + conversionHappened = true; + resolve("modified " + value); + }, 20); + }); + }, 1, 1); + let destructionHappened = false; + Convertor.learnDestroy("__TEST__longConversionProcessForTesting", _ => { + destructionHappened = true; + }); + + const dummyTile = MockSeadragon.getTile("", MockSeadragon.getTiledImage(), {cacheKey: "key"}); + const cache = MockSeadragon.getCacheRecord(); + cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); + cache.transformTo("__TEST__longConversionProcessForTesting").then(_ => { + test.ok(conversionHappened, "Interrupted conversion finished."); + test.ok(cache.loaded, "Cache is loaded."); + test.equal(cache.data, "modified /test/data/A.png", "We got the correct data."); + test.equal(cache.type, "__TEST__longConversionProcessForTesting", "Cache declares new type."); + test.equal(urlDestroy, 1, "Url was destroyed."); + + //destruction will likely happen after we finish current async callback + setTimeout(() => { + test.ok(destructionHappened, "Interrupted conversion finished."); + done(); + }, 25); + }); + test.ok(!cache.loaded, "Cache is still not loaded."); + test.equal(cache.data, undefined, "Cache is still not loaded."); + test.equal(cache.type, "__TEST__longConversionProcessForTesting", "Cache already declares new type."); + cache.destroy(); + test.equal(cache.type, "__TEST__longConversionProcessForTesting", + "Type not erased immediatelly as we still process the data."); + test.ok(!conversionHappened, "We destroyed cache before conversion finished."); + }); +})(); diff --git a/test/test.html b/test/test.html index fd992d65..2cbde4e9 100644 --- a/test/test.html +++ b/test/test.html @@ -3,6 +3,21 @@ OpenSeadragon QUnit + @@ -16,6 +31,7 @@ + @@ -25,6 +41,7 @@ + @@ -49,6 +66,7 @@ +