utils.js

/*********************************************************************************************************************  

PRSM Participatory System Mapper 

	Copyright (C) 2022  Nigel Gilbert prsm@prsm.uk

	This program is free software: you can redistribute it and/or modify
	it under the terms of the GNU General Public License as published by
	the Free Software Foundation, either version 3 of the License, or
	(at your option) any later version.

	This program is distributed in the hope that it will be useful,
	but WITHOUT ANY WARRANTY; without even the implied warranty of
	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
	GNU General Public License for more details.

	You should have received a copy of the GNU General Public License
	along with this program.  If not, see <https://www.gnu.org/licenses/>.


This module provides a set of utility functions used widely within the PRSM code.  
 ******************************************************************************************************************** */

import * as Hammer from '@egjs/hammerjs'
import iro from '@jaames/iro'

const MANUALURL = './doc/help/doc_build/manual/Introduction.html'
/**
 * attach an event listener
 *
 * @param {string} id - id of the element on which to hang the event listener
 * @param {string} event
 * @param {function} callback
 * @param {object} options
 */
export function listen(id, event, callback, options) {
	elem(id).addEventListener(event, callback, options)
}

/**
 * return the HTML element with the id
 * @param {string} id
 */
export function elem(id) {
	return document.getElementById(id)
}

export function pushnew(array, item) {
	if (array) {
		if (!array.includes(item)) array.push(item)
	} else array = [item]
	return array
}

/**
 * Create a random scale free network, used only for testing and demoing
 * Taken from the vis-network distribution
 *
 * Created by Alex on 5/20/2015.
 */
export function getScaleFreeNetwork(nodeCount) {
	var nodes = []
	var edges = []
	var connectionCount = []

	// randomly create some nodes and edges
	for (var i = 0; i < nodeCount; i++) {
		nodes.push({
			id: String(i),
			label: String(i),
			grp: 'group0',
			value: 1,
		})

		connectionCount[i] = 0

		// create edges in a scale-free-network way
		if (i == 1) {
			var from = i
			var to = 0
			edges.push({
				from: from.toString(),
				to: to.toString(),
				grp: 'edge0',
			})
			connectionCount[from]++
			connectionCount[to]++
		} else if (i > 1) {
			var conn = edges.length * 2
			var rand = Math.floor(seededRandom() * conn)
			var cum = 0
			var j = 0
			while (j < connectionCount.length && cum < rand) {
				cum += connectionCount[j]
				j++
			}

			from = i
			to = j
			edges.push({
				from: from.toString(),
				to: to.toString(),
				grp: 'edge0',
			})
			connectionCount[from]++
			connectionCount[to]++
		}
	}

	return {
		nodes: nodes,
		edges: edges,
	}
}

var randomSeed = 764 // Math.round(Math.random()*1000);
function seededRandom() {
	var x = Math.sin(randomSeed++) * 10000
	return x - Math.floor(x)
}

/**
 * return a GUID
 */
export function uuidv4() {
	return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
		(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
	)
}
/**
 * return true if obj has no properties, i.e. is {}
 * @param {Object} obj
 * @returns true or false
 */
export function isEmpty(obj) {
	for (let p in obj) return false
	return true
}
/*
 * Deep merge two or more objects together.
 * (c) 2019 Chris Ferdinandi, MIT License, https://gomakethings.com
 * if two objects have the same property, the value of the second one is used
 * @param   {Object[]}   objects  The objects to merge together
 * @returns {Object}            A new, merged, object
 */
export function deepMerge() {
	// Setup merged object
	let newObj = {}

	// Merge the object into the newObj object
	function merge(obj) {
		for (let prop in obj) {
			if (Object.prototype.hasOwnProperty.call(obj, prop)) {
				// If property is an object, merge properties
				if (Object.prototype.toString.call(obj[prop]) === '[object Object]') {
					newObj[prop] = deepMerge(newObj[prop], obj[prop])
				} else {
					newObj[prop] = obj[prop]
				}
			}
		}
	}

	// Loop through each object and conduct a merge
	for (let i = 0; i < arguments.length; i++) {
		merge(arguments[i])
	}

	return newObj
}

/**
 * returns a deep copy of the object
 * original replaced by new built-in
 * @param {Object} obj
 */
export function deepCopy(obj) {
	/* 	if (typeof obj !== 'object' || obj === null) {
		return obj
	}
	if (obj instanceof Array) {
		return obj.reduce((arr, item, i) => {
			arr[i] = deepCopy(item)
			return arr
		}, [])
	}
	if (obj instanceof Object) {
		return Object.keys(obj).reduce((newObj, key) => {
			newObj[key] = deepCopy(obj[key])
			return newObj
		}, {})
	} */
	return structuredClone(obj)
}
window.deepCopy = deepCopy
/**
 * compare two objects for deep equality
 * fast but doesn't cater for obscure cases
 * adapted from https://stackoverflow.com/questions/1068834/object-comparison-in-javascript
 * @param {Object} x
 * @param {Object} y
 */
export function object_equals(x, y) {
	if (x === y) return true
	// if both x and y are null or undefined and exactly the same

	if (!(x instanceof Object) || !(y instanceof Object)) return false
	// if they are not strictly equal, they both need to be Objects

	if (x.constructor !== y.constructor) return false
	// they must have the exact same prototype chain, the closest we can do is
	// test their constructor.

	for (let p in x) {
		if (!Object.prototype.hasOwnProperty.call(x, p)) continue
		// other properties were tested using x.constructor === y.constructor

		if (!Object.prototype.hasOwnProperty.call(y, p)) return false
		// allows to compare x[ p ] and y[ p ] when set to undefined

		if (x[p] === y[p]) continue
		// if they have the same strict value or identity then they are equal

		if (typeof x[p] !== 'object') return false
		// Numbers, Strings, Functions, Booleans must be strictly equal

		if (!object_equals(x[p], y[p])) return false
		// Objects and Arrays must be tested recursively
	}

	for (let p in y)
		if (Object.prototype.hasOwnProperty.call(y, p) && !Object.prototype.hasOwnProperty.call(x, p)) return false
	// allows x[ p ] to be set to undefined

	return true
}
window.object_equals = object_equals
/**
 * return a copy of an object, with the properties in the object propsToRemove removed
 * @param {Object} source
 * @param {Object} propsToRemove
 */
export function clean(source, propsToRemove) {
	let out = {}
	for (let key in source) {
		if (!(key in propsToRemove)) out[key] = source[key]
	}
	return out
}
/**
 * remove the given properties from all the objects in the array
 * @param {Array} arr array of objects
 * @param {string} propsToRemove
 */
export function cleanArray(arr, propsToRemove) {
	return arr.map((item) => {
		return clean(item, propsToRemove)
	})
}
/**
 * return a copy of an object that only includes the properties that are in allowed
 * @param {Object} obj the object to copy
 * @param {array} allowed list of allowed properties
 */
export function strip(obj, allowed) {
	return allowed.reduce((a, e) => ((a[e] = obj[e]), a), {})
}
/**
 * divide txt into lines to make it roughly square, with a
 * maximum width of width characters, but not breaking words and
 * respecting embedded line breaks (\n).
 * @param {string} txt
 * @param {number} width
 */
export function splitText(txt, width = 10) {
	let lines = ''
	let chunks = txt.trim().split('\n')
	chunks.forEach((chunk) => {
		let words = chunk.trim().split(/\s/)
		let nChars = chunk.trim().length
		if (nChars > 2 * width) width = Math.floor(Math.sqrt(nChars))

		for (let i = 0, linelength = 0; i < words.length; i++) {
			lines += words[i]
			if (i == words.length - 1) break
			linelength += words[i].length
			if (linelength > width) {
				lines += '\n'
				linelength = 0
			} else lines += ' '
		}
		lines += '\n'
	})
	return lines.trim()
}
/**
 * Performs intersection operation between called set and otherSet
 */
Set.prototype.intersection = function (otherSet) {
	let intersectionSet = new Set()
	for (var el of otherSet) if (this.has(el)) intersectionSet.add(el)
	return intersectionSet
}
/**
 * Convert a factor size into a percent (with any size below 30 as zero), for the input range slider
 * @param {Integer} size
 * @returns {Integer} percent
 */
export function factorSizeToPercent(size) {
	let fSize = (size - 20) / 2.5
	return isNaN(fSize) || fSize < 30 ? 0 : fSize
}
/**
 * Set the factor size according to the input range slider value (less then 5% is treated as the normal size)
 * @param {object} node
 * @param {integer} percent
 */
export function setFactorSizeFromPercent(node, percent) {
	if (percent < 5) {
		node.size = 25
		node.heightConstraint = node.widthConstraint = false
	} else {
		node.heightConstraint = node.widthConstraint = node.size = percent * 2.5 + 20
	}
}
/**
 * convert from style object properties to dashed border menu selection
 * @param {any} bDashes select menu value
 * @param {number} bWidth border width
 */
export function getDashes(bDashes, bWidth) {
	if (bWidth === 0) return 'none'
	if (bDashes === false) return 'solid'
	if (bDashes === true) return 'dashed'
	if (Array.isArray(bDashes)) {
		if (bDashes[0] === 10) return 'dashedLinks'
		return 'dots'
	}
	return null
}
/**
 * Convert from dashed menu selection to style object properties
 * @param {string} val
 */
export function convertDashes(val) {
	switch (val) {
		case 'dashed': // dashes [5,15] for node borders
			return true
		case 'dashedLinks': // dashes for links
			return [10, 10]
		case 'solid': // solid
			return false
		case 'none': //solid, zero width
			return false
		case 'dots':
			return [2, 8]
		default:
			return false
	}
}
/**
 * allow user to drag the element that has a header element that acts as the handle
 * @param {HTMLElement} el
 * @param {HTMLElement} header
 */
export function dragElement(el, header) {
	header.addEventListener('mouseenter', () => (header.style.cursor = 'move'))
	header.addEventListener('mouseout', () => (header.style.cursor = 'auto'))

	let mc = new Hammer.Manager(header, {
		recognizers: [[Hammer.Pan, {direction: Hammer.DIRECTION_ALL, threshold: 0}]],
	})
	// tie in the handler that will be called
	mc.on('pan', handleDrag)

	let lastPosX = 0
	let lastPosY = 0
	let isDragging = false
	let width = 0
	let height = 0

	function handleDrag(ev) {
		// DRAG STARTED
		// here, let's snag the current position
		// and keep track of the fact that we're dragging
		if (!isDragging) {
			isDragging = true
			lastPosX = el.offsetLeft
			lastPosY = el.offsetTop
			width = el.offsetWidth
			height = el.offsetHeight
		}

		// we simply need to determine where the x,y of this
		// object is relative to where it's "last" known position is
		// NOTE:
		//    deltaX and deltaY are cumulative
		// Thus we need to always calculate 'real x and y' relative
		// to the "lastPosX/Y"
		el.style.cursor = 'move'
		let posX = ev.deltaX + lastPosX
		if (posX < 0) posX = 0
		if (posX > window.innerWidth - width) posX = window.innerWidth - width
		let posY = ev.deltaY + lastPosY
		if (posY < 0) posY = 0
		if (posY > window.innerHeight - height) posY = window.innerHeight - height

		// move our element to that position
		el.style.left = posX + 'px'
		el.style.top = posY + 'px'

		// DRAG ENDED
		// this is where we simply forget we are dragging
		if (ev.isFinal) {
			isDragging = false
			el.style.cursor = 'auto'
		}
	}
}
/**
 * Create a context menu that pops up when elem is right clicked
 * @param {HTMLElement} elem click this to get a context menu
 * @param {array} menu array of menu options: ([{label: string, action: function to call when this option selected} {...}])
 */
