Last active
August 8, 2023 07:00
-
-
Save tombigel/6473f36407352fc21f1774d48f4740c7 to your computer and use it in GitHub Desktop.
Helpful Javascript functions I use frequently, mainly vanilla alternatives to lodash etc.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*! | |
* Deep merge two or more objects together. | |
* (c) 2019 Chris Ferdinandi, MIT License, https://gomakethings.com | |
* @param {Object} objects The objects to merge together | |
* @returns {Object} Merged values of defaults and options | |
*/ | |
export function deepMerge(...args: any[]) { | |
// Setup merged object | |
const newObj: Record<string, any> = {}; | |
// Merge the object into the newObj object | |
const merge = function (obj: Record<string, any>) { | |
for (const prop in obj) { | |
if (obj.hasOwnProperty(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 < args.length; i++) { | |
merge(args[i]); | |
} | |
return newObj; | |
} | |
/** | |
* Updated 3 nov 2022 - added types, leaner code | |
* Smart transform passed value to an array: | |
* If it's any primitive other than Array, wrap with an array | |
* If it's any "array like" itterator, transform to an array | |
* Useful for transforming Nodelists etc. to use with array functions | |
* @example | |
* tests: https://codepen.io/tombigel/pen/BajWqQY | |
*/ | |
export const isIterable = (value: unknown): boolean => | |
Symbol.iterator in Object(value); | |
export const toArray = (obj: unknown = []): Array<unknown> => | |
isIterable(obj) && typeof obj !== 'string' | |
? Array.from(obj as ArrayLike<any>) | |
: obj === null | |
? [] | |
: [obj]; | |
/** | |
* Validate if a value is a real number | |
* From https://stackoverflow.com/questions/18082/validate-decimal-numbers-in-javascript-isnumeric | |
* With one change - not supporting numeric strings | |
* @param {*} n | |
* @returns {boolean} | |
*/ | |
export function isNumber(n: unknown): boolean { | |
return !Number.isNaN(n) && Number.isFinite(n) | |
} | |
/** | |
* Validate if a string value is of the form '123.456' / '-123.456' / '+=123.456' / '-=123.456' | |
* This format is used in animation framework like GSAP | |
* @param {*} n | |
* @returns {boolean} | |
*/ | |
export function isRelativeNumber(n: unknown): boolean { | |
return typeof n === 'string' && /^(-|[+-]=)?\d*\.?\d+$/.test(n) | |
} | |
/** | |
* Validate if a value is a number, a number string or a relative number (+=/-=) | |
* @param {*} n | |
* @returns {boolean} | |
*/ | |
export function isNumberLike(n: unknown): boolean { | |
return isNumber(+n) || isRelativeNumber(n) | |
} | |
/** | |
* Validate if a value is an integer | |
* @param {*} n | |
* @returns {boolean} | |
*/ | |
export function isInteger(n: unknown): boolean { | |
return isNumber(n) && parseInt(n, 10) === n | |
} | |
/** | |
* Validate if a value is a real object (like lodash _.isObject - ish) | |
* Based on https://stackoverflow.com/a/14706877, Added check for isArray() | |
* @param {*} value | |
* @returns {boolean} | |
*/ | |
export function isObject(value: unknown): boolean { | |
const type = typeof value | |
return type === 'function' || type === 'object' && !Array.isArray(value) && !!value | |
} | |
/** | |
* Validate if a value is a key value object (like lodash _.isPlainObject) | |
* Based on https://medium.com/javascript-in-plain-english/javascript-check-if-a-variable-is-an-object-and-nothing-else-not-an-array-a-set-etc-a3987ea08fd7 | |
*/ | |
export function isPlainObject(value: unknown): boolean { | |
return Object.prototype.toString.call(value) === '[object Object]' | |
} | |
/** | |
* Validate if a value is an HTMLElement | |
* I avoid using instanceOf since it's not working across windows | |
*/ | |
export function isElement(value: unknown): boolean { | |
return isObject(value) && value.constructor.name.match(/HTML.*Element/) | |
} | |
/** | |
* Remove falsy values from an array (undefined, null, 0, '') (lodash compact) | |
*/ | |
export function compact(array: Array<unknown>): Array<unknown> { | |
return array.filter(v => v) | |
} | |
/** | |
* Remove duplicates from an array, (lodash uniq) | |
*/ | |
export function unique(array: Array<unknown>): Array<unknown> { | |
return [...new Set(array)] | |
} | |
/** | |
* Cross browser dom on ready function | |
* will fire immediately if dom is ready, or will wait. | |
* | |
* Can't remember where I got this from, but it's common knowledg. | |
* A differnet approach is to listen to ready state | |
* and wait to 'interactive' or 'complete' (complete will happen later, after all static resources loaded) | |
* | |
* @param {function} func the callback to run on ready | |
*/ | |
export function onReady(func) { | |
if (document.readyState === "loading") { | |
// Loading hasn't finished yet | |
document.addEventListener("DOMContentLoaded", () => func()); | |
} else { | |
// `DOMContentLoaded` has already fired | |
func(); | |
} | |
} | |
// Transforms and Vectors Math | |
/** | |
* Calc Z by perspective an scale | |
* Normalized to minimum of -999999px | |
* https://stackoverflow.com/a/13505718 | |
*/ | |
export function getZ(scale: number, perspective: number): number { | |
return perspective * (scale - 1) / (scale || 0.0001) | |
} | |
/** | |
* Get the scale factor of a rect so it covers a rotated self. | |
*/ | |
export function getRotatedBoundingRectScale(width: number, height: number, angle: number): number { | |
const angleInRad = angle * Math.PI / 180 | |
const newHeight = width * Math.abs(Math.sin(angleInRad)) + height * Math.abs(Math.cos(angleInRad)) | |
const newWidth = width * Math.abs(Math.cos(angleInRad)) + height * Math.abs(Math.sin(angleInRad)) | |
return Math.max(newHeight / height, newWidth / width) | |
} | |
/** | |
* Get the largest rect that covers a rotated rect | |
* Same as getRotatedBoundingRectScale(width, height, 45) | |
*/ | |
export function getRotatedMaxBoundingRectScale(width: number, height: number): number { | |
const diagonal = Math.sqrt(width ** 2 + height ** 2) | |
return diagonal / Math.min(width, height) | |
} | |
/** | |
* Get the distance an element should move inside a parent for a given angle | |
* such that the element will be exactly out of the parent bounds | |
**/ | |
export function getOutOfScreenDistance( | |
containerRect: Rect, | |
compRect: Rect, | |
angle: number, | |
) { | |
// Calculate x and y direction based on angle | |
const xDirection = Math.sign(Math.cos((angle * Math.PI) / 180)); | |
const yDirection = Math.sign(Math.sin((angle * Math.PI) / 180)); | |
const left = compRect.left - containerRect.left; | |
const top = compRect.top - containerRect.top; | |
// Calculate x and y distances between component and stage | |
const xDistance = | |
xDirection < 0 ? -left - compRect.width : containerRect.width - left; | |
const yDistance = | |
yDirection < 0 ? -top - compRect.height : containerRect.height - top; | |
// Calculate hypotenuse | |
const hypotenuse = Math.min( | |
yDistance / Math.sin((angle * Math.PI) / 180), | |
xDistance / Math.cos((angle * Math.PI) / 180), | |
); | |
return { | |
distance: Math.abs(hypotenuse), | |
x: Math.round(hypotenuse * Math.cos((angle * Math.PI) / 180)), | |
y: Math.round(hypotenuse * Math.sin((angle * Math.PI) / 180)), | |
}; | |
} | |
/** | |
* Lodash style Omit | |
*/ | |
export const omit = <Obj extends Record<string, any>, Keys extends Array<keyof Obj>>( | |
obj: Obj, | |
keys: Keys, | |
): Omit<Obj, Keys[number]> => | |
Object.fromEntries( | |
Object.entries(obj).filter(([key]) => !keys.includes(key)), | |
) as Omit<Obj, Keys[number]>; | |
/** | |
Lodash style Debounce | |
Example: calling debounce 100ms for 7 times: | |
| 100ms | 200ms | |
─A─B─C─ ─D─ ─ ─|─ ─ ─ E─ ─F─G─ ─ ─|─ ─ ─ | |
with {leading: false, trailing: true} | |
─ ─ ─ ─ ─ ─ ─ ─D─ ─ ─ ─ ─ ─ ─ ─ ─ G | |
with {leading: true, trailing: true} | |
─A─ ─ ─ ─ ─ ─ ─D─ ─ ─E─ ─ ─ ─ ─ ─ G | |
with {leading: true, trailing: false} | |
─A─ ─ ─ ─ ─ ─ ─ ─ ─ ─E | |
with {leading: false, trailing: false} (nothing) | |
─ ─ ─ ─ ─ ─ ─ | |
Exception: | |
with {leading: true, trailing: true} but called only once | |
─A─ ─ ─ ─ ─ ─ | |
*/ | |
type Callback = (...args: Array<any>) => void; | |
export const debounce = ( | |
fn: Callback, | |
ms = 0, | |
{ leading = false, trailing = true } = {}, | |
): Callback => { | |
let timeoutId: ReturnType<typeof setTimeout> | null = null; | |
return function (this: any, ...args: Array<any>) { | |
if (leading && timeoutId === null) { | |
fn.apply(this, args); | |
} | |
if (timeoutId) { | |
clearTimeout(timeoutId); | |
} | |
if (!trailing || !leading || timeoutId) { | |
timeoutId = setTimeout(() => { | |
if (trailing) { | |
fn.apply(this, args); | |
} | |
timeoutId = null; | |
}, ms); | |
} else { | |
// Special case for a single call with both leading and trailing (see tests) | |
timeoutId = setTimeout(() => { | |
timeoutId = null; | |
}, ms); | |
} | |
}; | |
}; | |
// NUMBERS!! | |
export type Point = [number, number]; | |
export type Points = Point[]; | |
/** | |
* Round a number to the closest multiply of 'step' integer | |
**/ | |
export function round(step: number, num: number): number { | |
const mod = step ? num % step : 0; | |
return mod > step / 2 ? num - mod + step : num - mod; | |
} | |
/** | |
* Round with decimal precision, default round to integer | |
*/ | |
export const roundPrecision = (num: number, precision: number = 0): number => +num.toFixed(precision); | |
/** | |
* Limit a number between 2 values, inclusive, order doesn't matter | |
*/ | |
export const clamp = (n1: number, n2?: number = n1, n3?: number = n2): number => { | |
const [min, num, max] = [n1, n2, n3].sort((a, b) => a - b); | |
return Math.min(max, Math.max(min, num)); | |
}; | |
/** | |
* Snap a number by distance to another number | |
*/ | |
export function snap(to: number, dist: number, num: number): number { | |
return Math.abs(num - to) <= dist ? to : num; | |
} | |
/** | |
* Snap a number by distance every multiple of another number | |
*/ | |
export function snapEvery(to: number, dist: number, num: number): number { | |
const d1 = num % to; | |
const d2 = to - (num % to); | |
return d1 <= dist ? num - d1 : d2 <= dist ? num + d2 : num; | |
} | |
/** | |
* Linear Interpulation | |
* If t = 0 returns a, if t = 1 returns b, and transitions the value in-between | |
*/ | |
export function lerp(a: number, b: number, t: number): number { | |
return a * (1 - t) + b * t; | |
} | |
/** | |
* Get the distance between 2 points | |
*/ | |
export function distance([x1, y1]: Point, [x2, y2]: Point): number { | |
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); | |
} | |
/** | |
* Map a value from one range 'a' to different range 'b' | |
*/ | |
export function mapRange(a1: number, a2: number, b1: number, b2: number, num: number) { | |
return ((num - a1) * (b2 - b1)) / (a2 - a1) + b1; | |
} | |
export const deg2rad = (angleInDeg: number): number => (angleInDeg * Math.PI) / 180; | |
export const rad2deg = (angleInRad: number): number => (angleInRad * 180) / Math.PI; | |
// COLORS!! | |
export type RGB = [number, number, number]; | |
export type RGBA = [number, number, number, number]; | |
/** | |
* https://stackoverflow.com/a/54024653/171155 | |
* based on math from wikipedia https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative | |
* @param h hue | |
* @param s saturation | |
* @param v brightness (value) | |
* @returns | |
*/ | |
export function hsb2rgb(h: number, s: number, v: number): RGB { | |
s = s / 100; | |
v = v / 100; | |
// input: h in [0,360] and s,v in [0,1] | |
const f = (n: number, k = (n + h / 60) % 6) => | |
v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); | |
return [ | |
Math.round(f(5) * 255), | |
Math.round(f(3) * 255), | |
Math.round(f(1) * 255), | |
]; | |
} | |
export function rgb2hsb(r: number, g: number, b: number): RGB { | |
r = r / 255; | |
g = g / 255; | |
b = b / 255; | |
// input: r,g,b in [0,1] | |
const v = Math.max(r, g, b); | |
const c = v - Math.min(r, g, b); | |
const h = | |
c && (v === r ? (g - b) / c : v === g ? 2 + (b - r) / c : 4 + (r - g) / c); | |
return [60 * (h < 0 ? h + 6 : h), (v && c / v) * 100, v * 100]; | |
} | |
export function hex2rgb(hex: string): RGB { | |
const bigint = parseInt(hex.slice(1) || '0', 16); | |
const r = (bigint >> 16) & 255; | |
const g = (bigint >> 8) & 255; | |
const b = bigint & 255; | |
return [r, g, b]; | |
} | |
/** | |
* Convert RGB to HEX, return hex in uppercase values | |
* @param r | |
* @param g | |
* @param b | |
* @retuens {string} as #RRGGBB | |
*/ | |
export function rgb2hex(r: number, g: number, b: number): string { | |
return `#${((1 << 24) + (r << 16) + (g << 8) + b) | |
.toString(16) | |
.slice(1) | |
.toUpperCase()}`; | |
} | |
/** | |
* Covert HSB (or HSV) to HSL | |
* Based on https://stackoverflow.com/a/66469632/171155 which in turn is based on wikipedia formulas | |
* @param {number} h 0 - 360 | |
* @param {number} s 0 - 100 | |
* @param {number} b 0 - 100 | |
* @returns [number, number, number] | |
*/ | |
export function hsb2hsl(h: number, s: number, b: number): RGB { | |
b = b / 100; | |
s = s / 100; | |
const hue = h; | |
const l = b * (1 - s / 2); | |
const saturation = | |
l === 0 || l === 1 ? 0 : ((b - l) / Math.min(l, 1 - l)) * 100; | |
const lightness = l * 100; | |
return [hue, saturation, lightness]; | |
} | |
/** | |
* Parse rgba (or rgb) array from a string | |
* @param {string} rgba | |
* expects any string that contains 3 integers separated by a comma and optional spaces | |
* with an optional 4th float number | |
* @returns {[number, number, number, number?]} | |
*/ | |
export function str2rgba(rgba: string): RGBA { | |
const [, ...rgbaArr] = rgba.match( | |
/(\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?/, | |
) || ['_', '0', '0', '0', '0']; | |
return rgbaArr.filter((num) => num).map((num) => +num) as RGBA; | |
} | |
export function hsb2hex(h: number, s: number, b: number): string { | |
return rgb2hex(...hsb2rgb(h, s, b)); | |
} | |
export function hex2hsb(hex: string): RGB { | |
return rgb2hsb(...hex2rgb(hex)); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment