Merge 750d45be81c09f4651cdd60fd2cd25e65a07ea19 into 89ae9c1376ebb86142283472bd4be8bcc102fd11

This commit is contained in:
Aiosa 2023-11-06 11:48:26 -07:00 committed by GitHub
commit 42ac1426d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2028 additions and 590 deletions

View File

@ -48,6 +48,8 @@ module.exports = function(grunt) {
"src/legacytilesource.js", "src/legacytilesource.js",
"src/imagetilesource.js", "src/imagetilesource.js",
"src/tilesourcecollection.js", "src/tilesourcecollection.js",
"src/priorityqueue.js",
"src/datatypeconvertor.js",
"src/button.js", "src/button.js",
"src/buttongroup.js", "src/buttongroup.js",
"src/rectangle.js", "src/rectangle.js",

387
src/datatypeconvertor.js Normal file
View File

@ -0,0 +1,387 @@
/*
* OpenSeadragon.convertor (static property)
*
* 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($){
/**
* modified from https://gist.github.com/Prottoy2938/66849e04b0bac459606059f5f9f3aa1a
* @private
*/
class WeightedGraph {
constructor() {
this.adjacencyList = {};
this.vertices = {};
}
addVertex(vertex) {
if (!this.vertices[vertex]) {
this.vertices[vertex] = new $.PriorityQueue.Node(0, vertex);
this.adjacencyList[vertex] = [];
return true;
}
return false;
}
addEdge(vertex1, vertex2, weight, transform) {
this.adjacencyList[vertex1].push({ target: this.vertices[vertex2], origin: this.vertices[vertex1], weight, transform });
}
/**
* @return {{path: *[], cost: number}|undefined} cheapest path for
*/
dijkstra(start, finish) {
let path = []; //to return at end
if (start === finish) {
return {path: path, cost: 0};
}
const nodes = new OpenSeadragon.PriorityQueue();
let smallestNode;
//build up initial state
for (let vertex in this.vertices) {
vertex = this.vertices[vertex];
if (vertex.value === start) {
vertex.key = 0; //keys are known distances
nodes.insertNode(vertex);
} else {
vertex.key = Infinity;
delete vertex.index;
}
vertex._previous = null;
}
// as long as there is something to visit
while (nodes.getCount() > 0) {
smallestNode = nodes.remove();
if (smallestNode.value === finish) {
break;
}
const neighbors = this.adjacencyList[smallestNode.value];
for (let neighborKey in neighbors) {
let edge = neighbors[neighborKey];
//relax node
let newCost = smallestNode.key + edge.weight;
let nextNeighbor = edge.target;
if (newCost < nextNeighbor.key) {
nextNeighbor._previous = smallestNode;
//key change
nodes.decreaseKey(nextNeighbor, newCost);
}
}
}
if (!smallestNode || !smallestNode._previous) {
return undefined; //no path
}
let finalCost = smallestNode.key; //final weight last node
// done, build the shortest path
while (smallestNode._previous) {
//backtrack
const to = smallestNode.value,
parent = smallestNode._previous,
from = parent.value;
path.push(this.adjacencyList[from].find(x => x.target.value === to));
smallestNode = parent;
}
return {
path: path.reverse(),
cost: finalCost
};
}
}
/**
* Node on the conversion path in OpenSeadragon.converter.getConversionPath().
* It can be also conversion to undefined if used as destructor implementation.
*
* @callback TypeConvertor
* @memberof OpenSeadragon
* @param {?} data data in the input format
* @return {?} data in the output format
*/
/**
* Node on the conversion path in OpenSeadragon.converter.getConversionPath().
*
* @typedef {Object} ConversionStep
* @memberof OpenSeadragon
* @param {OpenSeadragon.PriorityQueue.Node} target - Target node of the conversion step.
* Its value is the target format.
* @param {OpenSeadragon.PriorityQueue.Node} origin - Origin node of the conversion step.
* Its value is the origin format.
* @param {number} weight cost of the conversion
* @param {TypeConvertor} transform the conversion itself
*/
/**
* Class that orchestrates automated data types conversion. Do not instantiate
* this class, use OpenSeadragon.convertor - a global instance, instead.
* @class DataTypeConvertor
* @memberOf OpenSeadragon
*/
$.DataTypeConvertor = class {
constructor() {
this.graph = new WeightedGraph();
this.destructors = {};
// Teaching OpenSeadragon built-in conversions:
this.learn("canvas", "rasterUrl", (canvas) => canvas.toDataURL(), 1, 1);
this.learn("image", "rasterUrl", (image) => image.url);
this.learn("canvas", "context2d", (canvas) => canvas.getContext("2d"));
this.learn("context2d", "canvas", (context2D) => context2D.canvas);
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("rasterUrl", "image", (url) => {
return new $.Promise((resolve, reject) => {
const img = new Image();
img.onerror = img.onabort = reject;
img.onload = () => resolve(img);
img.src = url;
});
}, 1, 1);
}
/**
* FIXME: types are sensitive thing. Same data type might have different data semantics.
* - 'string' can be anything, for images, dataUrl or some URI, or incompatible stuff: vector data (JSON)
* - using $.type makes explicit requirements on its extensibility, and makes mess in naming
* - most types are [object X]
* - selected types are 'nice' -> string, canvas...
* - hard to debug
*
* Unique identifier (unlike toString.call(x)) to be guessed
* from the data value
*
* @function uniqueType
* @param x object to get unique identifier for
* - can be array, in that case, alphabetically-ordered list of inner unique types
* is returned (null, undefined are ignored)
* - if $.isPlainObject(x) is true, then the object can define
* getType function to specify its type
* - otherwise, toString.call(x) is applied to get the parameter description
* @return {string} unique variable descriptor
*/
guessType( x ) {
if (Array.isArray(x)) {
const types = [];
for (let item of x) {
if (item === undefined || item === null) {
continue;
}
const type = this.guessType(item);
if (!types.includes(type)) {
types.push(type);
}
}
types.sort();
return `Array [${types.join(",")}]`;
}
const guessType = $.type(x);
if (guessType === "dom-node") {
//distinguish nodes
return guessType.nodeName.toLowerCase();
}
if (guessType === "object") {
if ($.isFunction(x.getType)) {
return x.getType();
}
}
return guessType;
}
/**
* Teach the system to convert data type 'from' -> 'to'
* @param {string} from unique ID of the data item 'from'
* @param {string} to unique ID of the data item 'to'
* @param {OpenSeadragon.TypeConvertor} callback convertor that takes type 'from', and converts to type 'to'.
* Callback can return function. This function returns the data in type 'to',
* it can return also the value wrapped in a Promise (returned in resolve) or it can be async function.
* @param {Number} [costPower=0] positive cost class of the conversion, smaller or equal than 7.
* Should reflect the actual cost of the conversion:
* - if nothing must be done and only reference is retrieved (or a constant operation done),
* return 0 (default)
* - if a linear amount of work is necessary,
* return 1
* ... and so on, basically the number in O() complexity power exponent (for simplification)
* @param {Number} [costMultiplier=1] multiplier of the cost class, e.g. O(3n^2) would
* use costPower=2, costMultiplier=3; can be between 1 and 10^5
*/
learn(from, to, callback, costPower = 0, costMultiplier = 1) {
$.console.assert(costPower >= 0 && costPower <= 7, "[DataTypeConvertor] Conversion costPower must be between <0, 7>.");
$.console.assert($.isFunction(callback), "[DataTypeConvertor:learn] Callback must be a valid function!");
//we won't know if somebody added multiple edges, though it will choose some edge anyway
costPower++;
costMultiplier = Math.min(Math.max(costMultiplier, 1), 10 ^ 5);
this.graph.addVertex(from);
this.graph.addVertex(to);
this.graph.addEdge(from, to, costPower * 10 ^ 5 + costMultiplier, callback);
this._known = {}; //invalidate precomputed paths :/
}
/**
* Teach the system to destroy data type 'type'
* for example, textures loaded to GPU have to be also manually removed when not needed anymore.
* Needs to be defined only when the created object has extra deletion process.
* @param {string} type
* @param {OpenSeadragon.TypeConvertor} callback destructor, receives the object created,
* it is basically a type conversion to 'undefined' - thus the type.
*/
learnDestroy(type, callback) {
this.destructors[type] = callback;
}
/**
* Convert data item x of type 'from' to any of the 'to' types, chosen is the cheapest known conversion.
* Data is destroyed upon conversion. For different behavior, implement your conversion using the
* path rules obtained from getConversionPath().
* @param {*} x data item to convert
* @param {string} from data item type
* @param {string} to desired type(s)
* @return {OpenSeadragon.Promise<?>} promise resolution with type 'to' or undefined if the conversion failed
*/
convert(x, from, ...to) {
const conversionPath = this.getConversionPath(from, to);
if (!conversionPath) {
$.console.error(`[OpenSeadragon.convertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`);
return $.Promise.resolve();
}
const stepCount = conversionPath.length,
_this = this;
const step = (x, i) => {
if (i >= stepCount) {
return $.Promise.resolve(x);
}
let edge = conversionPath[i];
let y = edge.transform(x);
if (!y) {
$.console.warn(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting to %s)`, edge.target);
return $.Promise.resolve();
}
//node.value holds the type string
_this.destroy(edge.origin.value, x);
const result = $.type(y) === "promise" ? y : $.Promise.resolve(y);
return result.then(res => step(res, i + 1));
};
return step(x, 0);
}
/**
* Destroy the data item given.
* @param {string} type data type
* @param {?} data
*/
destroy(type, data) {
const destructor = this.destructors[type];
if (destructor) {
destructor(data);
}
}
/**
* Get possible system type conversions and cache result.
* @param {string} from data item type
* @param {string|string[]} to array of accepted types
* @return {[ConversionStep]|undefined} array of required conversions (returns empty array
* for from===to), or undefined if the system cannot convert between given types.
* Each object has 'transform' function that converts between neighbouring types, such
* that x = arr[i].transform(x) is valid input for convertor arr[i+1].transform(), e.g.
* arr[i+1].transform(arr[i].transform( ... )) is a valid conversion procedure.
*
* Note: if a function is returned, it is a callback called once the data is ready.
*/
getConversionPath(from, to) {
let bestConvertorPath, selectedType;
let knownFrom = this._known[from];
if (!knownFrom) {
this._known[from] = knownFrom = {};
}
if (Array.isArray(to)) {
$.console.assert(typeof to === "string" || to.length > 0, "[getConversionPath] conversion 'to' type must be defined.");
let bestCost = Infinity;
//FIXME: pre-compute all paths in 'to' array? could be efficient for multiple
// type system, but overhead for simple use cases... now we just use the first type if costs unknown
selectedType = to[0];
for (const outType of to) {
const conversion = knownFrom[outType];
if (conversion && bestCost > conversion.cost) {
bestConvertorPath = conversion;
bestCost = conversion.cost;
selectedType = outType;
}
}
} else {
$.console.assert(typeof to === "string", "[getConversionPath] conversion 'to' type must be defined.");
bestConvertorPath = knownFrom[to];
selectedType = to;
}
if (!bestConvertorPath) {
bestConvertorPath = this.graph.dijkstra(from, selectedType);
this._known[from][selectedType] = bestConvertorPath;
}
return bestConvertorPath ? bestConvertorPath.path : undefined;
}
};
/**
* Static convertor available throughout OpenSeadragon.
*
* Built-in conversions include types:
* - context2d canvas 2d context
* - image HTMLImage element
* - rasterUrl url string carrying or pointing to 2D raster data
* - canvas HTMLCanvas element
*
* @type OpenSeadragon.DataTypeConvertor
* @memberOf OpenSeadragon
*/
$.convertor = new $.DataTypeConvertor();
}(OpenSeadragon));

View File

@ -70,10 +70,10 @@ $.EventSource.prototype = {
* @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority.
*/ */
addOnceHandler: function(eventName, handler, userData, times, priority) { addOnceHandler: function(eventName, handler, userData, times, priority) {
var self = this; const self = this;
times = times || 1; times = times || 1;
var count = 0; let count = 0;
var onceHandler = function(event) { const onceHandler = function(event) {
count++; count++;
if (count === times) { if (count === times) {
self.removeHandler(eventName, onceHandler); self.removeHandler(eventName, onceHandler);
@ -92,12 +92,12 @@ $.EventSource.prototype = {
* @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority. * @param {Number} [priority=0] - Handler priority. By default, all priorities are 0. Higher number = priority.
*/ */
addHandler: function ( eventName, handler, userData, priority ) { addHandler: function ( eventName, handler, userData, priority ) {
var events = this.events[ eventName ]; let events = this.events[ eventName ];
if ( !events ) { if ( !events ) {
this.events[ eventName ] = events = []; this.events[ eventName ] = events = [];
} }
if ( handler && $.isFunction( handler ) ) { if ( handler && $.isFunction( handler ) ) {
var index = events.length, let index = events.length,
event = { handler: handler, userData: userData || null, priority: priority || 0 }; event = { handler: handler, userData: userData || null, priority: priority || 0 };
events[ index ] = event; events[ index ] = event;
while ( index > 0 && events[ index - 1 ].priority < events[ index ].priority ) { while ( index > 0 && events[ index - 1 ].priority < events[ index ].priority ) {
@ -115,14 +115,13 @@ $.EventSource.prototype = {
* @param {OpenSeadragon.EventHandler} handler - Function to be removed. * @param {OpenSeadragon.EventHandler} handler - Function to be removed.
*/ */
removeHandler: function ( eventName, handler ) { removeHandler: function ( eventName, handler ) {
var events = this.events[ eventName ], const events = this.events[ eventName ],
handlers = [], handlers = [];
i;
if ( !events ) { if ( !events ) {
return; return;
} }
if ( $.isArray( events ) ) { if ( $.isArray( events ) ) {
for ( i = 0; i < events.length; i++ ) { for ( let i = 0; i < events.length; i++ ) {
if ( events[i].handler !== handler ) { if ( events[i].handler !== handler ) {
handlers.push( events[ i ] ); handlers.push( events[ i ] );
} }
@ -137,7 +136,7 @@ $.EventSource.prototype = {
* @returns {number} amount of events * @returns {number} amount of events
*/ */
numberOfHandlers: function (eventName) { numberOfHandlers: function (eventName) {
var events = this.events[ eventName ]; const events = this.events[ eventName ];
if ( !events ) { if ( !events ) {
return 0; return 0;
} }
@ -154,7 +153,7 @@ $.EventSource.prototype = {
if ( eventName ){ if ( eventName ){
this.events[ eventName ] = []; this.events[ eventName ] = [];
} else{ } else{
for ( var eventType in this.events ) { for ( let eventType in this.events ) {
this.events[ eventType ] = []; this.events[ eventType ] = [];
} }
} }
@ -166,7 +165,7 @@ $.EventSource.prototype = {
* @param {String} eventName - Name of event to get handlers for. * @param {String} eventName - Name of event to get handlers for.
*/ */
getHandler: function ( eventName) { getHandler: function ( eventName) {
var events = this.events[ eventName ]; let events = this.events[ eventName ];
if ( !events || !events.length ) { if ( !events || !events.length ) {
return null; return null;
} }
@ -174,9 +173,8 @@ $.EventSource.prototype = {
[ events[ 0 ] ] : [ events[ 0 ] ] :
Array.apply( null, events ); Array.apply( null, events );
return function ( source, args ) { return function ( source, args ) {
var i, let length = events.length;
length = events.length; for ( let i = 0; i < length; i++ ) {
for ( i = 0; i < length; i++ ) {
if ( events[ i ] ) { if ( events[ i ] ) {
args.eventSource = source; args.eventSource = source;
args.userData = events[ i ].userData; args.userData = events[ i ].userData;
@ -186,6 +184,43 @@ $.EventSource.prototype = {
}; };
}, },
/**
* Get a function which iterates the list of all handlers registered for a given event,
* calling the handler for each and awaiting async ones.
* @function
* @param {String} eventName - Name of event to get handlers for.
*/
getAwaitingHandler: function ( eventName) {
let events = this.events[ eventName ];
if ( !events || !events.length ) {
return null;
}
events = events.length === 1 ?
[ events[ 0 ] ] :
Array.apply( null, events );
return function ( source, args ) {
// We return a promise that gets resolved after all the events finish.
// Returning loop result is not correct, loop promises chain dynamically
// and outer code could process finishing logics in the middle of event loop.
return new $.Promise(resolve => {
const length = events.length;
function loop(index) {
if ( index >= length || !events[ index ] ) {
resolve("Resolved!");
return null;
}
args.eventSource = source;
args.userData = events[ index ].userData;
let result = events[ index ].handler( args );
result = (!result || $.type(result) !== "promise") ? $.Promise.resolve() : result;
return result.then(() => loop(index + 1));
}
loop(0);
});
};
},
/** /**
* Trigger an event, optionally passing additional information. * Trigger an event, optionally passing additional information.
* @function * @function
@ -194,13 +229,31 @@ $.EventSource.prototype = {
*/ */
raiseEvent: function( eventName, eventArgs ) { raiseEvent: function( eventName, eventArgs ) {
//uncomment if you want to get a log of all events //uncomment if you want to get a log of all events
//$.console.log( eventName ); //$.console.log( "Event fired:", eventName );
var handler = this.getHandler( eventName ); const handler = this.getHandler( eventName );
if ( handler ) { if ( handler ) {
return handler( this, eventArgs || {} ); return handler( this, eventArgs || {} );
} }
return undefined; return undefined;
},
/**
* Trigger an event, optionally passing additional information.
* This events awaits every asynchronous or promise-returning function.
* @param {String} eventName - Name of event to register.
* @param {Object} eventArgs - Event-specific data.
* @return {OpenSeadragon.Promise|undefined} - Promise resolved upon the event completion.
*/
raiseEventAwaiting: function ( eventName, eventArgs ) {
//uncomment if you want to get a log of all events
//$.console.log( "Awaiting event fired:", eventName );
const awaitingHandler = this.getAwaitingHandler( eventName );
if ( awaitingHandler ) {
return awaitingHandler( this, eventArgs || {} );
}
return $.Promise.resolve("No handler for this event registered.");
} }
}; };

View File

@ -112,12 +112,38 @@ $.ImageJob.prototype = {
* Finish this job. * Finish this job.
* @param {*} data data that has been downloaded * @param {*} data data that has been downloaded
* @param {XMLHttpRequest} request reference to the request if used * @param {XMLHttpRequest} request reference to the request if used
* @param {string} errorMessage description upon failure * @param {string} dataType data type identifier
* old behavior: dataType treated as errorMessage if data is falsey value
*/ */
finish: function(data, request, errorMessage ) { finish: function(data, request, dataType) {
// old behavior, no deprecation due to possible finish calls with invalid data item (e.g. different error)
if (data === null || data === undefined || data === false) {
this.fail(dataType || "[downloadTileStart->finish()] Retrieved data is invalid!", request);
return;
}
this.data = data; this.data = data;
this.request = request; this.request = request;
this.errorMsg = null;
this.dataType = dataType;
if (this.jobId) {
window.clearTimeout(this.jobId);
}
this.callback(this);
},
/**
* Finish this job as a failure.
* @param {string} errorMessage description upon failure
* @param {XMLHttpRequest} request reference to the request if used
*/
fail: function(errorMessage, request) {
this.data = null;
this.request = request;
this.errorMsg = errorMessage; this.errorMsg = errorMessage;
this.dataType = null;
if (this.jobId) { if (this.jobId) {
window.clearTimeout(this.jobId); window.clearTimeout(this.jobId);
@ -180,10 +206,7 @@ $.ImageLoader.prototype = {
}; };
} }
var _this = this, const _this = this,
complete = function(job) {
completeJob(_this, job, options.callback);
},
jobOptions = { jobOptions = {
src: options.src, src: options.src,
tile: options.tile || {}, tile: options.tile || {},
@ -193,7 +216,7 @@ $.ImageLoader.prototype = {
crossOriginPolicy: options.crossOriginPolicy, crossOriginPolicy: options.crossOriginPolicy,
ajaxWithCredentials: options.ajaxWithCredentials, ajaxWithCredentials: options.ajaxWithCredentials,
postData: options.postData, postData: options.postData,
callback: complete, callback: (job) => completeJob(_this, job, options.callback),
abort: options.abort, abort: options.abort,
timeout: this.timeout timeout: this.timeout
}, },
@ -234,10 +257,10 @@ $.ImageLoader.prototype = {
* @param callback - Called once cleanup is finished. * @param callback - Called once cleanup is finished.
*/ */
function completeJob(loader, job, callback) { function completeJob(loader, job, callback) {
if (job.errorMsg !== '' && (job.data === null || job.data === undefined) && job.tries < 1 + loader.tileRetryMax) { if (job.errorMsg && job.data === null && job.tries < 1 + loader.tileRetryMax) {
loader.failedTiles.push(job); loader.failedTiles.push(job);
} }
var nextJob; let nextJob;
loader.jobsInProgress--; loader.jobsInProgress--;
@ -249,15 +272,15 @@ function completeJob(loader, job, callback) {
if (loader.tileRetryMax > 0 && loader.jobQueue.length === 0) { if (loader.tileRetryMax > 0 && loader.jobQueue.length === 0) {
if ((!loader.jobLimit || loader.jobsInProgress < loader.jobLimit) && loader.failedTiles.length > 0) { if ((!loader.jobLimit || loader.jobsInProgress < loader.jobLimit) && loader.failedTiles.length > 0) {
nextJob = loader.failedTiles.shift(); nextJob = loader.failedTiles.shift();
setTimeout(function () { setTimeout(function () {
nextJob.start(); nextJob.start();
}, loader.tileRetryDelay); }, loader.tileRetryDelay);
loader.jobsInProgress++; loader.jobsInProgress++;
} }
} }
callback(job.data, job.errorMsg, job.request); callback(job.data, job.errorMsg, job.request, job.dataType);
} }
}(OpenSeadragon)); }(OpenSeadragon));

View File

@ -34,250 +34,243 @@
(function ($) { (function ($) {
/** /**
* @class ImageTileSource * @class ImageTileSource
* @classdesc The ImageTileSource allows a simple image to be loaded * @classdesc The ImageTileSource allows a simple image to be loaded
* into an OpenSeadragon Viewer. * into an OpenSeadragon Viewer.
* There are 2 ways to open an ImageTileSource: * There are 2 ways to open an ImageTileSource:
* 1. viewer.open({type: 'image', url: fooUrl}); * 1. viewer.open({type: 'image', url: fooUrl});
* 2. viewer.open(new OpenSeadragon.ImageTileSource({url: fooUrl})); * 2. viewer.open(new OpenSeadragon.ImageTileSource({url: fooUrl}));
* *
* With the first syntax, the crossOriginPolicy, ajaxWithCredentials and * With the first syntax, the crossOriginPolicy, ajaxWithCredentials and
* useCanvas options are inherited from the viewer if they are not * useCanvas options are inherited from the viewer if they are not
* specified directly in the options object. * specified directly in the options object.
* *
* @memberof OpenSeadragon * @memberof OpenSeadragon
* @extends OpenSeadragon.TileSource * @extends OpenSeadragon.TileSource
* @param {Object} options Options object. * @param {Object} options Options object.
* @param {String} options.url URL of the image * @param {String} options.url URL of the image
* @param {Boolean} [options.buildPyramid=true] If set to true (default), a * @param {Boolean} [options.buildPyramid=true] If set to true (default), a
* pyramid will be built internally to provide a better downsampling. * pyramid will be built internally to provide a better downsampling.
* @param {String|Boolean} [options.crossOriginPolicy=false] Valid values are * @param {String|Boolean} [options.crossOriginPolicy=false] Valid values are
* 'Anonymous', 'use-credentials', and false. If false, image requests will * 'Anonymous', 'use-credentials', and false. If false, image requests will
* not use CORS preventing internal pyramid building for images from other * not use CORS preventing internal pyramid building for images from other
* domains. * domains.
* @param {String|Boolean} [options.ajaxWithCredentials=false] Whether to set * @param {String|Boolean} [options.ajaxWithCredentials=false] Whether to set
* the withCredentials XHR flag for AJAX requests (when loading tile sources). * the withCredentials XHR flag for AJAX requests (when loading tile sources).
* @param {Boolean} [options.useCanvas=true] Set to false to prevent any use * @param {Boolean} [options.useCanvas=true] Set to false to prevent any use
* of the canvas API. * of the canvas API.
*/ */
$.ImageTileSource = function (options) { $.ImageTileSource = class extends $.TileSource {
options = $.extend({ constructor(props) {
super($.extend({
buildPyramid: true, buildPyramid: true,
crossOriginPolicy: false, crossOriginPolicy: false,
ajaxWithCredentials: false, ajaxWithCredentials: false,
useCanvas: true useCanvas: true
}, options); }, props));
$.TileSource.apply(this, [options]); }
}; /**
* Determine if the data and/or url imply the image service is supported by
* this tile source.
* @function
* @param {Object|Array} data
* @param {String} url - optional
*/
supports(data, url) {
return data.type && data.type === "image";
}
/**
*
* @function
* @param {Object} options - the options
* @param {String} dataUrl - the url the image was retrieved from, if any.
* @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
* @returns {Object} options - A dictionary of keyword arguments sufficient
* to configure this tile sources constructor.
*/
configure(options, dataUrl, postData) {
return options;
}
/**
* Responsible for retrieving, and caching the
* image metadata pertinent to this TileSources implementation.
* @function
* @param {String} url
* @throws {Error}
*/
getImageInfo(url) {
const image = new Image(),
_this = this;
$.extend($.ImageTileSource.prototype, $.TileSource.prototype, /** @lends OpenSeadragon.ImageTileSource.prototype */{ if (this.crossOriginPolicy) {
/** image.crossOrigin = this.crossOriginPolicy;
* Determine if the data and/or url imply the image service is supported by }
* this tile source. if (this.ajaxWithCredentials) {
* @function image.useCredentials = this.ajaxWithCredentials;
* @param {Object|Array} data }
* @param {String} optional - url
*/
supports: function (data, url) {
return data.type && data.type === "image";
},
/**
*
* @function
* @param {Object} options - the options
* @param {String} dataUrl - the url the image was retrieved from, if any.
* @param {String} postData - HTTP POST data in k=v&k2=v2... form or null
* @returns {Object} options - A dictionary of keyword arguments sufficient
* to configure this tile sources constructor.
*/
configure: function (options, dataUrl, postData) {
return options;
},
/**
* Responsible for retrieving, and caching the
* image metadata pertinent to this TileSources implementation.
* @function
* @param {String} url
* @throws {Error}
*/
getImageInfo: function (url) {
var image = this._image = new Image();
var _this = this;
if (this.crossOriginPolicy) { $.addEvent(image, 'load', function () {
image.crossOrigin = this.crossOriginPolicy; _this.width = image.naturalWidth;
} _this.height = image.naturalHeight;
if (this.ajaxWithCredentials) { _this.aspectRatio = _this.width / _this.height;
image.useCredentials = this.ajaxWithCredentials; _this.dimensions = new $.Point(_this.width, _this.height);
} _this._tileWidth = _this.width;
_this._tileHeight = _this.height;
_this.tileOverlap = 0;
_this.minLevel = 0;
_this.image = image;
_this.levels = _this._buildLevels(image);
_this.maxLevel = _this.levels.length - 1;
$.addEvent(image, 'load', function () { _this.ready = true;
_this.width = image.naturalWidth;
_this.height = image.naturalHeight;
_this.aspectRatio = _this.width / _this.height;
_this.dimensions = new $.Point(_this.width, _this.height);
_this._tileWidth = _this.width;
_this._tileHeight = _this.height;
_this.tileOverlap = 0;
_this.minLevel = 0;
_this.levels = _this._buildLevels();
_this.maxLevel = _this.levels.length - 1;
_this.ready = true; // Note: this event is documented elsewhere, in TileSource
_this.raiseEvent('ready', {tileSource: _this});
});
// Note: this event is documented elsewhere, in TileSource $.addEvent(image, 'error', function () {
_this.raiseEvent('ready', {tileSource: _this}); _this.image = null;
// Note: this event is documented elsewhere, in TileSource
_this.raiseEvent('open-failed', {
message: "Error loading image at " + url,
source: url
}); });
});
$.addEvent(image, 'error', function () { image.src = url;
// Note: this event is documented elsewhere, in TileSource }
_this.raiseEvent('open-failed', { /**
message: "Error loading image at " + url, * @function
source: url * @param {Number} level
}); */
}); getLevelScale(level) {
let levelScale = NaN;
if (level >= this.minLevel && level <= this.maxLevel) {
levelScale =
this.levels[level].width /
this.levels[this.maxLevel].width;
}
return levelScale;
}
/**
* @function
* @param {Number} level
*/
getNumTiles(level) {
if (this.getLevelScale(level)) {
return new $.Point(1, 1);
}
return new $.Point(0, 0);
}
/**
* Retrieves a tile url
* @function
* @param {Number} level Level of the tile
* @param {Number} x x coordinate of the tile
* @param {Number} y y coordinate of the tile
*/
getTileUrl(level, x, y) {
if (level === this.maxLevel) {
return this.url; //for original image, preserve url
}
//make up url by positional args
return `${this.url}?l=${level}&x=${x}&y=${y}`;
}
image.src = url; getTilePostData(level, x, y) {
}, return {level: level, x: x, y: y};
/** }
* @function
* @param {Number} level
*/
getLevelScale: function (level) {
var levelScale = NaN;
if (level >= this.minLevel && level <= this.maxLevel) {
levelScale =
this.levels[level].width /
this.levels[this.maxLevel].width;
}
return levelScale;
},
/**
* @function
* @param {Number} level
*/
getNumTiles: function (level) {
var scale = this.getLevelScale(level);
if (scale) {
return new $.Point(1, 1);
} else {
return new $.Point(0, 0);
}
},
/**
* Retrieves a tile url
* @function
* @param {Number} level Level of the tile
* @param {Number} x x coordinate of the tile
* @param {Number} y y coordinate of the tile
*/
getTileUrl: function (level, x, y) {
var url = null;
if (level >= this.minLevel && level <= this.maxLevel) {
url = this.levels[level].url;
}
return url;
},
/**
* Retrieves a tile context 2D
* @function
* @param {Number} level Level of the tile
* @param {Number} x x coordinate of the tile
* @param {Number} y y coordinate of the tile
*/
getContext2D: function (level, x, y) {
var context = null;
if (level >= this.minLevel && level <= this.maxLevel) {
context = this.levels[level].context2D;
}
return context;
},
/**
* Destroys ImageTileSource
* @function
*/
destroy: function () {
this._freeupCanvasMemory();
},
// private /**
// * Retrieves a tile context 2D
// Builds the different levels of the pyramid if possible * @deprecated
// (i.e. if canvas API enabled and no canvas tainting issue). */
_buildLevels: function () { getContext2D(level, x, y) {
var levels = [{ $.console.warn('Using [TiledImage.getContext2D] (for plain images only) is deprecated. ' +
url: this._image.src, 'Use overridden downloadTileStart (https://openseadragon.github.io/examples/advanced-data-model/) instead.');
width: this._image.naturalWidth, var context = null;
height: this._image.naturalHeight if (level >= this.minLevel && level <= this.maxLevel) {
}]; context = this.levels[level].context2D;
}
return context;
}
if (!this.buildPyramid || !$.supportsCanvas || !this.useCanvas) { downloadTileStart(job) {
// We don't need the image anymore. Allows it to be GC. const tileData = job.postData;
delete this._image; if (tileData.level === this.maxLevel) {
return levels; job.finish(this.image, null, "image");
} return;
}
var currentWidth = this._image.naturalWidth; if (tileData.level >= this.minLevel && tileData.level <= this.maxLevel) {
var currentHeight = this._image.naturalHeight; const levelData = this.levels[tileData.level];
const context = this._createContext2D(this.image, levelData.width, levelData.height);
job.finish(context, null, "context2d");
return;
}
job.fail(`Invalid level ${tileData.level} for plain image source. Did you forget to set buildPyramid=true?`);
}
downloadTileAbort(job) {
//no-op
}
var bigCanvas = document.createElement("canvas"); // private
var bigContext = bigCanvas.getContext("2d"); //
// Builds the different levels of the pyramid if possible
// (i.e. if canvas API enabled and no canvas tainting issue).
_buildLevels(image) {
const levels = [{
url: image.src,
width: image.naturalWidth,
height: image.naturalHeight
}];
bigCanvas.width = currentWidth; if (!this.buildPyramid || !$.supportsCanvas || !this.useCanvas) {
bigCanvas.height = currentHeight;
bigContext.drawImage(this._image, 0, 0, currentWidth, currentHeight);
// We cache the context of the highest level because the browser
// is a lot faster at downsampling something it already has
// downsampled before.
levels[0].context2D = bigContext;
// We don't need the image anymore. Allows it to be GC.
delete this._image;
if ($.isCanvasTainted(bigCanvas)) {
// If the canvas is tainted, we can't compute the pyramid.
return levels;
}
// We build smaller levels until either width or height becomes
// 1 pixel wide.
while (currentWidth >= 2 && currentHeight >= 2) {
currentWidth = Math.floor(currentWidth / 2);
currentHeight = Math.floor(currentHeight / 2);
var smallCanvas = document.createElement("canvas");
var smallContext = smallCanvas.getContext("2d");
smallCanvas.width = currentWidth;
smallCanvas.height = currentHeight;
smallContext.drawImage(bigCanvas, 0, 0, currentWidth, currentHeight);
levels.splice(0, 0, {
context2D: smallContext,
width: currentWidth,
height: currentHeight
});
bigCanvas = smallCanvas;
bigContext = smallContext;
}
return levels; return levels;
}, }
/**
* Free up canvas memory let currentWidth = image.naturalWidth,
* (iOS 12 or higher on 2GB RAM device has only 224MB canvas memory, currentHeight = image.naturalHeight;
* and Safari keeps canvas until its height and width will be set to 0).
* @function // We cache the context of the highest level because the browser
*/ // is a lot faster at downsampling something it already has
_freeupCanvasMemory: function () { // downsampled before.
for (var i = 0; i < this.levels.length; i++) { levels[0].context2D = this._createContext2D(image, currentWidth, currentHeight);
if(this.levels[i].context2D){ // We don't need the image anymore. Allows it to be GC.
this.levels[i].context2D.canvas.height = 0;
this.levels[i].context2D.canvas.width = 0; if ($.isCanvasTainted(levels[0].context2D)) {
} // If the canvas is tainted, we can't compute the pyramid.
} this.buildPyramid = false;
}, return levels;
}); }
// We build smaller levels until either width or height becomes
// 1 pixel wide.
while (currentWidth >= 2 && currentHeight >= 2) {
currentWidth = Math.floor(currentWidth / 2);
currentHeight = Math.floor(currentHeight / 2);
levels.push({
width: currentWidth,
height: currentHeight,
});
}
return levels.reverse();
}
_createContext2D(data, w, h) {
const canvas = document.createElement("canvas"),
context = canvas.getContext("2d");
canvas.width = w;
canvas.height = h;
context.drawImage(data, 0, 0, w, h);
return context;
}
};
}(OpenSeadragon)); }(OpenSeadragon));

View File

@ -838,16 +838,20 @@ function OpenSeadragon( options ){
* @private * @private
*/ */
var class2type = { var class2type = {
'[object Boolean]': 'boolean', '[object Boolean]': 'boolean',
'[object Number]': 'number', '[object Number]': 'number',
'[object String]': 'string', '[object String]': 'string',
'[object Function]': 'function', '[object Function]': 'function',
'[object AsyncFunction]': 'function', '[object AsyncFunction]': 'function',
'[object Promise]': 'promise', '[object Promise]': 'promise',
'[object Array]': 'array', '[object Array]': 'array',
'[object Date]': 'date', '[object Date]': 'date',
'[object RegExp]': 'regexp', '[object RegExp]': 'regexp',
'[object Object]': 'object' '[object Object]': 'object',
'[object HTMLUnknownElement]': 'dom-node',
'[object HTMLImageElement]': 'image',
'[object HTMLCanvasElement]': 'canvas',
'[object CanvasRenderingContext2D]': 'context2d'
}, },
// Save a reference to some core methods // Save a reference to some core methods
toString = Object.prototype.toString, toString = Object.prototype.toString,
@ -2375,6 +2379,7 @@ function OpenSeadragon( options ){
// Note that our preferred API is that you pass in a single object; the named // Note that our preferred API is that you pass in a single object; the named
// arguments are for legacy support. // arguments are for legacy support.
// FIXME ^ are we ready to drop legacy support? since we abandoned old ES...
if( $.isPlainObject( url ) ){ if( $.isPlainObject( url ) ){
onSuccess = url.success; onSuccess = url.success;
onError = url.error; onError = url.error;
@ -2881,6 +2886,30 @@ function OpenSeadragon( options ){
} }
} }
/**
* Promise proxy in OpenSeadragon, can be removed once IE11 support is dropped
* @type {PromiseConstructor}
*/
$.Promise = (function () {
if (window.Promise) {
return window.Promise;
}
const promise = function () {};
//TODO consider supplying promise API via callbacks/polyfill
promise.prototype.then = function () {
throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises.";
};
promise.prototype.resolve = function () {
throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises.";
};
promise.prototype.reject = function () {
throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises.";
};
promise.prototype.finally = function () {
throw "OpenSeadragon needs promises API. Your browser do not support promises. You can add polyfill.js to import promises.";
};
return promise;
})();
}(OpenSeadragon)); }(OpenSeadragon));

360
src/priorityqueue.js Normal file
View File

@ -0,0 +1,360 @@
/*
* OpenSeadragon - Queue
*
* Copyright (C) 2023 OpenSeadragon contributors (modified)
* Copyright (C) Google Inc., The Closure Library Authors.
* https://github.com/google/closure-library
*
* SPDX-License-Identifier: Apache-2.0
*/
(function($) {
/**
* @class PriorityQueue
* @classdesc Fast priority queue. Implemented as a Heap.
*/
$.PriorityQueue = class {
/**
* @param {?OpenSeadragon.PriorityQueue} optHeap Optional Heap or
* Object to initialize heap with.
*/
constructor(optHeap = undefined) {
/**
* The nodes of the heap.
*
* This is a densely packed array containing all nodes of the heap, using
* the standard flat representation of a tree as an array (i.e. element [0]
* at the top, with [1] and [2] as the second row, [3] through [6] as the
* third, etc). Thus, the children of element `i` are `2i+1` and `2i+2`, and
* the parent of element `i` is `⌊(i-1)/2⌋`.
*
* The only invariant is that children's keys must be greater than parents'.
*
* @private @const {!Array<!Node>}
*/
this.nodes_ = [];
if (optHeap) {
this.insertAll(optHeap);
}
}
/**
* Insert the given value into the heap with the given key.
* @param {K} key The key.
* @param {V} value The value.
*/
insert(key, value) {
this.insertNode(new Node(key, value));
}
/**
* Insert node item.
* @param node
*/
insertNode(node) {
const nodes = this.nodes_;
node.index = nodes.length;
nodes.push(node);
this.moveUp_(node.index);
}
/**
* Adds multiple key-value pairs from another Heap or Object
* @param {?OpenSeadragon.PriorityQueue} heap Object containing the data to add.
*/
insertAll(heap) {
let keys, values;
if (heap instanceof $.PriorityQueue) {
keys = heap.getKeys();
values = heap.getValues();
// If it is a heap and the current heap is empty, I can rely on the fact
// that the keys/values are in the correct order to put in the underlying
// structure.
if (this.getCount() <= 0) {
const nodes = this.nodes_;
for (let i = 0; i < keys.length; i++) {
const node = new Node(keys[i], values[i]);
node.index = nodes.length;
nodes.push(node);
}
return;
}
} else {
throw "insertAll supports only OpenSeadragon.PriorityQueue object!";
}
for (let i = 0; i < keys.length; i++) {
this.insert(keys[i], values[i]);
}
}
/**
* Retrieves and removes the root value of this heap.
* @return {Node} The root node item removed from the root of the heap. Returns
* undefined if the heap is empty.
*/
remove() {
const nodes = this.nodes_;
const count = nodes.length;
const rootNode = nodes[0];
if (count <= 0) {
return undefined;
} else if (count == 1) { // eslint-disable-line
nodes.length = 0;
} else {
nodes[0] = nodes.pop();
if (nodes[0]) {
nodes[0].index = 0;
}
this.moveDown_(0);
}
if (rootNode) {
delete rootNode.index;
}
return rootNode;
}
/**
* Retrieves but does not remove the root value of this heap.
* @return {V} The value at the root of the heap. Returns
* undefined if the heap is empty.
*/
peek() {
const nodes = this.nodes_;
if (nodes.length == 0) { // eslint-disable-line
return undefined;
}
return nodes[0].value;
}
/**
* Retrieves but does not remove the key of the root node of this heap.
* @return {string} The key at the root of the heap. Returns undefined if the
* heap is empty.
*/
peekKey() {
return this.nodes_[0] && this.nodes_[0].key;
}
/**
* Move the node up in hierarchy
* @param {Node} node the node
* @param {K} key new ley, must be smaller than current key
*/
decreaseKey(node, key) {
if (node.index === undefined) {
node.key = key;
this.insertNode(node);
} else {
node.key = key;
this.moveUp_(node.index);
}
}
/**
* Moves the node at the given index down to its proper place in the heap.
* @param {number} index The index of the node to move down.
* @private
*/
moveDown_(index) {
const nodes = this.nodes_;
const count = nodes.length;
// Save the node being moved down.
const node = nodes[index];
// While the current node has a child.
while (index < (count >> 1)) {
const leftChildIndex = this.getLeftChildIndex_(index);
const rightChildIndex = this.getRightChildIndex_(index);
// Determine the index of the smaller child.
const smallerChildIndex = rightChildIndex < count &&
nodes[rightChildIndex].key < nodes[leftChildIndex].key ?
rightChildIndex :
leftChildIndex;
// If the node being moved down is smaller than its children, the node
// has found the correct index it should be at.
if (nodes[smallerChildIndex].key > node.key) {
break;
}
// If not, then take the smaller child as the current node.
nodes[index] = nodes[smallerChildIndex];
nodes[index].index = index;
index = smallerChildIndex;
}
nodes[index] = node;
if (node) {
node.index = index;
}
}
/**
* Moves the node at the given index up to its proper place in the heap.
* @param {number} index The index of the node to move up.
* @private
*/
moveUp_(index) {
const nodes = this.nodes_;
const node = nodes[index];
// While the node being moved up is not at the root.
while (index > 0) {
// If the parent is greater than the node being moved up, move the parent
// down.
const parentIndex = this.getParentIndex_(index);
if (nodes[parentIndex].key > node.key) {
nodes[index] = nodes[parentIndex];
nodes[index].index = index;
index = parentIndex;
} else {
break;
}
}
nodes[index] = node;
if (node) {
node.index = index;
}
}
/**
* Gets the index of the left child of the node at the given index.
* @param {number} index The index of the node to get the left child for.
* @return {number} The index of the left child.
* @private
*/
getLeftChildIndex_(index) {
return index * 2 + 1;
}
/**
* Gets the index of the right child of the node at the given index.
* @param {number} index The index of the node to get the right child for.
* @return {number} The index of the right child.
* @private
*/
getRightChildIndex_(index) {
return index * 2 + 2;
}
/**
* Gets the index of the parent of the node at the given index.
* @param {number} index The index of the node to get the parent for.
* @return {number} The index of the parent.
* @private
*/
getParentIndex_(index) {
return (index - 1) >> 1;
}
/**
* Gets the values of the heap.
* @return {!Array<*>} The values in the heap.
*/
getValues() {
const nodes = this.nodes_;
const rv = [];
const l = nodes.length;
for (let i = 0; i < l; i++) {
rv.push(nodes[i].value);
}
return rv;
}
/**
* Gets the keys of the heap.
* @return {!Array<string>} The keys in the heap.
*/
getKeys() {
const nodes = this.nodes_;
const rv = [];
const l = nodes.length;
for (let i = 0; i < l; i++) {
rv.push(nodes[i].key);
}
return rv;
}
/**
* Whether the heap contains the given value.
* @param {V} val The value to check for.
* @return {boolean} Whether the heap contains the value.
*/
containsValue(val) {
return this.nodes_.some((node) => node.value == val); // eslint-disable-line
}
/**
* Whether the heap contains the given key.
* @param {string} key The key to check for.
* @return {boolean} Whether the heap contains the key.
*/
containsKey(key) {
return this.nodes_.some((node) => node.value == key); // eslint-disable-line
}
/**
* Clones a heap and returns a new heap
* @return {!OpenSeadragon.PriorityQueue} A new Heap with the same key-value pairs.
*/
clone() {
return new $.PriorityQueue(this);
}
/**
* The number of key-value pairs in the map
* @return {number} The number of pairs.
*/
getCount() {
return this.nodes_.length;
}
/**
* Returns true if this heap contains no elements.
* @return {boolean} Whether this heap contains no elements.
*/
isEmpty() {
return this.nodes_.length === 0;
}
/**
* Removes all elements from the heap.
*/
clear() {
this.nodes_.length = 0;
}
};
$.PriorityQueue.Node = class {
constructor(key, value) {
/**
* The key.
* @private {K}
*/
this.key = key;
/**
* The value.
* @private {V}
*/
this.value = value;
/**
* The node index value. Updated in the heap.
* @type {number}
* @private
*/
this.index = 0;
}
clone() {
return new Node(this.key, this.value);
}
};
}(OpenSeadragon));

View File

@ -45,8 +45,8 @@
* @param {Boolean} exists Is this tile a part of a sparse image? ( Also has * @param {Boolean} exists Is this tile a part of a sparse image? ( Also has
* this tile failed to load? ) * this tile failed to load? )
* @param {String|Function} url The URL of this tile's image or a function that returns a url. * @param {String|Function} url The URL of this tile's image or a function that returns a url.
* @param {CanvasRenderingContext2D} context2D The context2D of this tile if it * @param {CanvasRenderingContext2D} [context2D=undefined] The context2D of this tile if it
* is provided directly by the tile source. * * is provided directly by the tile source. Deprecated: use Tile::setCache(...) instead.
* @param {Boolean} loadWithAjax Whether this tile image should be loaded with an AJAX request . * @param {Boolean} loadWithAjax Whether this tile image should be loaded with an AJAX request .
* @param {Object} ajaxHeaders The headers to send with this tile's AJAX request (if applicable). * @param {Object} ajaxHeaders The headers to send with this tile's AJAX request (if applicable).
* @param {OpenSeadragon.Rect} sourceBounds The portion of the tile to use as the source of the * @param {OpenSeadragon.Rect} sourceBounds The portion of the tile to use as the source of the
@ -115,7 +115,9 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja
* @member {CanvasRenderingContext2D} context2D * @member {CanvasRenderingContext2D} context2D
* @memberOf OpenSeadragon.Tile# * @memberOf OpenSeadragon.Tile#
*/ */
this.context2D = context2D; if (context2D) {
this.context2D = context2D;
}
/** /**
* Whether to load this tile's image with an AJAX request. * Whether to load this tile's image with an AJAX request.
* @member {Boolean} loadWithAjax * @member {Boolean} loadWithAjax
@ -136,11 +138,21 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja
cacheKey = $.TileSource.prototype.getTileHashKey(level, x, y, url, ajaxHeaders, postData); cacheKey = $.TileSource.prototype.getTileHashKey(level, x, y, url, ajaxHeaders, postData);
} }
/** /**
* The unique cache key for this tile. * The unique main cache key for this tile. Created automatically
* from the given tiledImage.source.getTileHashKey(...) implementation.
* @member {String} cacheKey * @member {String} cacheKey
* @memberof OpenSeadragon.Tile# * @memberof OpenSeadragon.Tile#
*/ */
this.cacheKey = cacheKey; this.cacheKey = cacheKey;
/**
* By default equal to tile.cacheKey, marks a cache associated with this tile
* that holds the cache original data (it was loaded with). In case you
* change the tile data, the tile original data should be left with the cache
* 'originalCacheKey' and the new, modified data should be stored in cache 'cacheKey'.
* @member {String} originalCacheKey
* @memberof OpenSeadragon.Tile#
*/
this.originalCacheKey = this.cacheKey;
/** /**
* Is this tile loaded? * Is this tile loaded?
* @member {Boolean} loaded * @member {Boolean} loaded
@ -252,6 +264,24 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja
* @memberof OpenSeadragon.Tile# * @memberof OpenSeadragon.Tile#
*/ */
this.isBottomMost = false; this.isBottomMost = false;
/**
* FIXME: I would like to remove this reference but there is no way
* to remove it since tile-unloaded event requires the tiledImage reference.
* And, unloadTilesFor(tiledImage) in cache uses it too. Storing the
* reference on a tile level rather than cache level is more efficient.
*
* Owner of this tile.
* @member {OpenSeadragon.TiledImage}
* @memberof OpenSeadragon.Tile#
*/
this.tiledImage = null;
/**
* Array of cached tile data associated with the tile.
* @member {Object} _caches
* @private
*/
this._caches = {};
}; };
/** @lends OpenSeadragon.Tile.prototype */ /** @lends OpenSeadragon.Tile.prototype */
@ -267,26 +297,12 @@ $.Tile.prototype = {
return this.level + "/" + this.x + "_" + this.y; return this.level + "/" + this.x + "_" + this.y;
}, },
// private
_hasTransparencyChannel: function() {
console.warn("Tile.prototype._hasTransparencyChannel() has been " +
"deprecated and will be removed in the future. Use TileSource.prototype.hasTransparency() instead.");
return !!this.context2D || this.getUrl().match('.png');
},
/** /**
* Renders the tile in an html container. * Renders the tile in an html container.
* @function * @function
* @param {Element} container * @param {Element} container
*/ */
drawHTML: function( container ) { drawHTML: function( container ) {
if (!this.cacheImageRecord) {
$.console.warn(
'[Tile.drawHTML] attempting to draw tile %s when it\'s not cached',
this.toString());
return;
}
if ( !this.loaded ) { if ( !this.loaded ) {
$.console.warn( $.console.warn(
"Attempting to draw tile %s when it's not yet loaded.", "Attempting to draw tile %s when it's not yet loaded.",
@ -297,10 +313,12 @@ $.Tile.prototype = {
//EXPERIMENTAL - trying to figure out how to scale the container //EXPERIMENTAL - trying to figure out how to scale the container
// content during animation of the container size. // content during animation of the container size.
if ( !this.element ) { if ( !this.element ) {
var image = this.getImage(); const image = this.getImage();
if (!image) { if (!image) {
$.console.warn(
'[Tile.drawHTML] attempting to draw tile %s when it\'s not cached',
this.toString());
return; return;
} }
@ -358,10 +376,10 @@ $.Tile.prototype = {
/** /**
* Get the Image object for this tile. * Get the Image object for this tile.
* @returns {Image} * @returns {?Image}
*/ */
getImage: function() { getImage: function() {
return this.cacheImageRecord.getImage(); return this.getData("image");
}, },
/** /**
@ -379,41 +397,158 @@ $.Tile.prototype = {
/** /**
* Get the CanvasRenderingContext2D instance for tile image data drawn * Get the CanvasRenderingContext2D instance for tile image data drawn
* onto Canvas if enabled and available * onto Canvas if enabled and available
* @returns {CanvasRenderingContext2D} * @returns {?CanvasRenderingContext2D}
*/ */
getCanvasContext: function() { getCanvasContext: function() {
return this.context2D || this.cacheImageRecord.getRenderedContext(); return this.getData("context2d");
}, },
/**
* The context2D of this tile if it is provided directly by the tile source.
* @deprecated
* @type {CanvasRenderingContext2D} context2D
*/
get context2D() {
$.console.error("[Tile.context2D] property has been deprecated. Use Tile::getCache().");
return this.getData("context2d");
},
/**
* The context2D of this tile if it is provided directly by the tile source.
* @deprecated
*/
set context2D(value) {
$.console.error("[Tile.context2D] property has been deprecated. Use Tile::setCache().");
this.setData(value, "context2d");
},
/**
* The default cache for this tile.
* @deprecated
* @type OpenSeadragon.CacheRecord
*/
get cacheImageRecord() {
$.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::getCache.");
return this.getCache(this.cacheKey);
},
/**
* The default cache for this tile.
* @deprecated
*/
set cacheImageRecord(value) {
$.console.error("[Tile.cacheImageRecord] property has been deprecated. Use Tile::setCache.");
this._caches[this.cacheKey] = value;
},
/**
* Get the default data for this tile
* @param {?string} [type=undefined] data type to require
*/
getData(type = undefined) {
const cache = this.getCache(this.cacheKey);
if (!cache) {
return undefined;
}
cache.getData(type); //returns a promise
//we return the data synchronously immediatelly (undefined if conversion happens)
return cache.data;
},
/**
* Invalidate the tile so that viewport gets updated.
*/
save() {
const parent = this.tiledImage;
if (parent) {
parent._needsDraw = true;
}
},
/**
* Set cache data
* @param {*} value
* @param {?string} [type=undefined] data type to require
*/
setData(value, type = undefined) {
this.setCache(this.cacheKey, value, type);
},
/**
* Read tile cache data object (CacheRecord)
* @param {string} key cache key to read that belongs to this tile
* @return {OpenSeadragon.CacheRecord}
*/
getCache: function(key) {
return this._caches[key];
},
/**
* Set tile cache, possibly multiple with custom key
* @param {string} key cache key, must be unique (we recommend re-using this.cacheTile
* value and extend it with some another unique content, by default overrides the existing
* main cache used for drawing, if not existing.
* @param {*} data data to cache - this data will be sent to the TileSource API for refinement.
* @param {?string} type data type, will be guessed if not provided
* @param [_safely=true] private
* @param [_cutoff=0] private
*/
setCache: function(key, data, type = undefined, _safely = true, _cutoff = 0) {
type = type || $.convertor.guessType(data);
if (_safely && key === this.cacheKey) {
//todo later, we could have drawers register their supported rendering type
// and OpenSeadragon would check compatibility automatically, now we render
// using two main types so we check their ability
const conversion = $.convertor.getConversionPath(type, "canvas", "image");
$.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);
}
this.tiledImage._tileCache.cacheTile({
data: data,
dataType: type,
tile: this,
cacheKey: key,
cutoff: _cutoff
});
},
/**
* FIXME:refactor
* @return {boolean}
*/
dataReady() {
return this.getCache(this.cacheKey).loaded;
},
/** /**
* Renders the tile in a canvas-based context. * Renders the tile in a canvas-based context.
* @function * @function
* @param {Canvas} context * @param {CanvasRenderingContext2D} context
* @param {Function} drawingHandler - Method for firing the drawing event. * @param {Function} drawingHandler - Method for firing the drawing event.
* drawingHandler({context, tile, rendered}) * drawingHandler({context, tile, rendered})
* where <code>rendered</code> is the context with the pre-drawn image. * where <code>rendered</code> is the context with the pre-drawn image.
* @param {Number} [scale=1] - Apply a scale to position and size * @param {Number} [scale=1] - Apply a scale to position and size
* @param {OpenSeadragon.Point} [translate] - A translation vector * @param {OpenSeadragon.Point} [translate] - A translation vector
* @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round * @param {Boolean} [shouldRoundPositionAndSize] - Tells whether to round
* position and size of tiles supporting alpha channel in non-transparency * position and size of tiles supporting alpha channel in non-transparency context.
* context.
* @param {OpenSeadragon.TileSource} source - The source specification of the tile. * @param {OpenSeadragon.TileSource} source - The source specification of the tile.
*/ */
drawCanvas: function( context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source) { drawCanvas: function( context, drawingHandler, scale, translate, shouldRoundPositionAndSize, source) {
var position = this.position.times($.pixelDensityRatio), var position = this.position.times($.pixelDensityRatio),
size = this.size.times($.pixelDensityRatio), size = this.size.times($.pixelDensityRatio),
rendered; rendered = this.getCanvasContext();
if (!this.context2D && !this.cacheImageRecord) { if (!rendered) {
$.console.warn( $.console.warn(
'[Tile.drawCanvas] attempting to draw tile %s when it\'s not cached', '[Tile.drawCanvas] attempting to draw tile %s when it\'s not cached',
this.toString()); this.toString());
return; return;
} }
rendered = this.getCanvasContext();
if ( !this.loaded || !rendered ){ if ( !this.loaded || !rendered ){
$.console.warn( $.console.warn(
"Attempting to draw tile %s when it's not yet loaded.", "Attempting to draw tile %s when it's not yet loaded.",
@ -495,15 +630,11 @@ $.Tile.prototype = {
/** /**
* Get the ratio between current and original size. * Get the ratio between current and original size.
* @function * @function
* @returns {Float} * @returns {Number}
*/ */
getScaleForEdgeSmoothing: function() { getScaleForEdgeSmoothing: function() {
var context; const context = this.getCanvasContext();
if (this.cacheImageRecord) { if (!context) {
context = this.cacheImageRecord.getRenderedContext();
} else if (this.context2D) {
context = this.context2D;
} else {
$.console.warn( $.console.warn(
'[Tile.drawCanvas] attempting to get tile scale %s when tile\'s not cached', '[Tile.drawCanvas] attempting to get tile scale %s when tile\'s not cached',
this.toString()); this.toString());
@ -548,6 +679,8 @@ $.Tile.prototype = {
this.element.parentNode.removeChild( this.element ); this.element.parentNode.removeChild( this.element );
} }
this.tiledImage = null;
this._caches = [];
this.element = null; this.element = null;
this.imgElement = null; this.imgElement = null;
this.loaded = false; this.loaded = false;

View File

@ -34,55 +34,285 @@
(function( $ ){ (function( $ ){
// private class /**
var TileRecord = function( options ) { * Cached Data Record, the cache object.
$.console.assert( options, "[TileCache.cacheTile] options is required" ); * Keeps only latest object type required.
$.console.assert( options.tile, "[TileCache.cacheTile] options.tile is required" ); *
$.console.assert( options.tiledImage, "[TileCache.cacheTile] options.tiledImage is required" ); * This class acts like the Maybe type:
this.tile = options.tile; * - it has 'loaded' flag indicating whether the tile data is ready
this.tiledImage = options.tiledImage; * - 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();
}
// private class destroy() {
var ImageRecord = function(options) {
$.console.assert( options, "[ImageRecord] options is required" );
$.console.assert( options.data, "[ImageRecord] options.data is required" );
this._tiles = [];
options.create.apply(null, [this, options.data, options.ownerTile]);
this._destroyImplementation = options.destroy.bind(null, this);
this.getImage = options.getImage.bind(null, this);
this.getData = options.getData.bind(null, this);
this.getRenderedContext = options.getRenderedContext.bind(null, this);
};
ImageRecord.prototype = {
destroy: function() {
this._destroyImplementation();
this._tiles = null; this._tiles = null;
}, this._data = null;
this._type = null;
this.loaded = false;
//make sure this gets destroyed even if loaded=false
if (this.loaded) {
$.convertor.destroy(this._type, this._data);
} else {
this._promise.then(x => $.convertor.destroy(this._type, x));
}
this._promise = $.Promise.resolve();
}
get data() {
return this._data;
}
get type() {
return this._type;
}
save() {
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) {
$.console.warn("[CacheRecord.addTile] Tile %s was added to an existing cache, but the tile is supposed to carry incompatible data type %s!", tile, type);
}
addTile: function(tile) {
$.console.assert(tile, '[ImageRecord.addTile] tile is required');
this._tiles.push(tile); this._tiles.push(tile);
}, }
removeTile: function(tile) { /**
for (var i = 0; i < this._tiles.length; i++) { * Remove tile dependency on this record.
* @param tile
*/
removeTile(tile) {
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;
} }
} }
$.console.warn('[ImageRecord.removeTile] trying to remove unknown tile', tile); $.console.warn('[CacheRecord.removeTile] trying to remove unknown tile', tile);
}, }
getTileCount: function() { /**
* Get the amount of tiles sharing this record.
* @return {number}
*/
getTileCount() {
return this._tiles.length; return this._tiles.length;
} }
/**
* Private conversion that makes sure the cache knows its data is ready
* @private
*/
_convert(from, to) {
const convertor = $.convertor,
conversionPath = convertor.getConversionPath(from, to);
if (!conversionPath) {
$.console.error(`[OpenSeadragon.convertor.convert] Conversion conversion ${from} ---> ${to} cannot be done!`);
return; //no-op
}
const originalData = this._data,
stepCount = conversionPath.length,
_this = this,
convert = (x, i) => {
if (i >= stepCount) {
_this._data = x;
_this.loaded = true;
return $.Promise.resolve(x);
}
let edge = conversionPath[i];
return $.Promise.resolve(edge.transform(x)).then(
y => {
if (!y) {
$.console.error(`[OpenSeadragon.convertor.convert] data mid result falsey value (while converting using %s)`, edge);
//try to recover using original data, but it returns inconsistent type (the log be hopefully enough)
_this._data = from;
_this._type = from;
_this.loaded = true;
return originalData;
}
//node.value holds the type string
convertor.destroy(edge.origin.value, x);
return convert(y, i + 1);
}
);
};
this.loaded = false;
this._data = undefined;
this._type = to;
this._promise = convert(originalData, 0);
}
}; };
//FIXME: really implement or throw away? new parameter would allow users to
// use this implementation instead of the above to allow caching for old data
// (for example in the default use, the data is downloaded as an image, and
// converted to a canvas -> the image record gets thrown away)
//
//FIXME: Note that this can be also achieved somewhat by caching the midresults
// as a single cache object instead. Also, there is the problem of lifecycle-oriented
// data types such as WebGL textures we want to unload manually: this looks like
// we really want to cache midresuls and have their custom destructors
// $.MemoryCacheRecord = class extends $.CacheRecord {
// constructor(memorySize) {
// super();
// this.length = memorySize;
// this.index = 0;
// this.content = [];
// this.types = [];
// this.defaultType = "image";
// }
//
// // overrides:
//
// destroy() {
// super.destroy();
// this.types = null;
// this.content = null;
// this.types = null;
// this.defaultType = null;
// }
//
// getData(type = this.defaultType) {
// let item = this.add(type, undefined);
// if (item === undefined) {
// //no such type available, get if possible
// //todo: possible unomptimal use, we could cache costs and re-use known paths, though it adds overhead...
// item = $.convertor.convert(this.current(), this.currentType(), type);
// this.add(type, item);
// }
// return item;
// }
//
// /**
// * @deprecated
// */
// get data() {
// $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use getData(...) instead!");
// return this.current();
// }
//
// /**
// * @deprecated
// * @param value
// */
// set data(value) {
// //FIXME: addTile bit bad name, related to the issue mentioned elsewhere
// $.console.warn("[MemoryCacheRecord.data] is deprecated property. Use addTile(...) instead!");
// this.defaultType = $.convertor.guessType(value);
// this.add(this.defaultType, value);
// }
//
// addTile(tile, data, type) {
// $.console.assert(tile, '[CacheRecord.addTile] tile is required');
//
// //allow overriding the cache - existing tile or different type
// if (this._tiles.includes(tile)) {
// this.removeTile(tile);
// } else if (!this.defaultType !== type) {
// this.defaultType = type;
// this.add(type, data);
// }
//
// this._tiles.push(tile);
// }
//
// // extends:
//
// add(type, item) {
// const index = this.hasIndex(type);
// if (index > -1) {
// //no index change, swap (optimally, move all by one - too expensive...)
// item = this.content[index];
// this.content[index] = this.content[this.index];
// } else {
// this.index = (this.index + 1) % this.length;
// }
// this.content[this.index] = item;
// this.types[this.index] = type;
// return item;
// }
//
// has(type) {
// for (let i = 0; i < this.types.length; i++) {
// const t = this.types[i];
// if (t === type) {
// return this.content[i];
// }
// }
// return undefined;
// }
//
// hasIndex(type) {
// for (let i = 0; i < this.types.length; i++) {
// const t = this.types[i];
// if (t === type) {
// return i;
// }
// }
// return -1;
// }
//
// current() {
// return this.content[this.index];
// }
//
// currentType() {
// return this.types[this.index];
// }
// };
/** /**
* @class TileCache * @class TileCache
* @memberof OpenSeadragon * @memberof OpenSeadragon
@ -92,24 +322,24 @@ ImageRecord.prototype = {
* @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in * @param {Number} [options.maxImageCacheCount] - See maxImageCacheCount in
* {@link OpenSeadragon.Options} for details. * {@link OpenSeadragon.Options} for details.
*/ */
$.TileCache = function( options ) { $.TileCache = class {
options = options || {}; constructor( options ) {
options = options || {};
this._maxImageCacheCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount; this._maxCacheItemCount = options.maxImageCacheCount || $.DEFAULT_SETTINGS.maxImageCacheCount;
this._tilesLoaded = []; this._tilesLoaded = [];
this._imagesLoaded = []; this._cachesLoaded = [];
this._imagesLoadedCount = 0; this._cachesLoadedCount = 0;
}; }
/** @lends OpenSeadragon.TileCache.prototype */
$.TileCache.prototype = {
/** /**
* @returns {Number} The total number of tiles that have been loaded by * @returns {Number} The total number of tiles that have been loaded by
* this TileCache. * this TileCache. Note that the tile might be recorded here mutliple times,
* once for each cache it uses.
*/ */
numTilesLoaded: function() { numTilesLoaded() {
return this._tilesLoaded.length; return this._tilesLoaded.length;
}, }
/** /**
* 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
@ -119,24 +349,27 @@ $.TileCache.prototype = {
* 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.
* @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.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.
* @param {Image} options.image - The image of the tile to cache. * Used if cacheKey not set.
* @param {OpenSeadragon.TiledImage} options.tiledImage - The TiledImage that owns that tile. * @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 * @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.
*/ */
cacheTile: function( 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" ); $.console.assert( options.tile, "[TileCache.cacheTile] options.tile is required" );
$.console.assert( options.tile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" ); $.console.assert( options.tile.cacheKey, "[TileCache.cacheTile] options.tile.cacheKey is required" );
$.console.assert( options.tiledImage, "[TileCache.cacheTile] options.tiledImage is required" );
var cutoff = options.cutoff || 0; let cutoff = options.cutoff || 0,
var insertionIndex = this._tilesLoaded.length; insertionIndex = this._tilesLoaded.length,
cacheKey = options.cacheKey || options.tile.cacheKey;
var imageRecord = this._imagesLoaded[options.tile.cacheKey]; let cacheRecord = this._cachesLoaded[options.tile.cacheKey];
if (!imageRecord) { if (!cacheRecord) {
if (!options.data) { 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 " +
@ -144,41 +377,54 @@ $.TileCache.prototype = {
options.data = options.image; options.data = options.image;
} }
$.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an ImageRecord" ); $.console.assert( options.data, "[TileCache.cacheTile] options.data is required to create an CacheRecord" );
imageRecord = this._imagesLoaded[options.tile.cacheKey] = new ImageRecord({ cacheRecord = this._cachesLoaded[options.tile.cacheKey] = new $.CacheRecord();
data: options.data, this._cachesLoadedCount++;
ownerTile: options.tile, } else if (cacheRecord.__zombie__) {
create: options.tiledImage.source.createTileCache, delete cacheRecord.__zombie__;
destroy: options.tiledImage.source.destroyTileCache, //revive cache, replace from _tilesLoaded so it won't get unloaded
getImage: options.tiledImage.source.getTileCacheDataAsImage, this._tilesLoaded.splice( cacheRecord.__index__, 1 );
getData: options.tiledImage.source.getTileCacheData, delete cacheRecord.__index__;
getRenderedContext: options.tiledImage.source.getTileCacheDataAsContext2D, insertionIndex--;
});
this._imagesLoadedCount++;
} }
imageRecord.addTile(options.tile); if (!options.dataType) {
options.tile.cacheImageRecord = imageRecord; $.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 // Note that just because we're unloading a tile doesn't necessarily mean
// we're unloading an image. With repeated calls it should sort itself out, though. // we're unloading its cache records. With repeated calls it should sort itself out, though.
if ( this._imagesLoadedCount > this._maxImageCacheCount ) { if ( this._cachesLoadedCount > this._maxCacheItemCount ) {
var worstTile = null; let worstTile = null;
var worstTileIndex = -1; let worstTileIndex = -1;
var worstTileRecord = null; let prevTile, worstTime, worstLevel, prevTime, prevLevel;
var prevTile, worstTime, worstLevel, prevTime, prevLevel, prevTileRecord;
for ( var i = this._tilesLoaded.length - 1; i >= 0; i-- ) { for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
prevTileRecord = this._tilesLoaded[ i ]; prevTile = this._tilesLoaded[ i ];
prevTile = prevTileRecord.tile;
//todo try different approach? the only ugly part, keep tilesLoaded array empty of unloaded tiles
if (!prevTile.loaded) {
//iterates from the array end, safe to remove
this._tilesLoaded.splice( i, 1 );
continue;
}
if ( prevTile.__zombie__ !== undefined ) {
//remove without hesitation, CacheRecord instance
worstTile = prevTile.__zombie__;
worstTileIndex = i;
break;
}
if ( prevTile.level <= cutoff || prevTile.beingDrawn ) { if ( prevTile.level <= cutoff || prevTile.beingDrawn ) {
continue; continue;
} else if ( !worstTile ) { } else if ( !worstTile ) {
worstTile = prevTile; worstTile = prevTile;
worstTileIndex = i; worstTileIndex = i;
worstTileRecord = prevTileRecord;
continue; continue;
} }
@ -188,64 +434,91 @@ $.TileCache.prototype = {
worstLevel = worstTile.level; worstLevel = worstTile.level;
if ( prevTime < worstTime || if ( prevTime < worstTime ||
( prevTime === worstTime && prevLevel > worstLevel ) ) { ( prevTime === worstTime && prevLevel > worstLevel )) {
worstTile = prevTile; worstTile = prevTile;
worstTileIndex = i; worstTileIndex = i;
worstTileRecord = prevTileRecord;
} }
} }
if ( worstTile && worstTileIndex >= 0 ) { if ( worstTile && worstTileIndex >= 0 ) {
this._unloadTile(worstTileRecord); this._unloadTile(worstTile, true);
insertionIndex = worstTileIndex; insertionIndex = worstTileIndex;
} }
} }
this._tilesLoaded[ insertionIndex ] = new TileRecord({ this._tilesLoaded[ insertionIndex ] = options.tile;
tile: options.tile, }
tiledImage: options.tiledImage
});
},
/** /**
* Clears all tiles associated with the specified tiledImage. * Clears all tiles associated with the specified tiledImage.
* @param {OpenSeadragon.TiledImage} tiledImage * @param {OpenSeadragon.TiledImage} tiledImage
*/ */
clearTilesFor: function( tiledImage ) { clearTilesFor( tiledImage ) {
$.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required'); $.console.assert(tiledImage, '[TileCache.clearTilesFor] tiledImage is required');
var tileRecord; let tile;
for ( var i = 0; i < this._tilesLoaded.length; ++i ) { for ( let i = this._tilesLoaded.length - 1; i >= 0; i-- ) {
tileRecord = this._tilesLoaded[ i ]; tile = this._tilesLoaded[ i ];
if ( tileRecord.tiledImage === tiledImage ) {
this._unloadTile(tileRecord); //todo try different approach? the only ugly part, keep tilesLoaded array empty of unloaded tiles
if (!tile.loaded) {
//iterates from the array end, safe to remove
this._tilesLoaded.splice( i, 1 ); this._tilesLoaded.splice( i, 1 );
i--; i--;
} else if ( tile.tiledImage === tiledImage ) {
this._unloadTile(tile, !tiledImage._zombieCache ||
this._cachesLoadedCount > this._maxCacheItemCount, i);
} }
} }
}, }
// private // private
getImageRecord: function(cacheKey) { getCacheRecord(cacheKey) {
$.console.assert(cacheKey, '[TileCache.getImageRecord] cacheKey is required'); $.console.assert(cacheKey, '[TileCache.getCacheRecord] cacheKey is required');
return this._imagesLoaded[cacheKey]; return this._cachesLoaded[cacheKey];
}, }
// private /**
_unloadTile: function(tileRecord) { * @param tile tile to unload
$.console.assert(tileRecord, '[TileCache._unloadTile] tileRecord is required'); * @param destroy destroy tile cache if the cache tile counts falls to zero
var tile = tileRecord.tile; * @param deleteAtIndex index to remove the tile record at, will not remove from _tiledLoaded if not set
var tiledImage = tileRecord.tiledImage; * @private
*/
_unloadTile(tile, destroy, deleteAtIndex) {
$.console.assert(tile, '[TileCache._unloadTile] tile is required');
tile.unload(); for (let key in tile._caches) {
tile.cacheImageRecord = null; 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--;
var imageRecord = this._imagesLoaded[tile.cacheKey]; //delete also the tile record
imageRecord.removeTile(tile); if (deleteAtIndex !== undefined) {
if (!imageRecord.getTileCount()) { this._tilesLoaded.splice( deleteAtIndex, 1 );
imageRecord.destroy(); }
delete this._imagesLoaded[tile.cacheKey]; } else if (deleteAtIndex !== undefined) {
this._imagesLoadedCount--; // #2 Tile is a zombie. Do not delete record, reuse.
// a bit dirty but performant... -> we can remove later, or revive
// we can do this, in array the tile is once for each its cache object
this._tilesLoaded[ deleteAtIndex ] = cacheRecord;
cacheRecord.__zombie__ = tile;
cacheRecord.__index__ = deleteAtIndex;
}
} 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. * Triggered when a tile has just been unloaded from memory.
@ -263,4 +536,5 @@ $.TileCache.prototype = {
} }
}; };
}( OpenSeadragon )); }( OpenSeadragon ));

View File

@ -161,8 +161,9 @@ $.TiledImage = function( options ) {
lastResetTime: 0, // Last time for which the tiledImage was reset. lastResetTime: 0, // Last time for which the tiledImage was reset.
_midDraw: false, // Is the tiledImage currently updating the viewport? _midDraw: false, // Is the tiledImage currently updating the viewport?
_needsDraw: true, // Does the tiledImage need to update the viewport again? _needsDraw: true, // Does the tiledImage need to update the viewport again?
_hasOpaqueTile: false, // Do we have even one fully opaque tile? _hasOpaqueTile: false, // Do we have even one fully opaque tile?
_tilesLoading: 0, // The number of pending tile requests. _tilesLoading: 0, // The number of pending tile requests.
_zombieCache: false, // Allow cache to stay in memory upon deletion.
//configurable settings //configurable settings
springStiffness: $.DEFAULT_SETTINGS.springStiffness, springStiffness: $.DEFAULT_SETTINGS.springStiffness,
animationTime: $.DEFAULT_SETTINGS.animationTime, animationTime: $.DEFAULT_SETTINGS.animationTime,
@ -337,6 +338,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
this.reset(); this.reset();
if (this.source.destroy) { if (this.source.destroy) {
$.console.warn("[TileSource.destroy] is deprecated. Use advanced data model API.");
this.source.destroy(); this.source.destroy();
} }
}, },
@ -1094,6 +1096,18 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
} }
}, },
/**
* Enable cache preservation even without this tile image,
* by default disabled. It means that upon removing,
* the tile cache does not get immediately erased but
* stays in the memory to be potentially re-used by other
* TiledImages.
* @param {boolean} allow
*/
allowZombieCache: function(allow) {
this._zombieCache = allow;
},
// private // private
_setScale: function(scale, immediately) { _setScale: function(scale, immediately) {
var sameTarget = (this._scaleSpring.target.value === scale); var sameTarget = (this._scaleSpring.target.value === scale);
@ -1277,11 +1291,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
// Load the new 'best' n tiles // Load the new 'best' n tiles
if (bestTiles && bestTiles.length > 0) { if (bestTiles && bestTiles.length > 0) {
bestTiles.forEach(function (tile) { for (let tile of bestTiles) {
if (tile && !tile.context2D) { if (tile) {
this._loadTile(tile, currentTime); this._loadTile(tile, currentTime);
} }
}, this); }
this._needsDraw = true; this._needsDraw = true;
this._setFullyLoaded(false); this._setFullyLoaded(false);
} else { } else {
@ -1460,12 +1474,12 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
levelVisibility, viewportCenter, numberOfTiles, currentTime, best){ levelVisibility, viewportCenter, numberOfTiles, currentTime, best){
var tile = this._getTile( var tile = this._getTile(
x, y, x, y,
level, level,
currentTime, currentTime,
numberOfTiles, numberOfTiles,
this._worldWidthCurrent, this._worldWidthCurrent,
this._worldHeightCurrent this._worldHeightCurrent
), ),
drawTile = drawLevel; drawTile = drawLevel;
@ -1516,13 +1530,23 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
levelVisibility levelVisibility
); );
if (!tile.loaded) { if (!tile.loaded && !tile.loading) {
if (tile.context2D) { // Tile was created or its data removed: check whether cache has the data before downloading.
this._setTileLoaded(tile); if (!tile.cacheKey) {
} else { tile.cacheKey = "";
var imageRecord = this._tileCache.getImageRecord(tile.cacheKey); tile.originalCacheKey = "";
if (imageRecord) { }
this._setTileLoaded(tile, imageRecord.getData()); const similarCacheRecord =
this._tileCache.getCacheRecord(tile.originalCacheKey) ||
this._tileCache.getCacheRecord(tile.cacheKey);
if (similarCacheRecord) {
const cutoff = this.source.getClosestLevel();
if (similarCacheRecord.loaded) {
this._setTileLoaded(tile, similarCacheRecord.data, cutoff, null, similarCacheRecord.type);
} else {
similarCacheRecord.getData().then(data =>
this._setTileLoaded(tile, data, cutoff, null, similarCacheRecord.type));
} }
} }
} }
@ -1578,7 +1602,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
urlOrGetter, urlOrGetter,
post, post,
ajaxHeaders, ajaxHeaders,
context2D,
tile, tile,
tilesMatrix = this.tilesMatrix, tilesMatrix = this.tilesMatrix,
tileSource = this.source; tileSource = this.source;
@ -1610,9 +1633,6 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
ajaxHeaders = null; ajaxHeaders = null;
} }
context2D = tileSource.getContext2D ?
tileSource.getContext2D(level, xMod, yMod) : undefined;
tile = new $.Tile( tile = new $.Tile(
level, level,
x, x,
@ -1620,12 +1640,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
bounds, bounds,
exists, exists,
urlOrGetter, urlOrGetter,
context2D, undefined,
this.loadTilesWithAjax, this.loadTilesWithAjax,
ajaxHeaders, ajaxHeaders,
sourceBounds, sourceBounds,
post, post,
tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post) tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post),
this
); );
if (this.getFlip()) { if (this.getFlip()) {
@ -1672,8 +1693,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
ajaxHeaders: tile.ajaxHeaders, ajaxHeaders: tile.ajaxHeaders,
crossOriginPolicy: this.crossOriginPolicy, crossOriginPolicy: this.crossOriginPolicy,
ajaxWithCredentials: this.ajaxWithCredentials, ajaxWithCredentials: this.ajaxWithCredentials,
callback: function( data, errorMsg, tileRequest ){ callback: function( data, errorMsg, tileRequest, dataType ){
_this._onTileLoad( tile, time, data, errorMsg, tileRequest ); _this._onTileLoad( tile, time, data, errorMsg, tileRequest, dataType );
}, },
abort: function() { abort: function() {
tile.loading = false; tile.loading = false;
@ -1690,9 +1711,11 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
* @param {*} data image data * @param {*} data image data
* @param {String} errorMsg * @param {String} errorMsg
* @param {XMLHttpRequest} tileRequest * @param {XMLHttpRequest} tileRequest
* @param {String} [dataType=undefined] data type, derived automatically if not set
*/ */
_onTileLoad: function( tile, time, data, errorMsg, tileRequest ) { _onTileLoad: function( tile, time, data, errorMsg, tileRequest, dataType ) {
if ( !data ) { //data is set to null on error by image loader, allow custom falsey values (e.g. 0)
if ( data === null ) {
$.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg ); $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg );
/** /**
* Triggered when a tile fails to load. * Triggered when a tile fails to load.
@ -1730,8 +1753,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
finish = function() { finish = function() {
var ccc = _this.source; var ccc = _this.source;
var cutoff = ccc.getClosestLevel(); var cutoff = ccc.getClosestLevel();
_this._setTileLoaded(tile, data, cutoff, tileRequest); _this._setTileLoaded(tile, data, cutoff, tileRequest, dataType);
}; };
// Check if we're mid-update; this can happen on IE8 because image load events for // Check if we're mid-update; this can happen on IE8 because image load events for
// cached images happen immediately there // cached images happen immediately there
@ -1739,7 +1762,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
finish(); finish();
} else { } else {
// Wait until after the update, in case caching unloads any tiles // Wait until after the update, in case caching unloads any tiles
window.setTimeout( finish, 1); window.setTimeout(finish, 1);
} }
}, },
@ -1747,77 +1770,94 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
* @private * @private
* @inner * @inner
* @param {OpenSeadragon.Tile} tile * @param {OpenSeadragon.Tile} tile
* @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object,
* @param {Number|undefined} cutoff * can be null: in that case, cache is assigned to a tile without further processing
* @param {XMLHttpRequest|undefined} tileRequest * @param {?Number} cutoff
* @param {?XMLHttpRequest} tileRequest
* @param {?String} [dataType=undefined] data type, derived automatically if not set
*/ */
_setTileLoaded: function(tile, data, cutoff, tileRequest) { _setTileLoaded: function(tile, data, cutoff, tileRequest, dataType) {
var increment = 0, tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
eventFinished = false, // -> reason why it is not in the constructor
_this = this; tile.setCache(tile.cacheKey, data, dataType, false, cutoff);
function getCompletionCallback() { let resolver = null;
if (eventFinished) { const _this = this,
$.console.error("Event 'tile-loaded' argument getCompletionCallback must be called synchronously. " + finishPromise = new $.Promise(r => {
"Its return value should be called asynchronously."); resolver = r;
} });
increment++;
return completionCallback;
}
function completionCallback() { function completionCallback() {
increment--; //do not override true if set (false is default)
if (increment === 0) { tile.hasTransparency = tile.hasTransparency || _this.source.hasTransparency(
undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData
);
//make sure cache data is ready for drawing, if not, request the desired format
const cache = tile.getCache(tile.cacheKey),
// TODO: dynamic type declaration from the drawer base class interface from v5.0 onwards
requiredType = _this._drawer.useCanvas ? "context2d" : "image";
if (!cache) {
$.console.warn("Tile %s not cached at the end of tile-loaded event: tile will not be drawn - it has no data!", tile);
resolver(tile);
} else if (cache.type !== requiredType) {
//initiate conversion as soon as possible if incompatible with the drawer
cache.getData(requiredType).then(_ => {
tile.loading = false;
tile.loaded = true;
resolver(tile);
});
} else {
tile.loading = false; tile.loading = false;
tile.loaded = true; tile.loaded = true;
tile.hasTransparency = _this.source.hasTransparency( resolver(tile);
tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData
);
if (!tile.context2D) {
_this._tileCache.cacheTile({
data: data,
tile: tile,
cutoff: cutoff,
tiledImage: _this
});
}
_this._needsDraw = true;
} }
//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();
} }
/** /**
* 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
* image has been downloaded and can be modified before being drawn to the canvas. * image has been downloaded and can be modified before being drawn to the canvas.
* This event awaits its handlers - they can return promises, or be async functions.
* *
* @event tile-loaded * @event tile-loaded awaiting event
* @memberof OpenSeadragon.Viewer * @memberof OpenSeadragon.Viewer
* @type {object} * @type {object}
* @property {Image|*} image - The image (data) of the tile. Deprecated. * @property {Image|*} image - The image (data) of the tile. Deprecated.
* @property {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object * @property {*} data image data, the data sent to ImageJob.prototype.finish(),
* by default an Image object. Deprecated
* @property {String} dataType type of the data
* @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile. * @property {OpenSeadragon.TiledImage} tiledImage - The tiled image of the loaded tile.
* @property {OpenSeadragon.Tile} tile - The tile which has been loaded. * @property {OpenSeadragon.Tile} tile - The tile which has been loaded.
* @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable). * @property {XMLHttpRequest} tileRequest - The AJAX request that loaded this tile (if applicable).
* @property {function} getCompletionCallback - A function giving a callback to call * @property {OpenSeadragon.Promise} - Promise resolved when the tile gets fully loaded.
* when the asynchronous processing of the image is done. The image will be * @property {function} getCompletionCallback - deprecated
* marked as entirely loaded when the callback has been called once for each
* call to getCompletionCallback.
*/ */
const promise = this.viewer.raiseEventAwaiting("tile-loaded", {
var fallbackCompletion = getCompletionCallback();
this.viewer.raiseEvent("tile-loaded", {
tile: tile, tile: tile,
tiledImage: this, tiledImage: this,
tileRequest: tileRequest, tileRequest: tileRequest,
promise: finishPromise,
get image() { get image() {
$.console.error("[tile-loaded] event 'image' has been deprecated. Use 'data' property instead."); $.console.error("[tile-loaded] event 'image' has been deprecated. Use 'tile.getData()' instead.");
return data; return data;
}, },
data: data, get data() {
getCompletionCallback: getCompletionCallback $.console.error("[tile-loaded] event 'data' has been deprecated. Use 'tile.getData()' instead.");
return data;
},
getCompletionCallback: function () {
$.console.error("[tile-loaded] getCompletionCallback is not supported: it is compulsory to handle the event with async functions if applicable.");
},
});
promise.then(completionCallback).catch(() => {
$.console.error("[tile-loaded] event finished with failure: there might be a problem with a plugin you are using.");
completionCallback();
}); });
eventFinished = true;
// In case the completion callback is never called, we at least force it once.
fallbackCompletion();
}, },
/** /**
@ -1978,7 +2018,9 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
useSketch = this.opacity < 1 || useSketch = this.opacity < 1 ||
(this.compositeOperation && this.compositeOperation !== 'source-over') || (this.compositeOperation && this.compositeOperation !== 'source-over') ||
(!this._isBottomItem() && (!this._isBottomItem() &&
this.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData)); (tile.hasTransparency || this.source.hasTransparency(
undefined, tile.getUrl(), tile.ajaxHeaders, tile.postData))
);
} }
var sketchScale; var sketchScale;

View File

@ -139,6 +139,12 @@ $.TileSource = function( width, height, tileSize, tileOverlap, minLevel, maxLeve
} ); } );
} }
/**
* Retrieve context2D of this tile source
* @memberOf OpenSeadragon.TileSource
* @function getContext2D
*/
/** /**
* Ratio of width to height * Ratio of width to height
* @member {Number} aspectRatio * @member {Number} aspectRatio
@ -682,9 +688,12 @@ $.TileSource.prototype = {
* The tile cache object is uniquely determined by this key and used to lookup * The tile cache object is uniquely determined by this key and used to lookup
* the image data in cache: keys should be different if images are different. * the image data in cache: keys should be different if images are different.
* *
* In case a tile has context2D property defined (TileSource.prototype.getContext2D) * You can return falsey tile cache key, in which case the tile will
* or its context2D is set manually; the cache is not used and this function * be created without invoking ImageJob --- but with data=null. Then,
* is irrelevant. * you are responsible for manually creating the cache data. This is useful
* particularly if you want to use empty TiledImage with client-side derived data
* only. The default tile-cache key is then called "" - an empty string.
*
* Note: default behaviour does not take into account post data. * Note: default behaviour does not take into account post data.
* @param {Number} level tile level it was fetched with * @param {Number} level tile level it was fetched with
* @param {Number} x x-coordinate in the pyramid level * @param {Number} x x-coordinate in the pyramid level
@ -692,6 +701,9 @@ $.TileSource.prototype = {
* @param {String} url the tile was fetched with * @param {String} url the tile was fetched with
* @param {Object} ajaxHeaders the tile was fetched with * @param {Object} ajaxHeaders the tile was fetched with
* @param {*} postData data the tile was fetched with (type depends on getTilePostData(..) return type) * @param {*} postData data the tile was fetched with (type depends on getTilePostData(..) return type)
* @return {?String} can return the cache key or null, in that case an empty cache is initialized
* without downloading any data for internal use: user has to define the cache contents manually, via
* the cache interface of this class.
*/ */
getTileHashKey: function(level, x, y, url, ajaxHeaders, postData) { getTileHashKey: function(level, x, y, url, ajaxHeaders, postData) {
function withHeaders(hash) { function withHeaders(hash) {
@ -722,10 +734,15 @@ $.TileSource.prototype = {
/** /**
* Decide whether tiles have transparency: this is crucial for correct images blending. * Decide whether tiles have transparency: this is crucial for correct images blending.
* Overriden on a tile level by setting tile.hasTransparency = true;
* @param context2D unused, deprecated argument
* @param url tile.getUrl() value for given tile
* @param ajaxHeaders tile.ajaxHeaders value for given tile
* @param post tile.post value for given tile
* @returns {boolean} true if the image has transparency * @returns {boolean} true if the image has transparency
*/ */
hasTransparency: function(context2D, url, ajaxHeaders, post) { hasTransparency: function(context2D, url, ajaxHeaders, post) {
return !!context2D || url.match('.png'); return url.match('.png');
}, },
/** /**
@ -737,15 +754,18 @@ $.TileSource.prototype = {
* @param {String} [context.ajaxHeaders] - Headers to add to the image request if using AJAX. * @param {String} [context.ajaxHeaders] - Headers to add to the image request if using AJAX.
* @param {Boolean} [context.ajaxWithCredentials] - Whether to set withCredentials on AJAX requests. * @param {Boolean} [context.ajaxWithCredentials] - Whether to set withCredentials on AJAX requests.
* @param {String} [context.crossOriginPolicy] - CORS policy to use for downloads * @param {String} [context.crossOriginPolicy] - CORS policy to use for downloads
* @param {String} [context.postData] - HTTP POST data (usually but not necessarily in k=v&k2=v2... form, * @param {?String|?Object} [context.postData] - HTTP POST data (usually but not necessarily
* see TileSource::getPostData) or null * in k=v&k2=v2... form, see TileSource::getPostData) or null
* @param {*} [context.userData] - Empty object to attach your own data and helper variables to. * @param {*} [context.userData] - Empty object to attach your own data and helper variables to.
* @param {Function} [context.finish] - Should be called unless abort() was executed, e.g. on all occasions, * @param {Function} [context.finish] - Should be called unless abort() was executed upon successful
* be it successful or unsuccessful request. * data retrieval.
* Usage: context.finish(data, request, errMessage). Pass the downloaded data object or null upon failure. * Usage: context.finish(data, request, dataType=undefined). Pass the downloaded data object
* Add also reference to an ajax request if used. Provide error message in case of failure. * add also reference to an ajax request if used. Optionally, specify what data type the data is.
* @param {Function} [context.fail] - Should be called unless abort() was executed upon unsuccessful request.
* Usage: context.fail(errMessage, request). Provide error message in case of failure,
* add also reference to an ajax request if used.
* @param {Function} [context.abort] - Called automatically when the job times out. * @param {Function} [context.abort] - Called automatically when the job times out.
* Usage: context.abort(). * Usage: if you decide to abort the request (no fail/finish will be called), call context.abort().
* @param {Function} [context.callback] @private - Called automatically once image has been downloaded * @param {Function} [context.callback] @private - Called automatically once image has been downloaded
* (triggered by finish). * (triggered by finish).
* @param {Number} [context.timeout] @private - The max number of milliseconds that * @param {Number} [context.timeout] @private - The max number of milliseconds that
@ -753,25 +773,26 @@ $.TileSource.prototype = {
* @param {string} [context.errorMsg] @private - The final error message, default null (set by finish). * @param {string} [context.errorMsg] @private - The final error message, default null (set by finish).
*/ */
downloadTileStart: function (context) { downloadTileStart: function (context) {
var dataStore = context.userData, const dataStore = context.userData,
image = new Image(); image = new Image();
dataStore.image = image; dataStore.image = image;
dataStore.request = null; dataStore.request = null;
var finish = function(error) { const finalize = function(error) {
if (!image) { if (error || !image) {
context.finish(null, dataStore.request, "Image load failed: undefined Image instance."); context.fail(error || "[downloadTileStart] Image load failed: undefined Image instance.",
dataStore.request);
return; return;
} }
image.onload = image.onerror = image.onabort = null; image.onload = image.onerror = image.onabort = null;
context.finish(error ? null : image, dataStore.request, error); context.finish(image, dataStore.request); //dataType="image" recognized automatically
}; };
image.onload = function () { image.onload = function () {
finish(); finalize();
}; };
image.onabort = image.onerror = function() { image.onabort = image.onerror = function() {
finish("Image load aborted."); finalize("[downloadTileStart] Image load aborted.");
}; };
// Load the tile with an AJAX request if the loadWithAjax option is // Load the tile with an AJAX request if the loadWithAjax option is
@ -791,21 +812,21 @@ $.TileSource.prototype = {
try { try {
blb = new window.Blob([request.response]); blb = new window.Blob([request.response]);
} catch (e) { } catch (e) {
var BlobBuilder = ( const BlobBuilder = (
window.BlobBuilder || window.BlobBuilder ||
window.WebKitBlobBuilder || window.WebKitBlobBuilder ||
window.MozBlobBuilder || window.MozBlobBuilder ||
window.MSBlobBuilder window.MSBlobBuilder
); );
if (e.name === 'TypeError' && BlobBuilder) { if (e.name === 'TypeError' && BlobBuilder) {
var bb = new BlobBuilder(); const bb = new BlobBuilder();
bb.append(request.response); bb.append(request.response);
blb = bb.getBlob(); blb = bb.getBlob();
} }
} }
// If the blob is empty for some reason consider the image load a failure. // If the blob is empty for some reason consider the image load a failure.
if (blb.size === 0) { if (blb.size === 0) {
finish("Empty image response."); finalize("[downloadTileStart] Empty image response.");
} else { } else {
// Create a URL for the blob data and make it the source of the image object. // Create a URL for the blob data and make it the source of the image object.
// This will still trigger Image.onload to indicate a successful tile load. // This will still trigger Image.onload to indicate a successful tile load.
@ -813,7 +834,7 @@ $.TileSource.prototype = {
} }
}, },
error: function(request) { error: function(request) {
finish("Image load aborted - XHR error"); finalize("[downloadTileStart] Image load aborted - XHR error");
} }
}); });
} else { } else {
@ -827,6 +848,8 @@ $.TileSource.prototype = {
/** /**
* Provide means of aborting the execution. * Provide means of aborting the execution.
* Note that if you override this function, you should override also downloadTileStart(). * Note that if you override this function, you should override also downloadTileStart().
* Note that calling job.abort() would create an infinite loop!
*
* @param {ImageJob} context job, the same object as with downloadTileStart(..) * @param {ImageJob} context job, the same object as with downloadTileStart(..)
* @param {*} [context.userData] - Empty object to attach (and mainly read) your own data. * @param {*} [context.userData] - Empty object to attach (and mainly read) your own data.
*/ */
@ -845,33 +868,44 @@ $.TileSource.prototype = {
* cacheObject parameter should be used to attach the data to, there are no * cacheObject parameter should be used to attach the data to, there are no
* conventions on how it should be stored - all the logic is implemented within *TileCache() functions. * conventions on how it should be stored - all the logic is implemented within *TileCache() functions.
* *
* Note that if you override any of *TileCache() functions, you should override all of them. * Note that
* @param {object} cacheObject context cache object * - data is cached automatically as cacheObject.data
* - if you override any of *TileCache() functions, you should override all of them.
* - these functions might be called over shared cache object managed by other TileSources simultaneously.
* @param {OpenSeadragon.CacheRecord} cacheObject context cache object
* @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object * @param {*} data image data, the data sent to ImageJob.prototype.finish(), by default an Image object
* @param {Tile} tile instance the cache was created with * @param {OpenSeadragon.Tile} tile instance the cache was created with
* @deprecated
*/ */
createTileCache: function(cacheObject, data, tile) { createTileCache: function(cacheObject, data, tile) {
cacheObject._data = data; $.console.error("[TileSource.createTileCache] has been deprecated. Use cache API of a tile instead.");
//no-op, we create the cache automatically
}, },
/** /**
* Cache object destructor, unset all properties you created to allow GC collection. * Cache object destructor, unset all properties you created to allow GC collection.
* Note that if you override any of *TileCache() functions, you should override all of them. * Note that if you override any of *TileCache() functions, you should override all of them.
* @param {object} cacheObject context cache object * Note that these functions might be called over shared cache object managed by other TileSources simultaneously.
* Original cache data is cacheObject.data, but do not delete it manually! It is taken care for,
* you might break things.
* @param {OpenSeadragon.CacheRecord} cacheObject context cache object
* @deprecated
*/ */
destroyTileCache: function (cacheObject) { destroyTileCache: function (cacheObject) {
cacheObject._data = null; // FIXME: still allow custom desctruction? probably not - cache system should handle all
cacheObject._renderedContext = null; $.console.error("[TileSource.destroyTileCache] has been deprecated. Use cache API of a tile instead.");
//no-op, handled internally
}, },
/** /**
* Raw data getter * Raw data getter, should return anything that is compatible with the system, or undefined
* Note that if you override any of *TileCache() functions, you should override all of them. * if the system can handle it.
* @param {object} cacheObject context cache object * @param {OpenSeadragon.CacheRecord} cacheObject context cache object
* @returns {*} cache data * @returns {*} cache data
* @deprecated
*/ */
getTileCacheData: function(cacheObject) { getTileCacheData: function(cacheObject) {
return cacheObject._data; return cacheObject.getData();
}, },
/** /**
@ -879,11 +913,14 @@ $.TileSource.prototype = {
* - plugins might need image representation of the data * - plugins might need image representation of the data
* - div HTML rendering relies on image element presence * - div HTML rendering relies on image element presence
* Note that if you override any of *TileCache() functions, you should override all of them. * Note that if you override any of *TileCache() functions, you should override all of them.
* @param {object} cacheObject context cache object * Note that these functions might be called over shared cache object managed by other TileSources simultaneously.
* @param {OpenSeadragon.CacheRecord} cacheObject context cache object
* @returns {Image} cache data as an Image * @returns {Image} cache data as an Image
* @deprecated
*/ */
getTileCacheDataAsImage: function(cacheObject) { getTileCacheDataAsImage: function(cacheObject) {
return cacheObject._data; //the data itself by default is Image $.console.error("[TileSource.getTileCacheDataAsImage] has been deprecated. Use cache API of a tile instead.");
return cacheObject.getData("image");
}, },
/** /**
@ -891,21 +928,13 @@ $.TileSource.prototype = {
* - most heavily used rendering method is a canvas-based approach, * - most heavily used rendering method is a canvas-based approach,
* convert the data to a canvas and return it's 2D context * convert the data to a canvas and return it's 2D context
* Note that if you override any of *TileCache() functions, you should override all of them. * Note that if you override any of *TileCache() functions, you should override all of them.
* @param {object} cacheObject context cache object * @param {OpenSeadragon.CacheRecord} cacheObject context cache object
* @returns {CanvasRenderingContext2D} context of the canvas representation of the cache data * @returns {CanvasRenderingContext2D} context of the canvas representation of the cache data
* @deprecated
*/ */
getTileCacheDataAsContext2D: function(cacheObject) { getTileCacheDataAsContext2D: function(cacheObject) {
if (!cacheObject._renderedContext) { $.console.error("[TileSource.getTileCacheDataAsContext2D] has been deprecated. Use cache API of a tile instead.");
var canvas = document.createElement( 'canvas' ); return cacheObject.getData("context2d");
canvas.width = cacheObject._data.width;
canvas.height = cacheObject._data.height;
cacheObject._renderedContext = canvas.getContext('2d');
cacheObject._renderedContext.drawImage( cacheObject._data, 0, 0 );
//since we are caching the prerendered image on a canvas
//allow the image to not be held in memory
cacheObject._data = null;
}
return cacheObject._renderedContext;
} }
}; };

View File

@ -415,6 +415,7 @@ $.Viewer = function( options ) {
// Create the tile cache // Create the tile cache
this.tileCache = new $.TileCache({ this.tileCache = new $.TileCache({
viewer: this,
maxImageCacheCount: this.maxImageCacheCount maxImageCacheCount: this.maxImageCacheCount
}); });
@ -1434,7 +1435,9 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
* (portions of the image outside of this area will not be visible). Only works on * (portions of the image outside of this area will not be visible). Only works on
* browsers that support the HTML5 canvas. * browsers that support the HTML5 canvas.
* @param {Number} [options.opacity=1] Proportional opacity of the tiled images (1=opaque, 0=hidden) * @param {Number} [options.opacity=1] Proportional opacity of the tiled images (1=opaque, 0=hidden)
* @param {Boolean} [options.preload=false] Default switch for loading hidden images (true loads, false blocks) * @param {Boolean} [options.preload=false] Default switch for loading hidden images (true loads, false blocks)
* @param {Boolean} [options.zombieCache] In the case that this method removes any TiledImage instance,
* allow the item-referenced cache to remain in memory even without active tiles. Default false.
* @param {Number} [options.degrees=0] Initial rotation of the tiled image around * @param {Number} [options.degrees=0] Initial rotation of the tiled image around
* its top left corner in degrees. * its top left corner in degrees.
* @param {Boolean} [options.flipped=false] Whether to horizontally flip the image. * @param {Boolean} [options.flipped=false] Whether to horizontally flip the image.
@ -1576,6 +1579,7 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
if (newIndex !== -1) { if (newIndex !== -1) {
queueItem.options.index = newIndex; queueItem.options.index = newIndex;
} }
queueItem.options.replaceItem.allowZombieCache(queueItem.options.zombieCache || false);
_this.world.removeItem(queueItem.options.replaceItem); _this.world.removeItem(queueItem.options.replaceItem);
} }

View File

@ -69,6 +69,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
/** /**
* Add the specified item. * Add the specified item.
* @param {OpenSeadragon.TiledImage} item - The item to add. * @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. * @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:add-item
* @fires OpenSeadragon.World.event:metrics-change * @fires OpenSeadragon.World.event:metrics-change

View File

@ -176,10 +176,20 @@
for ( var i in testLog ) { for ( var i in testLog ) {
if ( testLog.hasOwnProperty( i ) && testLog[i].push ) { if ( testLog.hasOwnProperty( i ) && testLog[i].push ) {
//Tile.tiledImage creates circular reference, copy object to avoid and allow JSON serialization
const tileCircularStructureReplacer = function (key, value) {
if (value instanceof OpenSeadragon.Tile) {
var instance = {};
Object.assign(instance, value);
delete value.tiledImage;
}
return value;
};
testConsole[i] = ( function ( arr ) { testConsole[i] = ( function ( arr ) {
return function () { return function () {
var args = Array.prototype.slice.call( arguments, 0 ); // Coerce to true Array var args = Array.prototype.slice.call( arguments, 0 ); // Coerce to true Array
arr.push( JSON.stringify( args ) ); // Store as JSON to avoid tedious array-equality tests arr.push( JSON.stringify( args, tileCircularStructureReplacer ) ); // Store as JSON to avoid tedious array-equality tests
}; };
} )( testLog[i] ); } )( testLog[i] );

View File

@ -224,12 +224,13 @@
}); });
QUnit.test('FullScreen', function(assert) { QUnit.test('FullScreen', function(assert) {
const done = assert.async();
if (!OpenSeadragon.supportsFullScreen) { if (!OpenSeadragon.supportsFullScreen) {
const done = assert.async();
assert.expect(0); assert.expect(0);
done(); done();
return; return;
} }
var timeWatcher = Util.timeWatcher(assert, 7000);
viewer.addHandler('open', function () { viewer.addHandler('open', function () {
assert.ok(!OpenSeadragon.isFullScreen(), 'Started out not fullscreen'); assert.ok(!OpenSeadragon.isFullScreen(), 'Started out not fullscreen');
@ -244,7 +245,7 @@
viewer.removeHandler('full-screen', checkExitingFullScreen); viewer.removeHandler('full-screen', checkExitingFullScreen);
assert.ok(!event.fullScreen, 'Disabling fullscreen'); assert.ok(!event.fullScreen, 'Disabling fullscreen');
assert.ok(!OpenSeadragon.isFullScreen(), 'Fullscreen disabled'); assert.ok(!OpenSeadragon.isFullScreen(), 'Fullscreen disabled');
done(); timeWatcher.done();
} }
// The 'new' headless mode allows us to enter fullscreen, so verify // The 'new' headless mode allows us to enter fullscreen, so verify
@ -254,11 +255,11 @@
viewer.removeHandler('full-screen', checkAcquiredFullScreen); viewer.removeHandler('full-screen', checkAcquiredFullScreen);
viewer.addHandler('full-screen', checkExitingFullScreen); viewer.addHandler('full-screen', checkExitingFullScreen);
assert.ok(event.fullScreen, 'Acquired fullscreen'); assert.ok(event.fullScreen, 'Acquired fullscreen');
assert.ok(OpenSeadragon.isFullScreen(), 'Fullscreen enabled'); assert.ok(OpenSeadragon.isFullScreen(), 'Fullscreen enabled. Note: this test might fail ' +
'because fullscreen might be blocked by your browser - not a trusted event!');
viewer.setFullScreen(false); viewer.setFullScreen(false);
}; };
viewer.addHandler('pre-full-screen', checkEnteringPreFullScreen); viewer.addHandler('pre-full-screen', checkEnteringPreFullScreen);
viewer.addHandler('full-screen', checkAcquiredFullScreen); viewer.addHandler('full-screen', checkAcquiredFullScreen);
viewer.setFullScreen(true); viewer.setFullScreen(true);
@ -330,7 +331,7 @@
height: 155 height: 155
} ] } ]
} ); } );
viewer.addOnceHandler('tile-drawn', function() { viewer.addOnceHandler('tile-drawn', function(e) {
assert.ok(OpenSeadragon.isCanvasTainted(viewer.drawer.context.canvas), assert.ok(OpenSeadragon.isCanvasTainted(viewer.drawer.context.canvas),
"Canvas should be tainted."); "Canvas should be tainted.");
done(); done();

View File

@ -33,10 +33,14 @@
} }
} }
function runTest(e) { function runTest(e, async=false) {
context.raiseEvent(eName, e); context.raiseEvent(eName, e);
} }
function runTestAwaiting(e, async=false) {
context.raiseEventAwaiting(eName, e);
}
QUnit.module( 'EventSource', { QUnit.module( 'EventSource', {
beforeEach: function () { beforeEach: function () {
context = new OpenSeadragon.EventSource(); context = new OpenSeadragon.EventSource();
@ -82,4 +86,58 @@
message: 'Prioritized callback order should follow [2,1,4,5,3].' message: 'Prioritized callback order should follow [2,1,4,5,3].'
}); });
}); });
QUnit.test('EventSource: async non-synchronized order', function(assert) {
context.addHandler(eName, executor(1, 5));
context.addHandler(eName, executor(2, 50));
context.addHandler(eName, executor(3));
context.addHandler(eName, executor(4));
runTest({
assert: assert,
done: assert.async(),
expected: [3, 4, 1, 2],
message: 'Async callback order should follow [3,4,1,2].'
});
});
QUnit.test('EventSource: async non-synchronized priority order', function(assert) {
context.addHandler(eName, executor(1, 5));
context.addHandler(eName, executor(2, 50), undefined, -100);
context.addHandler(eName, executor(3), undefined, -500);
context.addHandler(eName, executor(4), undefined, 675);
runTest({
assert: assert,
done: assert.async(),
expected: [4, 3, 1, 2],
message: 'Async callback order with priority should follow [4,3,1,2]. Async functions do not respect priority.'
});
});
QUnit.test('EventSource: async synchronized order', function(assert) {
context.addHandler(eName, executor(1, 5));
context.addHandler(eName, executor(2, 50));
context.addHandler(eName, executor(3));
context.addHandler(eName, executor(4));
runTestAwaiting({
waitForPromiseHandlers: true,
assert: assert,
done: assert.async(),
expected: [1, 2, 3, 4],
message: 'Async callback order should follow [1,2,3,4], since it is synchronized.'
});
});
QUnit.test('EventSource: async synchronized priority order', function(assert) {
context.addHandler(eName, executor(1, 5));
context.addHandler(eName, executor(2), undefined, -500);
context.addHandler(eName, executor(3, 50), undefined, -200);
context.addHandler(eName, executor(4), undefined, 675);
runTestAwaiting({
waitForPromiseHandlers: true,
assert: assert,
done: assert.async(),
expected: [4, 1, 3, 2],
message: 'Async callback order with priority should follow [4,1,3,2], since priority is respected when synchronized.'
});
});
} )(); } )();

View File

@ -2,6 +2,7 @@
(function () { (function () {
var viewer; var viewer;
var sleep = time => new Promise(res => setTimeout(res, time));
QUnit.module( 'Events', { QUnit.module( 'Events', {
beforeEach: function () { beforeEach: function () {
@ -1210,11 +1211,12 @@
var tile = event.tile; var tile = event.tile;
assert.ok( tile.loading, "The tile should be marked as loading."); assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded."); assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
setTimeout(function() { //make sure we require tile loaded status once the data is ready
event.promise.then(function() {
assert.notOk( tile.loading, "The tile should not be marked as loading."); assert.notOk( tile.loading, "The tile should not be marked as loading.");
assert.ok( tile.loaded, "The tile should be marked as loaded."); assert.ok( tile.loaded, "The tile should be marked as loaded.");
done(); done();
}, 0); });
} }
viewer.addHandler( 'tile-loaded', tileLoaded); viewer.addHandler( 'tile-loaded', tileLoaded);
@ -1226,51 +1228,61 @@
function tileLoaded ( event ) { function tileLoaded ( event ) {
viewer.removeHandler( 'tile-loaded', tileLoaded); viewer.removeHandler( 'tile-loaded', tileLoaded);
var tile = event.tile; var tile = event.tile;
var callback = event.getCompletionCallback();
assert.ok( tile.loading, "The tile should be marked as loading."); assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded."); assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
assert.ok( callback, "The event should have a callback."); event.promise.then( _ => {
setTimeout(function() {
assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
callback();
assert.notOk( tile.loading, "The tile should not be marked as loading."); assert.notOk( tile.loading, "The tile should not be marked as loading.");
assert.ok( tile.loaded, "The tile should be marked as loaded."); assert.ok( tile.loaded, "The tile should be marked as loaded.");
done(); done();
}, 0); });
} }
viewer.addHandler( 'tile-loaded', tileLoaded); viewer.addHandler( 'tile-loaded', tileLoaded);
viewer.open( '/test/data/testpattern.dzi' ); viewer.open( '/test/data/testpattern.dzi' );
} ); } );
QUnit.test( 'Viewer: tile-loaded event with 2 callbacks.', function (assert) { QUnit.test( 'Viewer: asynchronous tile processing.', function (assert) {
var done = assert.async(); var done = assert.async(),
function tileLoaded ( event ) { handledOnce = false;
viewer.removeHandler( 'tile-loaded', tileLoaded);
var tile = event.tile; const tileLoaded1 = async (event) => {
var callback1 = event.getCompletionCallback(); assert.ok( handledOnce, "tileLoaded1 with priority 5 should be called second.");
var callback2 = event.getCompletionCallback(); const tile = event.tile;
handledOnce = true;
assert.ok( tile.loading, "The tile should be marked as loading."); assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded."); assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
setTimeout(function() {
assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
callback1();
assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
setTimeout(function() {
assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
callback2();
assert.notOk( tile.loading, "The tile should not be marked as loading.");
assert.ok( tile.loaded, "The tile should be marked as loaded.");
done();
}, 0);
}, 0);
}
viewer.addHandler( 'tile-loaded', tileLoaded); event.promise.then(() => {
assert.notOk( tile.loading, "The tile should not be marked as loading.");
assert.ok( tile.loaded, "The tile should be marked as loaded.");
done();
done = null;
});
await sleep(10);
};
const tileLoaded2 = async (event) => {
assert.notOk( handledOnce, "TileLoaded2 with priority 10 should be called first.");
const tile = event.tile;
//remove handlers immediatelly, processing is async -> removing in the second function could
//get after a different tile gets processed
viewer.removeHandler( 'tile-loaded', tileLoaded1);
viewer.removeHandler( 'tile-loaded', tileLoaded2);
handledOnce = true;
assert.ok( tile.loading, "The tile should be marked as loading.");
assert.notOk( tile.loaded, "The tile should not be marked as loaded.");
event.promise.then(() => {
assert.notOk( tile.loading, "The tile should not be marked as loading.");
assert.ok( tile.loaded, "The tile should be marked as loaded.");
});
await sleep(30);
};
//first will get called tileLoaded2 although registered later
viewer.addHandler( 'tile-loaded', tileLoaded1, null, 5);
viewer.addHandler( 'tile-loaded', tileLoaded2, null, 10);
viewer.open( '/test/data/testpattern.dzi' ); viewer.open( '/test/data/testpattern.dzi' );
} ); } );

View File

@ -221,11 +221,12 @@
viewer.addHandler('open', function() { viewer.addHandler('open', function() {
var firstImage = viewer.world.getItemAt(0); var firstImage = viewer.world.getItemAt(0);
firstImage.addHandler('fully-loaded-change', function() { firstImage.addHandler('fully-loaded-change', function() {
var imageData = viewer.drawer.context.getImageData(0, 0, var aX = Math.round(500 * density), aY = Math.round(500 * density);
500 * density, 500 * density); var imageData = viewer.drawer.context.getImageData(0, 0, aX, aY);
// Pixel 250,250 will be in the hole of the A // Pixel 250,250 will be in the hole of the A
var expectedVal = getPixelValue(imageData, 250 * density, 250 * density); aX = Math.round(250 * density); aY = Math.round(250 * density);
var expectedVal = getPixelValue(imageData, aX, aY);
assert.notEqual(expectedVal.r, 0, 'Red channel should not be 0'); assert.notEqual(expectedVal.r, 0, 'Red channel should not be 0');
assert.notEqual(expectedVal.g, 0, 'Green channel should not be 0'); assert.notEqual(expectedVal.g, 0, 'Green channel should not be 0');
@ -237,8 +238,10 @@
success: function() { success: function() {
var secondImage = viewer.world.getItemAt(1); var secondImage = viewer.world.getItemAt(1);
secondImage.addHandler('fully-loaded-change', function() { secondImage.addHandler('fully-loaded-change', function() {
var imageData = viewer.drawer.context.getImageData(0, 0, 500 * density, 500 * density); var aX = Math.round(500 * density), aY = Math.round(500 * density);
var actualVal = getPixelValue(imageData, 250 * density, 250 * density); var imageData = viewer.drawer.context.getImageData(0, 0, aX, aY);
aX = Math.round(250 * density); aY = Math.round(250 * density);
var actualVal = getPixelValue(imageData, aX, aY);
assert.equal(actualVal.r, expectedVal.r, assert.equal(actualVal.r, expectedVal.r,
'Red channel should not change in transparent part of the A'); 'Red channel should not change in transparent part of the A');
@ -249,11 +252,12 @@
assert.equal(actualVal.a, expectedVal.a, assert.equal(actualVal.a, expectedVal.a,
'Alpha channel should not change in transparent part of the A'); 'Alpha channel should not change in transparent part of the A');
var onAVal = getPixelValue(imageData, 333 * density, 250 * density); aX = Math.round(333 * density); aY = Math.round(250 * density);
assert.equal(onAVal.r, 0, 'Red channel should be null on the A'); var onAVal = getPixelValue(imageData, aX, aY);
assert.equal(onAVal.g, 0, 'Green channel should be null on the A'); assert.equal(onAVal.r, 0, 'Red channel should be null on the A pixel (' + aX + ', ' + aY + ')');
assert.equal(onAVal.b, 0, 'Blue channel should be null on the A'); assert.equal(onAVal.g, 0, 'Green channel should be null on the A pixel (' + aX + ', ' + aY + ')');
assert.equal(onAVal.a, 255, 'Alpha channel should be 255 on the A'); assert.equal(onAVal.b, 0, 'Blue channel should be null on the A pixel (' + aX + ', ' + aY + ')');
assert.equal(onAVal.a, 255, 'Alpha channel should be 255 on the A pixel (' + aX + ', ' + aY + ')');
done(); done();
}); });

View File

@ -31,6 +31,9 @@
url: 'foo.jpg', url: 'foo.jpg',
cacheKey: 'foo.jpg', cacheKey: 'foo.jpg',
image: {}, image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {} unload: function() {}
}; };
@ -38,6 +41,9 @@
url: 'foo.jpg', url: 'foo.jpg',
cacheKey: 'foo.jpg', cacheKey: 'foo.jpg',
image: {}, image: {},
loaded: true,
tiledImage: fakeTiledImage1,
_caches: [],
unload: function() {} unload: function() {}
}; };
@ -84,6 +90,9 @@
url: 'different.jpg', url: 'different.jpg',
cacheKey: 'different.jpg', cacheKey: 'different.jpg',
image: {}, image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {} unload: function() {}
}; };
@ -91,6 +100,9 @@
url: 'same.jpg', url: 'same.jpg',
cacheKey: 'same.jpg', cacheKey: 'same.jpg',
image: {}, image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {} unload: function() {}
}; };
@ -98,6 +110,9 @@
url: 'same.jpg', url: 'same.jpg',
cacheKey: 'same.jpg', cacheKey: 'same.jpg',
image: {}, image: {},
loaded: true,
tiledImage: fakeTiledImage0,
_caches: [],
unload: function() {} unload: function() {}
}; };

View File

@ -3,6 +3,14 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>OpenSeadragon QUnit</title> <title>OpenSeadragon QUnit</title>
<script type="text/javascript">
window.QUnit = {
config: {
//one minute per test timeout
testTimeout: 60000
}
};
</script>
<link rel="stylesheet" href="/node_modules/qunit/qunit/qunit.css"> <link rel="stylesheet" href="/node_modules/qunit/qunit/qunit.css">
<link rel="stylesheet" href="/test/lib/jquery-ui-1.10.2/css/smoothness/jquery-ui-1.10.2.min.css"> <link rel="stylesheet" href="/test/lib/jquery-ui-1.10.2/css/smoothness/jquery-ui-1.10.2.min.css">
<link rel="stylesheet" href="/test/helpers/test.css"> <link rel="stylesheet" href="/test/helpers/test.css">