From 750d45be81c09f4651cdd60fd2cd25e65a07ea19 Mon Sep 17 00:00:00 2001 From: Aiosa Date: Sun, 24 Sep 2023 22:30:28 +0200 Subject: [PATCH] Implement asynchronous tile processing logic wrt. tile cache conversion. --- src/datatypeconvertor.js | 201 ++++++++++++++------ src/eventsource.js | 87 +++++++-- src/openseadragon.js | 24 +++ src/tile.js | 27 ++- src/tilecache.js | 352 +++++++++++++++++++++-------------- src/tiledimage.js | 112 ++++++----- test/modules/event-source.js | 60 +++++- test/modules/events.js | 78 ++++---- test/test.html | 8 + 9 files changed, 655 insertions(+), 294 deletions(-) diff --git a/src/datatypeconvertor.js b/src/datatypeconvertor.js index 2698b9d4..0309535f 100644 --- a/src/datatypeconvertor.js +++ b/src/datatypeconvertor.js @@ -34,7 +34,10 @@ (function($){ -//modified from https://gist.github.com/Prottoy2938/66849e04b0bac459606059f5f9f3aa1a +/** + * modified from https://gist.github.com/Prottoy2938/66849e04b0bac459606059f5f9f3aa1a + * @private + */ class WeightedGraph { constructor() { this.adjacencyList = {}; @@ -48,13 +51,12 @@ class WeightedGraph { } return false; } - addEdge(vertex1, vertex2, weight, data) { - this.adjacencyList[vertex1].push({ target: this.vertices[vertex2], weight, data }); + addEdge(vertex1, vertex2, weight, transform) { + this.adjacencyList[vertex1].push({ target: this.vertices[vertex2], origin: this.vertices[vertex1], weight, transform }); } /** * @return {{path: *[], cost: number}|undefined} cheapest path for - * */ dijkstra(start, finish) { let path = []; //to return at end @@ -95,7 +97,7 @@ class WeightedGraph { } } - if (!smallestNode._previous) { + if (!smallestNode || !smallestNode._previous) { return undefined; //no path } @@ -119,17 +121,47 @@ class WeightedGraph { } } -class DataTypeConvertor { +/** + * Node 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 {?} data data in the input format + * @return {?} data in the output format + */ + +/** + * 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.learn("canvas", "string", (canvas) => canvas.toDataURL(), 1, 1); - this.learn("image", "string", (image) => image.url); + // Teaching OpenSeadragon built-in conversions: + + this.learn("canvas", "rasterUrl", (canvas) => canvas.toDataURL(), 1, 1); + this.learn("image", "rasterUrl", (image) => image.url); this.learn("canvas", "context2d", (canvas) => canvas.getContext("2d")); this.learn("context2d", "canvas", (context2D) => context2D.canvas); - - //OpenSeadragon supports two conversions out of the box: canvas and image. this.learn("image", "canvas", (image) => { const canvas = document.createElement( 'canvas' ); canvas.width = image.width; @@ -138,20 +170,13 @@ class DataTypeConvertor { context.drawImage( image, 0, 0 ); return canvas; }, 1, 1); - - this.learn("string", "image", (url) => { - const img = new Image(); - img.src = url; - //FIXME: support async functions! some function conversions are async (like image here) - // and returning immediatelly will possibly cause the system work with incomplete data - // - a) remove canvas->image conversion path support - // - b) busy wait cycle (ugly as..) - // - c) async conversion execution (makes the whole cache -> transitively rendering async) - // - d) callbacks (makes the cache API more complicated) - while (!img.complete) { - console.log("Burning through CPU :)"); - } - return img; + this.learn("rasterUrl", "image", (url) => { + return new $.Promise((resolve, reject) => { + const img = new Image(); + img.onerror = img.onabort = reject; + img.onload = () => resolve(img); + img.src = url; + }); }, 1, 1); } @@ -198,7 +223,6 @@ class DataTypeConvertor { return guessType.nodeName.toLowerCase(); } - //todo consider event... if (guessType === "object") { if ($.isFunction(x.getType)) { return x.getType(); @@ -208,9 +232,12 @@ class DataTypeConvertor { } /** + * 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 {function} callback convertor that takes type 'from', and converts to type 'to' + * @param {OpenSeadragon.TypeConvertor} callback convertor that takes type 'from', and converts to type 'to'. + * Callback can return function. This function returns the data in 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), @@ -231,78 +258,130 @@ class DataTypeConvertor { this.graph.addVertex(from); this.graph.addVertex(to); this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback); - this._known = {}; + this._known = {}; //invalidate precomputed paths :/ } /** - * FIXME: we could convert as 'convert(x, from, ...to)' and get cheapest path to any of the data - * for example, we could say tile.getCache(key)..getData("image", "canvas") if we do not care what we use and - * our system would then choose the cheapest option (both can be rendered by html for example). - * - * FIXME: conversion should be allowed to await results (e.g. image creation), now it is buggy, - * because we do not await image creation... - * + * 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.TypeConvertor} 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(). * @param {*} x data item to convert * @param {string} from data item type - * @param {string} to desired type - * @return {*} data item with type 'to', or undefined if the conversion failed + * @param {string} to desired type(s) + * @return {OpenSeadragon.Promise} promise resolution with type 'to' or undefined if the conversion failed */ - convert(x, from, to) { + convert(x, from, ...to) { const conversionPath = this.getConversionPath(from, to); - if (!conversionPath) { - $.console.warn(`[DataTypeConvertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`); - return undefined; + $.console.error(`[OpenSeadragon.convertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`); + return $.Promise.resolve(); } - for (let node of conversionPath) { - x = node.data(x); - if (!x) { - $.console.warn(`[DataTypeConvertor.convert] data mid result falsey value (conversion to ${node.node})`); - return undefined; + const stepCount = conversionPath.length, + _this = this; + const step = (x, i) => { + if (i >= stepCount) { + return $.Promise.resolve(x); } + let edge = conversionPath[i]; + let y = edge.transform(x); + if (!y) { + $.console.warn(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting to %s)`, edge.target); + return $.Promise.resolve(); + } + //node.value holds the type string + _this.destroy(edge.origin.value, x); + const result = $.type(y) === "promise" ? y : $.Promise.resolve(y); + return result.then(res => step(res, i + 1)); + }; + return step(x, 0); + } + + /** + * Destroy the data item given. + * @param {string} type data type + * @param {?} data + */ + destroy(type, data) { + const destructor = this.destructors[type]; + if (destructor) { + destructor(data); } - return x; } /** * Get possible system type conversions and cache result. * @param {string} from data item type - * @param {string} to desired type - * @return {[object]|undefined} array of required conversions (returns empty array + * @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) { - $.console.assert(to.length > 0, "[getConversionPath] conversion 'to' type must be defined."); + getConversionPath(from, to) { + let bestConvertorPath, selectedType; + let knownFrom = this._known[from]; + if (!knownFrom) { + this._known[from] = knownFrom = {}; + } - let bestConvertorPath; - const knownFrom = this._known[from]; - if (knownFrom) { + if (Array.isArray(to)) { + $.console.assert(typeof to === "string" || 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 { - this._known[from] = {}; + $.console.assert(typeof to === "string", "[getConversionPath] conversion 'to' type must be defined."); + bestConvertorPath = knownFrom[to]; + selectedType = to; } + if (!bestConvertorPath) { - //FIXME: pre-compute all paths? could be efficient for multiple - // type system, but overhead for simple use cases... - bestConvertorPath = this.graph.dijkstra(from, to[0]); - this._known[from][to[0]] = bestConvertorPath; + bestConvertorPath = this.graph.dijkstra(from, selectedType); + this._known[from][selectedType] = bestConvertorPath; } return bestConvertorPath ? bestConvertorPath.path : undefined; } -} +}; /** - * Static convertor available throughout OpenSeadragon + * Static convertor available throughout OpenSeadragon. + * + * Built-in conversions include types: + * - context2d canvas 2d context + * - image HTMLImage element + * - rasterUrl url string carrying or pointing to 2D raster data + * - canvas HTMLCanvas element + * + * @type OpenSeadragon.DataTypeConvertor * @memberOf OpenSeadragon */ -$.convertor = new DataTypeConvertor(); +$.convertor = new $.DataTypeConvertor(); }(OpenSeadragon)); diff --git a/src/eventsource.js b/src/eventsource.js index 94949f0f..7d77e8d6 100644 --- a/src/eventsource.js +++ b/src/eventsource.js @@ -70,10 +70,10 @@ $.EventSource.prototype = { * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. */ 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); @@ -92,12 +92,12 @@ $.EventSource.prototype = { * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. */ addHandler: function ( eventName, handler, userData, priority ) { - 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 ) { @@ -115,14 +115,13 @@ $.EventSource.prototype = { * @param {OpenSeadragon.EventHandler} 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 ] ); } @@ -137,7 +136,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; } @@ -154,7 +153,7 @@ $.EventSource.prototype = { if ( eventName ){ this.events[ eventName ] = []; } else{ - for ( var eventType in this.events ) { + for ( let eventType in this.events ) { this.events[ eventType ] = []; } } @@ -166,7 +165,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; } @@ -174,9 +173,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; @@ -186,6 +184,43 @@ $.EventSource.prototype = { }; }, + /** + * 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. + */ + getAwaitingHandler: function ( eventName) { + 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("Resolved!"); + 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. * @function @@ -194,13 +229,31 @@ $.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 ); - var handler = this.getHandler( eventName ); + const handler = this.getHandler( eventName ); if ( handler ) { return handler( this, eventArgs || {} ); } return undefined; + }, + + /** + * Trigger an event, optionally passing additional information. + * This events awaits every asynchronous or promise-returning function. + * @param {String} eventName - Name of event to register. + * @param {Object} eventArgs - Event-specific data. + * @return {OpenSeadragon.Promise|undefined} - Promise resolved upon the event completion. + */ + raiseEventAwaiting: function ( eventName, eventArgs ) { + //uncomment if you want to get a log of all events + //$.console.log( "Awaiting event fired:", eventName ); + + const awaitingHandler = this.getAwaitingHandler( eventName ); + if ( awaitingHandler ) { + return awaitingHandler( this, eventArgs || {} ); + } + return $.Promise.resolve("No handler for this event registered."); } }; diff --git a/src/openseadragon.js b/src/openseadragon.js index 8fe17bea..982a8399 100644 --- a/src/openseadragon.js +++ b/src/openseadragon.js @@ -2886,6 +2886,30 @@ function OpenSeadragon( options ){ } } + /** + * Promise proxy in OpenSeadragon, can be removed once IE11 support is dropped + * @type {PromiseConstructor} + */ + $.Promise = (function () { + if (window.Promise) { + return window.Promise; + } + const promise = function () {}; + //TODO consider supplying promise API via callbacks/polyfill + promise.prototype.then = function () { + throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises."; + }; + promise.prototype.resolve = function () { + throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises."; + }; + promise.prototype.reject = function () { + throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises."; + }; + promise.prototype.finally = function () { + throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises."; + }; + return promise; + })(); }(OpenSeadragon)); diff --git a/src/tile.js b/src/tile.js index a8a33c01..db9b84ac 100644 --- a/src/tile.js +++ b/src/tile.js @@ -144,6 +144,15 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja * @memberof OpenSeadragon.Tile# */ this.cacheKey = cacheKey; + /** + * 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'. + * @member {String} originalCacheKey + * @memberof OpenSeadragon.Tile# + */ + this.originalCacheKey = this.cacheKey; /** * Is this tile loaded? * @member {Boolean} loaded @@ -441,14 +450,19 @@ $.Tile.prototype = { if (!cache) { return undefined; } - return cache.getData(type); + cache.getData(type); //returns a promise + //we return the data synchronously immediatelly (undefined if conversion happens) + return cache.data; }, /** * Invalidate the tile so that viewport gets updated. */ save() { - this._needsDraw = true; + const parent = this.tiledImage; + if (parent) { + parent._needsDraw = true; + } }, /** @@ -500,6 +514,15 @@ $.Tile.prototype = { }); }, + /** + * FIXME:refactor + * @return {boolean} + */ + dataReady() { + return this.getCache(this.cacheKey).loaded; + }, + + /** * Renders the tile in a canvas-based context. * @function diff --git a/src/tilecache.js b/src/tilecache.js index c191dbd9..b4dd60b9 100644 --- a/src/tilecache.js +++ b/src/tilecache.js @@ -37,21 +37,50 @@ /** * Cached Data Record, the cache object. * Keeps only latest object type required. + * + * This class acts like the Maybe type: + * - it has 'loaded' flag indicating whether the tile data is ready + * - it has 'data' property that has value if loaded=true + * + * Furthermore, it has a 'getData' function that returns a promise resolving + * with the value on the desired type passed to the function. + * * @typedef {{ - * getImage: function, + * destroy: function, + * save: function, * getData: function, - * getRenderedContext: function + * data: ?, + * loaded: boolean * }} OpenSeadragon.CacheRecord */ $.CacheRecord = class { constructor() { this._tiles = []; + this._data = null; + this.loaded = false; + this._promise = $.Promise.resolve(); } destroy() { this._tiles = null; this._data = null; this._type = null; + this.loaded = false; + //make sure this gets destroyed even if loaded=false + if (this.loaded) { + $.convertor.destroy(this._type, this._data); + } else { + this._promise.then(x => $.convertor.destroy(this._type, x)); + } + this._promise = $.Promise.resolve(); + } + + get data() { + return this._data; + } + + get type() { + return this._type; } save() { @@ -60,48 +89,46 @@ $.CacheRecord = class { } } - get data() { - $.console.warn("[CacheRecord.data] is deprecated property. Use getData(...) instead!"); - return this._data; - } - - set data(value) { - //FIXME: addTile bit bad name, related to the issue mentioned elsewhere - $.console.warn("[CacheRecord.data] is deprecated property. Use addTile(...) instead!"); - this._data = value; - this._type = $.convertor.guessType(value); - } - - getImage() { - return this.getData("image"); - } - - getRenderedContext() { - return this.getData("context2d"); - } - getData(type = this._type) { if (type !== this._type) { - this._data = $.convertor.convert(this._data, this._type, type); - this._type = type; + if (!this.loaded) { + $.console.warn("Attempt to call getData with desired type %s, the tile data type is %s and the tile is not loaded!", type, this._type); + return this._promise; + } + this._convert(this._type, type); } - return this._data; + return this._promise; } + /** + * Add tile dependency on this record + * @param tile + * @param data + * @param type + */ addTile(tile, data, type) { $.console.assert(tile, '[CacheRecord.addTile] tile is required'); //allow overriding the cache - existing tile or different type if (this._tiles.includes(tile)) { this.removeTile(tile); - } else if (!this._type !== type) { + + } else if (!this.loaded) { this._type = type; + this._promise = $.Promise.resolve(data); this._data = data; + this.loaded = true; + } else if (this._type !== type) { + $.console.warn("[CacheRecord.addTile] Tile %s was added to an existing cache, but the tile is supposed to carry incompatible data type %s!", tile, type); } this._tiles.push(tile); } + /** + * Remove tile dependency on this record. + * @param tile + */ removeTile(tile) { for (let i = 0; i < this._tiles.length; i++) { if (this._tiles[i] === tile) { @@ -113,123 +140,178 @@ $.CacheRecord = class { $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile); } + /** + * Get the amount of tiles sharing this record. + * @return {number} + */ getTileCount() { return this._tiles.length; } + + /** + * Private conversion that makes sure the cache knows its data is ready + * @private + */ + _convert(from, to) { + const convertor = $.convertor, + conversionPath = convertor.getConversionPath(from, to); + if (!conversionPath) { + $.console.error(`[OpenSeadragon.convertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`); + return; //no-op + } + + const originalData = this._data, + stepCount = conversionPath.length, + _this = this, + convert = (x, i) => { + if (i >= stepCount) { + _this._data = x; + _this.loaded = true; + return $.Promise.resolve(x); + } + let edge = conversionPath[i]; + return $.Promise.resolve(edge.transform(x)).then( + y => { + if (!y) { + $.console.error(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting using %s)`, edge); + //try to recover using original data, but it returns inconsistent type (the log be hopefully enough) + _this._data = from; + _this._type = from; + _this.loaded = true; + return originalData; + } + //node.value holds the type string + convertor.destroy(edge.origin.value, x); + return convert(y, i + 1); + } + ); + + }; + + this.loaded = false; + this._data = undefined; + this._type = to; + this._promise = convert(originalData, 0); + } }; //FIXME: really implement or throw away? new parameter would allow users to -// use this implementation isntead of the above to allow caching for old data +// use this implementation instead of the above to allow caching for old data // (for example in the default use, the data is downloaded as an image, and // converted to a canvas -> the image record gets thrown away) -$.MemoryCacheRecord = class extends $.CacheRecord { - constructor(memorySize) { - super(); - this.length = memorySize; - this.index = 0; - this.content = []; - this.types = []; - this.defaultType = "image"; - } - - // overrides: - - destroy() { - super.destroy(); - this.types = null; - this.content = null; - this.types = null; - this.defaultType = null; - } - - getData(type = this.defaultType) { - let item = this.add(type, undefined); - if (item === undefined) { - //no such type available, get if possible - //todo: possible unomptimal use, we could cache costs and re-use known paths, though it adds overhead... - item = $.convertor.convert(this.current(), this.currentType(), type); - this.add(type, item); - } - return item; - } - - /** - * @deprecated - */ - get data() { - $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use getData(...) instead!"); - return this.current(); - } - - /** - * @deprecated - * @param value - */ - set data(value) { - //FIXME: addTile bit bad name, related to the issue mentioned elsewhere - $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use addTile(...) instead!"); - this.defaultType = $.convertor.guessType(value); - this.add(this.defaultType, value); - } - - addTile(tile, data, type) { - $.console.assert(tile, '[CacheRecord.addTile] tile is required'); - - //allow overriding the cache - existing tile or different type - if (this._tiles.includes(tile)) { - this.removeTile(tile); - } else if (!this.defaultType !== type) { - this.defaultType = type; - this.add(type, data); - } - - this._tiles.push(tile); - } - - // extends: - - add(type, item) { - const index = this.hasIndex(type); - if (index > -1) { - //no index change, swap (optimally, move all by one - too expensive...) - item = this.content[index]; - this.content[index] = this.content[this.index]; - } else { - this.index = (this.index + 1) % this.length; - } - this.content[this.index] = item; - this.types[this.index] = type; - return item; - } - - has(type) { - for (let i = 0; i < this.types.length; i++) { - const t = this.types[i]; - if (t === type) { - return this.content[i]; - } - } - return undefined; - } - - hasIndex(type) { - for (let i = 0; i < this.types.length; i++) { - const t = this.types[i]; - if (t === type) { - return i; - } - } - return -1; - } - - current() { - return this.content[this.index]; - } - - currentType() { - return this.types[this.index]; - } -}; +// +//FIXME: Note that this can be also achieved somewhat by caching the midresults +// as a single cache object instead. Also, there is the problem of lifecycle-oriented +// data types such as WebGL textures we want to unload manually: this looks like +// we really want to cache midresuls and have their custom destructors +// $.MemoryCacheRecord = class extends $.CacheRecord { +// constructor(memorySize) { +// super(); +// this.length = memorySize; +// this.index = 0; +// this.content = []; +// this.types = []; +// this.defaultType = "image"; +// } +// +// // overrides: +// +// destroy() { +// super.destroy(); +// this.types = null; +// this.content = null; +// this.types = null; +// this.defaultType = null; +// } +// +// getData(type = this.defaultType) { +// let item = this.add(type, undefined); +// if (item === undefined) { +// //no such type available, get if possible +// //todo: possible unomptimal use, we could cache costs and re-use known paths, though it adds overhead... +// item = $.convertor.convert(this.current(), this.currentType(), type); +// this.add(type, item); +// } +// return item; +// } +// +// /** +// * @deprecated +// */ +// get data() { +// $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use getData(...) instead!"); +// return this.current(); +// } +// +// /** +// * @deprecated +// * @param value +// */ +// set data(value) { +// //FIXME: addTile bit bad name, related to the issue mentioned elsewhere +// $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use addTile(...) instead!"); +// this.defaultType = $.convertor.guessType(value); +// this.add(this.defaultType, value); +// } +// +// addTile(tile, data, type) { +// $.console.assert(tile, '[CacheRecord.addTile] tile is required'); +// +// //allow overriding the cache - existing tile or different type +// if (this._tiles.includes(tile)) { +// this.removeTile(tile); +// } else if (!this.defaultType !== type) { +// this.defaultType = type; +// this.add(type, data); +// } +// +// this._tiles.push(tile); +// } +// +// // extends: +// +// add(type, item) { +// const index = this.hasIndex(type); +// if (index > -1) { +// //no index change, swap (optimally, move all by one - too expensive...) +// item = this.content[index]; +// this.content[index] = this.content[this.index]; +// } else { +// this.index = (this.index + 1) % this.length; +// } +// this.content[this.index] = item; +// this.types[this.index] = type; +// return item; +// } +// +// has(type) { +// for (let i = 0; i < this.types.length; i++) { +// const t = this.types[i]; +// if (t === type) { +// return this.content[i]; +// } +// } +// return undefined; +// } +// +// hasIndex(type) { +// for (let i = 0; i < this.types.length; i++) { +// const t = this.types[i]; +// if (t === type) { +// return i; +// } +// } +// return -1; +// } +// +// current() { +// return this.content[this.index]; +// } +// +// currentType() { +// return this.types[this.index]; +// } +// }; /** * @class TileCache diff --git a/src/tiledimage.js b/src/tiledimage.js index 0f9863bb..a7a0f273 100644 --- a/src/tiledimage.js +++ b/src/tiledimage.js @@ -1530,15 +1530,23 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag levelVisibility ); - if (!tile.loaded) { + if (!tile.loaded && !tile.loading) { // Tile was created or its data removed: check whether cache has the data before downloading. if (!tile.cacheKey) { tile.cacheKey = ""; - this._setTileLoaded(tile, null); - } else { - const imageRecord = this._tileCache.getCacheRecord(tile.cacheKey); - if (imageRecord) { - this._setTileLoaded(tile, imageRecord.getData()); + tile.originalCacheKey = ""; + } + const similarCacheRecord = + this._tileCache.getCacheRecord(tile.originalCacheKey) || + this._tileCache.getCacheRecord(tile.cacheKey); + + if (similarCacheRecord) { + const cutoff = this.source.getClosestLevel(); + if (similarCacheRecord.loaded) { + this._setTileLoaded(tile, similarCacheRecord.data, cutoff, null, similarCacheRecord.type); + } else { + similarCacheRecord.getData().then(data => + this._setTileLoaded(tile, data, cutoff, null, similarCacheRecord.type)); } } } @@ -1762,80 +1770,94 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag * @private * @inner * @param {OpenSeadragon.Tile} tile - * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object + * @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 * @param {?XMLHttpRequest} tileRequest * @param {?String} [dataType=undefined] data type, derived automatically if not set */ _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) { - var increment = 0, - eventFinished = false, - _this = this; - tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back // -> reason why it is not in the constructor tile.setCache(tile.cacheKey, data, dataType, false, cutoff); - function getCompletionCallback() { - if (eventFinished) { - $.console.error("Event 'tile-loaded' argument getCompletionCallback must be called synchronously. " + - "Its return value should be called asynchronously."); - } - increment++; - return completionCallback; - } + let resolver = null; + const _this = this, + finishPromise = new $.Promise(r => { + resolver = r; + }); function completionCallback() { - increment--; - if (increment === 0) { + //do not override true if set (false is default) + tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency( + undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData + ); + //make sure cache data is ready for drawing, if not, request the desired format + const cache = tile.getCache(tile.cacheKey), + // TODO: dynamic type declaration from the drawer base class interface from v5.0 onwards + requiredType = _this._drawer.useCanvas ? "context2d" : "image"; + if (!cache) { + $.console.warn("Tile %s not cached at the end of tile-loaded event: tile will not be drawn - it has no data!", tile); + resolver(tile); + } else if (cache.type !== requiredType) { + //initiate conversion as soon as possible if incompatible with the drawer + cache.getData(requiredType).then(_ => { + tile.loading = false; + tile.loaded = true; + resolver(tile); + }); + } else { tile.loading = false; tile.loaded = true; - //do not override true if set (false is default) - tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency( - undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData - ); - //FIXME: design choice: cache tile now set automatically so users can do - // tile.getCache(...) inside this event, but maybe we would like to have users - // freedom to decide on the cache creation (note, tiles now MUST have cache, e.g. - // it is no longer possible to store all tiles in the memory as it was with context2D prop) - tile.save(); + resolver(tile); } + + //FIXME: design choice: cache tile now set automatically so users can do + // tile.getCache(...) inside this event, but maybe we would like to have users + // freedom to decide on the cache creation (note, tiles now MUST have cache, e.g. + // it is no longer possible to store all tiles in the memory as it was with context2D prop) + tile.save(); } - const fallbackCompletion = getCompletionCallback(); /** * Triggered when a tile has just been loaded in memory. That means that the * image has been downloaded and can be modified before being drawn to the canvas. + * This event awaits its handlers - they can return promises, or be async functions. * - * @event tile-loaded + * @event tile-loaded awaiting event * @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 {*} 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 {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. + * @property {OpenSeadragon.Promise} - Promise resolved when the tile gets fully loaded. + * @property {function} getCompletionCallback - deprecated */ - this.viewer.raiseEvent("tile-loaded", { + const promise = this.viewer.raiseEventAwaiting("tile-loaded", { tile: tile, tiledImage: this, tileRequest: tileRequest, + promise: finishPromise, get image() { - $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead."); + $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'tile.getData()' instead."); return data; }, - data: data, - dataType: dataType, - getCompletionCallback: getCompletionCallback + get data() { + $.console.error("[tile-loaded] event 'data' has been deprecated. Use 'tile.getData()' instead."); + return data; + }, + getCompletionCallback: function () { + $.console.error("[tile-loaded] getCompletionCallback is not supported: it is compulsory to handle the event with async functions if applicable."); + }, + }); + promise.then(completionCallback).catch(() => { + $.console.error("[tile-loaded] event finished with failure: there might be a problem with a plugin you are using."); + completionCallback(); }); - eventFinished = true; - // In case the completion callback is never called, we at least force it once. - fallbackCompletion(); }, /** 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 010b08b9..cf5171a3 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 () { @@ -1210,11 +1211,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); @@ -1226,51 +1228,61 @@ 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' ); } ); diff --git a/test/test.html b/test/test.html index fd992d65..6a28a1cf 100644 --- a/test/test.html +++ b/test/test.html @@ -3,6 +3,14 @@ OpenSeadragon QUnit +