openseadragon/src/world.js

628 lines
25 KiB
JavaScript

/*
* OpenSeadragon - World
*
* Copyright (C) 2009 CodePlex Foundation
* Copyright (C) 2010-2024 OpenSeadragon contributors
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* - Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* - Neither the name of CodePlex Foundation nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
(function( $ ){
/**
* @class World
* @memberof OpenSeadragon
* @extends OpenSeadragon.EventSource
* @classdesc Keeps track of all of the tiled images in the scene.
* @param {Object} options - World options.
* @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this World.
**/
$.World = function( options ) {
var _this = this;
$.console.assert( options.viewer, "[World] options.viewer is required" );
$.EventSource.call( this );
this.viewer = options.viewer;
this._items = [];
this._needsDraw = false;
this._autoRefigureSizes = true;
this._needsSizesFigured = false;
this._delegatedFigureSizes = function(event) {
if (_this._autoRefigureSizes) {
_this._figureSizes();
} else {
_this._needsSizesFigured = true;
}
};
this._figureSizes();
};
$.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.World.prototype */{
/**
* Add the specified item.
* @param {OpenSeadragon.TiledImage} item - The item to add.
* @param {Object} options - Options affecting insertion.
* @param {Number} [options.index] - Index for the item. If not specified, goes at the top.
* @fires OpenSeadragon.World.event:add-item
* @fires OpenSeadragon.World.event:metrics-change
*/
addItem: function( item, options ) {
$.console.assert(item, "[World.addItem] item is required");
$.console.assert(item instanceof $.TiledImage, "[World.addItem] only TiledImages supported at this time");
options = options || {};
if (options.index !== undefined) {
var index = Math.max(0, Math.min(this._items.length, options.index));
this._items.splice(index, 0, item);
} else {
this._items.push( item );
}
if (this._autoRefigureSizes) {
this._figureSizes();
} else {
this._needsSizesFigured = true;
}
this._needsDraw = true;
item.addHandler('bounds-change', this._delegatedFigureSizes);
item.addHandler('clip-change', this._delegatedFigureSizes);
/**
* Raised when an item is added to the World.
* @event add-item
* @memberOf OpenSeadragon.World
* @type {object}
* @property {OpenSeadragon.Viewer} eventSource - A reference to the World which raised the event.
* @property {OpenSeadragon.TiledImage} item - The item that has been added.
* @property {?Object} userData - Arbitrary subscriber-defined object.
*/
this.raiseEvent( 'add-item', {
item: item
} );
},
/**
* Get the item at the specified index.
* @param {Number} index - The item's index.
* @returns {OpenSeadragon.TiledImage} The item at the specified index.
*/
getItemAt: function( index ) {
$.console.assert(index !== undefined, "[World.getItemAt] index is required");
return this._items[ index ];
},
/**
* Get the index of the given item or -1 if not present.
* @param {OpenSeadragon.TiledImage} item - The item.
* @returns {Number} The index of the item or -1 if not present.
*/
getIndexOfItem: function( item ) {
$.console.assert(item, "[World.getIndexOfItem] item is required");
return $.indexOf( this._items, item );
},
/**
* @returns {Number} The number of items used.
*/
getItemCount: function() {
return this._items.length;
},
/**
* Change the index of a item so that it appears over or under others.
* @param {OpenSeadragon.TiledImage} item - The item to move.
* @param {Number} index - The new index.
* @fires OpenSeadragon.World.event:item-index-change
*/
setItemIndex: function( item, index ) {
$.console.assert(item, "[World.setItemIndex] item is required");
$.console.assert(index !== undefined, "[World.setItemIndex] index is required");
var oldIndex = this.getIndexOfItem( item );
if ( index >= this._items.length ) {
throw new Error( "Index bigger than number of layers." );
}
if ( index === oldIndex || oldIndex === -1 ) {
return;
}
this._items.splice( oldIndex, 1 );
this._items.splice( index, 0, item );
this._needsDraw = true;
/**
* Raised when the order of the indexes has been changed.
* @event item-index-change
* @memberOf OpenSeadragon.World
* @type {object}
* @property {OpenSeadragon.World} eventSource - A reference to the World which raised the event.
* @property {OpenSeadragon.TiledImage} item - The item whose index has
* been changed
* @property {Number} previousIndex - The previous index of the item
* @property {Number} newIndex - The new index of the item
* @property {?Object} userData - Arbitrary subscriber-defined object.
*/
this.raiseEvent( 'item-index-change', {
item: item,
previousIndex: oldIndex,
newIndex: index
} );
},
/**
* Remove an item.
* @param {OpenSeadragon.TiledImage} item - The item to remove.
* @fires OpenSeadragon.World.event:remove-item
* @fires OpenSeadragon.World.event:metrics-change
*/
removeItem: function( item ) {
$.console.assert(item, "[World.removeItem] item is required");
var index = $.indexOf(this._items, item );
if ( index === -1 ) {
return;
}
item.removeHandler('bounds-change', this._delegatedFigureSizes);
item.removeHandler('clip-change', this._delegatedFigureSizes);
item.destroy();
this._items.splice( index, 1 );
this._figureSizes();
this._needsDraw = true;
this._raiseRemoveItem(item);
},
/**
* Remove all items.
* @fires OpenSeadragon.World.event:remove-item
* @fires OpenSeadragon.World.event:metrics-change
*/
removeAll: function() {
// We need to make sure any pending images are canceled so the world items don't get messed up
this.viewer._cancelPendingImages();
var item;
var i;
for (i = 0; i < this._items.length; i++) {
item = this._items[i];
item.removeHandler('bounds-change', this._delegatedFigureSizes);
item.removeHandler('clip-change', this._delegatedFigureSizes);
item.destroy();
}
var removedItems = this._items;
this._items = [];
this._figureSizes();
this._needsDraw = true;
for (i = 0; i < removedItems.length; i++) {
item = removedItems[i];
this._raiseRemoveItem(item);
}
},
/**
* Forces the system consider all tiles across all tiled images
* as outdated, and fire tile update event on relevant tiles
* Detailed description is available within the 'tile-invalidated'
* event.
* @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
* @param {number} [tStamp=OpenSeadragon.now()] optionally provide tStamp of the update event
* @function
* @fires OpenSeadragon.Viewer.event:tile-invalidated
* @return {OpenSeadragon.Promise<?>}
*/
requestInvalidate: function (restoreTiles = true, tStamp = $.now()) {
$.__updated = tStamp;
// const priorityTiles = this._items.map(item => item._lastDrawn.map(x => x.tile)).flat();
// const promise = this.requestTileInvalidateEvent(priorityTiles, tStamp, restoreTiles);
// return promise.then(() => this.requestTileInvalidateEvent(this.viewer.tileCache.getLoadedTilesFor(null), tStamp, restoreTiles));
// Tile-first retrieval fires computation on tiles that share cache, which are filtered out by processing property
return this.requestTileInvalidateEvent(this.viewer.tileCache.getLoadedTilesFor(null), tStamp, restoreTiles);
// Cache-first update tile retrieval is nicer since there might be many tiles sharing
// return this.requestTileInvalidateEvent(new Set(Object.values(this.viewer.tileCache._cachesLoaded)
// .map(c => !c._destroyed && c._tiles[0])), tStamp, restoreTiles);
},
/**
* Requests tile data update.
* @function OpenSeadragon.Viewer.prototype._updateSequenceButtons
* @private
* @param {Iterable<OpenSeadragon.Tile>} tilesToProcess tiles to update
* @param {Number} tStamp timestamp in milliseconds, if active timestamp of the same value is executing,
* changes are added to the cycle, else they await next iteration
* @param {Boolean} [restoreTiles=true] if true, tile processing starts from the tile original data
* @param {Boolean} [_allowTileUnloaded=false] internal flag for calling on tiles that come new to the system
* @fires OpenSeadragon.Viewer.event:tile-invalidated
* @return {OpenSeadragon.Promise<?>}
*/
requestTileInvalidateEvent: function(tilesToProcess, tStamp, restoreTiles = true, _allowTileUnloaded = false) {
const tileList = [],
markedTiles = [];
for (const tile of tilesToProcess) {
// We allow re-execution on tiles that are in process but have too low processing timestamp,
// which must be solved by ensuring subsequent data calls in the suddenly outdated processing
// pipeline take no effect.
if (!tile || (!_allowTileUnloaded && !tile.loaded)) {
continue;
}
const tileCache = tile.getCache(tile.originalCacheKey);
if (tileCache.__invStamp && tileCache.__invStamp >= tStamp) {
continue;
}
for (let t of tileCache._tiles) {
// Mark all related tiles as processing and cache the references to unmark later on,
// last processing is set to old processing (null if finished)
t.lastProcess = t.processing;
t.processing = tStamp;
markedTiles.push(t);
}
tileCache.__invStamp = tStamp;
tileList.push(tile);
}
if (!tileList.length) {
return $.Promise.resolve();
}
// We call the event on the parent viewer window no matter what
const eventTarget = this.viewer.viewer || this.viewer;
// However, we must pick the correct drawer reference (navigator VS viewer)
const supportedFormats = this.viewer.drawer.getSupportedDataFormats();
const keepInternalCacheCopy = this.viewer.drawer.options.usePrivateCache;
const drawerId = this.viewer.drawer.getId();
const jobList = tileList.map(tile => {
const tiledImage = tile.tiledImage;
const originalCache = tile.getCache(tile.originalCacheKey);
let workingCache = null;
const getWorkingCacheData = (type) => {
if (workingCache) {
return workingCache.getDataAs(type, false);
}
const targetCopyKey = restoreTiles ? tile.originalCacheKey : tile.cacheKey;
const origCache = tile.getCache(targetCopyKey);
if (!origCache) {
$.console.error("[Tile::getData] There is no cache available for tile with key %s", targetCopyKey);
return $.Promise.reject();
}
// Here ensure type is defined, rquired by data callbacks
type = type || origCache.type;
workingCache = new $.CacheRecord().withTileReference(tile);
return origCache.getDataAs(type, true).then(data => {
workingCache.addTile(tile, data, type);
return workingCache.data;
});
};
const setWorkingCacheData = (value, type) => {
if (!workingCache) {
workingCache = new $.CacheRecord().withTileReference(tile);
workingCache.addTile(tile, value, type);
} else {
workingCache.setDataAs(value, type);
}
};
const atomicCacheSwap = () => {
if (workingCache) {
let newCacheKey = tile.buildDistinctMainCacheKey();
tiledImage._tileCache.injectCache({
tile: tile,
cache: workingCache,
targetKey: newCacheKey,
setAsMainCache: true,
tileAllowNotLoaded: false //todo what if called from load event?
});
} else if (restoreTiles) {
// If we requested restore, perform now
tiledImage._tileCache.restoreTilesThatShareOriginalCache(tile, tile.getCache(tile.originalCacheKey), true);
}
};
//todo docs
return eventTarget.raiseEventAwaiting('tile-invalidated', {
tile: tile,
tiledImage: tiledImage,
outdated: () => originalCache.__invStamp !== tStamp,
getData: getWorkingCacheData,
setData: setWorkingCacheData,
resetData: () => {
workingCache.destroy();
workingCache = null;
}
}).then(_ => {
if (originalCache.__invStamp === tStamp) {
if (workingCache) {
return workingCache.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then(c => {
if (c && originalCache.__invStamp === tStamp) {
atomicCacheSwap();
originalCache.__invStamp = null;
}
});
}
// If we requested restore, perform now
if (restoreTiles) {
const freshOriginalCacheRef = tile.getCache(tile.originalCacheKey);
tiledImage._tileCache.restoreTilesThatShareOriginalCache(tile, freshOriginalCacheRef, true);
return freshOriginalCacheRef.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then((c) => {
if (c && originalCache.__invStamp === tStamp) {
atomicCacheSwap();
originalCache.__invStamp = null;
}
});
}
const freshMainCacheRef = tile.getCache();
return freshMainCacheRef.prepareForRendering(drawerId, supportedFormats, keepInternalCacheCopy).then(() => {
originalCache.__invStamp = null;
});
} else if (workingCache) {
workingCache.destroy();
workingCache = null;
}
return null;
}).catch(e => {
$.console.error("Update routine error:", e);
});
});
return $.Promise.all(jobList).then(() => {
for (let tile of markedTiles) {
tile.lastProcess = false;
tile.processing = false;
}
this.draw();
});
},
/**
* Clears all tiles and triggers updates for all items.
*/
resetItems: function() {
for ( var i = 0; i < this._items.length; i++ ) {
this._items[i].reset();
}
},
/**
* Updates (i.e. animates bounds of) all items.
* @function
* @param viewportChanged Whether the viewport changed, which indicates that
* all TiledImages need to be updated.
*/
update: function(viewportChanged) {
var animated = false;
for ( var i = 0; i < this._items.length; i++ ) {
animated = this._items[i].update(viewportChanged) || animated;
}
return animated;
},
/**
* Draws all items.
*/
draw: function() {
this.viewer.drawer.draw(this._items);
this._needsDraw = false;
for (let item of this._items) {
this._needsDraw = item.setDrawn() || this._needsDraw;
}
},
/**
* @returns {Boolean} true if any items need updating.
*/
needsDraw: function() {
for ( var i = 0; i < this._items.length; i++ ) {
if ( this._items[i].needsDraw() ) {
return true;
}
}
return this._needsDraw;
},
/**
* @returns {OpenSeadragon.Rect} The smallest rectangle that encloses all items, in viewport coordinates.
*/
getHomeBounds: function() {
return this._homeBounds.clone();
},
/**
* To facilitate zoom constraints, we keep track of the pixel density of the
* densest item in the World (i.e. the item whose content size to viewport size
* ratio is the highest) and save it as this "content factor".
* @returns {Number} the number of content units per viewport unit.
*/
getContentFactor: function() {
return this._contentFactor;
},
/**
* As a performance optimization, setting this flag to false allows the bounds-change event handler
* on tiledImages to skip calculations on the world bounds. If a lot of images are going to be positioned in
* rapid succession, this is a good idea. When finished, setAutoRefigureSizes should be called with true
* or the system may behave oddly.
* @param {Boolean} [value] The value to which to set the flag.
*/
setAutoRefigureSizes: function(value) {
this._autoRefigureSizes = value;
if (value & this._needsSizesFigured) {
this._figureSizes();
this._needsSizesFigured = false;
}
},
/**
* Arranges all of the TiledImages with the specified settings.
* @param {Object} options - Specifies how to arrange.
* @param {Boolean} [options.immediately=false] - Whether to animate to the new arrangement.
* @param {String} [options.layout] - See collectionLayout in {@link OpenSeadragon.Options}.
* @param {Number} [options.rows] - See collectionRows in {@link OpenSeadragon.Options}.
* @param {Number} [options.columns] - See collectionColumns in {@link OpenSeadragon.Options}.
* @param {Number} [options.tileSize] - See collectionTileSize in {@link OpenSeadragon.Options}.
* @param {Number} [options.tileMargin] - See collectionTileMargin in {@link OpenSeadragon.Options}.
* @fires OpenSeadragon.World.event:metrics-change
*/
arrange: function(options) {
options = options || {};
var immediately = options.immediately || false;
var layout = options.layout || $.DEFAULT_SETTINGS.collectionLayout;
var rows = options.rows || $.DEFAULT_SETTINGS.collectionRows;
var columns = options.columns || $.DEFAULT_SETTINGS.collectionColumns;
var tileSize = options.tileSize || $.DEFAULT_SETTINGS.collectionTileSize;
var tileMargin = options.tileMargin || $.DEFAULT_SETTINGS.collectionTileMargin;
var increment = tileSize + tileMargin;
var wrap;
if (!options.rows && columns) {
wrap = columns;
} else {
wrap = Math.ceil(this._items.length / rows);
}
var x = 0;
var y = 0;
var item, box, width, height, position;
this.setAutoRefigureSizes(false);
for (var i = 0; i < this._items.length; i++) {
if (i && (i % wrap) === 0) {
if (layout === 'horizontal') {
y += increment;
x = 0;
} else {
x += increment;
y = 0;
}
}
item = this._items[i];
box = item.getBounds();
if (box.width > box.height) {
width = tileSize;
} else {
width = tileSize * (box.width / box.height);
}
height = width * (box.height / box.width);
position = new $.Point(x + ((tileSize - width) / 2),
y + ((tileSize - height) / 2));
item.setPosition(position, immediately);
item.setWidth(width, immediately);
if (layout === 'horizontal') {
x += increment;
} else {
y += increment;
}
}
this.setAutoRefigureSizes(true);
},
// private
_figureSizes: function() {
var oldHomeBounds = this._homeBounds ? this._homeBounds.clone() : null;
var oldContentSize = this._contentSize ? this._contentSize.clone() : null;
var oldContentFactor = this._contentFactor || 0;
if (!this._items.length) {
this._homeBounds = new $.Rect(0, 0, 1, 1);
this._contentSize = new $.Point(1, 1);
this._contentFactor = 1;
} else {
var item = this._items[0];
var bounds = item.getBounds();
this._contentFactor = item.getContentSize().x / bounds.width;
var clippedBounds = item.getClippedBounds().getBoundingBox();
var left = clippedBounds.x;
var top = clippedBounds.y;
var right = clippedBounds.x + clippedBounds.width;
var bottom = clippedBounds.y + clippedBounds.height;
for (var i = 1; i < this._items.length; i++) {
item = this._items[i];
bounds = item.getBounds();
this._contentFactor = Math.max(this._contentFactor,
item.getContentSize().x / bounds.width);
clippedBounds = item.getClippedBounds().getBoundingBox();
left = Math.min(left, clippedBounds.x);
top = Math.min(top, clippedBounds.y);
right = Math.max(right, clippedBounds.x + clippedBounds.width);
bottom = Math.max(bottom, clippedBounds.y + clippedBounds.height);
}
this._homeBounds = new $.Rect(left, top, right - left, bottom - top);
this._contentSize = new $.Point(
this._homeBounds.width * this._contentFactor,
this._homeBounds.height * this._contentFactor);
}
if (this._contentFactor !== oldContentFactor ||
!this._homeBounds.equals(oldHomeBounds) ||
!this._contentSize.equals(oldContentSize)) {
/**
* Raised when the home bounds or content factor change.
* @event metrics-change
* @memberOf OpenSeadragon.World
* @type {object}
* @property {OpenSeadragon.World} eventSource - A reference to the World which raised the event.
* @property {?Object} userData - Arbitrary subscriber-defined object.
*/
this.raiseEvent('metrics-change', {});
}
},
// private
_raiseRemoveItem: function(item) {
/**
* Raised when an item is removed.
* @event remove-item
* @memberOf OpenSeadragon.World
* @type {object}
* @property {OpenSeadragon.World} eventSource - A reference to the World which raised the event.
* @property {OpenSeadragon.TiledImage} item - The item's underlying item.
* @property {?Object} userData - Arbitrary subscriber-defined object.
*/
this.raiseEvent( 'remove-item', { item: item } );
}
});
}( OpenSeadragon ));