UNPKG

vis-util

Version:

utilitie collection for visjs

1,542 lines (1,513 loc) 120 kB
/** * vis-util * https://github.com/visjs/vis-util * * utilitie collection for visjs * * @version 6.0.0 * @date 2025-07-12T18:02:43.836Z * * @copyright (c) 2011-2017 Almende B.V, http://almende.com * @copyright (c) 2017-2019 visjs contributors, https://github.com/visjs * * @license * vis.js is dual licensed under both * * 1. The Apache 2.0 License * http://www.apache.org/licenses/LICENSE-2.0 * * and * * 2. The MIT License * http://opensource.org/licenses/MIT * * vis.js may be distributed under either license. */ import Emitter from 'component-emitter'; import RealHammer from '@egjs/hammerjs'; /** * Use this symbol to delete properies in deepObjectAssign. */ const DELETE = Symbol("DELETE"); /** * Pure version of deepObjectAssign, it doesn't modify any of it's arguments. * @param base - The base object that fullfils the whole interface T. * @param updates - Updates that may change or delete props. * @returns A brand new instance with all the supplied objects deeply merged. */ function pureDeepObjectAssign(base, ...updates) { return deepObjectAssign({}, base, ...updates); } /** * Deep version of object assign with additional deleting by the DELETE symbol. * @param values - Objects to be deeply merged. * @returns The first object from values. */ function deepObjectAssign(...values) { const merged = deepObjectAssignNonentry(...values); stripDelete(merged); return merged; } /** * Deep version of object assign with additional deleting by the DELETE symbol. * @remarks * This doesn't strip the DELETE symbols so they may end up in the final object. * @param values - Objects to be deeply merged. * @returns The first object from values. */ function deepObjectAssignNonentry(...values) { if (values.length < 2) { return values[0]; } else if (values.length > 2) { return deepObjectAssignNonentry(deepObjectAssign(values[0], values[1]), ...values.slice(2)); } const a = values[0]; const b = values[1]; if (a instanceof Date && b instanceof Date) { a.setTime(b.getTime()); return a; } for (const prop of Reflect.ownKeys(b)) { if (!Object.prototype.propertyIsEnumerable.call(b, prop)) ; else if (b[prop] === DELETE) { delete a[prop]; } else if (a[prop] !== null && b[prop] !== null && typeof a[prop] === "object" && typeof b[prop] === "object" && !Array.isArray(a[prop]) && !Array.isArray(b[prop])) { a[prop] = deepObjectAssignNonentry(a[prop], b[prop]); } else { a[prop] = clone(b[prop]); } } return a; } /** * Deep clone given object or array. In case of primitive simply return. * @param a - Anything. * @returns Deep cloned object/array or unchanged a. */ function clone(a) { if (Array.isArray(a)) { return a.map((value) => clone(value)); } else if (typeof a === "object" && a !== null) { if (a instanceof Date) { return new Date(a.getTime()); } return deepObjectAssignNonentry({}, a); } else { return a; } } /** * Strip DELETE from given object. * @param a - Object which may contain DELETE but won't after this is executed. */ function stripDelete(a) { for (const prop of Object.keys(a)) { if (a[prop] === DELETE) { delete a[prop]; } else if (typeof a[prop] === "object" && a[prop] !== null) { stripDelete(a[prop]); } } } /** * Seedable, fast and reasonably good (not crypto but more than okay for our * needs) random number generator. * @remarks * Adapted from {@link https://web.archive.org/web/20110429100736/http://baagoe.com:80/en/RandomMusings/javascript}. * Original algorithm created by Johannes Baagøe \<baagoe\@baagoe.com\> in 2010. */ /** * Create a seeded pseudo random generator based on Alea by Johannes Baagøe. * @param seed - All supplied arguments will be used as a seed. In case nothing * is supplied the current time will be used to seed the generator. * @returns A ready to use seeded generator. */ function Alea(...seed) { return AleaImplementation(seed.length ? seed : [Date.now()]); } /** * An implementation of [[Alea]] without user input validation. * @param seed - The data that will be used to seed the generator. * @returns A ready to use seeded generator. */ function AleaImplementation(seed) { let [s0, s1, s2] = mashSeed(seed); let c = 1; const random = () => { const t = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32 s0 = s1; s1 = s2; return (s2 = t - (c = t | 0)); }; random.uint32 = () => random() * 0x100000000; // 2^32 random.fract53 = () => random() + ((random() * 0x200000) | 0) * 1.1102230246251565e-16; // 2^-53 random.algorithm = "Alea"; random.seed = seed; random.version = "0.9"; return random; } /** * Turn arbitrary data into values [[AleaImplementation]] can use to generate * random numbers. * @param seed - Arbitrary data that will be used as the seed. * @returns Three numbers to use as initial values for [[AleaImplementation]]. */ function mashSeed(...seed) { const mash = Mash(); let s0 = mash(" "); let s1 = mash(" "); let s2 = mash(" "); for (let i = 0; i < seed.length; i++) { s0 -= mash(seed[i]); if (s0 < 0) { s0 += 1; } s1 -= mash(seed[i]); if (s1 < 0) { s1 += 1; } s2 -= mash(seed[i]); if (s2 < 0) { s2 += 1; } } return [s0, s1, s2]; } /** * Create a new mash function. * @returns A nonpure function that takes arbitrary [[Mashable]] data and turns * them into numbers. */ function Mash() { let n = 0xefc8249d; return function (data) { const string = data.toString(); for (let i = 0; i < string.length; i++) { n += string.charCodeAt(i); let h = 0.02519603282416938 * n; n = h >>> 0; h -= n; h *= n; n = h >>> 0; h -= n; n += h * 0x100000000; // 2^32 } return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 }; } /** * Setup a mock hammer.js object, for unit testing. * * Inspiration: https://github.com/uber/deck.gl/pull/658 * @returns {{on: noop, off: noop, destroy: noop, emit: noop, get: get}} */ function hammerMock() { const noop = () => {}; return { on: noop, off: noop, destroy: noop, emit: noop, get() { return { set: noop, }; }, }; } const Hammer$1 = typeof window !== "undefined" ? window.Hammer || RealHammer : function () { // hammer.js is only available in a browser, not in node.js. Replacing it with a mock object. return hammerMock(); }; /** * Turn an element into an clickToUse element. * When not active, the element has a transparent overlay. When the overlay is * clicked, the mode is changed to active. * When active, the element is displayed with a blue border around it, and * the interactive contents of the element can be used. When clicked outside * the element, the elements mode is changed to inactive. * @param {Element} container * @class Activator */ function Activator$1(container) { this._cleanupQueue = []; this.active = false; this._dom = { container, overlay: document.createElement("div"), }; this._dom.overlay.classList.add("vis-overlay"); this._dom.container.appendChild(this._dom.overlay); this._cleanupQueue.push(() => { this._dom.overlay.parentNode.removeChild(this._dom.overlay); }); const hammer = Hammer$1(this._dom.overlay); hammer.on("tap", this._onTapOverlay.bind(this)); this._cleanupQueue.push(() => { hammer.destroy(); // FIXME: cleaning up hammer instances doesn't work (Timeline not removed // from memory) }); // block all touch events (except tap) const events = [ "tap", "doubletap", "press", "pinch", "pan", "panstart", "panmove", "panend", ]; events.forEach((event) => { hammer.on(event, (event) => { event.srcEvent.stopPropagation(); }); }); // attach a click event to the window, in order to deactivate when clicking outside the timeline if (document && document.body) { this._onClick = (event) => { if (!_hasParent(event.target, container)) { this.deactivate(); } }; document.body.addEventListener("click", this._onClick); this._cleanupQueue.push(() => { document.body.removeEventListener("click", this._onClick); }); } // prepare escape key listener for deactivating when active this._escListener = (event) => { if ( "key" in event ? event.key === "Escape" : event.keyCode === 27 /* the keyCode is for IE11 */ ) { this.deactivate(); } }; } // turn into an event emitter Emitter(Activator$1.prototype); // The currently active activator Activator$1.current = null; /** * Destroy the activator. Cleans up all created DOM and event listeners */ Activator$1.prototype.destroy = function () { this.deactivate(); for (const callback of this._cleanupQueue.splice(0).reverse()) { callback(); } }; /** * Activate the element * Overlay is hidden, element is decorated with a blue shadow border */ Activator$1.prototype.activate = function () { // we allow only one active activator at a time if (Activator$1.current) { Activator$1.current.deactivate(); } Activator$1.current = this; this.active = true; this._dom.overlay.style.display = "none"; this._dom.container.classList.add("vis-active"); this.emit("change"); this.emit("activate"); // ugly hack: bind ESC after emitting the events, as the Network rebinds all // keyboard events on a 'change' event document.body.addEventListener("keydown", this._escListener); }; /** * Deactivate the element * Overlay is displayed on top of the element */ Activator$1.prototype.deactivate = function () { this.active = false; this._dom.overlay.style.display = "block"; this._dom.container.classList.remove("vis-active"); document.body.removeEventListener("keydown", this._escListener); this.emit("change"); this.emit("deactivate"); }; /** * Handle a tap event: activate the container * @param {Event} event The event * @private */ Activator$1.prototype._onTapOverlay = function (event) { // activate the container this.activate(); event.srcEvent.stopPropagation(); }; /** * Test whether the element has the requested parent element somewhere in * its chain of parent nodes. * @param {HTMLElement} element * @param {HTMLElement} parent * @returns {boolean} Returns true when the parent is found somewhere in the * chain of parent nodes. * @private */ function _hasParent(element, parent) { while (element) { if (element === parent) { return true; } element = element.parentNode; } return false; } // utility functions // parse ASP.Net Date pattern, // for example '/Date(1198908717056)/' or '/Date(1198908717056-0700)/' // code from http://momentjs.com/ const ASPDateRegex = /^\/?Date\((-?\d+)/i; // Color REs const fullHexRE = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; const shortHexRE = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; const rgbRE = /^rgb\( *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *\)$/i; const rgbaRE = /^rgba\( *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *([01]|0?\.\d+) *\)$/i; /** * Test whether given object is a number. * @param value - Input value of unknown type. * @returns True if number, false otherwise. */ function isNumber(value) { return value instanceof Number || typeof value === "number"; } /** * Remove everything in the DOM object. * @param DOMobject - Node whose child nodes will be recursively deleted. */ function recursiveDOMDelete(DOMobject) { if (DOMobject) { while (DOMobject.hasChildNodes() === true) { const child = DOMobject.firstChild; if (child) { recursiveDOMDelete(child); DOMobject.removeChild(child); } } } } /** * Test whether given object is a string. * @param value - Input value of unknown type. * @returns True if string, false otherwise. */ function isString(value) { return value instanceof String || typeof value === "string"; } /** * Test whether given object is a object (not primitive or null). * @param value - Input value of unknown type. * @returns True if not null object, false otherwise. */ function isObject(value) { return typeof value === "object" && value !== null; } /** * Test whether given object is a Date, or a String containing a Date. * @param value - Input value of unknown type. * @returns True if Date instance or string date representation, false otherwise. */ function isDate(value) { if (value instanceof Date) { return true; } else if (isString(value)) { // test whether this string contains a date const match = ASPDateRegex.exec(value); if (match) { return true; } else if (!isNaN(Date.parse(value))) { return true; } } return false; } /** * Copy property from b to a if property present in a. * If property in b explicitly set to null, delete it if `allowDeletion` set. * * Internal helper routine, should not be exported. Not added to `exports` for that reason. * @param a - Target object. * @param b - Source object. * @param prop - Name of property to copy from b to a. * @param allowDeletion - If true, delete property in a if explicitly set to null in b. */ function copyOrDelete(a, b, prop, allowDeletion) { let doDeletion = false; if (allowDeletion === true) { doDeletion = b[prop] === null && a[prop] !== undefined; } if (doDeletion) { delete a[prop]; } else { a[prop] = b[prop]; // Remember, this is a reference copy! } } /** * Fill an object with a possibly partially defined other object. * * Only copies values for the properties already present in a. * That means an object is not created on a property if only the b object has it. * @param a - The object that will have it's properties updated. * @param b - The object with property updates. * @param allowDeletion - If true, delete properties in a that are explicitly set to null in b. */ function fillIfDefined(a, b, allowDeletion = false) { // NOTE: iteration of properties of a // NOTE: prototype properties iterated over as well for (const prop in a) { if (b[prop] !== undefined) { if (b[prop] === null || typeof b[prop] !== "object") { // Note: typeof null === 'object' copyOrDelete(a, b, prop, allowDeletion); } else { const aProp = a[prop]; const bProp = b[prop]; if (isObject(aProp) && isObject(bProp)) { fillIfDefined(aProp, bProp, allowDeletion); } } } } } /** * Copy the values of all of the enumerable own properties from one or more source objects to a * target object. Returns the target object. * @param target - The target object to copy to. * @param source - The source object from which to copy properties. * @returns The target object. */ const extend = Object.assign; /** * Extend object a with selected properties of object b or a series of objects. * @remarks * Only properties with defined values are copied. * @param props - Properties to be copied to a. * @param a - The target. * @param others - The sources. * @returns Argument a. */ function selectiveExtend(props, a, ...others) { if (!Array.isArray(props)) { throw new Error("Array with property names expected as first argument"); } for (const other of others) { for (let p = 0; p < props.length; p++) { const prop = props[p]; if (other && Object.prototype.hasOwnProperty.call(other, prop)) { a[prop] = other[prop]; } } } return a; } /** * Extend object a with selected properties of object b. * Only properties with defined values are copied. * @remarks * Previous version of this routine implied that multiple source objects could * be used; however, the implementation was **wrong**. Since multiple (\>1) * sources weren't used anywhere in the `vis.js` code, this has been removed * @param props - Names of first-level properties to copy over. * @param a - Target object. * @param b - Source object. * @param allowDeletion - If true, delete property in a if explicitly set to null in b. * @returns Argument a. */ function selectiveDeepExtend(props, a, b, allowDeletion = false) { // TODO: add support for Arrays to deepExtend if (Array.isArray(b)) { throw new TypeError("Arrays are not supported by deepExtend"); } for (let p = 0; p < props.length; p++) { const prop = props[p]; if (Object.prototype.hasOwnProperty.call(b, prop)) { if (b[prop] && b[prop].constructor === Object) { if (a[prop] === undefined) { a[prop] = {}; } if (a[prop].constructor === Object) { deepExtend(a[prop], b[prop], false, allowDeletion); } else { copyOrDelete(a, b, prop, allowDeletion); } } else if (Array.isArray(b[prop])) { throw new TypeError("Arrays are not supported by deepExtend"); } else { copyOrDelete(a, b, prop, allowDeletion); } } } return a; } /** * Extend object `a` with properties of object `b`, ignoring properties which * are explicitly specified to be excluded. * @remarks * The properties of `b` are considered for copying. Properties which are * themselves objects are are also extended. Only properties with defined * values are copied. * @param propsToExclude - Names of properties which should *not* be copied. * @param a - Object to extend. * @param b - Object to take properties from for extension. * @param allowDeletion - If true, delete properties in a that are explicitly * set to null in b. * @returns Argument a. */ function selectiveNotDeepExtend(propsToExclude, a, b, allowDeletion = false) { // TODO: add support for Arrays to deepExtend // NOTE: array properties have an else-below; apparently, there is a problem here. if (Array.isArray(b)) { throw new TypeError("Arrays are not supported by deepExtend"); } for (const prop in b) { if (!Object.prototype.hasOwnProperty.call(b, prop)) { continue; } // Handle local properties only if (propsToExclude.includes(prop)) { continue; } // In exclusion list, skip if (b[prop] && b[prop].constructor === Object) { if (a[prop] === undefined) { a[prop] = {}; } if (a[prop].constructor === Object) { deepExtend(a[prop], b[prop]); // NOTE: allowDeletion not propagated! } else { copyOrDelete(a, b, prop, allowDeletion); } } else if (Array.isArray(b[prop])) { a[prop] = []; for (let i = 0; i < b[prop].length; i++) { a[prop].push(b[prop][i]); } } else { copyOrDelete(a, b, prop, allowDeletion); } } return a; } /** * Deep extend an object a with the properties of object b. * @param a - Target object. * @param b - Source object. * @param protoExtend - If true, the prototype values will also be extended. * (That is the options objects that inherit from others will also get the * inherited options). * @param allowDeletion - If true, the values of fields that are null will be deleted. * @returns Argument a. */ function deepExtend(a, b, protoExtend = false, allowDeletion = false) { for (const prop in b) { if (Object.prototype.hasOwnProperty.call(b, prop) || protoExtend === true) { if (typeof b[prop] === "object" && b[prop] !== null && Object.getPrototypeOf(b[prop]) === Object.prototype) { if (a[prop] === undefined) { a[prop] = deepExtend({}, b[prop], protoExtend); // NOTE: allowDeletion not propagated! } else if (typeof a[prop] === "object" && a[prop] !== null && Object.getPrototypeOf(a[prop]) === Object.prototype) { deepExtend(a[prop], b[prop], protoExtend); // NOTE: allowDeletion not propagated! } else { copyOrDelete(a, b, prop, allowDeletion); } } else if (Array.isArray(b[prop])) { a[prop] = b[prop].slice(); } else { copyOrDelete(a, b, prop, allowDeletion); } } } return a; } /** * Test whether all elements in two arrays are equal. * @param a - First array. * @param b - Second array. * @returns True if both arrays have the same length and same elements (1 = '1'). */ function equalArray(a, b) { if (a.length !== b.length) { return false; } for (let i = 0, len = a.length; i < len; i++) { if (a[i] != b[i]) { return false; } } return true; } /** * Get the type of an object, for example exports.getType([]) returns 'Array'. * @param object - Input value of unknown type. * @returns Detected type. */ function getType(object) { const type = typeof object; if (type === "object") { if (object === null) { return "null"; } if (object instanceof Boolean) { return "Boolean"; } if (object instanceof Number) { return "Number"; } if (object instanceof String) { return "String"; } if (Array.isArray(object)) { return "Array"; } if (object instanceof Date) { return "Date"; } return "Object"; } if (type === "number") { return "Number"; } if (type === "boolean") { return "Boolean"; } if (type === "string") { return "String"; } if (type === undefined) { return "undefined"; } return type; } /** * Used to extend an array and copy it. This is used to propagate paths recursively. * @param arr - First part. * @param newValue - The value to be aadded into the array. * @returns A new array with all items from arr and newValue (which is last). */ function copyAndExtendArray(arr, newValue) { return [...arr, newValue]; } /** * Used to extend an array and copy it. This is used to propagate paths recursively. * @param arr - The array to be copied. * @returns Shallow copy of arr. */ function copyArray(arr) { return arr.slice(); } /** * Retrieve the absolute left value of a DOM element. * @param elem - A dom element, for example a div. * @returns The absolute left position of this element in the browser page. */ function getAbsoluteLeft(elem) { return elem.getBoundingClientRect().left; } /** * Retrieve the absolute right value of a DOM element. * @param elem - A dom element, for example a div. * @returns The absolute right position of this element in the browser page. */ function getAbsoluteRight(elem) { return elem.getBoundingClientRect().right; } /** * Retrieve the absolute top value of a DOM element. * @param elem - A dom element, for example a div. * @returns The absolute top position of this element in the browser page. */ function getAbsoluteTop(elem) { return elem.getBoundingClientRect().top; } /** * Add a className to the given elements style. * @param elem - The element to which the classes will be added. * @param classNames - Space separated list of classes. */ function addClassName(elem, classNames) { let classes = elem.className.split(" "); const newClasses = classNames.split(" "); classes = classes.concat(newClasses.filter(function (className) { return !classes.includes(className); })); elem.className = classes.join(" "); } /** * Remove a className from the given elements style. * @param elem - The element from which the classes will be removed. * @param classNames - Space separated list of classes. */ function removeClassName(elem, classNames) { let classes = elem.className.split(" "); const oldClasses = classNames.split(" "); classes = classes.filter(function (className) { return !oldClasses.includes(className); }); elem.className = classes.join(" "); } /** * For each method for both arrays and objects. * In case of an array, the built-in Array.forEach() is applied (**No, it's not!**). * In case of an Object, the method loops over all properties of the object. * @param object - An Object or Array to be iterated over. * @param callback - Array.forEach-like callback. */ function forEach(object, callback) { if (Array.isArray(object)) { // array const len = object.length; for (let i = 0; i < len; i++) { callback(object[i], i, object); } } else { // object for (const key in object) { if (Object.prototype.hasOwnProperty.call(object, key)) { callback(object[key], key, object); } } } } /** * Convert an object into an array: all objects properties are put into the array. The resulting array is unordered. * @param o - Object that contains the properties and methods. * @returns An array of unordered values. */ const toArray = Object.values; /** * Update a property in an object. * @param object - The object whose property will be updated. * @param key - Name of the property to be updated. * @param value - The new value to be assigned. * @returns Whether the value was updated (true) or already strictly the same in the original object (false). */ function updateProperty(object, key, value) { if (object[key] !== value) { object[key] = value; return true; } else { return false; } } /** * Throttle the given function to be only executed once per animation frame. * @param fn - The original function. * @returns The throttled function. */ function throttle(fn) { let scheduled = false; return () => { if (!scheduled) { scheduled = true; requestAnimationFrame(() => { scheduled = false; fn(); }); } }; } /** * Cancels the event's default action if it is cancelable, without stopping further propagation of the event. * @param event - The event whose default action should be prevented. */ function preventDefault(event) { if (!event) { event = window.event; } if (!event) ; else if (event.preventDefault) { event.preventDefault(); // non-IE browsers } else { // @TODO: IE types? Does anyone care? event.returnValue = false; // IE browsers } } /** * Get HTML element which is the target of the event. * @param event - The event. * @returns The element or null if not obtainable. */ function getTarget(event = window.event) { // code from http://www.quirksmode.org/js/events_properties.html // @TODO: EventTarget can be almost anything, is it okay to return only Elements? let target = null; if (!event) ; else if (event.target) { target = event.target; } else if (event.srcElement) { target = event.srcElement; } if (!(target instanceof Element)) { return null; } if (target.nodeType != null && target.nodeType == 3) { // defeat Safari bug target = target.parentNode; if (!(target instanceof Element)) { return null; } } return target; } /** * Check if given element contains given parent somewhere in the DOM tree. * @param element - The element to be tested. * @param parent - The ancestor (not necessarily parent) of the element. * @returns True if parent is an ancestor of the element, false otherwise. */ function hasParent(element, parent) { let elem = element; while (elem) { if (elem === parent) { return true; } else if (elem.parentNode) { elem = elem.parentNode; } else { return false; } } return false; } const option = { /** * Convert a value into a boolean. * @param value - Value to be converted intoboolean, a function will be executed as `(() => unknown)`. * @param defaultValue - If the value or the return value of the function == null then this will be returned. * @returns Corresponding boolean value, if none then the default value, if none then null. */ asBoolean(value, defaultValue) { if (typeof value == "function") { value = value(); } if (value != null) { return value != false; } return defaultValue || null; }, /** * Convert a value into a number. * @param value - Value to be converted intonumber, a function will be executed as `(() => unknown)`. * @param defaultValue - If the value or the return value of the function == null then this will be returned. * @returns Corresponding **boxed** number value, if none then the default value, if none then null. */ asNumber(value, defaultValue) { if (typeof value == "function") { value = value(); } if (value != null) { return Number(value) || defaultValue || null; } return defaultValue || null; }, /** * Convert a value into a string. * @param value - Value to be converted intostring, a function will be executed as `(() => unknown)`. * @param defaultValue - If the value or the return value of the function == null then this will be returned. * @returns Corresponding **boxed** string value, if none then the default value, if none then null. */ asString(value, defaultValue) { if (typeof value == "function") { value = value(); } if (value != null) { return String(value); } return defaultValue || null; }, /** * Convert a value into a size. * @param value - Value to be converted intosize, a function will be executed as `(() => unknown)`. * @param defaultValue - If the value or the return value of the function == null then this will be returned. * @returns Corresponding string value (number + 'px'), if none then the default value, if none then null. */ asSize(value, defaultValue) { if (typeof value == "function") { value = value(); } if (isString(value)) { return value; } else if (isNumber(value)) { return value + "px"; } else { return defaultValue || null; } }, /** * Convert a value into a DOM Element. * @param value - Value to be converted into DOM Element, a function will be executed as `(() => unknown)`. * @param defaultValue - If the value or the return value of the function == null then this will be returned. * @returns The DOM Element, if none then the default value, if none then null. */ asElement(value, defaultValue) { if (typeof value == "function") { value = value(); } return value || defaultValue || null; }, }; /** * Convert hex color string into RGB color object. * @remarks * {@link http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb} * @param hex - Hex color string (3 or 6 digits, with or without #). * @returns RGB color object. */ function hexToRGB(hex) { let result; switch (hex.length) { case 3: case 4: result = shortHexRE.exec(hex); return result ? { r: parseInt(result[1] + result[1], 16), g: parseInt(result[2] + result[2], 16), b: parseInt(result[3] + result[3], 16), } : null; case 6: case 7: result = fullHexRE.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16), } : null; default: return null; } } /** * This function takes string color in hex or RGB format and adds the opacity, RGBA is passed through unchanged. * @param color - The color string (hex, RGB, RGBA). * @param opacity - The new opacity. * @returns RGBA string, for example 'rgba(255, 0, 127, 0.3)'. */ function overrideOpacity(color, opacity) { if (color.includes("rgba")) { return color; } else if (color.includes("rgb")) { const rgb = color .substr(color.indexOf("(") + 1) .replace(")", "") .split(","); return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + opacity + ")"; } else { const rgb = hexToRGB(color); if (rgb == null) { return color; } else { return "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + "," + opacity + ")"; } } } /** * Convert RGB \<0, 255\> into hex color string. * @param red - Red channel. * @param green - Green channel. * @param blue - Blue channel. * @returns Hex color string (for example: '#0acdc0'). */ function RGBToHex(red, green, blue) { return ("#" + ((1 << 24) + (red << 16) + (green << 8) + blue).toString(16).slice(1)); } /** * Parse a color property into an object with border, background, and highlight colors. * @param inputColor - Shorthand color string or input color object. * @param defaultColor - Full color object to fill in missing values in inputColor. * @returns Color object. */ function parseColor(inputColor, defaultColor) { if (isString(inputColor)) { let colorStr = inputColor; if (isValidRGB(colorStr)) { const rgb = colorStr .substr(4) .substr(0, colorStr.length - 5) .split(",") .map(function (value) { return parseInt(value); }); colorStr = RGBToHex(rgb[0], rgb[1], rgb[2]); } if (isValidHex(colorStr) === true) { const hsv = hexToHSV(colorStr); const lighterColorHSV = { h: hsv.h, s: hsv.s * 0.8, v: Math.min(1, hsv.v * 1.02), }; const darkerColorHSV = { h: hsv.h, s: Math.min(1, hsv.s * 1.25), v: hsv.v * 0.8, }; const darkerColorHex = HSVToHex(darkerColorHSV.h, darkerColorHSV.s, darkerColorHSV.v); const lighterColorHex = HSVToHex(lighterColorHSV.h, lighterColorHSV.s, lighterColorHSV.v); return { background: colorStr, border: darkerColorHex, highlight: { background: lighterColorHex, border: darkerColorHex, }, hover: { background: lighterColorHex, border: darkerColorHex, }, }; } else { return { background: colorStr, border: colorStr, highlight: { background: colorStr, border: colorStr, }, hover: { background: colorStr, border: colorStr, }, }; } } else { if (defaultColor) { const color = { background: inputColor.background || defaultColor.background, border: inputColor.border || defaultColor.border, highlight: isString(inputColor.highlight) ? { border: inputColor.highlight, background: inputColor.highlight, } : { background: (inputColor.highlight && inputColor.highlight.background) || defaultColor.highlight.background, border: (inputColor.highlight && inputColor.highlight.border) || defaultColor.highlight.border, }, hover: isString(inputColor.hover) ? { border: inputColor.hover, background: inputColor.hover, } : { border: (inputColor.hover && inputColor.hover.border) || defaultColor.hover.border, background: (inputColor.hover && inputColor.hover.background) || defaultColor.hover.background, }, }; return color; } else { const color = { background: inputColor.background || undefined, border: inputColor.border || undefined, highlight: isString(inputColor.highlight) ? { border: inputColor.highlight, background: inputColor.highlight, } : { background: (inputColor.highlight && inputColor.highlight.background) || undefined, border: (inputColor.highlight && inputColor.highlight.border) || undefined, }, hover: isString(inputColor.hover) ? { border: inputColor.hover, background: inputColor.hover, } : { border: (inputColor.hover && inputColor.hover.border) || undefined, background: (inputColor.hover && inputColor.hover.background) || undefined, }, }; return color; } } } /** * Convert RGB \<0, 255\> into HSV object. * @remarks * {@link http://www.javascripter.net/faq/rgb2hsv.htm} * @param red - Red channel. * @param green - Green channel. * @param blue - Blue channel. * @returns HSV color object. */ function RGBToHSV(red, green, blue) { red = red / 255; green = green / 255; blue = blue / 255; const minRGB = Math.min(red, Math.min(green, blue)); const maxRGB = Math.max(red, Math.max(green, blue)); // Black-gray-white if (minRGB === maxRGB) { return { h: 0, s: 0, v: minRGB }; } // Colors other than black-gray-white: const d = red === minRGB ? green - blue : blue === minRGB ? red - green : blue - red; const h = red === minRGB ? 3 : blue === minRGB ? 1 : 5; const hue = (60 * (h - d / (maxRGB - minRGB))) / 360; const saturation = (maxRGB - minRGB) / maxRGB; const value = maxRGB; return { h: hue, s: saturation, v: value }; } /** * Split a string with css styles into an object with key/values. * @param cssText - CSS source code to split into key/value object. * @returns Key/value object corresponding to {@link cssText}. */ function splitCSSText(cssText) { const tmpEllement = document.createElement("div"); const styles = {}; tmpEllement.style.cssText = cssText; for (let i = 0; i < tmpEllement.style.length; ++i) { styles[tmpEllement.style[i]] = tmpEllement.style.getPropertyValue(tmpEllement.style[i]); } return styles; } /** * Append a string with css styles to an element. * @param element - The element that will receive new styles. * @param cssText - The styles to be appended. */ function addCssText(element, cssText) { const cssStyle = splitCSSText(cssText); for (const [key, value] of Object.entries(cssStyle)) { element.style.setProperty(key, value); } } /** * Remove a string with css styles from an element. * @param element - The element from which styles should be removed. * @param cssText - The styles to be removed. */ function removeCssText(element, cssText) { const cssStyle = splitCSSText(cssText); for (const key of Object.keys(cssStyle)) { element.style.removeProperty(key); } } /** * Convert HSV \<0, 1\> into RGB color object. * @remarks * {@link https://gist.github.com/mjijackson/5311256} * @param h - Hue. * @param s - Saturation. * @param v - Value. * @returns RGB color object. */ function HSVToRGB(h, s, v) { let r; let g; let b; const i = Math.floor(h * 6); const f = h * 6 - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); switch (i % 6) { case 0: ((r = v), (g = t), (b = p)); break; case 1: ((r = q), (g = v), (b = p)); break; case 2: ((r = p), (g = v), (b = t)); break; case 3: ((r = p), (g = q), (b = v)); break; case 4: ((r = t), (g = p), (b = v)); break; case 5: ((r = v), (g = p), (b = q)); break; } return { r: Math.floor(r * 255), g: Math.floor(g * 255), b: Math.floor(b * 255), }; } /** * Convert HSV \<0, 1\> into hex color string. * @param h - Hue. * @param s - Saturation. * @param v - Value. * @returns Hex color string. */ function HSVToHex(h, s, v) { const rgb = HSVToRGB(h, s, v); return RGBToHex(rgb.r, rgb.g, rgb.b); } /** * Convert hex color string into HSV \<0, 1\>. * @param hex - Hex color string. * @returns HSV color object. */ function hexToHSV(hex) { const rgb = hexToRGB(hex); if (!rgb) { throw new TypeError(`'${hex}' is not a valid color.`); } return RGBToHSV(rgb.r, rgb.g, rgb.b); } /** * Validate hex color string. * @param hex - Unknown string that may contain a color. * @returns True if the string is valid, false otherwise. */ function isValidHex(hex) { const isOk = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(hex); return isOk; } /** * Validate RGB color string. * @param rgb - Unknown string that may contain a color. * @returns True if the string is valid, false otherwise. */ function isValidRGB(rgb) { return rgbRE.test(rgb); } /** * Validate RGBA color string. * @param rgba - Unknown string that may contain a color. * @returns True if the string is valid, false otherwise. */ function isValidRGBA(rgba) { return rgbaRE.test(rgba); } /** * This recursively redirects the prototype of JSON objects to the referenceObject. * This is used for default options. * @param fields - Names of properties to be bridged. * @param referenceObject - The original object. * @returns A new object inheriting from the referenceObject. */ function selectiveBridgeObject(fields, referenceObject) { if (referenceObject !== null && typeof referenceObject === "object") { // !!! typeof null === 'object' const objectTo = Object.create(referenceObject); for (let i = 0; i < fields.length; i++) { if (Object.prototype.hasOwnProperty.call(referenceObject, fields[i])) { if (typeof referenceObject[fields[i]] == "object") { objectTo[fields[i]] = bridgeObject(referenceObject[fields[i]]); } } } return objectTo; } else { return null; } } /** * This recursively redirects the prototype of JSON objects to the referenceObject. * This is used for default options. * @param referenceObject - The original object. * @returns The Element if the referenceObject is an Element, or a new object inheriting from the referenceObject. */ function bridgeObject(referenceObject) { if (referenceObject === null || typeof referenceObject !== "object") { return null; } if (referenceObject instanceof Element) { // Avoid bridging DOM objects return referenceObject; } const objectTo = Object.create(referenceObject); for (const i in referenceObject) { if (Object.prototype.hasOwnProperty.call(referenceObject, i)) { if (typeof referenceObject[i] == "object") { objectTo[i] = bridgeObject(referenceObject[i]); } } } return objectTo; } /** * This method provides a stable sort implementation, very fast for presorted data. * @param a - The array to be sorted (in-place). * @param compare - An order comparator. * @returns The argument a. */ function insertSort(a, compare) { for (let i = 0; i < a.length; i++) { const k = a[i]; let j; for (j = i; j > 0 && compare(k, a[j - 1]) < 0; j--) { a[j] = a[j - 1]; } a[j] = k; } return a; } /** * This is used to set the options of subobjects in the options object. * * A requirement of these subobjects is that they have an 'enabled' element * which is optional for the user but mandatory for the program. * * The added value here of the merge is that option 'enabled' is set as required. * @param mergeTarget - Either this.options or the options used for the groups. * @param options - Options. * @param option - Option key in the options argument. * @param globalOptions - Global options, passed in to determine value of option 'enabled'. */ function mergeOptions(mergeTarget, options, option, globalOptions = {}) { // Local helpers const isPresent = function (obj) { return obj !== null && obj !== undefined; }; const isObject = function (obj) { return obj !== null && typeof obj === "object"; }; // https://stackoverflow.com/a/34491287/1223531 const isEmpty = function (obj) { for (const x in obj) { if (Object.prototype.hasOwnProperty.call(obj, x)) { return false; } } return true; }; // Guards if (!isObject(mergeTarget)) { throw new Error("Parameter mergeTarget must be an object"); } if (!isObject(options)) { throw new Error("Parameter options must be an object"); } if (!isPresent(option)) { throw new Error("Parameter option must have a value"); } if (!isObject(globalOptions)) { throw new Error("Parameter globalOptions must be an object"); } // // Actual merge routine, separated from main logic // Only a single level of options is merged. Deeper levels are ref'd. This may actually be an issue. // const doMerge = function (target, options, option) { if (!isObject(target[option])) { target[option] = {}; } const src = options[option]; const dst = target[option]; for (const prop in src) { if (Object.prototype.hasOwnProperty.call(src, prop)) { dst[prop] = src[prop]; } } }; // Local initialization const srcOption = options[option]; const globalPassed = isObject(globalOptions) && !isEmpty(globalOptions); const globalOption = globalPassed ? globalOptions[option] : undefined; const globalEnabled = globalOption ? globalOption.enabled : undefined; ///////////////////////////////////////// // Main routine ///////////////////////////////////////// if (srcOption === undefined) { return; // Nothing to do } if (typeof srcOption === "boolean") { if (!isObject(mergeTarget[option])) { mergeTarget[option] = {}; } mergeTarget[option].enabled = srcOption; return; } if (srcOption === null && !isObject(mergeTarget[option])) { // If possible, explicit copy from globals if (isPresent(globalOption)) { mergeTarget[option] = Object.create(globalOption); } else { return; // Nothing to do } } if (!isObject(srcOption)) { return; } // // Ensure that 'enabled' is properly set. It is required internally // Note that the value from options will always overwrite the existing value // let enabled = true; // default value if (srcOption.enabled !== undefined) { enabled = srcOption.enabled; } else { // Take from globals, if present if (globalEnabled !== undefined) { enabled = globalOption.enabled; } } doMerge(mergeTarget, options, option); mergeTarget[option].enabled = enabled; } /** * This function does a binary search for a visible item in a sorted list. If we find a visible item, the code that uses * this function will then iterate in both directions over this sorted list to find all visible items. * @param orderedItems - Items ordered by start. * @param comparator - -1 is lower, 0 is equal, 1 is higher. * @param field - Property name on an item (That is item[field]). * @param field2 - Second property name on an item (That i