Implement cache manipulation strategy: default copy on access if tile in the rendering process, remove 'canvas' type support, many bugfixes and new tests.

This commit is contained in:
Aiosa 2023-11-26 21:32:26 +01:00
parent 2a1090ffa8
commit 2c67860c61
12 changed files with 1145 additions and 277 deletions

View File

@ -181,29 +181,33 @@ $.DataTypeConvertor = class {
constructor() { constructor() {
this.graph = new WeightedGraph(); this.graph = new WeightedGraph();
this.destructors = {}; this.destructors = {};
this.copyings = {};
// Teaching OpenSeadragon built-in conversions: // Teaching OpenSeadragon built-in conversions:
const imageCreator = (url) => new $.Promise((resolve, reject) => {
this.learn("canvas", "url", canvas => canvas.toDataURL(), 1, 1);
this.learn("image", "url", image => image.url);
this.learn("canvas", "context2d", canvas => canvas.getContext("2d"));
this.learn("context2d", "canvas", context2D => context2D.canvas);
this.learn("image", "canvas", image => {
const canvas = document.createElement( 'canvas' );
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage( image, 0, 0 );
return canvas;
}, 1, 1);
this.learn("url", "image", url => {
return new $.Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onerror = img.onabort = reject; img.onerror = img.onabort = reject;
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.src = url; img.src = url;
}); });
}, 1, 1); const canvasContextCreator = (imageData) => {
const canvas = document.createElement( 'canvas' );
canvas.width = imageData.width;
canvas.height = imageData.height;
const context = canvas.getContext('2d');
context.drawImage( imageData, 0, 0 );
return context;
};
this.learn("context2d", "url", ctx => ctx.canvas.toDataURL(), 1, 2);
this.learn("image", "url", image => image.url);
this.learn("image", "context2d", canvasContextCreator, 1, 1);
this.learn("url", "image", imageCreator, 1, 1);
//Copies
this.learn("image", "image", image => imageCreator(image.src), 1, 1);
this.learn("url", "url", url => url, 0, 1); //strings are immutable, no need to copy
this.learn("context2d", "context2d", ctx => canvasContextCreator(ctx.canvas));
} }
/** /**
@ -276,6 +280,9 @@ $.DataTypeConvertor = class {
$.console.assert(costPower >= 0 && costPower <= 7, "[DataTypeConvertor] Conversion costPower must be between <0, 7>."); $.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!"); $.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 //we won't know if somebody added multiple edges, though it will choose some edge anyway
costPower++; costPower++;
costMultiplier = Math.min(Math.max(costMultiplier, 1), 10 ^ 5); costMultiplier = Math.min(Math.max(costMultiplier, 1), 10 ^ 5);
@ -284,6 +291,7 @@ $.DataTypeConvertor = class {
this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback); this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback);
this._known = {}; //invalidate precomputed paths :/ this._known = {}; //invalidate precomputed paths :/
} }
}
/** /**
* Teach the system to destroy data type 'type' * Teach the system to destroy data type 'type'
@ -301,6 +309,9 @@ $.DataTypeConvertor = class {
* Convert data item x of type 'from' to any of the 'to' types, chosen is the cheapest known conversion. * 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 * Data is destroyed upon conversion. For different behavior, implement your conversion using the
* path rules obtained from getConversionPath(). * 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 {*} x data item to convert * @param {*} x data item to convert
* @param {string} from data item type * @param {string} from data item type
* @param {string} to desired type(s) * @param {string} to desired type(s)
@ -315,7 +326,7 @@ $.DataTypeConvertor = class {
const stepCount = conversionPath.length, const stepCount = conversionPath.length,
_this = this; _this = this;
const step = (x, i) => { const step = (x, i, destroy = true) => {
if (i >= stepCount) { if (i >= stepCount) {
return $.Promise.resolve(x); return $.Promise.resolve(x);
} }
@ -326,23 +337,46 @@ $.DataTypeConvertor = class {
return $.Promise.resolve(); return $.Promise.resolve();
} }
//node.value holds the type string //node.value holds the type string
_this.destroy(edge.origin.value, x); if (destroy) {
_this.destroy(x, edge.origin.value);
}
const result = $.type(y) === "promise" ? y : $.Promise.resolve(y); const result = $.type(y) === "promise" ? y : $.Promise.resolve(y);
return result.then(res => step(res, i + 1)); return result.then(res => step(res, i + 1));
}; };
return step(x, 0); //destroy only mid-results, but not the original value
return step(x, 0, false);
} }
/** /**
* Destroy the data item given. * Destroy the data item given.
* @param {string} type data type * @param {string} type data type
* @param {?} data * @param {?} data
* @return {OpenSeadragon.Promise<?>|undefined} promise resolution with data passed from constructor
*/ */
destroy(type, data) { copy(data, type) {
const copyTransform = this.copyings[type];
if (copyTransform) {
const y = copyTransform(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 {?} 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]; const destructor = this.destructors[type];
if (destructor) { if (destructor) {
destructor(data); const y = destructor(data);
return $.type(y) === "promise" ? y : $.Promise.resolve(y);
} }
return undefined;
} }
/** /**

View File

@ -263,7 +263,7 @@ $.extend( $.IIIFTileSource.prototype, $.TileSource.prototype, /** @lends OpenSea
if (data.preferredFormats) { if (data.preferredFormats) {
for (var f = 0; f < data.preferredFormats.length; f++ ) { 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]; data.tileFormat = data.preferredFormats[f];
break; break;
} }

View File

@ -707,6 +707,12 @@
* NOTE: passing POST data from URL by this feature only supports string values, however, * 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 * TileSource can send any data using POST as long as the header is correct
* (@see OpenSeadragon.TileSource.prototype.getTilePostData) * (@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.
*/ */
/** /**
@ -1207,6 +1213,7 @@ function OpenSeadragon( options ){
loadTilesWithAjax: false, loadTilesWithAjax: false,
ajaxHeaders: {}, ajaxHeaders: {},
splitHashDataForPost: false, splitHashDataForPost: false,
callTileLoadedWithCachedData: false,
//PAN AND ZOOM SETTINGS AND CONSTRAINTS //PAN AND ZOOM SETTINGS AND CONSTRAINTS
panHorizontal: true, panHorizontal: true,

View File

@ -284,6 +284,10 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja
* @private * @private
*/ */
this._caches = {}; this._caches = {};
/**
* @private
*/
this._cacheSize = 0;
}; };
/** @lends OpenSeadragon.Tile.prototype */ /** @lends OpenSeadragon.Tile.prototype */
@ -360,7 +364,7 @@ $.Tile.prototype = {
* @returns {Image} * @returns {Image}
*/ */
get 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(); return this.getImage();
}, },
@ -372,7 +376,7 @@ $.Tile.prototype = {
* @returns {String} * @returns {String}
*/ */
get url() { 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(); return this.getUrl();
}, },
@ -381,7 +385,14 @@ $.Tile.prototype = {
* @returns {?Image} * @returns {?Image}
*/ */
getImage: function() { getImage: function() {
return this.getData("image"); //TODO: after merge $.console.error("[Tile.getImage] property has been deprecated. Use [Tile.getData] instead.");
//this method used to ensure the underlying data model conformed to given type - convert instead of getData()
const cache = this.getCache(this.cacheKey);
if (!cache) {
return undefined;
}
cache.transformTo("image");
return cache.data;
}, },
/** /**
@ -402,7 +413,14 @@ $.Tile.prototype = {
* @returns {?CanvasRenderingContext2D} * @returns {?CanvasRenderingContext2D}
*/ */
getCanvasContext: function() { getCanvasContext: function() {
return this.getData("context2d"); //TODO: after merge $.console.error("[Tile.getCanvasContext] property has been deprecated. Use [Tile.getData] instead.");
//this method used to ensure the underlying data model conformed to given type - convert instead of getData()
const cache = this.getCache(this.cacheKey);
if (!cache) {
return undefined;
}
cache.transformTo("context2d");
return cache.data;
}, },
/** /**
@ -411,8 +429,8 @@ $.Tile.prototype = {
* @type {CanvasRenderingContext2D} context2D * @type {CanvasRenderingContext2D} context2D
*/ */
get context2D() { get context2D() {
$.console.error("[Tile.context2D] property has been deprecated. Use Tile::getCache()."); $.console.error("[Tile.context2D] property has been deprecated. Use [Tile.getData] instead.");
return this.getData("context2d"); return this.getCanvasContext();
}, },
/** /**
@ -420,7 +438,7 @@ $.Tile.prototype = {
* @deprecated * @deprecated
*/ */
set context2D(value) { set context2D(value) {
$.console.error("[Tile.context2D] property has been deprecated. Use Tile::setCache()."); $.console.error("[Tile.context2D] property has been deprecated. Use [Tile.setData] instead.");
this.setData(value, "context2d"); this.setData(value, "context2d");
}, },
@ -440,49 +458,63 @@ $.Tile.prototype = {
*/ */
set cacheImageRecord(value) { set cacheImageRecord(value) {
$.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::setCache."); $.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::setCache.");
this._caches[this.cacheKey] = value; const cache = this._caches[this.cacheKey];
if (!value) {
this.unsetCache(this.cacheKey);
} else {
const _this = this;
cache.await().then(x => _this.setCache(this.cacheKey, x, cache.type, false));
}
}, },
/** /**
* Get the default data for this tile * Get the default data for this tile
* @param {?string} [type=undefined] data type to require * @param {string} type data type to require
* @param {boolean?} [copy=this.loaded] whether to force copy retrieval
* @return {*|undefined} data in the desired type, or undefined if a conversion is ongoing * @return {*|undefined} data in the desired type, or undefined if a conversion is ongoing
*/ */
getData(type = undefined) { getData: function(type, copy = this.loaded) {
//we return the data synchronously immediatelly (undefined if conversion happens)
const cache = this.getCache(this.cacheKey); const cache = this.getCache(this.cacheKey);
if (!cache) { if (!cache) {
$.console.error("[Tile::getData] There is no cache available for tile with key " + this.cacheKey);
return undefined; return undefined;
} }
cache.getData(type); //returns a promise return cache.getDataAs(type, copy);
//we return the data synchronously immediatelly (undefined if conversion happens)
return cache.data;
},
/**
* Invalidate the tile so that viewport gets updated.
*/
save() {
const parent = this.tiledImage;
if (parent) {
parent._needsDraw = true;
}
}, },
/** /**
* Set cache data * Set cache data
* @param {*} value * @param {*} value
* @param {?string} [type=undefined] data type to require * @param {?string} type data type to require
* @param {boolean} [preserveOriginalData=true] if true and cacheKey === originalCacheKey,
* then stores the underlying data as 'original' and changes the cacheKey to point
* to a new data. This makes the Tile assigned to two cache objects.
*/ */
setData(value, type = undefined) { setData: function(value, type, preserveOriginalData = true) {
this.setCache(this.cacheKey, value, type); if (preserveOriginalData && this.cacheKey === this.originalCacheKey) {
//caches equality means we have only one cache:
// change current pointer to a new cache and create it: new tiles will
// not arrive at this data, but at originalCacheKey state
this.cacheKey = "mod://" + this.originalCacheKey;
return this.setCache(this.cacheKey, value, type)._promise;
}
//else overwrite cache
const cache = this.getCache(this.cacheKey);
if (!cache) {
$.console.error("[Tile::setData] There is no cache available for tile with key " + this.cacheKey);
return $.Promise.resolve();
}
return cache.setDataAs(value, type);
}, },
/** /**
* Read tile cache data object (CacheRecord) * Read tile cache data object (CacheRecord)
* @param {string} key cache key to read that belongs to this tile * @param {string?} [key=this.cacheKey] cache key to read that belongs to this tile
* @return {OpenSeadragon.CacheRecord} * @return {OpenSeadragon.CacheRecord}
*/ */
getCache: function(key) { getCache: function(key = this.cacheKey) {
return this._caches[key]; return this._caches[key];
}, },
@ -495,12 +527,15 @@ $.Tile.prototype = {
* @param {?string} type data type, will be guessed if not provided * @param {?string} type data type, will be guessed if not provided
* @param [_safely=true] private * @param [_safely=true] private
* @param [_cutoff=0] private * @param [_cutoff=0] private
* @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to.
*/ */
setCache: function(key, data, type = undefined, _safely = true, _cutoff = 0) { setCache: function(key, data, type = undefined, _safely = true, _cutoff = 0) {
if (!type && this.tiledImage && !this.tiledImage.__typeWarningReported) { if (!type) {
if (this.tiledImage && !this.tiledImage.__typeWarningReported) {
$.console.warn(this, "[Tile.setCache] called without type specification. " + $.console.warn(this, "[Tile.setCache] called without type specification. " +
"Automated deduction is potentially unsafe: prefer specification of data type explicitly."); "Automated deduction is potentially unsafe: prefer specification of data type explicitly.");
this.tiledImage.__typeWarningReported = true; this.tiledImage.__typeWarningReported = true;
}
type = $.convertor.guessType(data); type = $.convertor.guessType(data);
} }
@ -508,18 +543,54 @@ $.Tile.prototype = {
//todo later, we could have drawers register their supported rendering type //todo later, we could have drawers register their supported rendering type
// and OpenSeadragon would check compatibility automatically, now we render // and OpenSeadragon would check compatibility automatically, now we render
// using two main types so we check their ability // using two main types so we check their ability
const conversion = $.convertor.getConversionPath(type, "canvas", "image"); const conversion = $.convertor.getConversionPath(type, "context2d");
$.console.assert(conversion, "[Tile.setCache] data was set for the default tile cache we are unable" + $.console.assert(conversion, "[Tile.setCache] data was set for the default tile cache we are unable" +
"to render. Make sure OpenSeadragon.convertor was taught to convert type: " + type); "to render. Make sure OpenSeadragon.convertor was taught to convert type: " + type);
} }
this.tiledImage._tileCache.cacheTile({ const cachedItem = this.tiledImage._tileCache.cacheTile({
data: data, data: data,
dataType: type, dataType: type,
tile: this, tile: this,
cacheKey: key, cacheKey: key,
cutoff: _cutoff cutoff: _cutoff
}); });
const havingRecord = this._caches[key];
if (havingRecord !== cachedItem) {
if (!havingRecord) {
this._cacheSize++;
}
this._caches[key] = cachedItem;
}
return cachedItem;
},
/**
* Get the number of caches available to this tile
* @returns {number} number of caches
*/
getCacheSize: function() {
return this._cacheSize;
},
/**
* 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
*/
unsetCache: function(key, freeIfUnused = true) {
if (this.cacheKey === key) {
if (this.cacheKey !== this.originalCacheKey) {
this.cacheKey = this.originalCacheKey;
} else {
$.console.warn("[Tile.unsetCache] trying to remove the only cache that is used to draw the tile!");
}
}
if (this.tiledImage._tileCache.unloadCacheForTile(this, key, freeIfUnused)) {
//if we managed to free tile from record, we are sure we decreased cache count
this._cacheSize--;
delete this._caches[key];
}
}, },
/** /**
@ -680,10 +751,12 @@ $.Tile.prototype = {
this.tiledImage = null; this.tiledImage = null;
this._caches = []; this._caches = [];
this._cacheSize = 0;
this.element = null; this.element = null;
this.imgElement = null; this.imgElement = null;
this.loaded = false; this.loaded = false;
this.loading = false; this.loading = false;
this.cacheKey = this.originalCacheKey;
} }
}; };

View File

@ -47,65 +47,197 @@
* *
* @typedef {{ * @typedef {{
* destroy: function, * destroy: function,
* revive: function,
* save: function, * save: function,
* getData: function, * getDataAs: function,
* transformTo: function,
* data: ?, * data: ?,
* loaded: boolean * loaded: boolean
* }} OpenSeadragon.CacheRecord * }} OpenSeadragon.CacheRecord
*/ */
$.CacheRecord = class { $.CacheRecord = class {
constructor() { constructor() {
this._tiles = []; this.revive();
this._data = null;
this.loaded = false;
this._promise = $.Promise.resolve();
}
destroy() {
//make sure this gets destroyed even if loaded=false
if (this.loaded) {
$.convertor.destroy(this._type, this._data);
this._tiles = null;
this._data = null;
this._type = null;
this._promise = $.Promise.resolve();
} else {
this._promise.then(x => {
$.convertor.destroy(this._type, x);
this._tiles = null;
this._data = null;
this._type = null;
this._promise = $.Promise.resolve();
});
}
this.loaded = false;
} }
/**
* 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
* @return {any}
*/
get data() { get data() {
return this._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.
* @return {string}
*/
get type() { get type() {
return this._type; return this._type;
} }
save() { /**
for (let tile of this._tiles) { * Await ongoing process so that we get cache ready on callback.
tile._needsDraw = true; * @returns {null|*}
*/
await() {
if (!this._promise) { //if not cache loaded, do not fail
return $.Promise.resolve();
} }
return this._promise;
} }
getData(type = this._type) { getImage() {
$.console.error("[CacheRecord.getImage] options.image is deprecated. Moreover, it might not work" +
" correctly as the cache system performs conversion asynchronously in case the type needs to be converted.");
this.transformTo("image");
return this.data;
}
getRenderedContext() {
$.console.error("[CacheRecord.getRenderedContext] options.getRenderedContext is deprecated. Moreover, it might not work" +
" correctly as the cache system performs conversion asynchronously in case the type needs to be converted.");
this.transformTo("context2d");
return this.data;
}
/**
* Set the cache data. Asynchronous.
* @param {any} data
* @param {string} type
* @returns {OpenSeadragon.Promise<?>} the old cache data that has been overwritten
*/
setDataAs(data, type) {
//allow set data with destroyed state, destroys the data if necessary
$.console.assert(data !== undefined, "[CacheRecord.setDataAs] needs valid data to set!");
if (this._conversionJobQueue) {
//delay saving if ongiong conversion, these were registered first
let resolver = null;
const promise = new $.Promise((resolve, reject) => {
resolver = resolve;
});
this._conversionJobQueue.push(() => resolver(this._overwriteData(data, type)));
return promise;
}
return this._overwriteData(data, type);
}
/**
* Access the cache record data indirectly. Preferred way of data access. Asynchronous.
* @param {string?} [type=this.type]
* @param {boolean?} [copy=true] if false and same type is retrieved as the cache type,
* copy is not performed: note that this is potentially dangerous as it might
* introduce race conditions (you get a cache data direct reference you modify,
* but others might also access it, for example drawers to draw the viewport).
* @returns {OpenSeadragon.Promise<?>} desired data type in promise, undefined if the cache was destroyed
*/
getDataAs(type = this._type, copy = true) {
if (this.loaded && type === this._type) {
return copy ? $.convertor.copy(this._data, type) : this._promise;
}
return this._promise.then(data => {
//might get destroyed in meanwhile
if (this._destroyed) {
return undefined;
}
if (type !== this._type) { if (type !== this._type) {
return $.convertor.convert(data, this._type, type);
}
if (copy) { //convert does not copy data if same type, do explicitly
return $.convertor.copy(data, type);
}
return data;
});
}
/**
* Transform cache to desired type and get the data after conversion.
* Does nothing if the type equals to the current type. Asynchronous.
* @param {string} type
* @return {OpenSeadragon.Promise<?>|*}
*/
transformTo(type = this._type) {
if (!this.loaded || type !== this._type) {
if (!this.loaded) { 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); this._conversionJobQueue = this._conversionJobQueue || [];
return this._promise; let resolver = null;
const promise = new $.Promise((resolve, reject) => {
resolver = resolve;
});
this._conversionJobQueue.push(() => {
if (this._destroyed) {
return;
}
if (type !== this._type) {
//ensures queue gets executed after finish
this._convert(this._type, type);
this._promise.then(data => resolver(data));
} else {
//must ensure manually, but after current promise finished, we won't wait for the following job
this._promise.then(data => {
this._checkAwaitsConvert();
return resolver(data);
});
}
});
return promise;
} }
this._convert(this._type, type); this._convert(this._type, type);
} }
return this._promise; return this._promise;
} }
/**
* Set initial state, prepare for usage.
* Must not be called on active cache, e.g. first call destroy().
*/
revive() {
$.console.assert(!this.loaded && !this._type, "[CacheRecord::revive] must not be called when loaded!");
this._tiles = [];
this._data = null;
this._type = null;
this.loaded = false;
this._promise = null;
this._destroyed = false;
}
/**
* Free all the data and call data destructors if defined.
*/
destroy() {
delete this._conversionJobQueue;
this._destroyed = true;
//make sure this gets destroyed even if loaded=false
if (this.loaded) {
$.convertor.destroy(this._data, this._type);
this._tiles = null;
this._data = null;
this._type = null;
this._promise = null;
} else {
const oldType = this._type;
this._promise.then(x => {
//ensure old data destroyed
$.convertor.destroy(x, oldType);
//might get revived...
if (!this._destroyed) {
return;
}
this._tiles = null;
this._data = null;
this._type = null;
this._promise = null;
});
}
this.loaded = false;
}
/** /**
* Add tile dependency on this record * Add tile dependency on this record
* @param tile * @param tile
@ -113,6 +245,9 @@ $.CacheRecord = class {
* @param type * @param type
*/ */
addTile(tile, data, type) { addTile(tile, data, type) {
if (this._destroyed) {
return;
}
$.console.assert(tile, '[CacheRecord.addTile] tile is required'); $.console.assert(tile, '[CacheRecord.addTile] tile is required');
//allow overriding the cache - existing tile or different type //allow overriding the cache - existing tile or different type
@ -124,28 +259,28 @@ $.CacheRecord = class {
this._promise = $.Promise.resolve(data); this._promise = $.Promise.resolve(data);
this._data = data; this._data = data;
this.loaded = true; this.loaded = true;
} else if (this._type !== type) {
//pass: the tile data type will silently change
// as it inherits this cache
// todo do not call events?
} }
//else pass: the tile data type will silently change as it inherits this cache
this._tiles.push(tile); this._tiles.push(tile);
} }
/** /**
* Remove tile dependency on this record. * Remove tile dependency on this record.
* @param tile * @param tile
* @returns {Boolean} true if record removed
*/ */
removeTile(tile) { removeTile(tile) {
if (this._destroyed) {
return false;
}
for (let i = 0; i < this._tiles.length; i++) { for (let i = 0; i < this._tiles.length; i++) {
if (this._tiles[i] === tile) { if (this._tiles[i] === tile) {
this._tiles.splice(i, 1); this._tiles.splice(i, 1);
return; return true;
} }
} }
$.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile); $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile);
return false;
} }
/** /**
@ -153,7 +288,57 @@ $.CacheRecord = class {
* @return {number} * @return {number}
*/ */
getTileCount() { getTileCount() {
return this._tiles.length; 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();
});
}
/**
* Safely overwrite the cache data and return the old data
* @private
*/
_overwriteData(data, type) {
if (this._destroyed) {
//we take ownership of the data, destroy
$.convertor.destroy(data, type);
return $.Promise.resolve();
}
if (this.loaded) {
$.convertor.destroy(this._data, this._type);
this._type = type;
this._data = data;
this._promise = $.Promise.resolve(data);
return this._promise;
}
return this._promise.then(x => {
$.convertor.destroy(x, this._type);
this._type = type;
this._data = data;
this._promise = $.Promise.resolve(data);
return x;
});
} }
/** /**
@ -175,6 +360,7 @@ $.CacheRecord = class {
if (i >= stepCount) { if (i >= stepCount) {
_this._data = x; _this._data = x;
_this.loaded = true; _this.loaded = true;
_this._checkAwaitsConvert();
return $.Promise.resolve(x); return $.Promise.resolve(x);
} }
let edge = conversionPath[i]; let edge = conversionPath[i];
@ -189,7 +375,7 @@ $.CacheRecord = class {
return originalData; return originalData;
} }
//node.value holds the type string //node.value holds the type string
convertor.destroy(edge.origin.value, x); convertor.destroy(x, edge.origin.value);
return convert(y, i + 1); return convert(y, i + 1);
} }
); );
@ -232,15 +418,23 @@ $.TileCache = class {
return this._tilesLoaded.length; 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 * Caches the specified tile, removing an old tile if necessary to stay under the
* maxImageCacheCount specified on construction. Note that if multiple tiles reference * 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 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 * 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. * may temporarily surpass that number, but should eventually come back down to the max specified.
* @private
* @param {Object} options - Tile info. * @param {Object} options - Tile info.
* @param {OpenSeadragon.Tile} options.tile - The tile to cache. * @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.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey
* @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache. * @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache.
* Used if cacheKey not set. * Used if cacheKey not set.
* @param {Image} options.image - The image of the tile to cache. Deprecated. * @param {Image} options.image - The image of the tile to cache. Deprecated.
@ -249,30 +443,33 @@ $.TileCache = class {
* @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this * @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 * function will release an old tile. The cutoff option specifies a tile level at or below which
* tiles will not be released. * tiles will not be released.
* @returns {OpenSeadragon.CacheRecord} - The cache record the tile was attached to.
*/ */
cacheTile( options ) { cacheTile( options ) {
$.console.assert( options, "[TileCache.cacheTile] options is required" ); $.console.assert( options, "[TileCache.cacheTile] options is required" );
$.console.assert( options.tile, "[TileCache.cacheTile] options.tile is required" ); const theTile = options.tile;
$.console.assert( options.tile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); $.console.assert( theTile, "[TileCache.cacheTile] options.tile is required" );
$.console.assert( theTile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" );
let cutoff = options.cutoff || 0, let cutoff = options.cutoff || 0,
insertionIndex = this._tilesLoaded.length, insertionIndex = this._tilesLoaded.length,
cacheKey = options.cacheKey || options.tile.cacheKey; cacheKey = options.cacheKey || theTile.cacheKey;
let cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey]; let cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey];
if (!cacheRecord) { if (!cacheRecord) {
if (options.data === undefined) {
if (!options.data) {
$.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " + $.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " +
"has been deprecated and will be removed in the future."); "has been deprecated and will be removed in the future.");
options.data = options.image; options.data = options.image;
} }
$.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an CacheRecord" ); //allow anything but undefined, null, false (other values mean the data was set, for example '0')
$.console.assert( options.data !== undefined && options.data !== null && options.data !== false,
"[TileCache.cacheTile] options.data is required to create an CacheRecord" );
cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord(); cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord();
this._cachesLoadedCount++; this._cachesLoadedCount++;
} else if (!cacheRecord.getTileCount()) { } else if (cacheRecord._destroyed) {
//revive zombie cacheRecord.revive();
delete this._zombiesLoaded[cacheKey]; delete this._zombiesLoaded[cacheKey];
this._zombiesLoadedCount--; this._zombiesLoadedCount--;
} }
@ -282,11 +479,12 @@ $.TileCache = class {
"For easier use of the cache system, use the tile instance API."); "For easier use of the cache system, use the tile instance API.");
options.dataType = $.convertor.guessType(options.data); options.dataType = $.convertor.guessType(options.data);
} }
cacheRecord.addTile(options.tile, options.data, options.dataType);
options.tile._caches[ cacheKey ] = cacheRecord; cacheRecord.addTile(theTile, options.data, options.dataType);
// Note that just because we're unloading a tile doesn't necessarily mean // 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. // we're unloading its cache records. With repeated calls it should sort itself out, though.
let worstTileIndex = -1;
if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) { if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) {
//prefer zombie deletion, faster, better //prefer zombie deletion, faster, better
if (this._zombiesLoadedCount > 0) { if (this._zombiesLoadedCount > 0) {
@ -298,7 +496,6 @@ $.TileCache = class {
} }
} else { } else {
let worstTile = null; let worstTile = null;
let worstTileIndex = -1;
let prevTile, worstTime, worstLevel, prevTime, prevLevel; let prevTile, worstTime, worstLevel, prevTime, prevLevel;
for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
@ -325,13 +522,20 @@ $.TileCache = class {
} }
if ( worstTile && worstTileIndex >= 0 ) { if ( worstTile && worstTileIndex >= 0 ) {
this._unloadTile(worstTile, true); this.unloadTile(worstTile, true);
insertionIndex = worstTileIndex; insertionIndex = worstTileIndex;
} }
} }
} }
this._tilesLoaded[ insertionIndex ] = options.tile; if (theTile.getCacheSize() === 0) {
this._tilesLoaded[ insertionIndex ] = theTile;
} else if (worstTileIndex >= 0) {
//tile is already recorded, do not add tile, but remove the tile at insertion index
this._tilesLoaded.splice(insertionIndex, 1);
}
return cacheRecord;
} }
/** /**
@ -344,7 +548,7 @@ $.TileCache = class {
let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount; let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount;
if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) { if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) {
//prefer newer zombies //prefer newer (fresh ;) zombies
for (let zombie in this._zombiesLoaded) { for (let zombie in this._zombiesLoaded) {
this._zombiesLoaded[zombie].destroy(); this._zombiesLoaded[zombie].destroy();
delete this._zombiesLoaded[zombie]; delete this._zombiesLoaded[zombie];
@ -355,22 +559,60 @@ $.TileCache = class {
for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) { for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
tile = this._tilesLoaded[ i ]; tile = this._tilesLoaded[ i ];
//todo might be errorprone: tile.loading true--> problem! maybe set some other flag by if (tile.tiledImage === tiledImage) {
if (!tile.loaded) { if (!tile.loaded) {
//iterates from the array end, safe to remove //iterates from the array end, safe to remove
this._tilesLoaded.splice( i, 1 ); this._tilesLoaded.splice( i, 1 );
i--;
} else if ( tile.tiledImage === tiledImage ) { } else if ( tile.tiledImage === tiledImage ) {
//todo tile loading, if abort... we cloud notify the cache, maybe it works (cache destroy will wait for conversion...) this.unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i);
this._unloadTile(tile, !tiledImage._zombieCache || cacheOverflows, i); }
} }
} }
} }
// private /**
* Get cache record (might be a unattached record, i.e. a zombie)
* @param cacheKey
* @returns {OpenSeadragon.CacheRecord|undefined}
*/
getCacheRecord(cacheKey) { getCacheRecord(cacheKey) {
$.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required'); $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required');
return this._cachesLoaded[cacheKey]; return this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey];
}
/**
* Delete cache record for a given til
* @param {OpenSeadragon.Tile} tile
* @param {string} key cache key
* @param {boolean} destroy if true, empty cache is destroyed, else left as a zombie
* @private
*/
unloadCacheForTile(tile, key, destroy) {
const cacheRecord = this._cachesLoaded[key];
//unload record only if relevant - the tile exists in the record
if (cacheRecord) {
if (cacheRecord.removeTile(tile)) {
if (!cacheRecord.getTileCount()) {
if (destroy) {
// #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie)
cacheRecord.destroy();
} else {
// #2 Tile is a zombie. Do not delete record, reuse.
this._zombiesLoaded[key] = cacheRecord;
this._zombiesLoadedCount++;
}
// Either way clear cache
delete this._cachesLoaded[key];
this._cachesLoadedCount--;
}
return true;
}
$.console.error("[TileCache.unloadCacheForTile] System tried to delete tile from cache it " +
"does not belong to! This could mean a bug in the cache system.");
return false;
}
$.console.warn("[TileCache.unloadCacheForTile] Attempting to delete missing cache!");
return false;
} }
/** /**
@ -379,36 +621,19 @@ $.TileCache = class {
* @param deleteAtIndex index to remove the tile record at, will not remove from _tiledLoaded if not set * @param deleteAtIndex index to remove the tile record at, will not remove from _tiledLoaded if not set
* @private * @private
*/ */
_unloadTile(tile, destroy, deleteAtIndex) { unloadTile(tile, destroy, deleteAtIndex) {
$.console.assert(tile, '[TileCache._unloadTile] tile is required'); $.console.assert(tile, '[TileCache.unloadTile] tile is required');
for (let key in tile._caches) { for (let key in tile._caches) {
const cacheRecord = this._cachesLoaded[key]; //we are 'ok' to remove tile caches here since we later call destroy on tile, otherwise
if (cacheRecord) { //tile has count of its cache size --> would be inconsistent
cacheRecord.removeTile(tile); this.unloadCacheForTile(tile, key, destroy);
if (!cacheRecord.getTileCount()) {
if (destroy) {
// #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie)
cacheRecord.destroy();
delete this._cachesLoaded[tile.cacheKey];
this._cachesLoadedCount--;
} else if (deleteAtIndex !== undefined) {
// #2 Tile is a zombie. Do not delete record, reuse.
this._zombiesLoaded[ tile.cacheKey ] = cacheRecord;
this._zombiesLoadedCount++;
} }
//delete also the tile record //delete also the tile record
if (deleteAtIndex !== undefined) { if (deleteAtIndex !== undefined) {
this._tilesLoaded.splice( deleteAtIndex, 1 ); this._tilesLoaded.splice( deleteAtIndex, 1 );
} }
} else if (deleteAtIndex !== undefined) {
// #3 Cache stays. Tile record needs to be removed anyway, since the tile is removed.
this._tilesLoaded.splice( deleteAtIndex, 1 );
}
} else {
$.console.warn("[TileCache._unloadTile] Attempting to delete missing cache!");
}
}
const tiledImage = tile.tiledImage; const tiledImage = tile.tiledImage;
tile.unload(); tile.unload();

View File

@ -83,6 +83,8 @@
* Defaults to the setting in {@link OpenSeadragon.Options}. * Defaults to the setting in {@link OpenSeadragon.Options}.
* @param {Object} [options.ajaxHeaders={}] * @param {Object} [options.ajaxHeaders={}]
* A set of headers to include when making tile AJAX requests. * A set of headers to include when making tile AJAX requests.
* @param {Boolean} [options.callTileLoadedWithCachedData]
* Invoke tile-loded event for also for tiles loaded from cache if true.
*/ */
$.TiledImage = function( options ) { $.TiledImage = function( options ) {
var _this = this; var _this = this;
@ -184,7 +186,8 @@ $.TiledImage = function( options ) {
preload: $.DEFAULT_SETTINGS.preload, preload: $.DEFAULT_SETTINGS.preload,
compositeOperation: $.DEFAULT_SETTINGS.compositeOperation, compositeOperation: $.DEFAULT_SETTINGS.compositeOperation,
subPixelRoundingForTransparency: $.DEFAULT_SETTINGS.subPixelRoundingForTransparency, subPixelRoundingForTransparency: $.DEFAULT_SETTINGS.subPixelRoundingForTransparency,
maxTilesPerFrame: $.DEFAULT_SETTINGS.maxTilesPerFrame maxTilesPerFrame: $.DEFAULT_SETTINGS.maxTilesPerFrame,
callTileLoadedWithCachedData: $.DEFAULT_SETTINGS.callTileLoadedWithCachedData
}, options ); }, options );
this._preload = this.preload; this._preload = this.preload;
@ -1531,28 +1534,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
); );
if (!tile.loaded && !tile.loading) { if (!tile.loaded && !tile.loading) {
// Tile was created or its data removed: check whether cache has the data before downloading. // Tile was created or its data removed: check whether cache has the data.
if (!tile.cacheKey) { // this method sets tile.loading=true if data available, which prevents
tile.cacheKey = ""; // job creation later on
tile.originalCacheKey = ""; this._tryFindTileCacheRecord(tile);
}
//do not use tile.cacheKey: that cache might be different from what we really want
// since this request could come from different tiled image and might not want
// to use the modified data
const similarCacheRecord = this._tileCache.getCacheRecord(tile.originalCacheKey);
if (similarCacheRecord) {
const cutoff = this.source.getClosestLevel();
tile.loading = true;
tile.loaded = false;
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));
}
}
} }
if ( tile.loaded ) { if ( tile.loaded ) {
@ -1577,6 +1562,45 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
return best; return best;
}, },
/**
* @private
* @inner
* Try to find existing cache of the tile
* @param {OpenSeadragon.Tile} tile
*/
_tryFindTileCacheRecord: function(tile) {
if (!tile.cacheKey) {
tile.cacheKey = "";
tile.originalCacheKey = "";
}
let record = this._tileCache.getCacheRecord(tile.cacheKey);
const cutoff = this.source.getClosestLevel();
if (record) {
//setup without calling tile loaded event! tile cache is ready for usage,
tile.loading = true;
tile.loaded = false;
//set data as null, cache already has data, it does not overwrite
this._setTileLoaded(tile, null, cutoff, null, record.type,
this.callTileLoadedWithCachedData);
return true;
}
if (tile.cacheKey !== tile.originalCacheKey) {
//we found original data: this data will be used to re-execute the pipeline
record = this._tileCache.getCacheRecord(tile.originalCacheKey);
if (record) {
tile.loading = true;
tile.loaded = false;
//set data as null, cache already has data, it does not overwrite
this._setTileLoaded(tile, null, cutoff, null, record.type);
return true;
}
}
return false;
},
/** /**
* @private * @private
* @inner * @inner
@ -1779,8 +1803,9 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
* @param {?Number} cutoff * @param {?Number} cutoff
* @param {?XMLHttpRequest} tileRequest * @param {?XMLHttpRequest} tileRequest
* @param {?String} [dataType=undefined] data type, derived automatically if not set * @param {?String} [dataType=undefined] data type, derived automatically if not set
* @param {?Boolean} [withEvent=true] do not trigger event if true
*/ */
_setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) { _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType, withEvent = true) {
tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
// -> reason why it is not in the constructor // -> reason why it is not in the constructor
tile.setCache(tile.cacheKey, data, dataType, false, cutoff); tile.setCache(tile.cacheKey, data, dataType, false, cutoff);
@ -1811,7 +1836,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
resolver(tile); resolver(tile);
} else if (cache.type !== requiredType) { } else if (cache.type !== requiredType) {
//initiate conversion as soon as possible if incompatible with the drawer //initiate conversion as soon as possible if incompatible with the drawer
cache.getData(requiredType).then(_ => { cache.transformTo(requiredType).then(_ => {
tile.loading = false; tile.loading = false;
tile.loaded = true; tile.loaded = true;
resolver(tile); resolver(tile);
@ -1821,12 +1846,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
tile.loaded = true; tile.loaded = true;
resolver(tile); 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();
} }
function getCompletionCallback() { function getCompletionCallback() {
@ -1839,6 +1858,10 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
} }
const fallbackCompletion = getCompletionCallback(); const fallbackCompletion = getCompletionCallback();
if (!withEvent) {
fallbackCompletion();
return;
}
/** /**
* Triggered when a tile has just been loaded in memory. That means that the * Triggered when a tile has just been loaded in memory. That means that the

View File

@ -915,7 +915,8 @@ $.TileSource.prototype = {
* @deprecated * @deprecated
*/ */
getTileCacheData: function(cacheObject) { getTileCacheData: function(cacheObject) {
return cacheObject.getData(); $.console.error("[TileSource.getTileCacheData] has been deprecated. Use cache API of a tile instead.");
return cacheObject.getDataAs(undefined, false);
}, },
/** /**
@ -930,7 +931,7 @@ $.TileSource.prototype = {
*/ */
getTileCacheDataAsImage: function(cacheObject) { getTileCacheDataAsImage: function(cacheObject) {
$.console.error("[TileSource.getTileCacheDataAsImage] has been deprecated. Use cache API of a tile instead."); $.console.error("[TileSource.getTileCacheDataAsImage] has been deprecated. Use cache API of a tile instead.");
return cacheObject.getData("image"); return cacheObject.getImage();
}, },
/** /**
@ -944,7 +945,7 @@ $.TileSource.prototype = {
*/ */
getTileCacheDataAsContext2D: function(cacheObject) { getTileCacheDataAsContext2D: function(cacheObject) {
$.console.error("[TileSource.getTileCacheDataAsContext2D] has been deprecated. Use cache API of a tile instead."); $.console.error("[TileSource.getTileCacheDataAsContext2D] has been deprecated. Use cache API of a tile instead.");
return cacheObject.getData("context2d"); return cacheObject.getRenderedContext();
} }
}; };

View File

@ -1623,7 +1623,8 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
loadTilesWithAjax: queueItem.options.loadTilesWithAjax, loadTilesWithAjax: queueItem.options.loadTilesWithAjax,
ajaxHeaders: queueItem.options.ajaxHeaders, ajaxHeaders: queueItem.options.ajaxHeaders,
debugMode: _this.debugMode, debugMode: _this.debugMode,
subPixelRoundingForTransparency: _this.subPixelRoundingForTransparency subPixelRoundingForTransparency: _this.subPixelRoundingForTransparency,
callTileLoadedWithCachedData: _this.callTileLoadedWithCachedData,
}); });
if (_this.collectionMode) { if (_this.collectionMode) {

View File

@ -77,7 +77,7 @@
options.minLevel = 0; options.minLevel = 0;
options.maxLevel = options.gridSize.length - 1; options.maxLevel = options.gridSize.length - 1;
OpenSeadragon.TileSource.apply(this, [options]); $.TileSource.apply(this, [options]);
}; };
$.extend($.ZoomifyTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.ZoomifyTileSource.prototype */ { $.extend($.ZoomifyTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.ZoomifyTileSource.prototype */ {

View File

@ -1,6 +1,9 @@
/* global QUnit, testLog */ /* global QUnit, testLog */
(function() { (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; let viewer;
//we override jobs: remember original function //we override jobs: remember original function
@ -15,6 +18,82 @@
}, 20); }, 20);
} }
function createFakeTile(url, tiledImage, loading=false, loaded=true) {
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;
dummyTile.loading = loading;
dummyTile.loaded = loaded;
return dummyTile;
}
// 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, x => {
typeAtoB++;
return x+1;
});
Convertor.learn(T_B, T_C, x => {
typeBtoC++;
return x+1;
});
Convertor.learn(T_C, T_A, x => {
typeCtoA++;
return x+1;
});
Convertor.learn(T_D, T_A, x => {
typeDtoA++;
return x+1;
});
Convertor.learn(T_C, T_E, 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,x => {
copyA++;
return x+1;
});
Convertor.learn(T_B, T_B,x => {
copyB++;
return x+1;
});
Convertor.learn(T_C, T_C,x => {
copyC++;
return x-1;
});
Convertor.learn(T_D, T_D,x => {
copyD++;
return x+1;
});
Convertor.learn(T_E, T_E,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++;
});
// ---------- // ----------
QUnit.module('TileCache', { QUnit.module('TileCache', {
beforeEach: function () { beforeEach: function () {
@ -42,54 +121,38 @@
// ---------- // ----------
// TODO: this used to be async // TODO: this used to be async
QUnit.test('basics', function(assert) { QUnit.test('basics', function(assert) {
var done = assert.async(); const done = assert.async();
var fakeViewer = { const fakeViewer = {
raiseEvent: function() {} raiseEvent: function() {}
}; };
var fakeTiledImage0 = { const fakeTiledImage0 = {
viewer: fakeViewer, viewer: fakeViewer,
source: OpenSeadragon.TileSource.prototype source: OpenSeadragon.TileSource.prototype
}; };
var fakeTiledImage1 = { const fakeTiledImage1 = {
viewer: fakeViewer, viewer: fakeViewer,
source: OpenSeadragon.TileSource.prototype source: OpenSeadragon.TileSource.prototype
}; };
var fakeTile0 = { const tile0 = createFakeTile('foo.jpg', fakeTiledImage0);
url: 'foo.jpg', const tile1 = createFakeTile('foo.jpg', fakeTiledImage1);
cacheKey: 'foo.jpg',
image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {}
};
var fakeTile1 = { const cache = new OpenSeadragon.TileCache();
url: 'foo.jpg',
cacheKey: 'foo.jpg',
image: {},
loaded: true,
tiledImage: fakeTiledImage1,
_caches: [],
unload: function() {}
};
var cache = new OpenSeadragon.TileCache();
assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with'); assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with');
cache.cacheTile({ tile0._caches[tile0.cacheKey] = cache.cacheTile({
tile: fakeTile0, tile: tile0,
tiledImage: fakeTiledImage0 tiledImage: fakeTiledImage0
}); });
tile0._cacheSize++;
assert.equal(cache.numTilesLoaded(), 1, 'tile count after cache'); assert.equal(cache.numTilesLoaded(), 1, 'tile count after cache');
cache.cacheTile({ tile1._caches[tile1.cacheKey] = cache.cacheTile({
tile: fakeTile1, tile: tile1,
tiledImage: fakeTiledImage1 tiledImage: fakeTiledImage1
}); });
tile1._cacheSize++;
assert.equal(cache.numTilesLoaded(), 2, 'tile count after second cache'); assert.equal(cache.numTilesLoaded(), 2, 'tile count after second cache');
cache.clearTilesFor(fakeTiledImage0); cache.clearTilesFor(fakeTiledImage0);
@ -105,75 +168,369 @@
// ---------- // ----------
QUnit.test('maxImageCacheCount', function(assert) { QUnit.test('maxImageCacheCount', function(assert) {
var done = assert.async(); const done = assert.async();
var fakeViewer = { const fakeViewer = {
raiseEvent: function() {} raiseEvent: function() {}
}; };
var fakeTiledImage0 = { const fakeTiledImage0 = {
viewer: fakeViewer, viewer: fakeViewer,
source: OpenSeadragon.TileSource.prototype source: OpenSeadragon.TileSource.prototype
}; };
var fakeTile0 = { const tile0 = createFakeTile('different.jpg', fakeTiledImage0);
url: 'different.jpg', const tile1 = createFakeTile('same.jpg', fakeTiledImage0);
cacheKey: 'different.jpg', const tile2 = createFakeTile('same.jpg', fakeTiledImage0);
image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {}
};
var fakeTile1 = { const cache = new OpenSeadragon.TileCache({
url: 'same.jpg',
cacheKey: 'same.jpg',
image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {}
};
var fakeTile2 = {
url: 'same.jpg',
cacheKey: 'same.jpg',
image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {}
};
var cache = new OpenSeadragon.TileCache({
maxImageCacheCount: 1 maxImageCacheCount: 1
}); });
assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with'); assert.equal(cache.numTilesLoaded(), 0, 'no tiles to begin with');
cache.cacheTile({ tile0._caches[tile0.cacheKey] = cache.cacheTile({
tile: fakeTile0, tile: tile0,
tiledImage: fakeTiledImage0 tiledImage: fakeTiledImage0
}); });
tile0._cacheSize++;
assert.equal(cache.numTilesLoaded(), 1, 'tile count after add'); assert.equal(cache.numTilesLoaded(), 1, 'tile count after add');
cache.cacheTile({ tile1._caches[tile1.cacheKey] = cache.cacheTile({
tile: fakeTile1, tile: tile1,
tiledImage: fakeTiledImage0 tiledImage: fakeTiledImage0
}); });
tile1._cacheSize++;
assert.equal(cache.numTilesLoaded(), 1, 'tile count after add of second image'); assert.equal(cache.numTilesLoaded(), 1, 'tile count after add of second image');
cache.cacheTile({ tile2._caches[tile2.cacheKey] = cache.cacheTile({
tile: fakeTile2, tile: tile2,
tiledImage: fakeTiledImage0 tiledImage: fakeTiledImage0
}); });
tile2._cacheSize++;
assert.equal(cache.numTilesLoaded(), 2, 'tile count after additional same image'); assert.equal(cache.numTilesLoaded(), 2, 'tile count after additional same image');
done(); done();
}); });
//Tile API and cache interaction
QUnit.test('Tile API: basic conversion', function(test) {
const done = test.async();
const fakeViewer = {
raiseEvent: function() {}
};
const tileCache = new OpenSeadragon.TileCache();
const fakeTiledImage0 = {
viewer: fakeViewer,
source: OpenSeadragon.TileSource.prototype,
_tileCache: tileCache
};
const fakeTiledImage1 = {
viewer: fakeViewer,
source: OpenSeadragon.TileSource.prototype,
_tileCache: tileCache
};
//load data
const tile00 = createFakeTile('foo.jpg', fakeTiledImage0);
tile00.setCache(tile00.cacheKey, 0, T_A, false);
const tile01 = createFakeTile('foo2.jpg', fakeTiledImage0);
tile01.setCache(tile01.cacheKey, 0, T_B, false);
const tile10 = createFakeTile('foo3.jpg', fakeTiledImage1);
tile10.setCache(tile10.cacheKey, 0, T_C, false);
const tile11 = createFakeTile('foo3.jpg', fakeTiledImage1);
tile11.setCache(tile11.cacheKey, 0, T_C, false);
const tile12 = createFakeTile('foo.jpg', fakeTiledImage1);
tile12.setCache(tile12.cacheKey, 0, T_A, false);
const collideGetSet = async (tile, type) => {
const value = await tile.getData(type, false);
await tile.setData(value, type, false);
return value;
};
//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");
//test structure
const c00 = tile00.getCache(tile00.cacheKey);
test.equal(c00.getTileCount(), 2, "Two tiles share key = url = foo.jpg.");
const c01 = tile01.getCache(tile01.cacheKey);
test.equal(c01.getTileCount(), 1, "No tiles share key = url = foo2.jpg.");
const c10 = tile10.getCache(tile10.cacheKey);
test.equal(c10.getTileCount(), 2, "Two tiles share key = url = foo3.jpg.");
const c12 = tile12.getCache(tile12.cacheKey);
//test get/set data A
let value = await tile00.getData(undefined, false);
test.equal(typeAtoB, 0, "No conversion happened when requesting default type data.");
test.equal(value, 0, "No conversion, no increase in value A.");
//explicit type
value = await tile00.getData(T_A, false);
test.equal(typeAtoB, 0, "No conversion also for tile sharing the cache.");
test.equal(value, 0, "Again, no increase in value A.");
//copy & set type A
value = await tile00.getData(T_A, true);
test.equal(typeAtoB, 0, "No conversion also for tile sharing the cache.");
test.equal(copyA, 1, "A copy happened.");
test.equal(value, 1, "+1 conversion step happened.");
await tile00.setData(value, T_A, false); //overwrite
test.equal(tile00.cacheKey, tile00.originalCacheKey, "Overwriting cache: no change in value.");
test.equal(c00.type, T_A, "The tile cache data type was unchanged.");
//convert to B, async + sync behavior
value = await tile00.getData(T_B, false);
await tile00.setData(value, T_B, false); //overwrite
test.equal(typeAtoB, 1, "Conversion A->B happened.");
test.equal(value, 2, "+1 conversion step happened.");
//shares cache with tile12 (overwrite=false)
value = await tile12.getData(T_B, false);
test.equal(typeAtoB, 1, "Conversion A->B happened only once.");
test.equal(value, 2, "Value did not change.");
//test ASYNC get data
value = await tile12.getData(T_B);
await tile12.setData(value, T_B, false); //overwrite
test.equal(typeAtoB, 1, "No conversion happened when requesting default type data.");
test.equal(typeBtoC, 0, "No conversion happened when requesting default type data.");
test.equal(copyB, 1, "B type copied.");
test.equal(value, 3, "Copy, increase in value type B.");
// Async collisions testing
//convert to A, before that request conversion to A and B several times, since we copy
// there should be just exactly the right amount of conversions
tile12.getData(T_A); // B -> C -> A
tile12.getData(T_B); // no conversion, all run at the same time
tile12.getData(T_B); // no conversion, all run at the same time
tile12.getData(T_A); // B -> C -> A
tile12.getData(T_B); // no conversion, all run at the same time
value = await tile12.getData(T_A); // B -> C -> A
test.equal(typeAtoB, 1, "No conversion A->B.");
test.equal(typeBtoC, 3, "Conversion B->C happened three times.");
test.equal(typeCtoA, 3, "Conversion C->A happened three times.");
test.equal(typeDtoA, 0, "Conversion D->A did not happen.");
test.equal(typeCtoE, 0, "Conversion C->E did not happen.");
test.equal(value, 5, "+2 conversion step happened, other conversion steps are copies discarded " +
"(get data does not modify cache).");
//but direct requests on cache change await
//convert to A, before that request conversion to A and B several times, should finish accordingly
c12.transformTo(T_A); // B -> C -> A
c12.transformTo(T_B); // A -> B second time
c12.transformTo(T_B); // no-op
c12.transformTo(T_A); // B -> C -> A
c12.transformTo(T_B); // A -> B third time
//should finish with next await with 6 steps at this point, add two more and await end
value = await c12.transformTo(T_A); // B -> C -> A
test.equal(typeAtoB, 3, "Conversion A->B happened three times.");
test.equal(typeBtoC, 6, "Conversion B->C happened six times.");
test.equal(typeCtoA, 6, "Conversion C->A happened six times.");
test.equal(typeDtoA, 0, "Conversion D->A did not happen.");
test.equal(typeCtoE, 0, "Conversion C->E did not happen.");
test.equal(value, 11, "5-2+8 conversion step happened (the test above did not save the cache so 3 is value).");
await tile12.setData(value, T_B, false); // B -> C -> A
// Get set collide tries to modify the cache
collideGetSet(tile12, T_A); // B -> C -> A
collideGetSet(tile12, T_B); // no conversion, all run at the same time
collideGetSet(tile12, T_B); // no conversion, all run at the same time
collideGetSet(tile12, T_A); // B -> C -> A
collideGetSet(tile12, T_B); // no conversion, all run at the same time
//should finish with next await with 6 steps at this point, add two more and await end
value = await collideGetSet(tile12, T_A); // B -> C -> A
test.equal(typeAtoB, 3, "Conversion A->B not increased, not needed as all T_B requests resolve immediatelly.");
test.equal(typeBtoC, 9, "Conversion B->C happened three times more.");
test.equal(typeCtoA, 9, "Conversion C->A happened three times more.");
test.equal(typeDtoA, 0, "Conversion D->A did not happen.");
test.equal(typeCtoE, 0, "Conversion C->E did not happen.");
test.equal(value, 13, "11+2 steps (writes are colliding, just single write will happen).");
//shares cache with tile12
value = await tile00.getData(T_A, false);
test.equal(typeAtoB, 3, "Conversion A->B nor triggered.");
test.equal(value, 13, "Value did not change.");
//now set value with keeping origin
await tile00.setData(42, T_D, true);
test.equal(tile12.originalCacheKey, tile12.cacheKey, "Related tile not affected.");
test.equal(tile00.originalCacheKey, tile12.originalCacheKey, "Cache data was modified, original kept.");
test.notEqual(tile00.cacheKey, tile12.cacheKey, "Main cache keys changed.");
const newCache = tile00.getCache();
await newCache.transformTo(T_C);
test.equal(typeDtoA, 1, "Conversion D->A happens first time.");
test.equal(c12.data, 13, "Original cache value kept");
test.equal(c12.type, T_A, "Original cache type kept");
test.equal(c12, c00, "The same cache.");
test.equal(typeAtoB, 4, "Conversion A->B triggered.");
test.equal(newCache.type, T_C, "Original cache type kept");
test.equal(newCache.data, 45, "42+3 steps happened.");
//try again change in set data, now the cache gets overwritten
await tile00.setData(42, T_B, true);
test.equal(newCache.type, T_B, "Reset happened in place.");
test.equal(newCache.data, 42, "Reset happened in place.");
// Overwriting stress test with diff cache (see the same test as above, the same reasoning)
collideGetSet(tile00, T_A); // B -> C -> A
collideGetSet(tile00, T_B); // no conversion, all run at the same time
collideGetSet(tile00, T_B); // no conversion, all run at the same time
collideGetSet(tile00, T_A); // B -> C -> A
collideGetSet(tile00, T_B); // no conversion, all run at the same time
//should finish with next await with 6 steps at this point, add two more and await end
value = await collideGetSet(tile00, T_A); // B -> C -> A
test.equal(typeAtoB, 4, "Conversion A->B not increased.");
test.equal(typeBtoC, 13, "Conversion B->C happened three times more.");
//we converted D->C before, that's why C->A is one less
test.equal(typeCtoA, 12, "Conversion C->A happened three times more.");
test.equal(typeDtoA, 1, "Conversion D->A did not happen.");
test.equal(typeCtoE, 0, "Conversion C->E did not happen.");
test.equal(value, 44, "+2 writes value (writes collide, just one finishes last).");
test.equal(c12.data, 13, "Original cache value kept");
test.equal(c12.type, T_A, "Original cache type kept");
test.equal(c12, c00, "The same cache.");
//todo test destruction throughout the test above
//tile00.unload();
done();
})();
});
//Tile API and cache interaction
QUnit.test('Tile API Cache Interaction', function(test) {
const done = test.async();
const fakeViewer = {
raiseEvent: function() {}
};
const tileCache = new OpenSeadragon.TileCache();
const fakeTiledImage0 = {
viewer: fakeViewer,
source: OpenSeadragon.TileSource.prototype,
_tileCache: tileCache
};
const fakeTiledImage1 = {
viewer: fakeViewer,
source: OpenSeadragon.TileSource.prototype,
_tileCache: tileCache
};
//load data
const tile00 = createFakeTile('foo.jpg', fakeTiledImage0);
tile00.setCache(tile00.cacheKey, 0, T_A, false);
const tile01 = createFakeTile('foo2.jpg', fakeTiledImage0);
tile01.setCache(tile01.cacheKey, 0, T_B, false);
const tile10 = createFakeTile('foo3.jpg', fakeTiledImage1);
tile10.setCache(tile10.cacheKey, 0, T_C, false);
const tile11 = createFakeTile('foo3.jpg', fakeTiledImage1);
tile11.setCache(tile11.cacheKey, 0, T_C, false);
const tile12 = createFakeTile('foo.jpg', fakeTiledImage1);
tile12.setCache(tile12.cacheKey, 0, T_A, 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");
const c00 = tile00.getCache(tile00.cacheKey);
const c12 = tile12.getCache(tile12.cacheKey);
//now test multi-cache within tile
const theTileKey = tile00.cacheKey;
tile00.setData(42, T_E, true);
test.ok(tile00.cacheKey !== tile00.originalCacheKey, "Original cache key differs.");
test.equal(theTileKey, tile00.originalCacheKey, "Original cache key preserved.");
//now add artifically another record
tile00.setCache("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.");
test.equal(c00.getTileCount(), 2, "The cache still has only two tiles attached.");
test.equal(tile00.getCacheSize(), 3, "The tile has three cache objects.");
//related tile not really affected
test.equal(tile12.cacheKey, tile12.originalCacheKey, "Original cache key not affected elsewhere.");
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.");
test.equal(tile12.getCacheSize(), 1, "Related tile cache did not increase.");
//add and delete cache nothing changes
tile00.setCache("my_custom_cache2", 128, T_C);
tile00.unsetCache("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
tile00.setCache("my_custom_cache2", 17, T_C);
//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.unsetCache("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.");
//revive zombie
tile01.setCache("my_custom_cache2", 18, T_C);
const myCustomCache2OtherData = tile01.getCache("my_custom_cache2").data;
test.equal(myCustomCache2OtherData, myCustomCache2Data, "Caches are equal because revived.");
//again, keep zombie
tile01.unsetCache("my_custom_cache2", false);
//first create additional cache so zombie is not the youngest
tile01.setCache("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 CAP
tileCache._maxCacheItemCount = 7;
//does not trigger insertion - deletion, since we setData to cache that already exists, 43 value ignored
tile12.setData(43, T_B, true);
test.notEqual(tile12.cacheKey, tile12.originalCacheKey, "Original cache key differs.");
test.equal(theTileKey, tile12.originalCacheKey, "Original cache key preserved.");
test.equal(tileCache.numCachesLoaded(), 7, "The cache has still 7 items.");
//we called SET DATA with preserve=true on tile12 which was sharing cache with tile00, new cache is also shared
test.equal(tile00.originalCacheKey, tile12.originalCacheKey, "Original cache key matches between tiles.");
test.equal(tile00.cacheKey, tile12.cacheKey, "Modified cache key matches between tiles.");
test.equal(tile12.getCache().data, 42, "The value is not 43 as setData triggers cache share!");
//triggers insertion - deletion of zombie cache 'my_custom_cache2'
tile00.setCache("trigger-max-cache-handler", 5, T_C);
//reset CAP
tileCache._maxCacheItemCount = OpenSeadragon.DEFAULT_SETTINGS.maxImageCacheCount;
//try to revive zombie will fail: the zombie was deleted, we will find 18
tile01.setCache("my_custom_cache2", 18, T_C);
const myCustomCache2RecreatedData = tile01.getCache("my_custom_cache2").data;
test.notEqual(myCustomCache2RecreatedData, myCustomCache2Data, "Caches are not equal because created.");
test.equal(myCustomCache2RecreatedData, 18, "Cache data is actually as set to 18.");
test.equal(tileCache.numCachesLoaded(), 8, "The cache has now 8 items.");
//delete cache bound to other tiles, this tile has 4 caches:
// cacheKey: shared, originalCacheKey: shared, <custom cache key>, <custom cache key>
// note that cacheKey is shared because we called setData on two items that both create MOD cache
tileCache.unloadTile(tile00, true, tileCache._tilesLoaded.indexOf(tile00));
test.equal(tileCache.numCachesLoaded(), 6, "The cache has now 8-2 items.");
test.equal(tileCache.numTilesLoaded(), 4, "One tile removed.");
test.equal(c00.getTileCount(), 1, "The cache has still tile12 left.");
//now test tile destruction as zombie
//now test tile cache sharing
done();
})();
});
QUnit.test('Zombie Cache', function(test) { QUnit.test('Zombie Cache', function(test) {
const done = test.async(); const done = test.async();

View File

@ -27,6 +27,7 @@
// Replace conversion with our own system and test: __TEST__ prefix must be used, otherwise // Replace conversion with our own system and test: __TEST__ prefix must be used, otherwise
// other tests will interfere // 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, let imageToCanvas = 0, srcToImage = 0, context2DtoImage = 0, canvasToContext2D = 0, imageToUrl = 0,
canvasToUrl = 0; canvasToUrl = 0;
//set all same costs to get easy testing, know which path will be taken //set all same costs to get easy testing, know which path will be taken
@ -81,6 +82,7 @@
}); });
QUnit.module('TypeConversion', { QUnit.module('TypeConversion', {
beforeEach: function () { beforeEach: function () {
$('<div id="example"></div>').appendTo("#qunit-fixture"); $('<div id="example"></div>').appendTo("#qunit-fixture");
@ -113,7 +115,7 @@
test.ok(Convertor.getConversionPath("__TEST__url", "__TEST__image"), test.ok(Convertor.getConversionPath("__TEST__url", "__TEST__image"),
"Type conversion ok between TEST types."); "Type conversion ok between TEST types.");
test.ok(Convertor.getConversionPath("canvas", "context2d"), test.ok(Convertor.getConversionPath("url", "context2d"),
"Type conversion ok between real types."); "Type conversion ok between real types.");
test.equal(Convertor.getConversionPath("url", "__TEST__image"), undefined, test.equal(Convertor.getConversionPath("url", "__TEST__image"), undefined,
@ -124,15 +126,61 @@
done(); 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(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(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(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 & destruction', function (test) { QUnit.test('Manual Data Convertors: testing conversion, copies & destruction', function (test) {
const done = test.async(); const done = test.async();
//load image object: url -> image //load image object: url -> image
Convertor.convert("/test/data/A.png", "__TEST__url", "__TEST__image").then(i => { Convertor.convert("/test/data/A.png", "__TEST__url", "__TEST__image").then(i => {
test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion."); test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion.");
test.equal(srcToImage, 1, "Conversion happened."); 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(urlDestroy, 1, "Url destructor called.");
test.equal(imageDestroy, 0, "Image destructor not called."); test.equal(imageDestroy, 0, "Image destructor not called.");
return Convertor.convert(i, "__TEST__image", "__TEST__canvas"); return Convertor.convert(i, "__TEST__image", "__TEST__canvas");
}).then(c => { //path image -> canvas }).then(c => { //path image -> canvas
@ -140,7 +188,7 @@
test.equal(srcToImage, 1, "Conversion ulr->image did not happen."); test.equal(srcToImage, 1, "Conversion ulr->image did not happen.");
test.equal(imageToCanvas, 1, "Conversion image->canvas happened."); test.equal(imageToCanvas, 1, "Conversion image->canvas happened.");
test.equal(urlDestroy, 1, "Url destructor not called."); test.equal(urlDestroy, 1, "Url destructor not called.");
test.equal(imageDestroy, 1, "Image destructor called."); test.equal(imageDestroy, 0, "Image destructor not called unless we ask it.");
return Convertor.convert(c, "__TEST__canvas", "__TEST__image"); return Convertor.convert(c, "__TEST__canvas", "__TEST__image");
}).then(i => { //path canvas, image: canvas -> url -> image }).then(i => { //path canvas, image: canvas -> url -> image
test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion."); test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion.");
@ -152,8 +200,8 @@
test.equal(imageToUrl, 0, "Conversion image->url did not happened."); test.equal(imageToUrl, 0, "Conversion image->url did not happened.");
test.equal(urlDestroy, 2, "Url destructor called."); test.equal(urlDestroy, 2, "Url destructor called.");
test.equal(imageDestroy, 1, "Image destructor not called."); test.equal(imageDestroy, 0, "Image destructor not called.");
test.equal(canvasDestroy, 1, "Canvas destructor called."); test.equal(canvasDestroy, 0, "Canvas destructor called.");
test.equal(contex2DDestroy, 0, "Image destructor not called."); test.equal(contex2DDestroy, 0, "Image destructor not called.");
done(); done();
}); });
@ -170,19 +218,19 @@
cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url");
//load image object: url -> image //load image object: url -> image
cache.getData("__TEST__image").then(_ => { cache.transformTo("__TEST__image").then(_ => {
test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion."); test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion.");
test.equal(srcToImage, 1, "Conversion happened."); test.equal(srcToImage, 1, "Conversion happened.");
test.equal(urlDestroy, 1, "Url destructor called."); test.equal(urlDestroy, 1, "Url destructor called.");
test.equal(imageDestroy, 0, "Image destructor not called."); test.equal(imageDestroy, 0, "Image destructor not called.");
return cache.getData("__TEST__canvas"); return cache.transformTo("__TEST__canvas");
}).then(_ => { //path image -> canvas }).then(_ => { //path image -> canvas
test.equal(OpenSeadragon.type(cache.data), "canvas", "Got canvas object after conversion."); test.equal(OpenSeadragon.type(cache.data), "canvas", "Got canvas object after conversion.");
test.equal(srcToImage, 1, "Conversion ulr->image did not happen."); test.equal(srcToImage, 1, "Conversion ulr->image did not happen.");
test.equal(imageToCanvas, 1, "Conversion image->canvas happened."); test.equal(imageToCanvas, 1, "Conversion image->canvas happened.");
test.equal(urlDestroy, 1, "Url destructor not called."); test.equal(urlDestroy, 1, "Url destructor not called.");
test.equal(imageDestroy, 1, "Image destructor called."); test.equal(imageDestroy, 1, "Image destructor called.");
return cache.getData("__TEST__image"); return cache.transformTo("__TEST__image");
}).then(_ => { //path canvas, image: canvas -> url -> image }).then(_ => { //path canvas, image: canvas -> url -> image
test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion."); test.equal(OpenSeadragon.type(cache.data), "image", "Got image object after conversion.");
test.equal(srcToImage, 2, "Conversion ulr->image happened."); test.equal(srcToImage, 2, "Conversion ulr->image happened.");
@ -208,15 +256,113 @@
}); });
}); });
QUnit.test('Data Convertors via Cache object: testing set/get', function (test) {
const done = test.async();
const dummyRect = new OpenSeadragon.Rect(0, 0, 0, 0, 0);
const dummyTile = new OpenSeadragon.Tile(0, 0, 0, dummyRect, true, "",
undefined, true, null, dummyRect, "", "key");
const cache = new OpenSeadragon.CacheRecord();
cache.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", value => {
return new Promise((resolve, reject) => {
setTimeout(() => {
conversionHappened = true;
resolve("modified " + value);
}, 20);
});
}, 1, 1);
let longConversionDestroy = 0;
Convertor.learnDestroy("__TEST__longConversionProcessForTesting", _ => {
longConversionDestroy++;
});
const dummyRect = new OpenSeadragon.Rect(0, 0, 0, 0, 0);
const dummyTile = new OpenSeadragon.Tile(0, 0, 0, dummyRect, true, "",
undefined, true, null, dummyRect, "", "key");
const cache = new OpenSeadragon.CacheRecord();
cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url");
cache.getDataAs("__TEST__longConversionProcessForTesting").then(convertedData => {
test.equal(longConversionDestroy, 0, "Copy not 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.notOk(conversionHappened, "Nothing happened since before us the cache was deleted.");
//destruction will likely happen after we finish current async callback
setTimeout(async () => {
test.notOk(conversionHappened, "Still no conversion.");
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) { QUnit.test('Deletion cache while being in the conversion process', function (test) {
const done = test.async(); const done = test.async();
let conversionHappened = false; let conversionHappened = false;
Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", _ => { Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", value => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setTimeout(() => { setTimeout(() => {
conversionHappened = true; conversionHappened = true;
resolve("some interesting data"); resolve("modified " + value);
}, 20); }, 20);
}); });
}, 1, 1); }, 1, 1);
@ -231,10 +377,10 @@
const cache = new OpenSeadragon.CacheRecord(); const cache = new OpenSeadragon.CacheRecord();
cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url"); cache.addTile(dummyTile, "/test/data/A.png", "__TEST__url");
cache.getData("__TEST__longConversionProcessForTesting").then(_ => { cache.transformTo("__TEST__longConversionProcessForTesting").then(_ => {
test.ok(conversionHappened, "Interrupted conversion finished."); test.ok(conversionHappened, "Interrupted conversion finished.");
test.ok(cache.loaded, "Cache is loaded."); test.ok(cache.loaded, "Cache is loaded.");
test.equal(cache.data, "some interesting data", "We got the correct data."); 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(cache.type, "__TEST__longConversionProcessForTesting", "Cache declares new type.");
test.equal(urlDestroy, 1, "Url was destroyed."); test.equal(urlDestroy, 1, "Url was destroyed.");
@ -253,6 +399,7 @@
test.ok(!conversionHappened, "We destroyed cache before conversion finished."); test.ok(!conversionHappened, "We destroyed cache before conversion finished.");
}); });
// TODO: The ultimate integration test: // TODO: The ultimate integration test:
// three items: one plain image data // three items: one plain image data
// one modified image data by two different plugins // one modified image data by two different plugins

View File

@ -33,7 +33,7 @@
<script src="/test/modules/event-source.js"></script> <script src="/test/modules/event-source.js"></script>
<script src="/test/modules/viewerretrieval.js"></script> <script src="/test/modules/viewerretrieval.js"></script>
<script src="/test/modules/basic.js"></script> <script src="/test/modules/basic.js"></script>
<script src="/test/modules/typeConversion.js"></script> <script src="/test/modules/type-conversion.js"></script>
<script src="/test/modules/strings.js"></script> <script src="/test/modules/strings.js"></script>
<script src="/test/modules/formats.js"></script> <script src="/test/modules/formats.js"></script>
<script src="/test/modules/iiif.js"></script> <script src="/test/modules/iiif.js"></script>