Fork me on GitHub

Source: utils.js

/* globals
 G_DEBUG
 NRMSE
 ImageMSSSIM
 G_GUI_Controller
 UTIF
 G_AUTO_PREVIEW_LIMIT
 G_IMG_METRIC_ENABLED
 G_APP_NAME
 */

/* exported GetOptimalBoxWidth */

/**
 * Used for display, for number.toFixed() rounding.
 * @namespace G_MATH_TOFIXED
 */
const G_MATH_TOFIXED = {
	/**
	 * @type {number}
	 * @description The minimum number of decimal digits.
	 */
	MIN: 1,
	/** The short or standard number of decimal digits. */
	SHORT: 2,
	/** The maximum or "longest" number of decimal digits. */
	LONG: 4
};

/**
 * Calculated the size to use for each drawing box/stage.
 * Edit the values in the functions to change the box sizing.
 * @returns The size to use.
 */
function GetOptimalBoxWidth(){
	// Values used to calculate the size of each box/stage
	var boxesPerPageWidth = 5;
	// count-in the width of the borders of the boxes
	var boxBorderW = 2 * (parseInt($('.box:first').css('border-width')) || 1);
	var scrollBarW = 15; // scroll bar width
	var boxSizeMax = 300; //max width for the boxes

	// make sure to have an integer value to prevent slight sizing differences between each box
	var calculatedBoxSize = Math.ceil(Math.max(
		(document.body.clientWidth / boxesPerPageWidth) - boxBorderW - scrollBarW,
		boxSizeMax));
	
	return calculatedBoxSize;
}

/**
 * Various utility and helper functions
 * @namespace Utils
 */
