Source: lib/util.js

/**
 * @fileoverview Utility helper functions used throughout the project.
 */

// ---------------------- RESTORING SERIALISED OBJS -----------------------

/**
 * Convert a serialised p5.Color back into a live {@link p5.Color} instance.
 * @param {object} obj Serialised colour object.
 * @returns {p5.Color|object}
 */
function restoreSerializedP5Color(obj) {
	if (!(obj.levels && obj.mode)) return obj;
	push();
	colorMode(RGB);
	const col = color(obj.levels);
	pop();
	return col;
}

/**
 * Convert a plain object to a {@link Vec3D} if it contains x, y, and z.
 * @param {object} obj Potentially serialised vector.
 * @returns {Vec3D|object}
 */
function restoreSerializedVec3D(obj) {
	// always use before Vec2D version when used in combination
	if ([obj.x, obj.y, obj.z].some(v => v === undefined)) return obj;
	return new Vec3D(obj.x, obj.y, obj.z);
}

/**
 * Convert a plain object to a {@link Vec2D} if it contains x and y.
 * @param {object} obj Potentially serialised vector.
 * @returns {Vec2D|object}
 */
function restoreSerializedVec2D(obj) {
	if ([obj.x, obj.y].some(v => v === undefined)) return obj;
	return new Vec2D(obj.x, obj.y);
}

// ----------------------------- DRAWING ------------------------------

/**
 * Execute a drawing function wrapped in push/pop calls. Can also operate on a
 * {@link p5.Graphics} instance if provided.
 * @param {...any} args Either (pg, fn) or (fn).
 */
function pushpop() {
	// pushpop(pg, () => { ... })
	if (arguments.length == 2) {
		const pg = arguments[0];
		const func = arguments[1];
		pg.push();
		func();
		pg.pop();
		return;
	}
	// pushpop(() => { ... })
	if (arguments.length == 1) {
		const func = arguments[0];
		push();
		func();
		pop();
		return;
	}
}

/**
 * Draw a bezier fillet between two points around a centre.
 * @param {Vec2D} filletStart
 * @param {Vec2D} filletEnd
 * @param {Vec2D} cen Centre of the fillet.
 */
function toxiFillet(filletStart, filletEnd, cen) {
	const cpts = bezierFilletControlPoints(filletStart, filletEnd, cen);
	toxiBezierVertex(cpts[0], cpts[1], filletEnd);
}

/**
 * Calculate control points for a fillet bezier curve.
 * @param {Vec2D} filletStart
 * @param {Vec2D} filletEnd
 * @param {Vec2D} cen
 * @returns {Vec2D[]}
 */
function bezierFilletControlPoints(filletStart, filletEnd, cen) {
	let a = filletStart.sub(cen);
	let b = filletEnd.sub(cen);
	let q1 = a.dot(a);
	let q2 = q1 + a.dot(b);
	let k2 = ((4 / 3) * (sqrt(2 * q1 * q2) - q2)) / (a.x * b.y - a.y * b.x);

	let x2 = cen.x + a.x - k2 * a.y;
	let y2 = cen.y + a.y + k2 * a.x;
	let x3 = cen.x + b.x + k2 * b.y;
	let y3 = cen.y + b.y - k2 * b.x;
	return [new Vec2D(x2, y2), new Vec2D(x3, y3)];
}

/**
 * Convenience wrapper returning the intersection of two {@link Line2D} lines.
 * @param {Line2D} lineA
 * @param {Line2D} lineB
 * @returns {Vec2D}
 */
function intersectionPoint(lineA, lineB) {
	let res = lineA.intersectLine(lineB);
	return res.pos;
}

/**
 * p5 wrapper for toxiclibs bezier vertex convenience.
 * @param {Vec2D} cp1 Control point 1
 * @param {Vec2D} cp2 Control point 2
 * @param {Vec2D} p2 End point
 */
function toxiBezierVertex(cp1, cp2, p2) {
	bezierVertex(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y);
}

/**
 * p5 vertex wrapper using a {@link Vec2D}.
 * @param {Vec2D} v
 */
function toxiVertex(v) {
	vertex(...vectorComponents(v));
}

/**
 * Convert a vector into an array of its numeric components.
 * @param {Vec2D|Vec3D} v
 * @returns {number[]}
 */
function vectorComponents(v) {
	return Object.values({ ...v });
}

/**
 * Draw an image fitted to the canvas centre.
 * @param {p5.Image} img Image to draw.
 * @param {boolean} doFill Fit or contain flag.
 */
function imageCentered(img, doFill) {
	image(
		img,
		0,
		0,
		width,
		height,
		0,
		0,
		img.width,
		img.height,
		doFill ? COVER : CONTAIN
	);
}

function imageCenteredXYScale(
	img,
	doFill,
	posX = 0,
	posY = 0,
	sc = 1,
	doFlipHorizontal = false
) {
	push();
	{
		resetMatrix();
		if (theShader !== undefined) translate(-width / 2, -height / 2);
		imageMode(CENTER);

		const am = width / height;
		const aimg = img.width / img.height;
		const doFitVertical = (am > aimg) ^ doFill;

		let imgFitW = doFitVertical ? height * aimg : width;
		let imgFitH = doFitVertical ? height : width / aimg;

		translate(width * 0.5, height * 0.5);

		let renderW = imgFitW * sc;
		let renderH = imgFitH * sc;
		let dx = (-posX * (renderW - width)) / 2;
		let dy = (-posY * (renderH - height)) / 2;
		translate(dx, dy);

		scale(sc);

		if (doFlipHorizontal) scale(-1, 1);

		image(img, 0, 0, imgFitW, imgFitH);
	}
	pop();
}

function pgImageCenteredXYScale(
	pg,
	img,
	doFill,
	posX = 0,
	posY = 0,
	sc = 1,
	doFlipHorizontal = false
) {
	pg.push();
	{
		pg.resetMatrix();
		if (theShader !== undefined) pg.translate(-width / 2, -height / 2);
		pg.imageMode(CENTER);

		const am = pg.width / pg.height;
		const aimg = img.width / img.height;
		const doFitVertical = (am > aimg) ^ doFill;

		let imgFitW = doFitVertical ? pg.height * aimg : pg.width;
		let imgFitH = doFitVertical ? pg.height : pg.width / aimg;

		pg.translate(width * 0.5, height * 0.5);

		let renderW = imgFitW * sc;
		let renderH = imgFitH * sc;
		let dx = (-posX * (renderW - pg.width)) / 2;
		let dy = (-posY * (renderH - pg.height)) / 2;
		pg.translate(dx, dy);

		pg.scale(sc);

		if (doFlipHorizontal) pg.scale(-1, 1);

		pg.image(img, 0, 0, imgFitW, imgFitH);
	}
	pg.pop();
}

function getMouseMappedToCenteredPg(pg, doFill) {
	// main aspect ratio, pg aspect ratio
	let am = width / height,
		apg = pg.width / pg.height;

	// pg is fitted to screen height
	let isPgFitVertical = am > apg;

	let pgSc = isPgFitVertical ? height / pg.height : width / pg.width;

	let pgMouse = isPgFitVertical
		? new Vec2D(
				map(
					(mouseX - width / 2) / ((pg.width * pgSc) / 2),
					-1,
					1,
					0,
					pg.width
				),
				map(mouseY, 0, height, 0, pg.height)
		  )
		: new Vec2D(
				map(mouseX, 0, width, 0, pg.width),
				map(
					(mouseY - height / 2) / ((pg.height * pgSc) / 2),
					-1,
					1,
					0,
					pg.height
				)
		  );
	return pgMouse;
}

// ----------------------------- COLOURS ------------------------------

/**
 * Generate a random RGB colour.
 * @returns {p5.Color}
 */
function randCol() {
	return color(random(255), random(255), random(255));
}

/**
 * Calculate the luminance of a colour.
 * @param {p5.Color|number[]} col
 * @returns {number}
 */
function lum(col) {
	if (!col.levels) col = color(col);
	return (
		(0.2125 * col.levels[0]) / 255 +
		(0.7154 * col.levels[1]) / 255 +
		(0.0721 * col.levels[2]) / 255
	);
}

