openseadragon/src/tilecache.js
Aiosa 219049976c Add tests for zombie and data type conversion, ensure destructors are called.
Fix bugs (zombie was disabled on item replace, fix zombie cache system by separating to its own cache array). Fix CacheRecord destructor & dijkstra. Deduce cache only from originalCacheKey. Force explicit type declaration with types on users.
2023-11-18 20:16:35 +01:00

434 lines
17 KiB
JavaScript

/*
* OpenSeadragon - TileCache
*
* Copyright (C) 2009 CodePlex Foundation
* Copyright (C) 2010-2023 OpenSeadragon contributors
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* - Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* - Neither the name of CodePlex Foundation nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
(function( $ ){
/**
* Cached Data Record, the cache object.
* Keeps only latest object type required.
*
* This class acts like the Maybe type:
* - it has 'loaded' flag indicating whether the tile data is ready
* - it has 'data' property that has value if loaded=true
*
* Furthermore, it has a 'getData' function that returns a promise resolving
* with the value on the desired type passed to the function.
*
* @typedef {{
* destroy: function,
* save: function,
* getData: function,
* data: ?,
* loaded: boolean
* }} OpenSeadragon.CacheRecord
*/
$.CacheRecord = class {
constructor() {
this._tiles = [];
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;
}
get data() {
return this._data;
}
get type() {
return this._type;
}
save() {
for (let tile of this._tiles) {
tile._needsDraw = true;
}
}
getData(type = this._type) {
if (type !== this._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._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.loaded) {
this._type = type;
this._promise = $.Promise.resolve(data);
this._data = data;
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?
}
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) {
this._tiles.splice(i, 1);
return;
}
}
$.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);
}
};
/**
* @class TileCache
* @memberof OpenSeadragon
* @classdesc Stores all the tiles displayed in a {@link OpenSeadragon.Viewer}.
* You generally won't have to interact with the TileCache directly.
* @param {Object} options - Configuration for this TileCache.
* @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in
* {@link OpenSeadragon.Options} for details.
*/
$.TileCache = class {
constructor( options ) {
options = options || {};
this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount;
this._tilesLoaded = [];
this._zombiesLoaded = [];
this._zombiesLoadedCount = 0;
this._cachesLoaded = [];
this._cachesLoadedCount = 0;
}
/**
* @returns {Number} The total number of tiles that have been loaded by
* this TileCache. Note that the tile might be recorded here mutliple times,
* once for each cache it uses.
*/
numTilesLoaded() {
return this._tilesLoaded.length;
}
/**
* Caches the specified tile, removing an old tile if necessary to stay under the
* maxImageCacheCount specified on construction. Note that if multiple tiles reference
* the same image, there may be more tiles than maxImageCacheCount; the goal is to keep
* the number of images below that number. Note, as well, that even the number of images
* may temporarily surpass that number, but should eventually come back down to the max specified.
* @param {Object} options - Tile info.
* @param {OpenSeadragon.Tile} options.tile - The tile to cache.
* @param {String} [options.cacheKey=undefined] - Cache Key to use. Defaults to options.tile.cacheKey
* @param {String} options.tile.cacheKey - The unique key used to identify this tile in the cache.
* Used if cacheKey not set.
* @param {Image} options.image - The image of the tile to cache. Deprecated.
* @param {*} options.data - The data of the tile to cache.
* @param {string} [options.dataType] - The data type of the tile to cache. Required.
* @param {Number} [options.cutoff=0] - If adding this tile goes over the cache max count, this
* function will release an old tile. The cutoff option specifies a tile level at or below which
* tiles will not be released.
*/
cacheTile( options ) {
$.console.assert( options, "[TileCache.cacheTile] options is required" );
$.console.assert( options.tile, "[TileCache.cacheTile] options.tile is required" );
$.console.assert( options.tile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" );
let cutoff = options.cutoff || 0,
insertionIndex = this._tilesLoaded.length,
cacheKey = options.cacheKey || options.tile.cacheKey;
let cacheRecord = this._cachesLoaded[cacheKey] || this._zombiesLoaded[cacheKey];
if (!cacheRecord) {
if (!options.data) {
$.console.error("[TileCache.cacheTile] options.image was renamed to options.data. '.image' attribute " +
"has been deprecated and will be removed in the future.");
options.data = options.image;
}
$.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an CacheRecord" );
cacheRecord = this._cachesLoaded[cacheKey] = new $.CacheRecord();
this._cachesLoadedCount++;
} else if (!cacheRecord.getTileCount()) {
//revive zombie
delete this._zombiesLoaded[cacheKey];
this._zombiesLoadedCount--;
}
if (!options.dataType) {
$.console.error("[TileCache.cacheTile] options.dataType is newly required. " +
"For easier use of the cache system, use the tile instance API.");
options.dataType = $.convertor.guessType(options.data);
}
cacheRecord.addTile(options.tile, options.data, options.dataType);
options.tile._caches[ cacheKey ] = cacheRecord;
// Note that just because we're unloading a tile doesn't necessarily mean
// we're unloading its cache records. With repeated calls it should sort itself out, though.
if ( this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount ) {
//prefer zombie deletion, faster, better
if (this._zombiesLoadedCount > 0) {
for (let zombie in this._zombiesLoaded) {
this._zombiesLoaded[zombie].destroy();
delete this._zombiesLoaded[zombie];
this._zombiesLoadedCount--;
break;
}
} else {
let worstTile = null;
let worstTileIndex = -1;
let prevTile, worstTime, worstLevel, prevTime, prevLevel;
for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
prevTile = this._tilesLoaded[ i ];
if ( prevTile.level <= cutoff || prevTile.beingDrawn ) {
continue;
} else if ( !worstTile ) {
worstTile = prevTile;
worstTileIndex = i;
continue;
}
prevTime = prevTile.lastTouchTime;
worstTime = worstTile.lastTouchTime;
prevLevel = prevTile.level;
worstLevel = worstTile.level;
if ( prevTime < worstTime ||
( prevTime === worstTime && prevLevel > worstLevel )) {
worstTile = prevTile;
worstTileIndex = i;
}
}
if ( worstTile && worstTileIndex >= 0 ) {
this._unloadTile(worstTile, true);
insertionIndex = worstTileIndex;
}
}
}
this._tilesLoaded[ insertionIndex ] = options.tile;
}
/**
* Clears all tiles associated with the specified tiledImage.
* @param {OpenSeadragon.TiledImage} tiledImage
*/
clearTilesFor( tiledImage ) {
$.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required');
let tile;
let cacheOverflows = this._cachesLoadedCount + this._zombiesLoadedCount > this._maxCacheItemCount;
if (tiledImage._zombieCache && cacheOverflows && this._zombiesLoadedCount > 0) {
//prefer newer zombies
for (let zombie in this._zombiesLoaded) {
this._zombiesLoaded[zombie].destroy();
delete this._zombiesLoaded[zombie];
}
this._zombiesLoadedCount = 0;
cacheOverflows = this._cachesLoadedCount > this._maxCacheItemCount;
}
for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
tile = this._tilesLoaded[ i ];
//todo might be errorprone: tile.loading true--> problem! maybe set some other flag by
if (!tile.loaded) {
//iterates from the array end, safe to remove
this._tilesLoaded.splice( i, 1 );
i--;
} 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);
}
}
}
// private
getCacheRecord(cacheKey) {
$.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required');
return this._cachesLoaded[cacheKey];
}
/**
* @param tile tile to unload
* @param destroy destroy tile cache if the cache tile counts falls to zero
* @param deleteAtIndex index to remove the tile record at, will not remove from _tiledLoaded if not set
* @private
*/
_unloadTile(tile, destroy, deleteAtIndex) {
$.console.assert(tile, '[TileCache._unloadTile] tile is required');
for (let key in tile._caches) {
const cacheRecord = this._cachesLoaded[key];
if (cacheRecord) {
cacheRecord.removeTile(tile);
if (!cacheRecord.getTileCount()) {
if (destroy) {
// #1 tile marked as destroyed (e.g. too much cached tiles or not a zombie)
cacheRecord.destroy();
delete this._cachesLoaded[tile.cacheKey];
this._cachesLoadedCount--;
} 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
if (deleteAtIndex !== undefined) {
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;
tile.unload();
/**
* Triggered when a tile has just been unloaded from memory.
*
* @event tile-unloaded
* @memberof OpenSeadragon.Viewer
* @type {object}
* @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the unloaded tile.
* @property {OpenSeadragon.Tile} tile - The tile which has been unloaded.
* @property {boolean} destroyed - False if the tile data was kept in the system.
*/
tiledImage.viewer.raiseEvent("tile-unloaded", {
tile: tile,
tiledImage: tiledImage,
destroyed: destroy
});
}
};
}( OpenSeadragon ));