From 005fa974cbc65e3ff18ce75acbea67b01636bace Mon Sep 17 00:00:00 2001 From: wimrijnders Date: Sat, 13 May 2017 21:54:29 +0200 Subject: [PATCH] Fix #2952 Pre-render node images for interpolation (#3010) * CachedImage with preredendered zoom images; first working version. * Fixes for missing images and usage brokenImage. * Remove unused method * Added height member of CachedImage, note about svg's --- lib/network/CachedImage.js | 160 ++++++++++++++++++ lib/network/Images.js | 65 ++++--- .../components/nodes/util/CircleImageBase.js | 37 +--- 3 files changed, 204 insertions(+), 58 deletions(-) create mode 100644 lib/network/CachedImage.js diff --git a/lib/network/CachedImage.js b/lib/network/CachedImage.js new file mode 100644 index 00000000..6e027f08 --- /dev/null +++ b/lib/network/CachedImage.js @@ -0,0 +1,160 @@ + +/** + * Associates a canvas to a given image, containing a number of renderings + * of the image at various sizes. + * + * This technique is known as 'mipmapping'. + * + * NOTE: Images can also be of type 'data:svg+xml`. This code also works + * for svg, but the mipmapping may not be necessary. + */ +class CachedImage { + constructor(image) { + this.NUM_ITERATIONS = 4; // Number of items in the coordinates array + + this.image = new Image(); + this.canvas = document.createElement('canvas'); + } + + + /** + * Called when the image has been succesfully loaded. + */ + init() { + if (this.initialized()) return; + + var w = this.image.width; + var h = this.image.height; + + // Ease external access + this.width = w; + this.height = h; + + // Make canvas as small as possible + this.canvas.width = 3*w/4; + this.canvas.height = h/2; + + // Coordinates and sizes of images contained in the canvas + // Values per row: [top x, left y, width, height] + this.coordinates = [ + [ 0 , 0 , w/2 , h/2], + [ w/2 , 0 , w/4 , h/4], + [ w/2 , h/4, w/8 , h/8], + [ 5*w/8, h/4, w/16, h/16] + ]; + + this._fillMipMap(); + } + + + /** + * @return {Boolean} true if init() has been called, false otherwise. + */ + initialized() { + return (this.coordinates !== undefined); + } + + + /** + * Redraw main image in various sizes to the context. + * + * The rationale behind this is to reduce artefacts due to interpolation + * at differing zoom levels. + * + * Source: http://stackoverflow.com/q/18761404/1223531 + * + * This methods takes the resizing out of the drawing loop, in order to + * reduce performance overhead. + * + * @private + */ + _fillMipMap() { + var ctx = this.canvas.getContext('2d'); + + // First zoom-level comes from the image + var to = this.coordinates[0]; + ctx.drawImage(this.image, to[0], to[1], to[2], to[3]); + + // The rest are copy actions internal to the canvas/context + for (let iterations = 1; iterations < this.NUM_ITERATIONS; iterations++) { + let from = this.coordinates[iterations - 1]; + let to = this.coordinates[iterations]; + + ctx.drawImage(this.canvas, + from[0], from[1], from[2], from[3], + to[0], to[1], to[2], to[3] + ); + } + } + + + /** + * Draw the image, using the mipmap if necessary. + * + * MipMap is only used if param factor > 2; otherwise, original bitmap + * is resized. This is also used to skip mipmap usage, e.g. by setting factor = 1 + * + * Credits to 'Alex de Mulder' for original implementation. + * + * ctx {Context} context on which to draw zoomed image + * factor {Float} scale factor at which to draw + */ + drawImageAtPosition(ctx, factor, left, top, width, height) { + if (factor > 2 && this.initialized()) { + // Determine which zoomed image to use + factor *= 0.5; + let iterations = 0; + while (factor > 2 && iterations < this.NUM_ITERATIONS) { + factor *= 0.5; + iterations += 1; + } + + if (iterations >= this.NUM_ITERATIONS) { + iterations = this.NUM_ITERATIONS - 1; + } + //console.log("iterations: " + iterations); + + let from = this.coordinates[iterations]; + ctx.drawImage(this.canvas, + from[0], from[1], from[2], from[3], + left, top, width, height + ); + } else if (this._isImageOk()) { + // Draw image directly + ctx.drawImage(this.image, left, top, width, height); + } + } + + + /** + * Check if image is loaded + * + * Source: http://stackoverflow.com/a/1977898/1223531 + * + * @private + */ + _isImageOk(img) { + var img = this.image; + + // During the onload event, IE correctly identifies any images that + // weren’t downloaded as not complete. Others should too. Gecko-based + // browsers act like NS4 in that they report this incorrectly. + if (!img.complete) { + return false; + } + + // However, they do have two very useful properties: naturalWidth and + // naturalHeight. These give the true size of the image. If it failed + // to load, either of these should be zero. + + if (typeof img.naturalWidth !== "undefined" && img.naturalWidth === 0) { + return false; + } + + // No other way of checking: assume it’s ok. + return true; + } +} + + +export default CachedImage; diff --git a/lib/network/Images.js b/lib/network/Images.js index 464e4585..e9d5833f 100644 --- a/lib/network/Images.js +++ b/lib/network/Images.js @@ -1,29 +1,17 @@ +import CachedImage from './CachedImage'; + + /** * @class Images * This class loads images and keeps them stored. */ -class Images{ +class Images { constructor(callback){ this.images = {}; this.imageBroken = {}; this.callback = callback; } - /** - * @param {string} url The Url to cache the image as - * @return {Image} imageToLoadBrokenUrlOn The image object - */ - _addImageToCache (url, imageToCache) { - // IE11 fix -- thanks dponch! - if (imageToCache.width === 0) { - document.body.appendChild(imageToCache); - imageToCache.width = imageToCache.offsetWidth; - imageToCache.height = imageToCache.offsetHeight; - document.body.removeChild(imageToCache); - } - - this.images[url] = imageToCache; - } /** * @param {string} url The original Url that failed to load, if the broken image is successfully loaded it will be added to the cache using this Url as the key so that subsequent requests for this Url will return the broken image @@ -37,12 +25,11 @@ class Images{ //Clear the old subscription to the error event and put a new in place that only handle errors in loading the brokenImageUrl imageToLoadBrokenUrlOn.onerror = () => { console.error("Could not load brokenImage:", brokenUrl); - //Add an empty image to the cache so that when subsequent load calls are made for the url we don't try load the image and broken image again - this._addImageToCache(url, new Image()); + // cache item will contain empty image, this should be OK for default }; //Set the source of the image to the brokenUrl, this is actually what kicks off the loading of the broken image - imageToLoadBrokenUrlOn.src = brokenUrl; + imageToLoadBrokenUrlOn.image.src = brokenUrl; } /** @@ -65,28 +52,50 @@ class Images{ if (cachedImage) return cachedImage; //Create a new image - var img = new Image(); + var img = new CachedImage(); + + // Need to add to cache here, otherwise final return will spawn different copies of the same image, + // Also, there will be multiple loads of the same image. + this.images[url] = img; //Subscribe to the event that is raised if the image loads successfully - img.onload = () => { - //Add the image to the cache and then request a redraw - this._addImageToCache(url, img); + img.image.onload = () => { + // Properly init the cached item and then request a redraw + this._fixImageCoordinates(img.image); + img.init(); this._redrawWithImage(img); }; //Subscribe to the event that is raised if the image fails to load - img.onerror = () => { + img.image.onerror = () => { console.error("Could not load image:", url); //Try and load the image specified by the brokenUrl using this._tryloadBrokenUrl(url, brokenUrl, img); } - //Set the source of the image to the url, this is actuall what kicks off the loading of the image - img.src = url; + //Set the source of the image to the url, this is what actually kicks off the loading of the image + img.image.src = url; //Return the new image return img; - } + } + + + /** + * IE11 fix -- thanks dponch! + * + * Local helper function + * + * @private + */ + _fixImageCoordinates(imageToCache) { + if (imageToCache.width === 0) { + document.body.appendChild(imageToCache); + imageToCache.width = imageToCache.offsetWidth; + imageToCache.height = imageToCache.offsetHeight; + document.body.removeChild(imageToCache); + } + } } -export default Images; \ No newline at end of file +export default Images; diff --git a/lib/network/modules/components/nodes/util/CircleImageBase.js b/lib/network/modules/components/nodes/util/CircleImageBase.js index 99bff14a..86fd133e 100644 --- a/lib/network/modules/components/nodes/util/CircleImageBase.js +++ b/lib/network/modules/components/nodes/util/CircleImageBase.js @@ -1,4 +1,6 @@ -import NodeBase from '../util/NodeBase' +import NodeBase from './NodeBase'; +import CachedImage from '../../../../CachedImage'; + /** * NOTE: This is a bad base class @@ -122,37 +124,12 @@ class CircleImageBase extends NodeBase { // draw shadow if enabled this.enableShadow(ctx, values); - let factor = (this.imageObj.width / this.width) / this.body.view.scale; - if (factor > 2 && this.options.shapeProperties.interpolation === true) { - let w = this.imageObj.width; - let h = this.imageObj.height; - var can2 = document.createElement('canvas'); - can2.width = w; - can2.height = w; - var ctx2 = can2.getContext('2d'); - - factor *= 0.5; - w *= 0.5; - h *= 0.5; - ctx2.drawImage(this.imageObj, 0, 0, w, h); - - let distance = 0; - let iterations = 1; - while (factor > 2 && iterations < 4) { - ctx2.drawImage(can2, distance, 0, w, h, distance+w, 0, w/2, h/2); - distance += w; - factor *= 0.5; - w *= 0.5; - h *= 0.5; - iterations += 1; - } - ctx.drawImage(can2, distance, 0, w, h, this.left, this.top, this.width, this.height); - } - else { - // draw image - ctx.drawImage(this.imageObj, this.left, this.top, this.width, this.height); + let factor = 1; + if (this.options.shapeProperties.interpolation === true) { + factor = (this.imageObj.width / this.width) / this.body.view.scale; } + this.imageObj.drawImageAtPosition(ctx, factor, this.left, this.top, this.width, this.height); // disable shadows for other elements. this.disableShadow(ctx, values);