/**
 * Convert a p5 color to a hex string.
 * @param {p5.Color} col
 * @param {boolean} [doAlpha=false]
 * @returns {string}
 */
function colorToHexString(col, doAlpha = false) {
	let levels = col.levels;
	if (!doAlpha) levels = levels.slice(0, 3);
	return '#' + levels.map(l => l.toString(16).padStart(2, '0')).join('');
}

/**
 * Interpolate between two colours using the OKLab colour space.
 * @param {p5.Color} col1
 * @param {p5.Color} col2
 * @param {number} t Interpolation factor [0,1]
 * @returns {p5.Color}
 */
function lerpColorOKLab(col1, col2, t) {
	// OKLab colour interpolation
	// more info: https://bottosson.github.io/posts/oklab/
	const srgbToLinear = x => {
		return x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4);
	};
	const linearToSrgb = x => {
		return x <= 0.0031308 ? x * 12.92 : 1.055 * pow(x, 1 / 2.4) - 0.055;
	};

	function rgbToOKLab(r, g, b) {
		r = srgbToLinear(r);
		g = srgbToLinear(g);
		b = srgbToLinear(b);

		// RGB to LMS
		let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
		let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
		let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;

		let l_ = Math.cbrt(l);
		let m_ = Math.cbrt(m);
		let s_ = Math.cbrt(s);

		// LMS to OKLab
		return {
			L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
			A: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
			B: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
		};
	}

	function oklabToRGB(L, A, B) {
		let l_ = L + 0.3963377774 * A + 0.2158037573 * B;
		let m_ = L - 0.1055613458 * A - 0.0638541728 * B;
		let s_ = L - 0.0894841775 * A - 1.291485548 * B;

		l_ = l_ ** 3;
		m_ = m_ ** 3;
		s_ = s_ ** 3;

		let r = +4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_;
		let g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_;
		let b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_;

		r = linearToSrgb(r);
		g = linearToSrgb(g);
		b = linearToSrgb(b);

		let col;
		// preserve current main color mode
		push();
		{
			colorMode(RGB);
			col = color(
				constrain(r * 255, 0, 255),
				constrain(g * 255, 0, 255),
				constrain(b * 255, 0, 255)
			);
		}
		pop();
		return col;
	}

	// p5.Color._array is [r, g, b, a] in range [0,1]
	const lab1 = rgbToOKLab(...col1._array.slice(0, 3));
	const lab2 = rgbToOKLab(...col2._array.slice(0, 3));

	const L = lerp(lab1.L, lab2.L, t);
	const A = lerp(lab1.A, lab2.A, t);
	const B = lerp(lab1.B, lab2.B, t);

	colorMode(RGB);
	return oklabToRGB(L, A, B);
}

// ----------------------------- TIME ------------------------------

/**
 * Configure the animation duration in seconds.
 * @param {number} _duration
 */
function setDuration(_duration) {
	nFrames = int(_duration * FR);
	duration = nFrames / float(FR); // seconds
}

/**
 * Update global time and progress variables.
 */
function setTime() {
	if (!isPlaying) frameCount--;

	ptime = time;

	if (doRunRealTime) {
		time = (millis() / 1000) * speed;
		progress = time / speed / nFrames;
	} else {
		progress =
			(isCapturingFrames && doCaptureStartFromFrame0
				? savedFrameCount
				: frameCount) / nFrames;
		time = progress * duration * speed;
	}

	dtime = time - ptime;
}

/**
 * Get the current UNIX timestamp in seconds.
 * @returns {number}
 */
function getUNIX() {
	return Math.floor(new Date().getTime() / 1000);
}

/**
 * Generate a compact base64 timestamp string.
 * @returns {string}
 */
function getTimestamp() {
	return toB64(new Date().getTime());
}

/**
 * Generate a random Date between two dates.
 * @param {string|Date} date1
 * @param {string|Date} date2
 * @returns {Date}
 */
function randomDate(date1, date2) {
	function randomValueBetween(min, max) {
		return Math.random() * (max - min) + min;
	}
	let d1 = date1 || '01-01-1970';
	let d2 = date2 || new Date().toLocaleDateString();
	d1 = new Date(d1).getTime();
	d2 = new Date(d2).getTime();
	if (d1 > d2) {
		return new Date(randomValueBetween(d2, d1));
	} else {
		return new Date(randomValueBetween(d1, d2));
	}
}

// ----------------------------- STRINGS UTIL ------------------------------

/**
 * Capitalise the first character of a string.
 * @param {string} inputString
 * @returns {string}
 */
function capitalizeFirstLetter(inputString) {
	return inputString.charAt(0).toUpperCase() + inputString.slice(1);
}

if (!String.prototype.format) {
	/**
	 * Basic string templating helper.
	 * Usage: "{0} {1}".format(a, b)
	 * @this {String}
	 * @param {...any} args Values to substitute
	 * @returns {string}
	 */
	String.prototype.format = function () {
		var args = arguments;
		return this.replace(/{(\d+)}/g, function (match, number) {
			return typeof args[number] != 'undefined' ? args[number] : match;
		});
	};
}

// ----------------------------- MEMORY ------------------------------

/**
 * Roughly estimate the memory footprint of a JavaScript object.
 * @param {object} object
 * @returns {number} Size in bytes
 */
function computeRoughSizeOfObject(object) {
	const objectList = [];
	const stack = [object];
	let bytes = 0;
	while (stack.length) {
		const value = stack.pop();

		switch (typeof value) {
			case 'boolean':
				bytes += 4;
				break;
			case 'string':
				bytes += value.length * 2;
				break;
			case 'number':
				bytes += 8;
				break;
			case 'object':
				if (!objectList.includes(value)) {
					objectList.push(value);
					for (const prop in value) {
						if (value.hasOwnProperty(prop)) {
							stack.push(value[prop]);
						}
					}
				}
				break;
		}
	}
	return bytes;
}

// ----------------------------- SYSTEM ------------------------------

/**
 * Determine if the current platform is macOS.
 * @returns {boolean}
 */
function isMac() {
	return window.navigator.platform.toLowerCase().indexOf('mac') > -1;
}

/**
 * Cross-browser helper for retrieving wheel delta.
 * @param {WheelEvent} evt
 * @returns {number}
 */
function getWheelDistance(evt) {
	if (!evt) evt = event;
	let w = evt.wheelDelta,
		d = evt.detail;
	if (d) {
		if (w) return (w / d / 40) * d > 0 ? 1 : -1;
		// Opera
		else return -d / 3; // Firefox;         TODO: do not /3 for OS X
	} else return w / 120; // IE/Safari/Chrome TODO: /3 for Chrome OS X
}

// ----------------------------- DATA/IO ------------------------------

/**
 * Check if two arrays contain the same values in the same order.
 * @param {Array} a
 * @param {Array} b
 * @returns {boolean}
 */
function isArraysEqual(a, b) {
	if (a === b) return true;
	if (a == null || b == null) return false;
	if (a.length !== b.length) return false;
	// If you don't care about the order of the elements inside
	// the array, you should sort both arrays here.
	// Please note that calling sort on an array will modify that array.
	// you might want to clone your array first.
	for (let i = 0; i < a.length; i++) {
		if (a[i] !== b[i]) return false;
	}
	return true;
}

/**
 * Copy the current canvas bitmap to the system clipboard.
 */
function copyCanvasToClipboard() {
	canvas.elt.toBlob(blob => {
		navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
	});
}

/**
 * Digits used for base64 style encoding.
 * @constant {string}
 * @global
 */
const b64Digits =
	'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
/**
 * Convert a number to a base64-like string.
 * @param {number} n
 * @returns {string}
 */
const toB64 = n =>
	n
		.toString(2)
		.split(/(?=(?:.{6})+(?!.))/g)
		.map(v => b64Digits[parseInt(v, 2)])
		.join('');
/**
 * Parse a base64-like string back into a number.
 * @param {string} s64
 * @returns {number}
 */
const fromB64 = s64 =>
	s64.split('').reduce((s, v) => s * 64 + b64Digits.indexOf(v), 0);