export function addContextMenu(elem, menu) {
	const menuEl = document.createElement('div')
	menuEl.classList.add('context-menu')
	document.body.appendChild(menuEl)
	elem.addEventListener('contextmenu', (event) => {
		event.preventDefault()
		const {clientX: mouseX, clientY: mouseY} = event
		let posX =
			window.innerWidth - mouseX < menuEl.offsetWidth + 4 ? window.innerWidth - menuEl.offsetWidth - 4 : mouseX
		let posY =
			window.innerHeight - mouseY < menuEl.offsetHeight + 4
				? window.innerHeight - menuEl.offsetHeight - 4
				: mouseY
		menuEl.style.top = `${posY}px`
		menuEl.style.left = `${posX}px`
		menuEl.classList.remove('visible')
		setTimeout(() => {
			menuEl.classList.add('visible')
		})
	})
	document.body.addEventListener('click', () => {
		menuEl.classList.remove('visible')
	})
	menu.forEach((item) => {
		const {label, action} = item
		let option = document.createElement('div')
		option.classList.add('item')
		option.innerHTML = label
		option.addEventListener('click', () => {
			document.body.removeChild(menuEl)
			action()
		})
		option.addEventListener('contextmenu', (event) => event.preventDefault())
		menuEl.appendChild(option)
	})
}

const SEA_CREATURES = Object.freeze([
	'walrus',
	'seal',
	'fish',
	'shark',
	'clam',
	'coral',
	'whale',
	'crab',
	'lobster',
	'starfish',
	'eel',
	'dolphin',
	'squid',
	'jellyfish',
	'ray',
	'shrimp',
	'herring',
	'angler',
	'mackerel',
	'salmon',
	'urchin',
	'anemone',
	'morel',
	'axolotl',
	'blobfish',
	'tubeworm',
	'seabream',
	'seaweed',
	'anchovy',
	'cod',
	'barramundi',
	'carp',
	'crayfish',
	'haddock',
	'hake',
	'octopus',
	'plaice',
	'sardine',
	'skate',
	'sturgeon',
	'swordfish',
	'whelk',
])

const ADJECTIVES = Object.freeze([
	'cute',
	'adorable',
	'lovable',
	'happy',
	'sandy',
	'bubbly',
	'friendly',
	'drifting',
	'huge',
	'big',
	'small',
	'giant',
	'massive',
	'tiny',
	'nippy',
	'odd',
	'perfect',
	'rude',
	'wonderful',
	'agile',
	'beautiful',
	'bossy',
	'candid',
	'carnivorous',
	'clever',
	'cold',
	'cold-blooded',
	'colorful',
	'cuddly',
	'curious',
	'cute',
	'dangerous',
	'deadly',
	'domestic',
	'dominant',
	'energetic',
	'fast',
	'feisty',
	'ferocious',
	'fierce',
	'fluffy',
	'friendly',
	'furry',
	'fuzzy',
	'grumpy',
	'hairy',
	'heavy',
	'herbivorous',
	'jealous',
	'large',
	'lazy',
	'loud',
	'lovable',
	'loving',
	'malicious',
	'maternal',
	'mean',
	'messy',
	'nocturnal',
	'noisy',
	'nosy',
	'picky',
	'playful',
	'poisonous',
	'quick',
	'rough',
	'sassy',
	'scaly',
	'short',
	'shy',
	'slimy',
	'slow',
	'small',
	'smart',
	'smelly',
	'soft',
	'spikey',
	'stinky',
	'strong',
	'stubborn',
	'submissive',
	'tall',
	'tame',
	'tenacious',
	'territorial',
	'tiny',
	'vicious',
	'warm',
	'wild',
])

let colors = [
	'#00ffff',
	'#f0ffff',
	'#f5f5dc',
	'#0000ff',
	'#a52a2a',
	'#00008b',
	'#008b8b',
	'#a9a9a9',
	'#006400',
	'#bdb76b',
	'#8b008b',
	'#556b2f',
	'#ff8c00',
	'#9932cc',
	'#8b0000',
	'#e9967a',
	'#9400d3',
	'#ff00ff',
	'#ffd700',
	'#008000',
	'#4b0082',
	'#f0e68c',
	'#add8e6',
	'#e0ffff',
	'#90ee90',
	'#d3d3d3',
	'#ffb6c1',
	'#ffffe0',
	'#00ff00',
	'#ff00ff',
	'#800000',
	'#000080',
	'#808000',
	'#ffa500',
	'#ffc0cb',
	'#800080',
	'#ff0000',
	'#c0c0c0',
	'#ffff00',
]

