diff --git a/public/scripts/uniqolor.js b/public/scripts/uniqolor.js new file mode 100644 index 000000000..f2ceebd5c --- /dev/null +++ b/public/scripts/uniqolor.js @@ -0,0 +1,303 @@ +const SATURATION_BOUND = [0, 100]; +const LIGHTNESS_BOUND = [0, 100]; + +const pad2 = str => `${str.length === 1 ? '0' : ''}${str}`; + +const clamp = (num, min, max) => Math.max(Math.min(num, max), min); + +const random = (min, max) => Math.floor(Math.random() * ((max - min) + 1)) + min; + +const randomExclude = (min, max, exclude) => { + const r = random(min, max); + + for (let i = 0; i < exclude?.length; i++) { + const value = exclude[i]; + + if (value?.length === 2 && r >= value[0] && r <= value[1]) { + return randomExclude(min, max, exclude); + } + } + + return r; +}; + +/** + * Generate hashCode + * @param {string} str + * @return {number} + */ +const hashCode = str => { + const len = str.length; + let hash = 0; + + for (let i = 0; i < len; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash &= hash; // Convert to 32bit integer + } + + return hash; +}; + +/** +* Clamps `num` within the inclusive `range` bounds +* @param {number} num +* @param {number|Array} range +* @return {number} +*/ +const boundHashCode = (num, range) => { + if (typeof range === 'number') { + return range; + } + + return (num % Math.abs(range[1] - range[0])) + range[0]; +}; + +/** + * Sanitizing the `range` + * @param {number|Array} range + * @param {Array} bound + * @return {number|Array} + */ +const sanitizeRange = (range, bound) => { + if (typeof range === 'number') { + return clamp(Math.abs(range), ...bound); + } + + if (range.length === 1 || range[0] === range[1]) { + return clamp(Math.abs(range[0]), ...bound); + } + + return [ + Math.abs(clamp(range[0], ...bound)), + clamp(Math.abs(range[1]), ...bound), + ]; +}; + +/** + * @param {number} p + * @param {number} q + * @param {number} t + * @return {number} + */ +const hueToRgb = (p, q, t) => { + if (t < 0) { + t += 1; + } else if (t > 1) { + t -= 1; + } + + if (t < 1 / 6) { + return p + ((q - p) * 6 * t); + } + + if (t < 1 / 2) { + return q; + } + + if (t < 2 / 3) { + return p + ((q - p) * ((2 / 3) - t) * 6); + } + + return p; +}; + +/** + * Converts an HSL color to RGB + * @param {number} h Hue + * @param {number} s Saturation + * @param {number} l Lightness + * @return {Array} + */ +const hslToRgb = (h, s, l) => { + let r; + let g; + let b; + + h /= 360; + s /= 100; + l /= 100; + + if (s === 0) { + // achromatic + r = g = b = l; + } else { + const q = l < 0.5 + ? l * (1 + s) + : (l + s) - (l * s); + const p = (2 * l) - q; + + r = hueToRgb(p, q, h + (1 / 3)); + g = hueToRgb(p, q, h); + b = hueToRgb(p, q, h - (1 / 3)); + } + + return [ + Math.round(r * 255), + Math.round(g * 255), + Math.round(b * 255), + ]; +}; + +/** + * Determines 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; // eslint-disable-line max-len + +/** + * Converts an HSL color to string format + * @param {number} h Hue + * @param {number} s Saturation + * @param {number} l Lightness + * @return {string} + */ +const hslToString = (h, s, l) => `hsl(${h}, ${s}%, ${l}%)`; + +/** + * Converts RGB color to string format + * @param {number} r Red + * @param {number} g Green + * @param {number} b Blue + * @param {string} format Color format + * @return {string} + */ +const rgbFormat = (r, g, b, format) => { + switch (format) { + case 'rgb': + return `rgb(${r}, ${g}, ${b})`; + case 'hex': + default: + return `#${pad2(r.toString(16))}${pad2(g.toString(16))}${pad2(b.toString(16))}`; + } +}; + +/** + * Generate unique color from `value` + * @param {string|number} value + * @param {Object} [options={}] + * @param {string} [options.format='hex'] + * The color format, it can be one of `hex`, `rgb` or `hsl` + * @param {number|Array} [options.saturation=[50, 55]] + * Determines the color saturation, it can be a number or a range between 0 and 100 + * @param {number|Array} [options.lightness=[50, 60]] + * Determines the color lightness, it can be a number or a range between 0 and 100 + * @param {number} [options.differencePoint=130] + * Determines the color brightness difference point. We use it to obtain the `isLight` value + * in the output, it can be a number between 0 and 255 + * @return {Object} + * @example + * + * ```js + * uniqolor('Hello world!') + * // { color: "#5cc653", isLight: true } + * + * uniqolor('Hello world!', { format: 'rgb' }) + * // { color: "rgb(92, 198, 83)", isLight: true } + * + * uniqolor('Hello world!', { + * saturation: 30, + * lightness: [70, 80], + * }) + * // { color: "#afd2ac", isLight: true } + * + * uniqolor('Hello world!', { + * saturation: 30, + * lightness: [70, 80], + * differencePoint: 200, + * }) + * // { color: "#afd2ac", isLight: false } + * ``` + */ +const uniqolor = (value, { + format = 'hex', + saturation = [50, 55], + lightness = [50, 60], + differencePoint = 130, +} = {}) => { + const hash = Math.abs(hashCode(String(value))); + const h = boundHashCode(hash, [0, 360]); + const s = boundHashCode(hash, sanitizeRange(saturation, SATURATION_BOUND)); + const l = boundHashCode(hash, sanitizeRange(lightness, LIGHTNESS_BOUND)); + const [r, g, b] = hslToRgb(h, s, l); + + return { + color: format === 'hsl' + ? hslToString(h, s, l) + : rgbFormat(r, g, b, format), + isLight: rgbIsLight(r, g, b, differencePoint), + }; +}; + +/** + * Generate random color + * @param {Object} [options={}] + * @param {string} [options.format='hex'] + * The color format, it can be one of `hex`, `rgb` or `hsl` + * @param {number|Array} [options.saturation=[50, 55]] + * Determines the color saturation, it can be a number or a range between 0 and 100 + * @param {number|Array} [options.lightness=[50, 60]] + * Determines the color lightness, it can be a number or a range between 0 and 100 + * @param {number} [options.differencePoint=130] + * Determines the color brightness difference point. We use it to obtain the `isLight` value + * in the output, it can be a number between 0 and 255 + * @param {Array} [options.excludeHue] + * Exclude certain hue ranges. For example to exclude red color range: `[[0, 20], [325, 359]]` + * @return {Object} + * @example + * + * ```js + * // Generate random color + * uniqolor.random() + * // { color: "#644cc8", isLight: false } + * + * // Generate a random color with HSL format + * uniqolor.random({ format: 'hsl' }) + * // { color: "hsl(89, 55%, 60%)", isLight: true } + * + * // Generate a random color in specific saturation and lightness + * uniqolor.random({ + * saturation: 80, + * lightness: [70, 80], + * }) + * // { color: "#c7b9da", isLight: true } + * + * // Generate a random color but exclude red color range + * uniqolor.random({ + * excludeHue: [[0, 20], [325, 359]], + * }) + * // {color: '#53caab', isLight: true} + * ``` + */ +uniqolor.random = ({ + format = 'hex', + saturation = [50, 55], + lightness = [50, 60], + differencePoint = 130, + excludeHue, +} = {}) => { + saturation = sanitizeRange(saturation, SATURATION_BOUND); + lightness = sanitizeRange(lightness, LIGHTNESS_BOUND); + + const h = excludeHue ? randomExclude(0, 359, excludeHue) : random(0, 359); + const s = typeof saturation === 'number' + ? saturation + : random(...saturation); + const l = typeof lightness === 'number' + ? lightness + : random(...lightness); + const [r, g, b] = hslToRgb(h, s, l); + + return { + color: format === 'hsl' + ? hslToString(h, s, l) + : rgbFormat(r, g, b, format), + isLight: rgbIsLight(r, g, b, differencePoint), + }; +}; + +export default uniqolor;