Skip to content

Instantly share code, notes, and snippets.

@tombigel
Last active August 8, 2023 07:00
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tombigel/6473f36407352fc21f1774d48f4740c7 to your computer and use it in GitHub Desktop.
Save tombigel/6473f36407352fc21f1774d48f4740c7 to your computer and use it in GitHub Desktop.
Helpful Javascript functions I use frequently, mainly vanilla alternatives to lodash etc.
/*!
* 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