const random = (items) => items[(Math.random() * items.length) | 0]

/**
 * Determine whether the RGB color is light or not
 * http://www.w3.org/TR/AERT#color-contrast
 * @param  {number}  r               Red
 * @param  {number}  g               Green
 * @param  {number}  b               Blue
 * @param  {number}  differencePoint
 * @return {boolean}
 */
const rgbIsLight = (r, g, b, differencePoint) => (r * 299 + g * 587 + b * 114) / 1000 >= differencePoint

/**
 * return a random colour, with a flag to show whether the color is light or dark,
 *  to suggest whether text applied should be white or black
 * @returns {Object} {color: string, isLight: boolean}
 */
function randomColour() {
	const color = random(colors)
	const rgb = color.replace('#', '')
	return {
		color: color,
		isLight: rgbIsLight(
			parseInt(rgb.substring(0, 2), 16),
			parseInt(rgb.substring(2, 4), 16),
			parseInt(rgb.substring(4, 6), 16),
			128
		),
	}
}

const capitalize = (string) => string[0].toUpperCase() + string.slice(1)

/**
 * return a random fancy name for an avatar, with a random colour
 */
export function generateName() {
	let name = capitalize(random(ADJECTIVES)) + ' ' + capitalize(random(SEA_CREATURES))

	return {
		...randomColour(),
		name: name,
		anon: true,
		asleep: false,
	}
}

/*----------- Status messages ---------------------------------------
 */
/**
 * show status message at the bottom of the window
 * @param {string} msg
 */
export function statusMsg(msg) {
	elem('statusBar').innerHTML = htmlEntities(msg)
}
/**
 * show alert messages at the bottom of the window
 * @param {string} msg
 * @param {string} [status] type of msg - info, warn, error
 * @param {boolean} [dontFade] if true, don't fade the message in and out
 */
export function alertMsg(msg, status, dontFade) {
	let errMsgElement = elem('errMsg')
	switch (status) {
		case 'info':
			errMsgElement.style.backgroundColor = 'black'
			errMsgElement.style.color = 'white'
			break
		case 'warn':
			errMsgElement.style.backgroundColor = '#FFEB3B'
			errMsgElement.style.color = 'black'
			break
		case 'error':
			errMsgElement.style.backgroundColor = 'red'
			errMsgElement.style.color = 'white'
			break
		default:
			console.log('Unknown status in alertMsg: ' + status)
			return
	}
	errMsgElement.innerHTML = msg
	if (dontFade) {
		errMsgElement.style.opacity = 1
	} else {
		listen('errMsg', 'animationend', () => {
			elem('errMsg').classList.remove('fadeInAndOut')
		})
		errMsgElement.classList.add('fadeInAndOut')
	}
}
/**
 * replace special characters with their HTML entity codes
 * @param {string} str
 */
function htmlEntities(str) {
	return String(str)
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;')
		.replace(/'/g, '&quot;')
}
/**
 * remove any previous message from the status bar
 */
export function clearStatusBar() {
	statusMsg(' ')
}
/**
 * shorten the label if necessary and add an ellipsis
 * @param {string} label text to shorten
 * @param {number} maxLength if longer than this, cut the excess
 */
const SHORTLABELLEN = 25 // when listing node labels, use ellipsis after this number of chars

export function shorten(label, maxLength = SHORTLABELLEN) {
	return label.length > maxLength ? label.substring(0, maxLength) + '...' : label
}
/**
 * return the initials of the given name as a string: Nigel Gilbert -> NG
 * @param {string} name
 */
export function initials(name) {
	return name
		.replace(/[^A-Za-z0-9À-ÿ ]/gi, '')
		.replace(/ +/gi, ' ')
		.match(/(^\S\S?|\b\S)?/g)
		.join('')
		.match(/(^\S|\S$)?/g)
		.join('')
		.toUpperCase()
}

/**********************************************************colours ************************************************** */

const hiddenOpacity = 0.1
/**
 * set this node to its 'hidden' appearance (very faint), or restore it to its usual appearance
 * @param {object} node
 * @param {boolean} hide
 * @returns {object} node
 */
export function setNodeHidden(node, hide) {
	node.nodeHidden = hide
	node.opacity = hide ? hiddenOpacity : 1.0
	node.font.color = rgba(node.font.color, hide ? hiddenOpacity : 1.0)
	return node
}
/**
 * set this edge to its 'hidden' appearance (very faint), or restore it to its usual appearance
 * @param {object} edge
 * @param {boolean} hide
 * @returns {object} edge
 */
export function setEdgeHidden(edge, hide) {
	edge.edgeHidden = hide
	edge.color.opacity = hide ? hiddenOpacity : 1.0
	if (!edge.font.color) edge.font.color = 'rgba(0,0,0,1)'
	edge.font.color = rgba(edge.font.color, hide ? hiddenOpacity : 1.0)
	return edge
}

/**
 * convert an rgb(a) string to rgba with given alpha value
 */
function rgba(rgb, alpha) {
	if (rgb.indexOf('a') == -1) rgb = rgb.replace('rgb', 'rgba').replace(')', ',0.0)')
	return rgb.replace(/[^,]*$/, ` ${alpha})`)
}
/**
 * return the hex value for the CSS color in str (which may be a color name, e.g. white, or a hex number
 * or any other legal CSS color value)
 * @param {string} str
 */
export function standardize_color(str) {
	if (!str) return '#000000'
	if (str.charAt(0) === '#') return str
	let ctx = document.createElement('canvas').getContext('2d')
	ctx.fillStyle = str
	return ctx.fillStyle
}

/**
 * return the inverse/complementary colour
 * @param {string} color as a hex string
 * @returns hex string
 */
export function invertColor(color) {
	return '#' + ('000000' + (0xffffff ^ parseInt(color.substring(1), 16)).toString(16)).slice(-6)
}

/**
 * closure to generate a sequence of colours (as rgb strings, e.g. 'rgb(246,121,16)')
 * based on https://krazydad.com/tutorials/makecolors.php
 */
export const makeColor = (function () {
	let counter = 0
	let freq = 0.3,
		phase1 = 0,
		phase2 = 2,
		phase3 = 4,
		center = 128,
		width = 127
	return function () {
		counter += 1
		let red = Math.sin(freq * counter + phase1) * width + center
		let grn = Math.sin(freq * counter + phase2) * width + center
		let blu = Math.sin(freq * counter + phase3) * width + center
		return 'rgb(' + Math.round(red) + ',' + Math.round(grn) + ',' + Math.round(blu) + ')'
	}
})()

window.makeColor = makeColor
/**
 * Determine whether a color is light or dark (so text in a contrasting color can be overlaid)
 * from https://awik.io/determine-color-bright-dark-using-javascript/
 * @param {string} color
 * @returns 'light' or 'dark'
 */
export function lightOrDark(color) {
	// Variables for red, green, blue values
	let r, g, b, hsp

	// Check the format of the color, HEX or RGB?
	if (color.match(/^rgb/)) {
		// If RGB --> store the red, green, blue values in separate variables
		color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/)

		r = color[1]
		g = color[2]
		b = color[3]
	} else {
		// If hex --> Convert it to RGB: http://gist.github.com/983661
		color = +('0x' + color.slice(1).replace(color.length < 5 && /./g, '$&$&'))

		r = color >> 16
		g = (color >> 8) & 255
		b = color & 255
	}

	// HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html
	hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b))

	// Using the HSP value, determine whether the color is light or dark
	if (hsp > 127.5) {
		return 'light'
	} else {
		return 'dark'
	}
}

/* --------------------color picker -----------------------------*/

export class CP {
	constructor() {
		this.container = document.createElement('div')
		this.container.className = 'color-picker-container'
		this.container.id = 'colorPicker'
		let controls = document.createElement('div')
		controls.id = 'colorPickerControls'
		this.container.appendChild(controls)
		document.querySelector('body').insertAdjacentElement('beforeend', this.container)

		// see https://iro.js.org/guide.html#getting-started
		this.colorPicker = new iro.ColorPicker('#colorPickerControls', {
			width: 160,
			color: 'rgb(255, 255, 255)',
			borderWidth: 1,
			borderColor: 'rgb(255, 255, 255)',
			margin: 0,
		})

		// set up a grid of squares to hold last 8 selected colors
		this.colorCache = document.createElement('div')
		this.colorCache.id = 'colorCache'
		this.colorCache.className = 'color-cache'
		for (let i = 0; i < 8; i++) {
			let c = document.createElement('div')
			c.id = 'color' + i
			c.className = 'cached-color'
			// prefill with standard colours
			c.style.backgroundColor = [
				'rgb(255, 0, 0)',
				'rgb(0, 255, 0)',
				'rgb(0, 0, 255)',
				'rgb(255, 255, 0)',
				'rgb(255, 255, 255)',
				'rgb(0, 0, 0)',
				'rgb(154, 219, 180)',
				'rgb(219, 110, 103)',
			][i]
			c.addEventListener('click', (e) => {
				let color = e.target.style.backgroundColor
				if (color) this.colorPicker.color.rgbString = e.target.style.backgroundColor
			})
			this.colorCache.appendChild(c)
		}
		document.getElementById('colorPickerControls').insertAdjacentElement('afterend', this.colorCache)
	}

	/**
	 * attach a color picker to an element to recolor the background to that element
	 * @param {string} wellId the id of the DOM element to attach the color picker to
	 * @param {string} callback - function to call when the color has been chosen, with that color as argument
	 */
	createColorPicker(wellId, callback, onChange) {
		let well = elem(wellId)
		well.style.backgroundColor = '#ffffff'
		// add listener to display picker when well clicked
		well.addEventListener('click', (event) => {
			this.container.style.display = 'block'
			let netPane = elem('net-pane').getBoundingClientRect()
			// locate picker so it does not go outside netPane
			let top = event.clientY + well.offsetHeight + 10
			if (top > netPane.bottom - this.container.offsetHeight)
				top = netPane.bottom - this.container.offsetHeight - 10
			if (top < netPane.top) top = netPane.top + 10
			let left = event.clientX - this.container.offsetWidth / 2
			if (left < netPane.left) left = netPane.left + 10
			if (left > netPane.right - this.container.offsetWidth)
				left = netPane.right - this.container.offsetWidth - 10
			this.container.style.top = `${top}px`
			this.container.style.left = `${left}px`
			this.container.well = well
			this.container.callback = callback
			this.container.onChange = onChange
			this.colorPicker.color.rgbString = well.style.backgroundColor
			this.onclose = this.closeColorPicker.bind(this)
			document.addEventListener('click', this.onclose, true)

			// update well as color is changed
			this.colorPicker.on('color:change', (color) => {
				well.style.backgroundColor = color.rgbString
				if (onChange) onChange()
			})
		})
	}
	/**
	 * Report chosen colour when user clicks outside of picker (and well)
	 * Hide the picker and save the colour choice in the previously selected colour grid
	 * @param {event} event
	 */
	closeColorPicker(event) {
		if (!(this.container.contains(event.target) || this.container.well.contains(event.target))) {
			this.container.style.display = 'none'
			document.removeEventListener('click', this.onclose, true)
			let color = this.container.well.style.backgroundColor
			// save the chosen color for future selection if it is not already there
			this.saveColor(color)

			let callback = this.container.callback
			if (callback) callback(color)
			this.colorPicker.off('color:change')
		}
	}
	/**
	 * Save the color in the previously selected colour grid, if not already saved
	 * into a free slot, or if there isn't one shift the current colours to the left
	 * and save the new at the right end
	 * @param {string} color
	 */
	saveColor(color) {
		let saveds = this.colorCache.children
		for (let i = 0; i < 8; i++) {
			if (saveds[i].style.backgroundColor == color) return
		}
		for (let i = 0; i < 8; i++) {
			if (saveds[i].style.backgroundColor == '') {
				saveds[i].style.backgroundColor = color
				return
			}
		}
		for (let i = 0, j = 1; j < 8; i++, j++) {
			saveds[i].style.backgroundColor = saveds[j].style.backgroundColor
		}
		saveds[7].style.backgroundColor = color
	}
}

/********************************************************************** text ************************************************ */
/**
 * Returns a nicely formatted Date (or time if the date is today), given a Time value (from Date() )
 * @param {number} utc
 * @param {boolean} full - if true, don't use Today in date
 */
export function timeAndDate(utc, full = false) {
	let time = new Date()
	time.setTime(utc)
	if (!full && time.toDateString() == new Date().toDateString()) {
		// return Today, 12:34
		return (
			'Today, ' +
			time.toLocaleString('en-GB', {
				hour: '2-digit',
				minute: '2-digit',
			})
		)
	}
	if (!full && time.getFullYear() == new Date().getFullYear()) {
		// return 12 Sept, 12:34
		return time.toLocaleString('en-GB', {
			day: '2-digit',
			month: 'short',
			hour: '2-digit',
			minute: '2-digit',
		})
	}
	// return 12 Sep 2023, 12:34
	return time
		.toLocaleString('en-GB', {
			day: '2-digit',
			month: 'short',
			year: 'numeric',
			hour: '2-digit',
			minute: '2-digit',
		})
		.replace('Sept', 'Sep')
}
/**
 * positions the caret at the end of text in a contenteditable div
 * @param {*} contentEditableElement
 */
export function setEndOfContenteditable(contentEditableElement) {
	let range = document.createRange() //Create a range (a range is a like the selection but invisible)
	range.selectNodeContents(contentEditableElement) //Select the entire contents of the element with the range
	range.collapse(false) //collapse the range to the end point. false means collapse to end rather than the start
	let selection = window.getSelection() //get the selection object (allows you to change selection)
	selection.removeAllRanges() //remove any selections already made
	selection.addRange(range) //make the range you have just created the visible selection
}

/**
 * @returns a string with current time to the nearest millisecond
 */
export function exactTime(time) {
	let d = time ? new Date(time) : new Date()
	return `${d.toLocaleTimeString()}:${d.getMilliseconds()} `
}

export function capitalizeFirstLetter(string) {
	return string.charAt(0).toUpperCase() + string.slice(1)
}
export function lowerFirstLetter(string) {
	return string.charAt(0).toLowerCase() + string.slice(1)
}
/**
 *
 * @param {number} bytes integer to convert
 * @param {boolean} si use base 10 (true) or base 2 (false)
 * @returns {string} e.g. humanFileSize(1929637) => 1.9MB
 */
export function humanSize(bytes, si = true) {
	let u,
		b = bytes,
		t = si ? 1000 : 1024
	;['', si ? 'k' : 'K', ...'MGTPEZY'].find((x) => ((u = x), (b /= t), b ** 2 < 1))
	return `${u ? (t * b).toFixed(1) : bytes}${u}${!si && u ? 'i' : ''}B`
}
/**
 * test whether the editor has any content
 * (could be an empty string or a Quill insert operation of just a single newline character)
 * @param {object} quill editor
 * @returns boolean
 */
export function isQuillEmpty(quill) {
	if ((quill.getContents()['ops'] || []).length !== 1) {
		return false
	}
	return quill.getText().trim().length === 0
}
/**
 * Replace all \n, \r with a space
 * @param {string} str
 * @returns string
 */
export function stripNL(str) {
	return str.replace(/\r?\n|\r/g, ' ')
}

/**
 * display help page in a separate window
 */
export function displayHelp() {
	window.open(MANUALURL, 'helpWindow')
}