const Utils = {
	/**
	 * Makes a new stage 'box' with a layer added, and some settings
	 * @param {Element} parentContainer the DOM element of the parent container in which to add a stage 'box'.
	 * @param {*} w the width of the stage
	 * @param {*} h the height of the stage
	 * @returns the drawing stage.
	 */
	newStageTemplate: function(parentContainer, w, h) {
		var $e = $('<div/>').addClass('box').appendTo(parentContainer);
		var stage = new Konva.Stage({
			container: $e.get(0),
			width: w,
			height: h
		});

		// then create layer and to stage
		var layer = new Konva.Layer({
			listening: false // faster render
		});

		// add and push
		stage.add(layer);

		// turn off by default antialiasing/smoothing
		// important do that AFTER you added layer to a stage
		// https://github.com/konvajs/konva/issues/306#issuecomment-351263036
		layer.imageSmoothingEnabled(false);

		return stage;
	},

	/**
	 * Initiates the image resource load with a callback once the image is loaded.
	 * @param {string} url The url pointing to the image to load.
	 * @param {function} callback The callback function to call/run once the image is loaded.
	 * @param {boolean} _allowRetryAsUTIF Used internally to prevent a recursive retry loop with the UTIF decoder.
	 */
	loadImage: function(url, callback, _allowRetryAsUTIF = true) {
		var imageObj = new Image();
		imageObj.onload = callback;

		imageObj.onerror = function(e){
			// eslint-disable-next-line no-magic-numbers
			if (_allowRetryAsUTIF && e.target.src.substring(0,22) == "data:image/tiff;base64") {
				console.warn("ERROR: could not load the given TIFF image. Retrying with UTIF decoder.", e);
				Utils._loadImageUTIF(url, callback);
			} else {
				console.error("ERROR: could not load the given image.", e);
			}
		};
		
		imageObj.src = url;
	},

	/**
	 * Used internally by @see {@link Utils.loadImage} to retry loading TIFFs with the
	 * UTIF.js decoder that otherwise failed with the built-in decoder.
	 * @param {*} url The image base64 URL/URI.
	 * @param {*} callback a function to call when image.onload happens.
	 */
	_loadImageUTIF: async function(url, callback) {
		// useful links
		// https://github.com/photopea/UTIF.js/
		// https://observablehq.com/@ehouais/decoding-tiff-image-data
		// https://stackoverflow.com/a/52410044/883015

		// 
		// let blob = await fetch(url).then(r => r.blob());
		await fetch(url).then(response => response.blob()) // get the url as a blob
			.then(blob => blob.arrayBuffer()) // get the data as a array/buffer
			.then(UTIF.bufferToURI) // decode the data as an RGBA8 image data URI
			.then(function(decoded_as_rgba8_url){
				// load the image once again as usual...
				Utils.loadImage(decoded_as_rgba8_url, callback, false);
			}); 
	},

	/**
	 * Attempts to get the value or text within a given element/control.
	 * @param {object|jQuery} $e the jquery wrapped DOM element.
	 * @returns the value contained or represented in the given control/element.
	 */
	getInputValueInt: function($e){
		var rawValue = parseInt($e.val());
		if (isNaN(rawValue))
			return parseInt($e.attr('placeholder'));
		return rawValue;
	},

	getRowsInput: function(){ return G_GUI_Controller.pixelCountY; },
	getColsInput: function(){ return G_GUI_Controller.pixelCountX; },
	getBrightnessInput: function(){ return G_GUI_Controller.brightness; },
	getContrastInput: function(){ return G_GUI_Controller.contrast; },
	getGlobalBCInput: function(){ return G_GUI_Controller.globalBC; },
	getCellWInput: function(){ return this.getInputValueInt($('#iCellW')); },
	getCellHInput: function(){ return this.getInputValueInt($('#iCellH')); },
	getSpotXInput: function(){ return this.getInputValueInt($('#iSpotX')); },
	getSpotYInput: function(){ return this.getInputValueInt($('#iSpotY')); },
	getSpotAngleInput: function(){ return this.getInputValueInt($('#iSpotAngle')); },
	getGroundtruthImage: function(){ return G_GUI_Controller.groundTruthImg; },
	getPixelSizeNmInput: function(){ return G_GUI_Controller.pixelSize_nm; },
	setPixelSizeNmInput: function(val){ G_GUI_Controller.controls.pixelSize_nm.setValue(val); },
	getShowRulerInput: function(){ return G_GUI_Controller.showRuler; },
	getSpotLayoutOpacityInput: function(){ return G_GUI_Controller.previewOpacity; },
	getImageMetricAlgorithm: function(){ return G_GUI_Controller.imageMetricAlgo; },
	getImageSmoothing: function(){ return G_GUI_Controller.imageSmoothing; },
	getImageFillMode: function(){
		// TODO: maybe add a GUI option to toggle between fit, fill, stretch modes...
		// just a default for now, until support for this is implemented
		// https://github.com/joedf/ImgBeamer/issues/7

		// TODO: likely have a global 'enum' of all the fill modes?
		
		// return "fit";
		return "squish";
	},

	/**
	 * Creates a Zoom event handler to be used on a stage.
	 * Holding the shift key scales at half the rate.
	 * @param {object} stage the drawing stage
	 * @param {object} konvaObj the figure or object on the stage to change.
	 * @param {function} callback a callback for when the zoom event handler is called.
	 * @param {number} scaleFactor the scale factor per "tick"
	 * @param {number|function} scaleMin the scale minimum allowed defined as a number or function.
	 * @param {number|function} scaleMax the scale maximum allowed defined as a number or function.
	 * @returns the created event handler
	 */
	// eslint-disable-next-line no-magic-numbers
	MakeZoomHandler: function(stage, konvaObj, callback=null, scaleFactor=1.2, scaleMin=0, scaleMax=Infinity){
		var _self = this;
		var handler = function(e){
			// modified from https://konvajs.org/docs/sandbox/Zooming_Relative_To_Pointer.html 
			e.evt.preventDefault(); // stop default scrolling
			
			var scaleBy = scaleFactor;
			
			// Do half rate scaling, if shift is pressed
			if (e.evt.shiftKey) {
				scaleBy = 1 +((scaleBy-1) / 2);
			}

			// how to scale? Zoom in? Or zoom out?
			let direction = e.evt.deltaY > 0 ? -1 : 1;
			var oldScale = konvaObj.scaleX();
			var pointer = stage.getPointerPosition();
			var newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;

			// Allow scale[Min/Max] to be functions or numbers...
			var _scaleMin = (typeof scaleMin == 'function') ? scaleMin(oldScale, newScale) : scaleMin;
			var _scaleMax = (typeof scaleMax == 'function') ? scaleMax(oldScale, newScale) : scaleMax;

			// Limit scale based on given bounds
			var finalScale = Math.min(_scaleMax, Math.max(_scaleMin, newScale));
			
			if (pointer != null)
				_self.scaleCenteredOnPoint(pointer, konvaObj, oldScale, finalScale);
			else {
				if (G_DEBUG) {
					console.warn("MakeZoomHandler got a null pointer...");
				}
			}

			stage.draw();

			if (typeof callback == 'function')
				callback(e);
		};

		return handler;
	},

	/**
	 * Creates and adds a pinch-Zoom (multi-touch touchscreens) event handler to be used on a shape.
	 * for more info, see https://konvajs.org/docs/sandbox/Multi-touch_Scale_Shape.html
	 * @param {*} activeShape the shape or object to scale.
	 * @param {function} callback a callback for when the zoom event handler is called.
	 * @param {number} scaleMin the scale minimum allowed.
	 * @param {number} scaleMax the scale maximum allowed.
	 */
	AddPinchScaleHandlers: function(stage, activeShape, callback=null, scaleMin=0, scaleMax=Infinity){
		var lastDist = 0;
		stage.getContent().addEventListener('touchmove', function(evt){
			// ensure we have valid event object with at least 2 touch points
			if (evt == null || evt.touches == null || evt.touches.length < 2)
				return null;

			// e.preventDefault(); // stop default events
			
			// get the touch points
			var touch1 = evt.touches[0];
			var touch2 = evt.touches[1];

			if (touch1 && touch2) {
				var dist = Utils.distance(touch1.clientX, touch1.clientY, touch2.clientX, touch2.clientY);
		
				if (!lastDist) {
					lastDist = dist;
				}

				// calculate scaling amount
				var currentScale = activeShape.scaleX();
				var scaleBy = (currentScale * dist) / lastDist;
				// Limit scale based on given bounds
				var finalScale = Math.min(scaleMax, Math.max(scaleMin, scaleBy));

				// do the scale
				// TODO: this seems to be skidding, jumps in the shape.x() and .y() values
				// possibly due to race conditions between events?
				Utils.scaleOnCenter(stage, activeShape, currentScale, finalScale);
				// Utils.scaleCenteredOnPoint(stage.getPointerPosition(), activeShape, currentScale, finalScale);

				lastDist = dist;
	
				if (typeof callback == 'function')
					callback(e);
			}
		});
		
		stage.getContent().addEventListener('touchend', function(){
			lastDist = 0;
		});
	},

	/**
	 * Creates a ruler control / drawable on the given layer of a konva stage.
	 * Scaling is calculated using the stage size, the input image size,
	 * and the (globally set) image pixel size in "real" / physical units.
	 * @param {*} layer The layer of the stage to draw on.
	 * @param {*} oImg The input image.
	 * @param {*} x1 the starting x coordinate of the ruler line.
	 * @param {*} y1 the starting y coordinate of the ruler line.
	 * @param {*} x2 the ending x coordinate of the ruler line.
	 * @param {*} y2 the ending y coordinate of the ruler line.
	 * @returns an object with the property "element" for the drawable control,
	 * a getLengthNm() method to get the current length of the ruler in physical units,
	 * a getPixelSize(lengthNm) method to calculate the pixel size in physical (nm) units
	 * based on the new specified length of the ruler in physical (nm) units,
	 * and a doUpdate() method to update the ruler to represent the its latest state.
	 */
	CreateRuler: function(layer, oImg, x1 = 0, y1 = 100, x2 = 100, y2 = 100) {
		var stage = layer.getStage();

		var lengthNm = 0;

		var updateCalc = function(){
			//TODO: maybe have scaling function for this...?
			var linePts = line.points();
			var pxSizeNmX = Utils.getPixelSizeNmInput();
			
			// we need to scale by stage size as well and image size...
			var pt1 = Utils.stageToImagePixelCoordinates(linePts[0], linePts[1], stage, oImg);
			var pt2 = Utils.stageToImagePixelCoordinates(linePts[2], linePts[3], stage, oImg);

			// and convert to "real" units
			// currently we only have pixel size in X direction
			var nm1 = Utils.imagePixelToRealCoordinates(pt1.x, pt1.y, pxSizeNmX);
			var nm2 = Utils.imagePixelToRealCoordinates(pt2.x, pt2.y, pxSizeNmX);

			// before we make the distance calculation
			// this is done to support non-square pixels
			var distNm = Utils.distance(nm1.x, nm1.y, nm2.x, nm2.y);
			var fmt = Utils.formatUnitNm(distNm);
			text.text(fmt.value.toFixed(G_MATH_TOFIXED.SHORT) + " " + fmt.unit);

			lengthNm = distNm;
		};

		var calculateNewPixelSize = function(lengthNm){
			// does the "reverse" calculation for the pixel size
			
			// get points in image pixel coordinates
			var linePts = line.points();
			var pt1 = Utils.stageToImagePixelCoordinates(linePts[0], linePts[1], stage, oImg);
			var pt2 = Utils.stageToImagePixelCoordinates(linePts[2], linePts[3], stage, oImg);

			// compute x/y components and scale it accordingly
			var dx = Math.abs(pt1.x - pt2.x);
			var dy = Math.abs(pt1.y - pt2.y);

			// compute angle
			// https://stackoverflow.com/a/9614122/883015
			var radAngle = Math.atan2(dy, dx);

			// decompose the given length in to x/y components
			// this is done to support non-square pixels
			var length = {
				x: lengthNm * Math.cos(radAngle),
				y: lengthNm * Math.sin(radAngle),
			};

			// calculate pixel size
			var pxSizeNm = {
				x: length.x / dx,
				y: length.y / dy,
			};

			return pxSizeNm;
		};

		var anchorMove = function(e, anchor){
			// shift-key makes straight horizontal line
			if (e.evt.shiftKey) {
				if (anchor == anchors.start) {
					anchors.start.y(anchors.end.y());
				} else {
					anchors.end.y(anchors.start.y());
				}
			}

			// ctrl-key makes straight vertical line
			else if (e.evt.ctrlKey) {
				if (anchor == anchors.start) {
					anchors.start.x(anchors.end.x());
				} else {
					anchors.end.x(anchors.start.x());
				}
			}

			line.points([
				anchors.start.x() - line.x(),
				anchors.start.y() - line.y(),
				anchors.end.x() - line.x(),
				anchors.end.y() - line.y()
			]);
		};

		var anchors = {
			start: this._CreateAnchor(x1, y1, anchorMove),
			end: this._CreateAnchor(x2, y2, anchorMove),
		};

		var group = new Konva.Group({
			draggable: true,
		});
		var line = new Konva.Arrow({
			pointerAtBeginning: true,
			points: [x1, y1, x2, y2],
			strokeWidth: 2,
			fill: "lime",
			stroke: 'lime',
		});
		line.on("mouseover", function(){ this.strokeWidth(4); });
		line.on("mouseout", function(){ this.strokeWidth(2); });
		group.on('mouseover', function(){ document.body.style.cursor = "pointer"; });
		group.on('mouseout', function(){
			document.body.style.cursor = "default";
			tooltip.hide();
		});
		group.on('mousemove', function(){
			var mousePos = stage.getPointerPosition();
			tooltip.position(mousePos);
			var offset = 5;
			tooltip.offsetX(-offset);
			tooltip.offsetY(-offset);
			tooltip.show();
		});

		var updateLabel = function(){
			updateCalc();
			var linePts = line.points();
			label.position({
				x: (linePts[0] + linePts[2]) / 2,
				y: (linePts[1] + linePts[3]) / 2,
			});
			label.offsetX(label.width() / 2);
			label.offsetY(label.height() / 2);
		};
		group.on('dragmove', updateLabel);

		var label = new Konva.Label();
		var text = new Konva.Text({
			text: '0.00 nm',
			fontFamily: 'monospace',
			fontSize: 12,
			// fontStyle: 'bold',
			padding: 5,
			fill: 'lime',
			fillAfterStrokeEnabled: true,
			stroke: 'black',
			listening: false,
		});
		label.add(text);

		var tooltip = new Konva.Text({
			text: 'Double-click to set the pixel size / scaling.',
			fontSize: 12,
			width: 150,
			padding: 8,
			fill: 'white',
			fillAfterStrokeEnabled: true,
			stroke: 'black',
			visible: false,
			listening: false,
		});

		group.add(line, anchors.start, anchors.end, label);
		layer.add(group, tooltip);

		updateLabel();

		// return {"group": group, "archors": anchors, "line": line};
		return {
			element: group,
			getLengthNm: function(){ return lengthNm; },
			getPixelSize: function(lengthNm){
				return calculateNewPixelSize(lengthNm);
			},
			doUpdate: function(){ updateLabel(); },
		};
	},

	_CreateAnchor: function(x, y, onMove, strokeWidth = 2) {
		// modified from:
		// https://konvajs.org/docs/sandbox/Modify_Curves_with_Anchor_Points.html
		var anchor = new Konva.Circle({
			x: x,
			y: y,
			radius: 5,
			stroke: "#666",
			fill: "#ddd",
			strokeWidth: strokeWidth,
			draggable: true,
			opacity: 0.4,
		});

		// add hover styling
		anchor.on("mouseover", function () {
			document.body.style.cursor = "pointer";
			this.strokeWidth(strokeWidth + 2);
		});
		anchor.on("mouseout", function () {
			document.body.style.cursor = "default";
			this.strokeWidth(strokeWidth);
		});
		anchor.on("dragmove", function (e) {
			if (typeof onMove == 'function')
				onMove(e, this);
		});

		return anchor;
	},

	/**
	 * Creates a random integer between 0 and the given maximum.
	 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random
	 * @param {number} max the largest value possible.
	 * @returns a random number.
	 */
	getRandomInt: function(max) {
		return Math.floor(Math.random() * max);
	},

	/**
	 * Initiates a download of the given resource, like programmatically clicking on a download link.
	 * function from:
	 * https://konvajs.org/docs/data_and_serialization/High-Quality-Export.html
	 * https://stackoverflow.com/a/15832662/512042
	 * @param {string} uri a url pointing to the resource to download.
	 * @param {*} name the filename to use for the downloaded file.
	 */
	downloadURI: function(uri, name) {
		var link = document.createElement('a');
		link.download = name;
		link.href = uri;
		document.body.appendChild(link);
		link.click();
		document.body.removeChild(link);
		// delete link;
	},

	/**
	 * Updates the displayed statistics or parameters on the Spot profile.
	 * @param {*} stage the drawing stage for the spot profile and where to display the values.
	 * @param {*} beam the beam used for the spot layout and sampling of the image (after scaling).
	 * @param {*} cellSize the size of a cell in the raster grid of the resulting image.
	 * @param {*} userImage the scaled image by the user (in spot content) used to size the beam.
	 */
	updateDisplayBeamParams: function(stage, beam, cellSize, userImage, onDblClick) {
		// calculate and display the values
		const infoclass = "parameterDisplay";
		var element = this.ensureInfoBox(stage, infoclass, onDblClick);
		if (element) {
			var beamSizeA = beam.radiusX() * beam.scaleX(),
			beamSizeB = beam.radiusY() * beam.scaleY();
		
			// swap them so that is beamSizeA the larger one, for convention
			if (beamSizeA < beamSizeB) { [beamSizeA, beamSizeB] = [beamSizeB, beamSizeA]; }
			// https://www.cuemath.com/geometry/eccentricity-of-ellipse/
			var eccentricity = Math.sqrt(1 - (Math.pow(beamSizeB,2) / Math.pow(beamSizeA,2)));

			var spotSizeX = NaN, spotSizeY = NaN;
			if (typeof userImage != 'undefined'){
				var bw = (beam.width()*beam.scaleX()) / userImage.scaleX();
				var bh = (beam.height()*beam.scaleY()) / userImage.scaleY();

				spotSizeX = (bw / cellSize.w)*100;
				spotSizeY = (bh / cellSize.h)*100;
			}

			// display it
			element.innerHTML = 'Eccentricity: '+eccentricity.toFixed(G_MATH_TOFIXED.SHORT) +'<br>'
			+ 'Rotation: '+beam.rotation().toFixed(G_MATH_TOFIXED.MIN)+"°" +'<br>'
			+ 'Width: '+spotSizeX.toFixed(G_MATH_TOFIXED.MIN)+'%' +'<br>'
			+ 'Height: '+spotSizeY.toFixed(G_MATH_TOFIXED.MIN)+'%';

			// tooltip
			element.title = 'Double-click to change the spot width.';
		}
	},

	/**
	 * Calculates cell size based on given object (eg. rect, stage, or image), rows and cols
	 * @param {*} rect a Konva object that has a width and height, usually a rect, image, or stage.
	 * @param {number} rows (optional) the number of rows to split the area into.
	 * If not is provided, attempts to get it from gui/input.
	 * @param {number} cols (optional) the number of columns to split the area into.
	 * If not is provided, attempts to get it from gui/input.
	 * @returns the size (w,h) of a cell in the raster grid.
	 */
	computeCellSize: function(rect, rows = -1, cols = -1){
		if (rows <= 0) { rows = this.getRowsInput(); }
		if (cols <= 0) { cols = this.getColsInput(); }

		var cellSize = {
			w: rect.width() / cols,
			h: rect.height() / rows,
		};
		return cellSize;
	},

	/**
	 * Calculates the magnification based on the given rectangles' width and scaleX
	 * @param {*} rectBase The original object
	 * @param {*} rectScaled The scaled object
	 * @returns The magnification (ratio)
	 */
	computeMagLevel: function(rectBase, rectScaled) {
		var rW = (rectScaled.width() * rectScaled.scaleX()) / (rectBase.width() * rectBase.scaleX());
		return rW;
	},

	/**
	 * Displays and updates the magnification.
	 * @param {*} destStage The stage to display the info on.
	 * @param {*} scaledRect The shape to calculate the magnification from compared to its stage.
	 */
	updateMagInfo: function(destStage, scaledRect) {
		// add/update the mag disp. text
		const infoclass = "magDisplay";
		var element = this.ensureInfoBox(destStage, infoclass);
		if (element) {
			var magLevel = this.computeMagLevel(scaledRect.getStage(), scaledRect);
			var fmtMag = magLevel.toFixed(G_MATH_TOFIXED.SHORT) + 'X';

			// display it
			element.innerHTML = fmtMag;
			G_GUI_Controller.digitalMag = fmtMag;
		}
	},

	/**
	 * Displays and updates the Image metrics, if {@link G_IMG_METRIC_ENABLED} is true.
	 * @param {*} sourceStage The stage for the ground truth / reference image
	 * @param {*} destStage The stage for the image to compare
	 */
	updateImageMetricsInfo: function(sourceStage, destStage) {
		// create info dialog as needed
		const dialogId = "dialog-imgMetric";
		const eTitle = "Double-click for more information.";
		var onDblClick = function(){
			let dialog = $('#'+dialogId);
			if (dialog.length) {
				dialog.dialog('open');
			} else {
				var elem = $("<div/>")
				.attr({
					'id': dialogId,
					'title': G_APP_NAME + " - Image Quality Metric"
				})
				.css({'display':'none'})
				.addClass('jui')
				.html(`
				<div>
				<input type="hidden" autofocus="autofocus" />

				<p><b>Image Quality</b></p>

				<p>
				The intended use of an image metric in this application is more of a qualitative
				nature, rather than quantitative. The user should be able to grasp any trends in the
				change of the image quality metric when the imaging parameters are changed.
				</p>

				<p>
				That said, it is the trends or change in the image quality metric values that
				are important, more so than the values themselves.
				Other than the MSE and PSNR algorithms, a value of 0.0 indicates the lowest
				score or match when compared to the original (ground truth) image. Whereas
				a maximum score of 1.0 indicates a perfect match. Naturally, the ground truth image
				is assumed to be of optimum quality for this comparison.
				<p>

				<details>
				<summary><b>Additional Information</b></p></summary>
				<p>For performance reasons, the metric is only updated at every quarter of the image
				drawn, or if the draw-rate is fast, <i>i.e.</i>, less than 50 ms/row
				(for non SSIM-based algorithms).
				</p>

				<p>Unfortunately, there is no flawless or foolproof image quality metric.
				Over 20 different image metrics have been reviewed and compared by
				<a href="https://www.sciencedirect.com/science/article/pii/S2214241X15000206">
				Jagalingam and Hegde in a 2015 paper</a>, each with
				their different strengths and weaknesses.
				</p>
				<ul>
				<li>More information on the purpose and intended use can be found
				<a href="https://github.com/joedf/CAS741_w23/blob/main/docs/SRS/SRS.pdf">here</a>.</li>
				<li>A comparison of various image quality metrics used in this application is available
				<a href="https://github.com/joedf/CAS741_w23/blob/main/docs/VnVReport/VnVReport.pdf">here</a>.</li>
				</ul>
				</details>

				</div>
				`);

				elem.dialog({
					modal: true,
					width: 540,
					buttons: {
						Ok: function() {
							$( this ).dialog( "close" );
						}
					}
				});
			}
		};

		// calculate and display
		const infoclass = "metricsDisplay";
		var element = this.ensureInfoBox(destStage, infoclass, onDblClick, eTitle);
		if (element) {

			// Show/hide the img-metric based on the global boolean
			$(element).toggle(G_IMG_METRIC_ENABLED);
			
			// only do the calc, if enabled
			if (G_IMG_METRIC_ENABLED) {
				// compare without Image Smoothing
				const imageSmoothing = false;

				// get ground truth image
				var refImage = this.getFirstImageFromStage(sourceStage);
				var refData = this.getKonvaImageData(refImage, imageSmoothing);

				// get the image without the row/draw indicator
				var finalImage = this.getVirtualSEM_KonvaImage(destStage);
				var finalData = this.getKonvaImageData(finalImage, imageSmoothing);

				// Do the metric calculation here
				// based on the algorithm/metric chosen...
				var metricValue = 0;
				var algo = Utils.getImageMetricAlgorithm();
				if (algo.indexOf('SSIM') >= 0) {
					// needed for the SSIM / MS-SSIM library
					const img_channel_count = 4;
					refData.channels = img_channel_count;
					finalData.channels = img_channel_count;

					let metrics = ImageMSSSIM.compare(refData, finalData);
					if (algo == "MS-SSIM") {
						metricValue = metrics.msssim;
					} else {
						metricValue = metrics.ssim;
					}
				} else { // 'MSE', 'PSNR', 'iNRMSE', 'iNMSE'
					let metrics = NRMSE.compare(refData, finalData);
					switch(algo) {
						case 'MSE': metricValue = metrics.mse; break;
						case 'PSNR': metricValue = metrics.psnr; break;
						case 'iNMSE': metricValue = metrics.inmse; break;
						default: metricValue = metrics.inrmse;
					}
				}

				// display it
				element.innerHTML = algo + " = " + metricValue.toFixed(G_MATH_TOFIXED.LONG);
			}
		}
	},

	updateSubregionPixelSize: function(destStage, subregionImage, imageObj){
		var rows = Utils.getRowsInput(), cols = Utils.getColsInput();

		var rect = {
			w: subregionImage.width() * subregionImage.scaleX(),
			h: subregionImage.height() * subregionImage.scaleY(),
		};

		// TODO: maybe get the ground truth image stage for the size info instead,
		// we are likely "cheating" here because all stages share the same size
		// in the current design...
		var gt_stage_size = destStage.size();

		var pxSizeNm = Utils.getPixelSizeNmInput();
		var fullImgSize = {
			w: imageObj.naturalWidth * pxSizeNm,
			h: imageObj.naturalHeight * pxSizeNm,
		};
		// similar formula to the used for the subregion rect in the groundtruth view
		var subregionSizeNm = {
			w: (gt_stage_size.width / rect.w) * fullImgSize.w,
			h: (gt_stage_size.height / rect.h) * fullImgSize.h,
		};

		// get optimal / formated unit
		// TODO: maybe use "this." instead of "Utils."
		// do it for all functions too?
		var fmtPxSize = Utils.formatUnitNm(
			subregionSizeNm.w / cols,
			subregionSizeNm.h / rows
		);

		// display coords & FOV size
		Utils.updateExtraInfo(destStage,
			fmtPxSize.value.toFixed(G_MATH_TOFIXED.SHORT) + ' x '
			+ fmtPxSize.value2.toFixed(G_MATH_TOFIXED.SHORT)
			+ ' ' + fmtPxSize.unit + '/px'
		);
	},

	/**
	 * Displays and updates additional info on the given stage
	 * @param {*} destStage The stage to display info on.
	 * @param {string} infoText Text to display.
	 */
	updateExtraInfo: function(destStage, infoText) {
		const infoclass = "extraInfoDisplay";
		var element = this.ensureInfoBox(destStage, infoclass);
		if (element) {
			// display it
			element.innerHTML = infoText;
		}	
	},

	/**
	 * Conditionally displays a small warning icon if it meets the G_AUTO_PREVIEW_LIMIT.
	 * This icon can be hovered or double-clicked to obtain
	 * a message explaining the drawing is done row-by-row instead of frame-by-frame
	 * for performance / responsiveness.
	 * @param {*} stage The stage to display it on.
	 * @todo Currently, only used for the Subregion resampled stage, could be used elsewhere?
	 */
	updateVSEM_ModeWarning: function(stage) {
		// add Row-by-row / vSEM mode warning
		var vSEM_note = $(stage.getContainer()).find('.vsem_mode').first();
		var alreadyAdded = vSEM_note.length > 0;

		// check if we should create it and add the UI element
		if (!alreadyAdded) {
			vSEM_note = Utils.ensureInfoBox(stage, 'vsem_mode',
				function(){
					alert($(this).attr('title'));
				}
			);
			if (vSEM_note) {
				// warn icon from GitHub's octicons (https://github.com/primer/octicons)
				// eslint-disable-next-line max-len
				const warnIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#FFFF00" d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>';
				vSEM_note.innerHTML = warnIcon;
				vSEM_note.title = "For higher pixel counts, the drawing is done "
					+ "row-by-row instead of frame-by-frame "
					+ "for improved performance / responsiveness.";
				$(vSEM_note).hide();
			}
		}

		// check if we should show/hide it
		var rows = Utils.getRowsInput(), cols = Utils.getColsInput();
		var showWarnVSEM = (rows*cols > G_AUTO_PREVIEW_LIMIT);
		$(vSEM_note).toggle(showWarnVSEM);
	},

	/** 
	 * Updates the displayed element to be shown/hidden according
	 * to the advanced mode setting. Affects all HTML elements with
	 * the class "advancedMode".
	 */
	updateAdvancedMode: function(){
		var isAdvModeON = false;
		if (typeof G_GUI_Controller !== 'undefined' || G_GUI_Controller !== null){
			isAdvModeON = G_GUI_Controller.advancedMode;
		}
		
		$('.advancedMode').toggle(isAdvModeON);
	},

	/**
	 * Gets or creates an info-box element on the given stage.
	 * @param {*} stage The stage on which display/have the info-box.
	 * @param {string} className The class name of the info-box DOM element.
	 * @param {function} [onDblClick] bound on creation, the event handler / callback for on-doubleclick event
	 * @param {string} [title] Optional title / tooltip text.
	 * @returns the info-box DOM element.
	 */
	ensureInfoBox: function(stage, className, onDblClick, title) {
		// get stage container
		var eStage = $(stage.getContainer());
		
		// check if we create the element already
		var e = eStage.children('.'+className+':first');
		if (e.length <= 0) {
			// not found, so create it
			eStage.prepend('<span class="infoBox '+className+'"></span>');
			e = eStage.children('.'+className+':first');
			
			if (typeof title == 'string') {
				e.attr('title', title);
			}
		}

		// return the non-jquery-wrapped DOM element
		var element = e.get(0);

		// but return false if not found or unsuccessful
		if (typeof element == 'undefined')
			return false;

		// attach the dbclick event handler if one was given
		// we bind everytime instead of only on-creation to prevent
		// any issues with stale references in the given handler function.
		if (typeof onDblClick == 'function') {
			// ensure we remove all previous dblclick handlers so dont end up
			// with multiple instances of the handler being triggered...
			e.unbind('dblclick').on('dblclick', onDblClick);
		}

		return element;
	},

	/**
	 * Calculates a new size (width and height) for the given object to fit in a stage's view bounds.
	 * @param {number} w the original width
	 * @param {number} h the original height
	 * @param {number} maxDimension The largest dimension (whether width or height) to fit in.
	 * @param {boolean} fillMode Whether to do a "fill", "fit" / "letterbox", "crop", or "squish" fit.
	 * @returns the new calculated size
	 * @todo fillMode is not yet fully supported, see https://github.com/joedf/ImgBeamer/issues/7
	 */
	fitImageProportions: function(w, h, maxDimension, fillMode="squish"){
		var mode = fillMode.toLowerCase().trim();

		// image ratio to "fit" in canvas
		var ratio = (w > h ? (w / maxDimension) : (h / maxDimension)); // fit
		if (mode == "fill"){
			ratio = (w > h ? (h / maxDimension) : (w / maxDimension)); // fill
		}
		if (mode == "squish") {
			return {w: maxDimension, h: maxDimension};
		}

		var iw = w/ratio; //, ih = h/ratio;
		return {w: iw, h: iw};
	},

	/**
	 * Scales the given shape, and moves it to preserve original center
	 * @todo maybe, no need to have oldScale specified, can be obtained from shape.scale() ...
	 * @todo possibly simplify this like {@link Utils.centeredScale} and remove the other?
	 * @param {*} stage The stage of the shape object
	 * @param {*} shape the shape object itself
	 * @param {*} oldScale the shapes old scale
	 * @param {*} newScale the new scale
	 */
	scaleOnCenter: function(stage, shape, oldScale, newScale){
		var stageCenter = {
			x: stage.width()/2 - stage.x(),
			y: stage.height()/2 - stage.y()
		};
		this.scaleCenteredOnPoint(stageCenter, shape, oldScale, newScale);
	},

	/**
	 * shorthand for @see {@link Utils.scaleOnCenter}.
	 * Gets the stage and original scale from the shape directly.
	 * @param {*} shape the shape to scale
	 * @param {*} newScale the new scale.
	 */
	centeredScale: function(shape, newScale){
		this.scaleOnCenter(shape.getStage(), shape, shape.scaleX(), newScale);
	},

	/**
	 * Scales the given shaped while keeping it centered on the given point.
	 * @param {*} point the centering point.
	 * @param {*} shape the shape to scale and position.
	 * @param {*} oldScale the shape's original scale
	 * @param {*} newScale the shape's new scale
	 */
	scaleCenteredOnPoint: function(point, shape, oldScale, newScale){
		// could be expanded to do both x and y scaling
		shape.scale({x: newScale, y: newScale});
		var oldPos = {
			x: (point.x - shape.x()) / oldScale,
			y: (point.y - shape.y()) / oldScale,
		};
		shape.position({
			x: point.x - oldPos.x * newScale,
			y: point.y - oldPos.y * newScale,
		});
	},

	/**
	 * Computes the average pixel value assuming an RGBA format, with a max of 255 for each component.
	 * @param {ImageData} raw The image data (access to pixel data).
	 * @returns an array of the average pixel value [R,G,B,A].
	 */
	get_avg_pixel_rgba: function(raw) {
		var blanks = 0;
		var d = raw.data;

		var sum = [0, 0, 0, 0];

		for (var i = 0; i < d.length; i += 4) {
			// Optimization note, with greyscale we only need to process one component...
			const px = [d[i], d[i+1], d[i+2], d[1+3]];
			// var r = px[0], g = px[1], b = px[2], a = px[3];

			if (px.every(c => c === 0)) {
				blanks += 1;
			} else {
				sum[0] += px[0];
				sum[1] += px[1];
				sum[2] += px[2];
				sum[3] += px[3];
			}
		}

		var total = raw.width * raw.height;
		// or eq... var total = d.length / 4;
		var fills = Math.max(1, total - blanks);

		var avg = [
			Math.round(sum[0] / fills),
			Math.round(sum[1] / fills),
			Math.round(sum[2] / fills),
			(255 - Math.round(sum[3] / fills)) / 255 // rgba - alpha is 0.0 to 1.0
		];

		var percent = (blanks / total) * 100;

		if (G_DEBUG) {
			console.log(blanks, total, percent);
			console.log("avg px=", avg.toString());
		}

		return avg;
	},

	/**
	 * Computes the average grayscale pixel value assuming an RGBA format, with a max of 255 for each component.
	 * However, only the R (red) component is considered.
	 * @param {ImageData} raw The image data (access to pixel data).
	 * @returns a number representing the average pixel value intensity (0 to 255).
	 */
	get_avg_pixel_gs: function(raw) {
		var blanks = 0;
		var d = raw.data;

		var sum = 0;

		// Optimization note, with greyscale we only need to process one component...
		for (var i = 0; i < d.length; i += 4) {
			const px = d[i];
			const alpha = d[i+3];

			if (px === 0 && alpha === 0) {
				blanks += 1;
			} else {
				sum += px;
			}
		}

		var total = raw.width * raw.height;
		var fills = Math.max(1, total - blanks);

		var avg = Math.round(sum / fills);
		
		return avg;
	},

	/**
	 * Draws a grid on a given drawing stage.
	 * Based on solution-1 from: https://longviewcoder.com/2021/12/08/konva-a-better-grid
	 * @param {*} gridLayer a layer on the stage to use for drawing the grid on.
	 * @param {*} rect a rectangle represing the size and position of the grid to draw.
	 * @param {number} rows the number of rows in the grid.
	 * @param {number} cols the number of columns in the grid.
	 * @param {*} lineColor the line color of the grid.
	 * @returns the cell size (width, height)
	 */
	drawGrid: function(gridLayer, rect, rows, cols, lineColor) {

		if (typeof lineColor == 'undefined' || lineColor == null || lineColor.length < 1)
			lineColor = 'rgba(255, 255, 255, 0.8)';

		var startX = rect.x();
		var startY = rect.y();

		var stepSizeX = rect.width() / cols;
		var stepSizeY = rect.height() / rows;
	
		const xSize = gridLayer.width(), // stage.width(), 
			ySize = gridLayer.height(), // stage.height(),
			xSteps = cols, //Math.round(xSize/ stepSizeX), 
			ySteps = rows; //Math.round(ySize / stepSizeY);

		// draw vertical lines
		for (let i = 0; i <= xSteps; i++) {
			gridLayer.add(
				new Konva.Line({
					x: startX + (i * stepSizeX),
					points: [0, 0, 0, ySize],
					stroke: lineColor,
					strokeWidth: 1,
				})
			);
		}
		//draw Horizontal lines
		for (let i = 0; i <= ySteps; i++) {
			gridLayer.add(
				new Konva.Line({
					y: startY + (i * stepSizeY),
					points: [0, 0, xSize, 0],
					stroke: lineColor,
					strokeWidth: 1,
				})
			);
		}

		gridLayer.batchDraw();

		var cellInfo = {
			width: stepSizeX,
			height: stepSizeY,
		};

		return cellInfo;
	},

	/**
	 * Draws a given shape repeatedly (clones) in a grid pattern.
	 * The shape will be drawn {@link rows} by {@link cols} times.
	 * Originally based from drawGrid() ...
	 * @param {*} layer The layer to draw on
	 * @param {*} rect The bounds of the grid pattern
	 * @param {*} shape The shape to draw
	 * @param {number} rows the number of rows for the grid
	 * @param {number} cols the number of columns for the grid
	 */
	repeatDrawOnGrid: function(layer, rect, shape, rows, cols) {
		var startX = rect.x();
		var startY = rect.y();

		var stepSizeX = rect.width() / cols;
		var stepSizeY = rect.height() / rows;

		var cellCenterX = stepSizeX / 2;
		var cellCenterY = stepSizeY / 2;

		// interate over X
		for (let i = 0; i < cols; i++) {
			// interate over Y
			for (let j = 0; j < rows; j++) {
				var shapeCopy = shape.clone();

				shapeCopy.x(startX + (i * stepSizeX) + cellCenterX);
				shapeCopy.y(startY + (j * stepSizeY) + cellCenterY);

				layer.add( shapeCopy );
			}
		}

		layer.batchDraw();
	},

	/**
	 * Draws a "stenciled" version of the given image and probe/shape based the grid parameters.
	 * "Stenciles" on a grid with an array of "cloned" spots.
	 * @param {*} previewStage The stage to draw on.
	 * @param {*} image The image to draw and "stencil".
	 * @param {*} probe The shape to stencil image with repeatedly in a grid pattern
	 * @param {number} rows The number of rows for the grid
	 * @param {number} cols The number of columns for the grid
	 * @param {*} rect The bounds for the grid
	 * @todo Likely remove it, deprecated and no longer used by anything...
	 */
	computeResampledPreview: function(previewStage, image, probe, rows, cols, rect){
		var previewLayer = previewStage.getLayers()[0];
		previewLayer.destroyChildren();

		var gr = image.clone();
		gr.globalCompositeOperation('source-in');

		this.repeatDrawOnGrid(previewLayer, rect, probe, rows, cols);
		previewLayer.add(gr);
	},

	/**
	 * Draws a resampled image with the given spot/probe.
	 * Samples on a grid with an array of "cloned" spots.
	 * The sampling grid fits the full size of the destination stage.
	 * @deprecated This has been replaced by {@link Utils.computeResampledSlow} due to accuracy concerns.
	 * @todo review this function, maybe remove or improve.
	 * @param {*} sourceStage The stage for the original image to pixel data from.
	 * @param {*} destStage The destination stage to draw on.
	 * @param {*} image The image size and position.
	 * @param {*} probe The sampling shape or probe
	 * @param {number} rows The number of rows for the sampling grid
	 * @param {number} cols The number of columns for the sampling grid
	 */
	computeResampledFast: function(sourceStage, destStage, image, probe, rows, cols){
		var destLayer = destStage.getLayers()[0];
		destLayer.destroyChildren(); 

		var layer = sourceStage.getLayers()[0];
		// layer.cache();
		var ctx = layer.getContext();


		var lRatio = ctx.getCanvas().pixelRatio;

		
		// process each grid cell
		var startX = image.x(), startY = image.y();
		var stepSizeX = image.width() / cols, stepSizeY = image.height() / rows;

		// interate over X
		for (let i = 0; i < cols; i++) {
			// interate over Y
			for (let j = 0; j < rows; j++) {
				var cellX = startX + (i * stepSizeX);
				var cellY = startY + (j * stepSizeY);
				var cellW = stepSizeX;
				var cellH = stepSizeY;

				var pxData = ctx.getImageData(
					cellX * lRatio,
					cellY * lRatio,
					cellW * lRatio,
					cellH * lRatio
				);

				if (!G_DEBUG) {
					// var avgPx = this.get_avg_pixel_rgba(pxData);
					// var avgColor = "rgba("+ avgPx.join(',') +")";
					var avg = Utils.get_avg_pixel_gs(pxData);
					var avgColor = "rgba("+[avg,avg,avg,1].join(',')+")";

					var cPixel = new Konva.Rect({
						listening: false,
						x: cellX,
						y: cellY,
						width: cellW,
						height: cellH,
						fill: avgColor,
					});

					destLayer.add(cPixel);

				} else {
					const canvas = document.createElement('canvas');
					canvas.width = cellW * lRatio;
					canvas.height = cellH * lRatio;
					canvas.getContext('2d').putImageData(pxData, 0, 0);
					const cPixel = new Konva.Image({
						image: canvas,
						listening: false,
						x: cellX,
						y: cellY,
						width: cellW,
						height: cellH,
					});

					destLayer.add(cPixel);
				}
			}
		}

		if (G_DEBUG) {
			this.drawGrid(destLayer, image, rows, cols);
		}
	},

	/**
	 * Essentially, this is {@link Utils.computeResampledFast}, but corrected for spot size larger than the cell size.
	 * Samples on a grid with an array of "cloned" spots.
	 * {@link Utils.computeResampledFast} limits the sampling to the cell size, and takes in smaller version of the
	 * image that is already drawn and "compositied" in a Konva Stage, instead of the original larger image...
	 * @param {*} sourceStage The stage to get the subregion area
	 * @param {*} destStage The stage to draw on
	 * @param {*} oImage The ground truth/source/original image to get data from.
	 * @param {*} probe The spot/probe to sample with
	 * @param {number} rows The number of rows for the sampling grid
	 * @param {number} cols The number of columns for the sampling grid
	 * @param {number} rect The bounds of the sampling grid
	 * @param {number} rowStart The row to start iterating over.
	 * @param {number} rowEnd The row at which to stop iterating over.
	 * @param {number} colStart The column to start iterating over.
	 * @param {number} colEnd The column at which to stop iterating over.
	 * @param {boolean} doClear Whether the layer should be cleared before drawing.
	 * @param {boolean} useLastLayer Whether to use the last (true) or first (false) layer to draw on.
	 */
	computeResampledSlow: function(sourceStage, destStage, oImage, probe, rows, cols, rect,
		rowStart = 0, rowEnd = -1, colStart = 0, colEnd = -1, doClear = true, useLastLayer = false){
		
		var layers = destStage.getLayers();
		var destLayer = layers[0];
		if (useLastLayer) { destLayer = layers[layers.length-1]; }
		if (doClear) { destLayer.destroyChildren(); }

		var pImage = oImage.image(),
		canvas = document.createElement('canvas'),
		// canvas = document.getElementById('testdemo'),
		ctx = canvas.getContext('2d');

		// get and transform cropped region based on user-sized konva-image for resampling
		var sx = (sourceStage.x() - oImage.x()) / oImage.scaleX();
		var sy = (sourceStage.y() - oImage.y()) / oImage.scaleY();
		var sw = sourceStage.width() / oImage.scaleX();
		var sh = sourceStage.height() / oImage.scaleY();

		canvas.width = destStage.width();
		canvas.height = destStage.height();
		

		var rw = (oImage.width() / pImage.naturalWidth);
		var rh = (oImage.height() / pImage.naturalHeight);

		ctx.drawImage(pImage,
			sx / rw, sy /rh, // crop x, y
			sw / rw, sh /rh, // crop width, height
			0, 0,
			destStage.width(),
			destStage.height()
			);

		var image = canvas; //oImage.image();

	/*
		var sx = (oImage.x() - sourceStage.x());// * oImage.scaleX();
		var sy = (oImage.y() - sourceStage.y());// * oImage.scaleY();
		var sw = sourceStage.width() * oImage.scaleX();
		var sh = sourceStage.height() * oImage.scaleY();
		destLayer.add(new Konva.Image({
			x: sx,
			y: sy,
			width: sw,
			height: sh,
			image: pImage,
		}));

		return;
		*/

		// process each grid cell
		var startX = 0, startY = 0;
		// var stepSizeX = image.naturalWidth / cols, stepSizeY = image.naturalHeight / rows;
		var stepSizeX = destStage.width() / cols, stepSizeY = destStage.height() / rows;

		var startX_stage = rect.x(), startY_stage = rect.y();
		var stepSizeX_stage = rect.width() / cols, stepSizeY_stage = rect.height() / rows;

		if (colEnd < 0) { colEnd = cols; }
		if (rowEnd < 0) { rowEnd = rows; }

		// interate over X
		for (let i = colStart; i < colEnd; i++) {
			// interate over Y
			for (let j = rowStart; j < rowEnd; j++) {
				var cellX = startX + (i * stepSizeX);
				var cellY = startY + (j * stepSizeY);
				var cellW = stepSizeX;
				var cellH = stepSizeY;

				probe.x(cellX + cellW/2);
				probe.y(cellY + cellH/2);

				var avg = this.ComputeProbeValue_gs(image, probe);
				var avgColor = "rgba("+[avg,avg,avg,1].join(',')+")";

				// Konva drawing
				var cellX_stage = startX_stage + (i * stepSizeX_stage);
				var cellY_stage = startY_stage + (j * stepSizeY_stage);
				var cellW_stage = stepSizeX_stage;
				var cellH_stage = stepSizeY_stage;

				var cPixel = new Konva.Rect({
					listening: false,
					x: cellX_stage,
					y: cellY_stage,
					width: cellW_stage,
					height: cellH_stage,
					fill: avgColor,
				});

				destLayer.add(cPixel);
			}
		}
	},

	/**
	 * Converts an angle in radians to degrees
	 * @param {number} angle the angle in radians.
	 * @returns the angle in degrees
	 */
	toDegrees: function(angle) { return angle * (180 / Math.PI); },

	/**
	 * Converts an angle in degrees to radians
	 * @param {number} angle the angle in degrees.
	 * @returns the angle in radians
	 */
	toRadians: function(angle) { return angle * (Math.PI / 180); },

	/**
	 * Calculates the euclidean distance.
	 * @param {*} x1 
	 * @param {*} y1 
	 * @param {*} x2 
	 * @param {*} y2 
	 * @returns the distance in the given coordinates' units.
	 */
	distance: function(x1, y1, x2, y2) {
		// https://stackoverflow.com/a/33743107/883015
		var dist = Math.hypot(x2-x1, y2-y1);
		return dist;
	},

	/**
	 * Gets the image scaling based on the Konva.Image size vs the image's true or 'natural' size.
	 * @param {*} konvaImage The konva image object
	 * @param {*} imageObj The actual image's HTML/DOM object
	 * @returns an object with the calculated values as properties 'x' and 'y'.
	 */
	imagePixelScaling: function(konvaImage, imageObj) {
		return {
			x: (konvaImage.width() / imageObj.naturalWidth),
			y: (konvaImage.height() / imageObj.naturalHeight),
		};
	},

	/**
	 * Convert stage to unit square coordinates
	 * @param {*} x 
	 * @param {*} y 
	 * @param {*} stage coordinates source stage
	 * @returns unit square coordinates
	 */
	stageToUnitCoordinates: function(x, y, stage){
		var centered = {
			x: x - (stage.width() / 2),
			y: y - (stage.height() / 2),
		};

		var unit = {
			x: centered.x / stage.width(),
			y: centered.y / stage.height(),
		};

		return unit;
	},

	/**
	 * Convert unit square to image pixel coordinates.
	 * @param {*} x 
	 * @param {*} y 
	 * @param {*} imageObj the original image object (with a width and height property)
	 * @returns image pixel coordinates
	 */
	unitToImagePixelCoordinates: function(x, y, imageObj) {
		return {
			x: x * imageObj.naturalWidth,
			y: y * imageObj.naturalHeight,
		};
	},

	/**
	 * Convert stage to Image pixel coordinates
	 * @param {*} x 
	 * @param {*} y 
	 * @param {*} stage coordinates source stage
	 * @param {*} imageObj the original image object (with a width and height property)
	 * @returns image pixel coordinates
	 */
	stageToImagePixelCoordinates: function(x, y, stage, imageObj) {
		var unit = this.stageToUnitCoordinates(x, y, stage);
		var ipixel = this.unitToImagePixelCoordinates(unit.x, unit.y, imageObj);
		return ipixel;
	},

	/**
	 * Convert image pixel to coordinates in "real" (or scaled) units
	 * @param {*} x 
	 * @param {*} y 
	 * @param {*} pxSizeX the width of a pixel in "real" units
	 * @param {*} pxSizeY the height of a pixel in "real" units
	 * @returns "real" coordinates
	 */
	imagePixelToRealCoordinates: function(x, y, pxSizeX, pxSizeY = null) {
		if (pxSizeY == null) { pxSizeY = pxSizeX; }
		return {
			x: x * pxSizeX,
			y: y * pxSizeY,
		};
	},
	
	/**
	 * Formats the values given to the appropriate display unit (nm or μm).
	 * @param {*} value_in_nm a value in nm.
	 * @param {*} value2_in_nm (optional) a value in nm.
	 * @returns an object containing the adjusted values and selected unit.
	 */
	formatUnitNm: function(value_in_nm, value2_in_nm = 0){
		/* eslint-disable no-magic-numbers */
		var out = {
			value: value_in_nm,
			value2: value2_in_nm,
			unit: "nm",
		};

		if (Math.abs(out.value) > 1000 || Math.abs(out.value2) > 1000) {
			out.value /= 1000;
			out.value2 /= 1000;
			out.unit = "μm";
		}
		/* eslint-enable no-magic-numbers */

		return out;
	},

	/**
	 * Generate a filename with a timestamp and the given prefix and counter.
	 * @param {string} prefix the filename prefix
	 * @param {number} counter a counter that has been incremented elsewhere
	 * @param {string} [fileExt="png"] the file extension
	 * @returns the filename.
	 */
	GetSuggestedFileName: function(prefix, counter, fileExt = "png"){
		const ISODateEnd = 10;
		var datestamp = new Date().toISOString().slice(0, ISODateEnd).replaceAll('-','.');
		var sCounter = String(counter).padStart(3,'0');
		var filename = prefix+"-"+datestamp+"-"+sCounter+"."+fileExt;
		return filename;
	},

	/** Used internally, for @see {@link Utils.ComputeProbeValue_gs} */
	_COMPUTE_GS_CANVAS: null,

	/**
	 * Gets the average pixel value (grayscale intensity) with the given image and one probe.
	 * @param {*} image the image to get pixel data from
	 * @param {*} probe the sampling shape/spot.
	 * @param {number} superScale factor to scale up ("blow-up") the image for the sampling.
	 * @returns the computed grayscale color
	 */
	ComputeProbeValue_gs: function(image, probe, superScale=1) {
		// var iw = image.naturalWidth, ih = image.naturalHeight;

		// get ellipse info
		var ellipseInfo = probe;
		if (typeof probe.getStage == 'function') { // if Konva Ellipse
			ellipseInfo = {
				centerX: probe.x(),
				centerY: probe.y(),
				rotationRad: this.toRadians(probe.rotation()),
				radiusX: probe.radiusX(),
				radiusY: probe.radiusY()
			};
		}

		// optimization is to reduce search area to max bounds possible of the ellipse
		var maxRadius = Math.max(ellipseInfo.radiusX, ellipseInfo.radiusY);
		var maxDiameter = 2 * maxRadius;

		var cvSize = maxDiameter * superScale;

		// create an offscreen canvas, if not already done
		if (this._COMPUTE_GS_CANVAS == null) {
			this._COMPUTE_GS_CANVAS = new OffscreenCanvas(cvSize, cvSize);
		}

		var cv = this._COMPUTE_GS_CANVAS;
		// var cv = document.createElement('canvas');
		// if (G_DEBUG) {
		// 	document.body.appendChild(cv);
		// }
		cv.width = cvSize;
		cv.height = cvSize;

		if (cv.width == 0 || cv.height == 0)
			return 0;

		var ctx = cv.getContext('2d');
		ctx.imageSmoothingEnabled = false;

		// since we are reusing the offscreen canvas, we should clear it each time
		// to prevent getting artifacts from previous draws
		ctx.clearRect(0, 0, cv.width, cv.height);

		// draw the image
		// ctx.drawImage(image, 0, 0);
		// optimization, to draw only the necessary area of the image
		ctx.drawImage(image,
			ellipseInfo.centerX - maxRadius,
			ellipseInfo.centerY - maxRadius,
			maxDiameter,
			maxDiameter,
			0,
			0,
			cv.width,
			cv.height);

		// then, set the composite operation
		ctx.globalCompositeOperation = 'destination-in'; //TODO: Is this right? or 'source-in'?

		// then draw the pixel selection shape
		ctx.beginPath();
		ctx.ellipse(
			maxDiameter / 2 * superScale,
			maxDiameter / 2 * superScale,
			ellipseInfo.radiusX * superScale,
			ellipseInfo.radiusY * superScale,
			ellipseInfo.rotationRad,
			0, 2 * Math.PI);
		ctx.fillStyle = 'white';
		ctx.fill();

		// grab the pixel data from the pixel selection area
		var pxData = ctx.getImageData(0,0,cv.width,cv.height);

		// hack to directly use Konva's built-in filters code
		if (typeof Konva != 'undefined') {
			var brightnessFunc = Konva.Filters.Brighten.bind({
				brightness: () => this.getBrightnessInput()});
			var contrastFunc = Konva.Filters.Contrast.bind({
				contrast: () => this.getContrastInput()});
			// apply it directly to out image data before we sample it.
			brightnessFunc(pxData);
			contrastFunc(pxData);
		}

		// compute the average pixel (excluding 0-0-0-0 rgba pixels)
		var pxColor = this.get_avg_pixel_gs(pxData);

		// delete the canvas
		//document.body.removeChild(cv);
		ctx = null;
		cv = null;

		return pxColor;
	},

	/**
	 * Applies Brightness/Contrast (B/C) values to a given Konva stage or drawable.
	 * @param {*} drawable The Konva stage or drawable / drawElement.
	 * @param {*} brightness The brightness value, from -1 to 1.
	 * @param {*} contrast The contrast value, mainly from -100 to 100.
	 */
	applyBrightnessContrast: function(drawable, brightness=0, contrast=0) {
		// cache step is need for filter effects to be visible.
		// https://konvajs.org/docs/performance/Shape_Caching.html
		drawable.cache();

		// Filters: https://konvajs.org/api/Konva.Filters.html
		// Brightness => https://konvajs.org/docs/filters/Brighten.html
		// Contrast => https://konvajs.org/docs/filters/Contrast.html
		var currentFilters = drawable.filters();
		// null check, default to empty array if n/a.
		currentFilters = currentFilters != null ? currentFilters : [];
		// Add filter if not already included...
		var currentFiltersByName = currentFilters.map(x => x.name);
		var filtersToSet = currentFilters;
		var added = 0;
		['Brighten', 'Contrast'].forEach(filterName => {
			if (!currentFiltersByName.includes(filterName)) {
				filtersToSet.push(Konva.Filters[filterName]);
				added++;
			}
		});
		drawable.filters(filtersToSet);
		if (G_DEBUG) {
			console.log("filters added:", added);
		}

		// apply B/C filter values
		drawable.brightness(brightness);
		drawable.contrast(contrast);
	},

	/**
	 * Sets the image Smoothing option for a given stage.
	 * Currently, this only affects the "base" layer.
	 * For now it doesn't make sense to remove smoothing from overlay layers
	 * which are currently used for annotations and such.
	 * @param {*} stage The stage
	 * @param {*} enabled True for enabled, false for disabled
	 */
	setStageImageSmoothing: function(stage, enabled=true){
		var layers = stage.getLayers();

		// if we need / want this for all layers, we could loop over all layers
		if (layers.length > 0) {
			var baseLayer = layers[0];
			baseLayer.imageSmoothingEnabled(enabled);
			// 2024.02.05: imageSmoothingQuality is not yet fully supported...
			// baseLayer.imageSmoothingQuality = 'low';
		}
	},

	/**
	 * Find and gets the first "image" type from the first layer of the given Konva stage
	 * @param {*} stage the stage to search through
	 * @returns the first image object
	 */
	getFirstImageFromStage: function(stage){
		var image = stage.getLayers()[0].getChildren(function(x){
			return x.getClassName() == 'Image';
		})[0];

		return image;
	},

	/**
	 * Creates a new Konva.Rect object based on the position and size of a given konva object.
	 * @param {*} kObject A konva object that has a size and postion, such as a stage, image, or rect.
	 * @returns a rectable object with x, y, width, and height functions.
	 */
	getRectFromKonvaObject: function(kObject){
		return new Konva.Rect({
			x: kObject.x(),
			y: kObject.y(),
			width: kObject.width(),
			height: kObject.height(),
		});
	},

	/**
	 * Finds (non-recusrive) the first layer with a matching on a given stage, null if n/a.
	 * @param {*} stage The stage or element with layers.
	 * @param {*} layerName The name of the layer.
	 */
	getLayerByName: function(stage, layerName){
		var layers = stage.getLayers();
		for (let i = 0; i < layers.length; i++) {
			const layer = layers[i];
			const name = layer.name();
			if (name == layerName) {
				return layer;
			}
		}
		return null;
	},

	/**
	 * get the image without the row/draw indicator
	 * @param {*} stage the stage to search through
	 * @returns the image object
	 */
	getVirtualSEM_KonvaImage: function(stage){
		// should be the only "Image" child on the first layer...
		return this.getFirstImageFromStage(stage);
	},

	/**
	 * get the imageData (pixels) from a given konva object/image
	 * @param {*} konvaObject the shape/image/object to get image data from
	 * @param {number} pixelRatio the pixel ratio to scale (larger means ~higher resolution)
	 * @param {boolean} imageSmoothing Set to true to use image smoothing
	 * @returns The image data
	 */
	getKonvaImageData: function(konvaObject, pixelRatio=2, imageSmoothing=true) {
		// TODO: maybe we get higher DPI / density images?
		var cnv = konvaObject.toCanvas({"pixelRatio": pixelRatio, "imageSmoothingEnabled": imageSmoothing});
		var ctx = cnv.getContext('2d');
		var data = ctx.getImageData(0, 0, cnv.width, cnv.height);
		return data;
	},

	/** Displays a message/dialog box with information about this application. */
	ShowAboutMessage: function(){
		/* eslint-disable max-len */
		const id = 'dialog-about';
		var about = $('#'+id);
		if (about.length) {
			about.dialog('open');
		} else {
			var elem = $("<div/>")
			.attr({
				'id': id,
				'title': "About " + G_APP_NAME
			})
			.css({'display':'none'})
			.addClass('jui')
			// fix jquery-ui auto-focus bug: https://stackoverflow.com/a/14748517/883015
			.html(`
			<div>
			<input type="hidden" autofocus="autofocus" />
			<div style="float: left;margin: 0 4px;"><img src="src/img/icon128.png" width="48"></div>
			<p><b>`	+G_APP_NAME+ `</b> was created as an easy-to-use tool to understand the effects of 
			the spot size to pixel size ratio on image clarity and resolution
			in the SEM image formation / rasterization process.</p>
			
			<p><b>Application Development</b></p>
			<ul>
			<li>Main developer: Joachim de Fourestier</li>
			<li>Original concept: Michael W. Phaneuf</li>
			</ul>

			<details open>
			<summary><b>Source Code and Documentation</b></summary>
			<ul>
			<li>Source code: <a href="https://github.com/joedf/ImgBeamer">https://github.com/joedf/ImgBeamer</a></li>
			<li>Code documentation: <a href="https://joedf.github.io/ImgBeamer/jsdocs">https://joedf.github.io/ImgBeamer/jsdocs</a></li>
			<li>Application design: <a href="https://github.com/joedf/CAS741_w23">https://github.com/joedf/CAS741_w23</a></li>
			<li>Quick start guide: <a href="https://joedf.github.io/ImgBeamer/misc/ImgBeamer_QS_guide.pdf">ImgBeamer_QS_guide.pdf</a></li>
			</ul>
			</details>
			
			<details>
			<summary><b>Image Contributions</b></p></summary>
			<ul>
			<li>Bavley Guerguis for the APT needle image <q>APT_needle.png</q></li>
			<li>Joachim de Fourestier for the <q>El Laco tephra (EL-JM-P4)</q> images
			<q>tephra_448nm.png</q>, <q>tephra_200nm.png</q>,
			and the virtual <q>grains</q> images.
			</li>
			</ul>
			<p>All images belong to their respective owners and are used here with permission.</p>
			</details>

			<details>
			<summary><b>Open-Source Libraries</b></p></summary>
			<ul>
			<li><a href="https://konvajs.org">Konva.js</a> - HTML5 2d canvas js library</li>
			<li><a href="https://jquery.com/">jQuery</a>
			and <a href="https://jqueryui.com">jQuery-ui</a> - HTML DOM manipulation and UI elements</li>
			<li><a href="https://github.com/dataarts/dat.gui">dat.gui</a> - Lightweight GUI for changing variables</li>
			<li><a href="https://github.com/photopea/UTIF.js">UTIF.js</a> - Fast and advanced TIFF decoder</li>
			<li><a href="https://github.com/darosh/image-ms-ssim-js">image-ms-ssim.js</a> - Image multi-scale structural similarity (MS-SSIM)</li>
			</ul>
			</details>

			</div>
			`).appendTo('body');

			elem.dialog({
				modal: true,
				width: 540,
				buttons: {
					Ok: function() {
						$( this ).dialog( "close" );
					}
				}
			});
		}
		/* eslint-enable max-len */
	}
};

↑ Top